Passed
Push — master ( d17b70...413065 )
by Divine Niiquaye
03:12
created

Router   B

Complexity

Total Complexity 44

Size/Duplication

Total Lines 395
Duplicated Lines 0 %

Test Coverage

Coverage 95.31%

Importance

Changes 4
Bugs 0 Features 0
Metric Value
eloc 121
c 4
b 0
f 0
dl 0
loc 395
ccs 122
cts 128
cp 0.9531
rs 8.8798
wmc 44

15 Methods

Rating   Name   Duplication   Size   Complexity  
A getRoutes() 0 3 1
A setNamespace() 0 3 1
A getRoute() 0 7 2
A __construct() 0 12 1
A addRouteListener() 0 4 2
A handle() 0 20 2
A match() 0 33 6
A getAllowedMethods() 0 11 3
A assertRoute() 0 20 4
A marshalMatchedRoute() 0 21 3
A generateResponse() 0 23 6
A generateUri() 0 30 5
A setProfiler() 0 3 1
A addParameters() 0 10 3
A addRoute() 0 15 4

How to fix   Complexity   

Complex Class

Complex classes like Router 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 Router, 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 DivineNii\Invoker\Interfaces\InvokerInterface;
21
use DivineNii\Invoker\Invoker;
22
use Flight\Routing\Exceptions\DuplicateRouteException;
23
use Flight\Routing\Exceptions\MethodNotAllowedException;
24
use Flight\Routing\Exceptions\RouteNotFoundException;
25
use Flight\Routing\Exceptions\UriHandlerException;
26
use Flight\Routing\Exceptions\UrlGenerationException;
27
use Flight\Routing\Interfaces\RouteInterface;
28
use Flight\Routing\Interfaces\RouteListenerInterface;
29
use Flight\Routing\Interfaces\RouteMatcherInterface;
30
use Psr\Container\ContainerInterface;
31
use Psr\Http\Message\ResponseFactoryInterface;
32
use Psr\Http\Message\ResponseInterface;
33
use Psr\Http\Message\ServerRequestInterface;
34
use Psr\Http\Message\UriFactoryInterface;
35
use Psr\Http\Message\UriInterface;
36
use Psr\Http\Server\RequestHandlerInterface;
37
38
/**
39
 * Router
40
 */
