Test Failed
Pull Request — master (#16)
by Divine Niiquaye
02:30
created

RouteMatcher   D

Complexity

Total Complexity 59

Size/Duplication

Total Lines 288
Duplicated Lines 0 %

Importance

Changes 4
Bugs 1 Features 0
Metric Value
eloc 113
dl 0
loc 288
rs 4.08
c 4
b 1
f 0
wmc 59

10 Methods

Rating   Name   Duplication   Size   Complexity  
A interpolate() 0 9 4
C match() 0 44 12
B matchRoute() 0 42 11
A getCompiler() 0 3 1
B generateUri() 0 31 8
A fetchOptions() 0 15 4
A compareDomain() 0 20 6
B buildPath() 0 28 9
A __construct() 0 15 3
A isCompiled() 0 3 1

How to fix   Complexity   

Complex Class

Complex classes like RouteMatcher often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use RouteMatcher, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
declare(strict_types=1);
4
5
/*
6
 * This file is part of Flight Routing.
7
 *
8
 * PHP version 7.1 and above required
9
 *
10
 * @author    Divine Niiquaye Ibok <[email protected]>
11
 * @copyright 2019 Biurad Group (https://biurad.com/)
12
 * @license   https://opensource.org/licenses/BSD-3-Clause License
13
 *
14
 * For the full copyright and license information, please view the LICENSE
15
 * file that was distributed with this source code.
16
 */
17
18
namespace Flight\Routing;
19
20
use Flight\Routing\Exceptions\{MethodNotAllowedException, UriHandlerException, UrlGenerationException};
21
use Flight\Routing\Interfaces\{RouteCompilerInterface, RouteMatcherInterface};
22
use Psr\Http\Message\{ServerRequestInterface, UriInterface};
23
24
/**
25
 * The bidirectional route matcher responsible for matching
26
 * HTTP request and generating url from routes.
27
 *
28
 * @author Divine Niiquaye Ibok <[email protected]>
29
 */
30
class RouteMatcher implements RouteMatcherInterface
31
{
32
    private const URI_FIXERS = [
33
        '[]' => '',
34
        '[/]' => '',
35
        '[' => '',
36
        ']' => '',
37
        '://' => '://',
38
        '//' => '/',
39
    ];
40
41
    /** @var \Iterator<int,Route>|\Iterator<int,array> */
42
    protected $routes = [];
43
44
    /** @var Matchers\SimpleRouteDumper|array|null */
45
    private $dumper = null;
46
47
    /** @var RouteCompilerInterface */
48
    private $compiler;
49
50
    public function __construct(\Iterator $collection, ?RouteCompilerInterface $compiler = null, string $cacheFile = null)
51
    {
52
        $this->compiler = $compiler ?? new Matchers\SimpleRouteCompiler();
53
        $this->routes = $collection;
54
55
        if (!empty($cacheFile)) {
56
            if (\file_exists($cacheFile)) {
57
                $cachedRoutes = include $cacheFile;
58
                $this->routes = new \ArrayIterator($cachedRoutes[3]);
59
60
                // Remove routes ...
61
                unset($cachedRoutes[3]);
62
            }
63
64
            $this->dumper = $cachedRoutes ?? new Matchers\SimpleRouteDumper($cacheFile);
65
        }
66
    }
67
68
    /**
69
     * {@inheritdoc}
70
     */
71
    public function match(ServerRequestInterface $request): ?Route
72
    {
73
        $requestUri = $request->getUri();
74
75
        // Resolve request path to match sub-directory or /index.php/path
76
        if (empty($resolvedPath = $request->getServerParams()['PATH_INFO'] ?? '')) {
77
            $resolvedPath = $requestUri->getPath();
78
        }
79
80
        if ('/' !== $resolvedPath && isset(Route::URL_PREFIX_SLASHES[$resolvedPath[-1]])) {
81
            $resolvedPath = \substr($resolvedPath, 0, -1);
82
        }
83
84
        [$matchedRoute, $matchedDomains, $variables] = $this->matchRoute($resolvedPath = \rawurldecode($resolvedPath));
85
86
        if ($matchedRoute instanceof Route) {
87
            $schemes = $matchedRoute->get('schemes');
88
89
            if (!\array_key_exists($request->getMethod(), $matchedRoute->get('methods'))) {
90
                throw new MethodNotAllowedException(\array_keys($matchedRoute->get('methods')), $requestUri->getPath(), $request->getMethod());
91
            }
92
93
            if (!empty($schemes) && !\array_key_exists($requestUri->getScheme(), $schemes)) {
94
                throw new UriHandlerException(\sprintf('Unfortunately current scheme "%s" is not allowed on requested uri [%s]', $requestUri->getScheme(), $resolvedPath), 400);
95
            }
96
97
            if (!empty($matchedDomains)) {
98
                if (null === $hostVars = $this->compareDomain($matchedDomains, $requestUri)) {
99
                    throw new UriHandlerException(\sprintf('Unfortunately current domain "%s" is not allowed on requested uri [%s]', $requestUri->getHost(), $resolvedPath), 400);
100
                }
101
102
                $variables = \array_replace($variables, $hostVars);
103
            }
104
105
            foreach ($variables as $key => $value) {
106
                if (\is_int($key)) {
107
                    continue;
108
                }
109
110
                $matchedRoute->argument($key, $value);
111
            }
112
        }
113
114
        return $matchedRoute;
115
    }
116
117
    /**
118
     * {@inheritdoc}
119
     *
120
     * @return string of fully qualified URL for named route
121
     */
122
    public function generateUri(string $routeName, array $parameters = [], array $queryParams = []): string
123
    {
124
        foreach ($this->routes as $route) {
125
            if (!$route instanceof Route) {
126
                $route = Route::__set_state($route);
127
            }
128
129
            if ($routeName === $route->get('name')) {
130
                $compiledRoute = $this->isCompiled() ? \unserialize($this->dumper[2][$routeName]) : $this->compiler->compile($route, true);
131
                $uriRoute = $this->buildPath($route, $compiledRoute, $parameters);
132
133
                // Incase query is added to uri.
134
                if ([] !== $queryParams) {
135
                    $uriRoute .= '?' . \http_build_query($queryParams);
136
                }
137
138
                if (!\str_contains($uriRoute, '://')) {
139
                    $prefix = '.'; // Append missing "." at the beginning of the $uri.
140
141
                    if ('/' !== @$uriRoute[0]) {
142
                        $prefix .= '/';
143
                    }
144
145
                    $uriRoute = $prefix . $uriRoute;
146
                }
147
148
                return $uriRoute;
149
            }
150
        }
151
152
        throw new UrlGenerationException(\sprintf('Unable to generate a URL for the named route "%s" as such route does not exist.', $routeName), 404);
153
    }
154
155
    public function getCompiler(): RouteCompilerInterface
156
    {
157
        return $this->compiler;
158
    }
159
160
    /**
161
     * Return true if routes are compiled.
162
     */
163
    public function isCompiled(): bool
164
    {
165
        return \is_array($this->dumper);
166
    }
167
168
    /**
169
     * This method is used to build uri, this can be overwritten.
170
     *
171
     * @param array<int|string,int|string> $parameters
172
     */
173
    protected function buildPath(Route $route, CompiledRoute $compiledRoute, array $parameters): string
174
    {
175
        $path = $host = '';
176
177
        // Fetch and merge all possible parameters + variables keys + route defaults ...
178
        $parameters = $this->fetchOptions($parameters, \array_keys($compiledRoute->getVariables()));
179
        $parameters = $parameters + $route->get('defaults') + $compiledRoute->getVariables();
180
        $hostRegexps = $compiledRoute->getHostsRegex();
181
182
        if (\is_string($hostRegexps) || (\is_array($hostRegexps) && 1 === \count($hostRegexps))) {
183
            $host = $hostRegexps;
184
        }
185
186
        if (!empty($schemes = $route->get('schemes'))) {
187
            $schemes = [isset($_SERVER['HTTPS']) ? 'https' : 'http' => true];
188
189
            if (empty($host)) {
190
                $host = $_SERVER['HTTP_HOST'] ?? '';
191
            }
192
        }
193
194
        if (!empty($host)) {
195
            // If we have s secured scheme, it should be served
196
            $hostScheme = isset($schemes['https']) ? 'https' : (\array_key_last($schemes) ?? 'http');
197
            $path = "{$hostScheme}://" . \trim($this->interpolate($host, $parameters), '.');
198
        }
199
200
        return $path .= $this->interpolate($compiledRoute->getRegex(), $parameters);
201
    }
202
203
    /**
204
     * Match Route based on HTTP request path.
205
     */
206
    protected function matchRoute(string $resolvedPath): array
207
    {
208
        if (\is_array($dumper = $this->dumper)) {
209
            [$staticRoutes, $regexpList] = $dumper;
210
211
            if ([null, [], []] !== $matchedRoute = $staticRoutes[$resolvedPath] ?? [null, [], []]) {
212
                $route = $this->routes[$matchedRoute[0]];
213
                $matchedRoute[0] = $route instanceof Route ? $route : Route::__set_state($route);
214
215
                return $matchedRoute;
216
            }
217
218
            if (1 === \preg_match($regexpList[0], $resolvedPath, $urlVariables)) {
219
                $route = $this->routes[$routeId = $urlVariables['MARK']];
220
                [$matchedDomains, $variables, $varKeys] = $regexpList[1][$routeId];
221
222
                foreach ($varKeys as $index => $key) {
223
                    $variables[$key] = $urlVariables[$index] ?? null;
224
                }
225
226
                return [$route instanceof Route ? $route : Route::__set_state($route), $matchedDomains, $variables];
227
            }
228
229
            return $matchedRoute;
230
        }
231
232
        foreach ($this->routes as $route) {
233
            $compiledRoute = $this->compiler->compile($route);
234
235
            // https://tools.ietf.org/html/rfc7231#section-6.5.5
236
            if ($resolvedPath === $compiledRoute->getStatic() || 1 === \preg_match($compiledRoute->getRegex(), $resolvedPath, $uriVars)) {
237
                try {
238
                    return [$route, $compiledRoute->getHostsRegex(), ($uriVars ?? []) + $compiledRoute->getVariables()];
239
                } finally {
240
                    if ($dumper instanceof Matchers\SimpleRouteDumper) {
241
                        $dumper->dump($this->routes, $this->compiler);
242
                    }
243
                }
244
            }
245
        }
246
247
        return [null, [], []];
248
    }
249
250
    /**
251
     * Interpolate string with given values.
252
     *
253
     * @param array<int|string,mixed> $values
254
     */
255
    private function interpolate(string $string, array $values): string
256
    {
257
        $replaces = self::URI_FIXERS;
258
259
        foreach ($values as $key => $value) {
260
            $replaces["<{$key}>"] = (\is_array($value) || $value instanceof \Closure) ? '' : $value;
261
        }
262
263
        return \strtr($string, $replaces);
264
    }
265
266
    /**
267
     * Fetch uri segments and query parameters.
268
     *
269
     * @param array<int|string,mixed> $parameters
270
     * @param array<int|string,mixed> $allowed
271
     *
272
     * @return array<int|string,mixed>
273
     */
274
    private function fetchOptions($parameters, array $allowed): array
275
    {
276
        $result = [];
277
278
        foreach ($parameters as $key => $parameter) {
279
            if (\is_numeric($key) && isset($allowed[$key])) {
280
                // this segment fetched keys from given parameters either by name or by position
281
                $key = $allowed[$key];
282
            }
283
284
            // TODO: String must be normalized here
285
            $result[$key] = $parameter;
286
        }
287
288
        return $result;
289
    }
290
291
    /**
292
     * Check if given request domain matches given route domain.
293
     *
294
     * @param string|string[] $routeDomains
295
     *
296
     * @return array<int|string,mixed>|null
297
     */
298
    protected function compareDomain($routeDomains, UriInterface $requestUri): ?array
299
    {
300
        $hostAndPort = $requestUri->getHost();
301
302
        // Added port to host for matching ...
303
        if (null !== $requestUri->getPort()) {
304
            $hostAndPort .= ':' . $requestUri->getPort();
305
        }
306
307
        if (\is_string($routeDomains)) {
308
            return 1 === \preg_match($routeDomains, $hostAndPort, $parameters) ? $parameters : null;
309
        }
310
311
        foreach ($routeDomains as $routeDomain) {
312
            if (1 === \preg_match($routeDomain, $hostAndPort, $parameters)) {
313
                return $parameters;
314
            }
315
        }
316
317
        return null;
318
    }
319
}
320