41
class Router implements RequestHandlerInterface
42
{
43 1
    use Traits\ValidationTrait;
44
45
    public const TYPE_REQUIREMENT = 1;
46
47
    public const TYPE_DEFAULT = 0;
48
49
    /** @var RouteMatcherInterface */
50
    private $matcher;
51
52
    /** @var InvokerInterface */
53
    private $resolver;
54
55
    /** @var callable */
56
    private $response;
57
58
    /** @var UriFactoryInterface */
59
    private $uriFactory;
60
61
    /** @var string */
62
    private $namespace;
63
64
    /** @var RouteInterface[] */
65
    private $routes = [];
66
67
    /** @var RouteListenerInterface[] */
68
    private $listeners = [];
69
70
    /** @var array<int,array<string,mixed>> */
71
    private $attributes = [];
72
73
    /** @var null|ProfileRoute */
74
    private $profiler;
75
76 57
    public function __construct(
77
        ResponseFactoryInterface $responseFactory,
78
        UriFactoryInterface $uriFactory,
79
        ?RouteMatcherInterface $matcher = null,
80
        ?InvokerInterface $resolver = null,
81
        ?ContainerInterface $container = null
82
    ) {
83 57
        $this->resolver  = $resolver ?? new Invoker([], $container);
84 57
        $this->matcher   = $matcher ?? new Services\SimpleRouteMatcher();
85
86 57
        $this->uriFactory      = $uriFactory;
87 57
        $this->response        = [$responseFactory, 'createResponse'];
88 57
    }
89
90
    /**
91
     * Gets the router routes
92
     *
93
     * @return RouteInterface[]
94
     */
95 1
    public function getRoutes(): array
96
    {
97 1
        return \array_values($this->routes);
98
    }
99
100
    /**
101
     * Set Namespace for route handlers/controllers
102
     *
103
     * @param string $namespace
104
     */
105 4
    public function setNamespace(string $namespace): void
106
    {
107 4
        $this->namespace = rtrim($namespace, '\\/') . '\\';
108 4
    }
109
110
    /**
111
     * @param ProfileRoute $profiler
112
     */
113
    public function setProfiler(?ProfileRoute $profiler = null): void
114
    {
115
        $this->profiler = $profiler ?? new ProfileRoute();
116
    }
117
118
    /**
119
     * Adds the given route(s) to the router
120
     *
121
     * @param RouteInterface ...$routes
122
     *
123
     * @throws DuplicateRouteException
124
     */
125 55
    public function addRoute(RouteInterface ...$routes): void
126
    {
127 55
        foreach ($routes as $route) {
128 55
            $name = $route->getName();
129
130 55
            if (isset($this->routes[$name])) {
131 1
                throw new DuplicateRouteException(
132 1
                    \sprintf('A route with the name "%s" already exists.', $name)
133
                );
134
            }
135
136 55
            $this->routes[$name] = $route;
137
138 55
            if (null !== $this->profiler) {
139
                $this->profiler->addProfile(new ProfileRoute($name, $route));
140
            }
141
        }
142 55
    }
143
144
    /**
145
     * Adds the given route(s) listener to the router
146
     *
147
     * @param RouteListenerInterface ...$listeners
148
     */
149 1
    public function addRouteListener(RouteListenerInterface ...$listeners): void
150
    {
151 1
        foreach ($listeners as $listener) {
152 1
            $this->listeners[] = $listener;
153
        }
154 1
    }
155
156
    /**
157
     * Adds parameters.
158
     *
159
     * This method implements a fluent interface.
160
     *
161
     * @param array<string,mixed> $parameters The parameters
162
     * @param int                 $type
163
     */
164 1
    public function addParameters(array $parameters, int $type = self::TYPE_REQUIREMENT): void
165
    {
166 1
        foreach ($parameters as $key => $regex) {
167 1
            if (self::TYPE_DEFAULT === $type) {
168 1
                $this->attributes[self::TYPE_DEFAULT] = [$key => $regex];
169
170 1
                continue;
171
            }
172
173 1
            $this->attributes[self::TYPE_REQUIREMENT] = [$key => $regex];
174
        }
175 1
    }
176
177
    /**
178
     * Gets allowed methods
179
     *
180
     * @return string[]
181
     */
182 1
    public function getAllowedMethods(): array
183
    {
184 1
        $methods = [];
185
186 1
        foreach ($this->routes as $route) {
187 1
            foreach ($route->getMethods() as $method) {
188 1
                $methods[$method] = true;
189
            }
190
        }
191
192 1
        return \array_keys($methods);
193
    }
194
195
    /**
196
     * Gets a route for the given name
197
     *
198
     * @param string $name
199
     *
200
     * @throws RouteNotFoundException
201
     *
202
     * @return RouteInterface
203
     */
204 5
    public function getRoute(string $name): RouteInterface
205
    {
206 5
        if (!isset($this->routes[$name])) {
207 2
            throw new RouteNotFoundException(\sprintf('No route found for the name "%s".', $name));
208
        }
209
210 3
        return $this->routes[$name];
211
    }
212
213
    /**
214
     * Generate a URI from the named route.
215
     *
216
     * Takes the named route and any parameters, and attempts to generate a
217
     * URI from it. Additional router-dependent query may be passed.
218
     *
219
     * Once there are no missing parameters in the URI we will encode
220
     * the URI and prepare it for returning to the user. If the URI is supposed to
221
     * be absolute, we will return it as-is. Otherwise we will remove the URL's root.
222
     *
223
     * @param string                       $routeName   route name
224
     * @param array<string,string>         $parameters  key => value option pairs to pass to the
225
     *                                                  router for purposes of generating a URI; takes precedence over options
226
     *                                                  present in route used to generate URI
227
     * @param array<int|string,int|string> $queryParams Optional query string parameters
228
     *
229
     * @throws UrlGenerationException if the route name is not known
230
     *                                or a parameter value does not match its regex
231
     *
232
     * @return UriInterface of fully qualified URL for named route
233
     */
234 3
    public function generateUri(string $routeName, array $parameters = [], array $queryParams = []): UriInterface
235
    {
236
        try {
237 3
            $route = $this->getRoute($routeName);
238 1
        } catch (RouteNotFoundException $e) {
239 1
            throw new UrlGenerationException(
240 1
                \sprintf(
241 1
                    'Unable to generate a URL for the named route "%s" as such route does not exist.',
242 1
                    $routeName
243
                ),
244 1
                404
245
            );
246
        }
247
248 2
        $prefix     = '.'; // Append missing "." at the beginning of the $uri.
249 2
        $createdUri = $this->matcher->buildPath($route, $parameters);
0 ignored issues
show
Unused Code introduced by
The assignment to $createdUri is dead and can be removed.
Loading history...
250
251
        // Making routing on sub-folders easier
252 2
        if (\strpos($createdUri = $this->matcher->buildPath($route, $parameters), '/') !== 0) {
253 2
            $prefix .= '/';
254
        }
255
256
        // Incase query is added to uri.
257 2
        if (!empty($queryParams)) {
258 1
            $createdUri .= '?' . \http_build_query($queryParams);
259
        }
260
261 2
        $createdUri = \rtrim(\strpos($createdUri, '://') !== false ? $createdUri : $prefix . $createdUri, '/');
262
263 2
        return $this->uriFactory->createUri($createdUri);
264
    }
265
266
    /**
267
     * Looks for a route that matches the given request
268
     *
269
     * @param ServerRequestInterface $request
270
     *
271
     * @throws MethodNotAllowedException
272
     * @throws UriHandlerException
273
     * @throws RouteNotFoundException
274
     *
275
     * @return RouteHandler
276
     */
277 48
    public function match(ServerRequestInterface &$request): RouteHandler
278
    {
279 48
        $requestUri  = $request->getUri();
280 48
        $basePath    = \dirname($request->getServerParams()['SCRIPT_NAME'] ?? '');
281 48
        $requestPath = \substr($requestUri->getPath(), \strlen($basePath)) ?: '/';
282
283 48
        if ('cli' === PHP_SAPI) {
284 48
            $requestPath = $requestUri->getPath();
285
        }
286
287
        // Get the request matching format.
288 48
        $route = $this->marshalMatchedRoute(
289
            [
290 48
                $request->getMethod(),
291 48
                $requestUri->getScheme(),
292 48
                $requestUri->getHost(),
293 48
                \rawurldecode(\strlen($requestPath) > 1 ? \rtrim($requestPath, '/') : $requestPath),
294
            ]
295
        );
296
297 44
        if ($route instanceof RouteInterface) {
298 42
            if (null !== $this->profiler) {
299
                $this->profiler->setMatched($route->getName());
300
            }
301 42
            $request = $request->withAttribute(Route::class, $route);
302
303 42
            return new RouteHandler($this->generateResponse($route), ($this->response)());
304
        }
305
306 2
        throw new RouteNotFoundException(
307 2
            \sprintf(
308 2
                'Unable to find the controller for path "%s". The route is wrongly configured.',
309 2
                $requestPath
310
            )
311
        );
312
    }
313
314
    /**
315
     * {@inheritDoc}
316
     */
317 22
    public function handle(ServerRequestInterface $request): ResponseInterface
318
    {
319
        // Get the Route Handler ready for dispatching
320 22
        $routingResults = $this->match($request);
321
322
        /** @var RouteInterface $route */
323 18
        $route = $request->getAttribute(Route::class);
324
325
        // Add Middlewares on route ...
326 18
        $pipeline = new RoutePipeline($this->resolver->getContainer());
327 18
        $pipeline->addMiddleware(...$route->getMiddlewares());
328
329 18
        if (\count($pipeline->getMiddlewares()) > 0) {
330
            // This middleware is in the priority map; but, this is the first middleware we have
331
            // encountered from the map thus far. We'll save its current index plus its index
332
            // from the priority map so we can compare against them on the next iterations.
333 6
            return $pipeline->process($request, $routingResults);
334
        }
335
336 12
        return $routingResults->handle($request);
337
    }
338
339
    /**
340
     * Generate the response so it can be served
341
     *
342
     * @param RouteInterface $route
343
     *
344
     * @return callable
345
     */
346 42
    private function generateResponse(RouteInterface $route): callable
347
    {
348 42
        return function (ServerRequestInterface $request, ResponseInterface $response) use ($route) {
349 35
            $handler   = $this->resolveController($request, $route);
350 35
            $arguments = [\get_class($request) => $request, \get_class($response) => $response];
351
352 35
            if ($handler instanceof ResponseInterface) {
353 11
                return $handler;
354
            }
355
356 24
            foreach ($this->listeners as $listener) {
357
                // Only allow default Invoker class
358 1
                if ($this->resolver instanceof Invoker) {
359 1
                    $listener->onRoute($request, $route, $this->resolver->getCallableResolver()->resolve($handler));
0 ignored issues
show
Bug introduced by
It seems like $handler can also be of type object; however, parameter $callable of DivineNii\Invoker\CallableResolver::resolve() does only seem to accept callable|string|string[], maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

359
                    $listener->onRoute($request, $route, $this->resolver->getCallableResolver()->resolve(/** @scrutinizer ignore-type */ $handler));
Loading history...
360
                }
361
            }
362
363
            try {
364 24
                return $this->resolver->call($handler, \array_merge($route->getArguments(), $arguments));
0 ignored issues
show
Bug introduced by
It seems like $handler can also be of type object; however, parameter $callable of DivineNii\Invoker\Interf...nvokerInterface::call() does only seem to accept callable|string|string[], maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

364
                return $this->resolver->call(/** @scrutinizer ignore-type */ $handler, \array_merge($route->getArguments(), $arguments));
Loading history...
365
            } finally {
366 24
                if (null !== $this->profiler) {
367 24
                    foreach ($this->profiler->getProfiles() as $profiler) {
368
                        $profiler->leave();
369
                    }
370
                }
371
            }
372 42
        };
373
    }
374
375
    /**
376
     * Marshals a route result based on the results of matching URL from set of routes.
377
     *
378
     * @param string[] $process
379
     *
380
     * @throws MethodNotAllowedException
381
     * @throws UriHandlerException
382
     *
383
     * @return null|RouteInterface
384
     */
385 48
    private function marshalMatchedRoute(array $process): ?RouteInterface
386
    {
387 48
        foreach ($this->routes as $route) {
388
            // Let's match the routes
389 48
            $match      = $this->matcher->compileRoute($this->mergeAttributes($route));
390 48
            $parameters = $hostParameters = [];
391
392
            // https://tools.ietf.org/html/rfc7231#section-6.5.5
393 48
            if (!$this->compareUri($match->getRegex(), $process[3], $parameters)) {
394 14
                continue;
395
            }
396
397 46
            $this->assertRoute($route, $match->getRegex(true), $hostParameters, $process);
398
399 42
            return $route->setArguments($this->mergeDefaults(
400 42
                \array_replace($parameters, $hostParameters) ?? $match->getVariables(),
401 42
                $route->getDefaults()
402
            ));
403
        }
404
405 2
        return null;
406
    }
407
408
    /**
409
     * Asserts the Route's method and domain scheme.
410
     *
411
     * @param RouteInterface           $route
412
     * @param string                   $domain
413
     * @param array<int|string,string> $parameters
414
     * @param array<int,mixed>         $attributes
415
     */
416 46
    private function assertRoute(RouteInterface $route, string $domain, array &$parameters, array $attributes): void
417
    {
418 46
        [$method, $scheme, $host, $path] = $attributes;
419 46
        $parameters                      = [];
420
421 46
        if (!$this->compareMethod($route->getMethods(), $method)) {
422 2
            throw new MethodNotAllowedException($route->getMethods(), $path, $method);
423
        }
424
425 44
        if (!$this->compareDomain($domain, $host, $parameters)) {
426 1
            throw new UriHandlerException(
427 1
                \sprintf('Unfortunately current domain "%s" is not allowed on requested uri [%s]', $host, $path),
428 1
                400
429
            );
430
        }
431
432 43
        if (!$this->compareScheme($route->getSchemes(), $scheme)) {
433 1
            throw new UriHandlerException(
434 1
                \sprintf('Unfortunately current scheme "%s" is not allowed on requested uri [%s]', $scheme, $path),
435 1
                400
436
            );
437
        }
438 42
    }
439
}
440