Passed
Push — master ( c3db73...9f07a5 )
by Anatoly
52s queued 11s
created

Router   B

Complexity

Total Complexity 45

Size/Duplication

Total Lines 466
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 18
Bugs 0 Features 1
Metric Value
wmc 45
eloc 107
c 18
b 0
f 1
dl 0
loc 466
ccs 110
cts 110
cp 1
rs 8.8

20 Methods

Rating   Name   Duplication   Size   Complexity  
A getEventDispatcher() 0 3 1
A getMatchedRoute() 0 3 1
A run() 0 25 3
A addHost() 0 3 1
A process() 0 8 2
A addPatterns() 0 4 2
A getRoute() 0 10 2
A generateUri() 0 7 1
A addMiddleware() 0 12 3
B match() 0 36 6
A getAllowedMethods() 0 10 3
A getMiddlewares() 0 3 1
A load() 0 4 2
A setEventDispatcher() 0 3 1
A getRoutes() 0 3 1
A getHosts() 0 3 1
A compareHosts() 0 15 6
A addRoute() 0 12 3
A handle() 0 20 3
A addHosts() 0 4 2

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 declare(strict_types=1);
2
3
/**
4
 * It's free open-source software released under the MIT License.
5
 *
6
 * @author Anatoly Fenric <[email protected]>
7
 * @copyright Copyright (c) 2018, Anatoly Fenric
8
 * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE
9
 * @link https://github.com/sunrise-php/http-router
10
 */
11
12
namespace Sunrise\Http\Router;
13
14
/**
15
 * Import classes
16
 */
17
use Fig\Http\Message\RequestMethodInterface;
18
use Psr\Http\Message\ResponseInterface;
19
use Psr\Http\Message\ServerRequestInterface;
20
use Psr\Http\Server\MiddlewareInterface;
21
use Psr\Http\Server\RequestHandlerInterface;
22
use Sunrise\Http\Router\Event\RouteEvent;
23
use Sunrise\Http\Router\Exception\InvalidArgumentException;
24
use Sunrise\Http\Router\Exception\MethodNotAllowedException;
25
use Sunrise\Http\Router\Exception\PageNotFoundException;
26
use Sunrise\Http\Router\Exception\RouteNotFoundException;
27
use Sunrise\Http\Router\Loader\LoaderInterface;
28
use Sunrise\Http\Router\RequestHandler\CallableRequestHandler;
29
use Sunrise\Http\Router\RequestHandler\QueueableRequestHandler;
30
use Symfony\Contracts\EventDispatcher\EventDispatcherInterface;
31
32
/**
33
 * Import functions
34
 */
35
use function array_flip;
36
use function array_keys;
37
use function array_values;
38
use function get_class;
39
use function spl_object_hash;
40
use function sprintf;
41
42
/**
43
 * Router
44
 */
45
class Router implements MiddlewareInterface, RequestHandlerInterface, RequestMethodInterface
46
{
47
48
    /**
49
     * Server Request attribute name for routing error instance
50
     *
51
     * @var string
52
     */
53
    public const ATTR_NAME_FOR_ROUTING_ERROR = '@routing-error';
54
55
    /**
56
     * Global patterns
57
     *
58
     * @var array<string, string>
59
     *
60
     * @since 2.9.0
61
     */
62
    public static $patterns = [
63
        '@slug' => '[0-9a-z-]+',
64
        '@uuid' => '[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}',
65
    ];
66
67
    /**
68
     * The router host table
69
     *
70
     * @var array<string, string[]>
71
     */
72
    private $hosts = [];
73
74
    /**
75
     * The router routes
76
     *
77
     * @var RouteInterface[]
78
     */
79
    private $routes = [];
80
81
    /**
82
     * The router middlewares
83
     *
84
     * @var MiddlewareInterface[]
85
     */
86
    private $middlewares = [];
87
88
    /**
89
     * The router's matched route
90
     *
91
     * @var RouteInterface|null
92
     */
93
    private $matchedRoute = null;
94
95
    /**
96
     * The router's event dispatcher
97
     *
98
     * @var EventDispatcherInterface|null
99
     *
100
     * @since 2.13.0
101
     */
102
    private $eventDispatcher = null;
103
104
    /**
105
     * Gets the router host table
106
     *
107
     * @return array
108
     *
109
     * @since 2.6.0
110
     */
111 3
    public function getHosts() : array
112
    {
113 3
        return $this->hosts;
114
    }
115
116
    /**
117
     * Gets the router routes
118
     *
119
     * @return RouteInterface[]
120
     */
121 7
    public function getRoutes() : array
122
    {
123 7
        return array_values($this->routes);
124
    }
125
126
    /**
127
     * Gets the router middlewares
128
     *
129
     * @return MiddlewareInterface[]
130
     */
131 11
    public function getMiddlewares() : array
132
    {
133 11
        return array_values($this->middlewares);
134
    }
135
136
    /**
137
     * Gets the router's matched route
138
     *
139
     * @return RouteInterface|null
140
     */
141 2
    public function getMatchedRoute() : ?RouteInterface
142
    {
143 2
        return $this->matchedRoute;
144
    }
145
146
    /**
147
     * Gets the router's event dispatcher
148
     *
149
     * @return EventDispatcherInterface|null
150
     *
151
     * @since 2.13.0
152
     */
153 1
    public function getEventDispatcher() : ?EventDispatcherInterface
154
    {
155 1
        return $this->eventDispatcher;
156
    }
157
158
    /**
159
     * Adds the given patterns to the router
160
     *
161
     * ```php
162
     * $router->addPatterns([
163
     *   '@digit' => '\d+',
164
     *   '@word' => '\w+',
165
     * ]);
166
     *
167
     * $route->setPath('/{foo<@digit>}/{bar<@word>}');
168
     * ```
169
     *
170
     * @param array<string, string> $patterns
171
     *
172
     * @return void
173
     *
174
     * @since 2.11.0
175
     */
176 2
    public function addPatterns(array $patterns) : void
177
    {
178 2
        foreach ($patterns as $alias => $pattern) {
179 2
            self::$patterns[$alias] = $pattern;
180
        }
181
    }
182
183
    /**
184
     * Adds the given aliases for hostnames to the router's host table
185
     *
186
     * ```php
187
     * $router->addHosts([
188
     *   'local' => ['127.0.0.1', 'localhost'],
189
     * ]);
190
     *
191
     * $route->setHost('local');
192
     * ```
193
     *
194
     * @param array<string, string[]> $hosts
195
     *
196
     * @return void
197
     *
198
     * @since 2.11.0
199
     */
200 2
    public function addHosts(array $hosts) : void
201
    {
202 2
        foreach ($hosts as $alias => $hostnames) {
203 2
            $this->addHost($alias, ...$hostnames);
204
        }
205
    }
206
207
    /**
208
     * Adds the given alias for hostname(s) to the router's host table
209
     *
210
     * @param string $alias
211
     * @param string ...$hostnames
212
     *
213
     * @return void
214
     *
215
     * @since 2.6.0
216
     */
217 4
    public function addHost(string $alias, string ...$hostnames) : void
218
    {
219 4
        $this->hosts[$alias] = $hostnames;
220
    }
221
222
    /**
223
     * Adds the given route(s) to the router
224
     *
225
     * @param RouteInterface ...$routes
226
     *
227
     * @return void
228
     *
229
     * @throws InvalidArgumentException
230
     *         if one of the given routes already exists.
231
     */
232 27
    public function addRoute(RouteInterface ...$routes) : void
233
    {
234 27
        foreach ($routes as $route) {
235 27
            $name = $route->getName();
236 27
            if (isset($this->routes[$name])) {
237 1
                throw new InvalidArgumentException(sprintf(
238
                    'The route "%s" already exists.',
239
                    $name
240
                ));
241
            }
242
243 27
            $this->routes[$name] = $route;
244
        }
245
    }
246
247
    /**
248
     * Adds the given middleware(s) to the router
249
     *
250
     * @param MiddlewareInterface ...$middlewares
251
     *
252
     * @return void
253
     *
254
     * @throws InvalidArgumentException
255
     *         if one of the given middlewares already exists.
256
     */
257 7
    public function addMiddleware(MiddlewareInterface ...$middlewares) : void
258
    {
259 7
        foreach ($middlewares as $middleware) {
260 7
            $hash = spl_object_hash($middleware);
261 7
            if (isset($this->middlewares[$hash])) {
262 1
                throw new InvalidArgumentException(sprintf(
263
                    'The middleware "%s" already exists.',
264 1
                    get_class($middleware)
265
                ));
266
            }
267
268 7
            $this->middlewares[$hash] = $middleware;
269
        }
270
    }
271
272
    /**
273
     * Sets the given event dispatcher to the router
274
     *
275
     * @param EventDispatcherInterface|null $eventDispatcher
276
     *
277
     * @return void
278
     *
279
     * @since 2.13.0
280
     */
281 3
    public function setEventDispatcher(?EventDispatcherInterface $eventDispatcher) : void
282
    {
283 3
        $this->eventDispatcher = $eventDispatcher;
284
    }
285
286
    /**
287
     * Gets allowed methods
288
     *
289
     * @return string[]
290
     */
291 1
    public function getAllowedMethods() : array
292
    {
293 1
        $methods = [];
294 1
        foreach ($this->routes as $route) {
295 1
            foreach ($route->getMethods() as $method) {
296 1
                $methods[$method] = true;
297
            }
298
        }
299
300 1
        return array_keys($methods);
301
    }
302
303
    /**
304
     * Gets a route for the given name
305
     *
306
     * @param string $name
307
     *
308
     * @return RouteInterface
309
     *
310
     * @throws RouteNotFoundException
311
     */
312 3
    public function getRoute(string $name) : RouteInterface
313
    {
314 3
        if (!isset($this->routes[$name])) {
315 1
            throw new RouteNotFoundException(sprintf(
316
                'No route found for the name "%s".',
317
                $name
318
            ));
319
        }
320
321 2
        return $this->routes[$name];
322
    }
323
324
    /**
325
     * Generates a URI for the given named route
326
     *
327
     * @param string $name
328
     * @param array $attributes
329
     * @param bool $strict
330
     *
331
     * @return string
332
     *
333
     * @throws RouteNotFoundException
334
     *         If the given named route wasn't found.
335
     *
336
     * @throws Exception\InvalidAttributeValueException
337
     *         It can be thrown in strict mode, if an attribute value is not valid.
338
     *
339
     * @throws Exception\MissingAttributeValueException
340
     *         If a required attribute value is not given.
341
     */
342 1
    public function generateUri(string $name, array $attributes = [], bool $strict = false) : string
343
    {
344 1
        $route = $this->getRoute($name);
345
346 1
        $attributes += $route->getAttributes();
347
348 1
        return path_build($route->getPath(), $attributes, $strict);
0 ignored issues
show
Bug introduced by
The function path_build was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

348
        return /** @scrutinizer ignore-call */ path_build($route->getPath(), $attributes, $strict);
Loading history...
349
    }
350
351
    /**
352
     * Looks for a route that matches the given request
353
     *
354
     * @param ServerRequestInterface $request
355
     *
356
     * @return RouteInterface
357
     *
358
     * @throws MethodNotAllowedException
359
     * @throws PageNotFoundException
360
     */
361 17
    public function match(ServerRequestInterface $request) : RouteInterface
362
    {
363 17
        $requestHost = $request->getUri()->getHost();
364 17
        $requestPath = $request->getUri()->getPath();
365 17
        $requestMethod = $request->getMethod();
366 17
        $allowedMethods = [];
367
368 17
        foreach ($this->routes as $route) {
369 17
            if (!$this->compareHosts($route->getHost(), $requestHost)) {
370 1
                continue;
371
            }
372
373
            // https://github.com/sunrise-php/http-router/issues/50
374
            // https://tools.ietf.org/html/rfc7231#section-6.5.5
375 17
            if (!path_match($route->getPath(), $requestPath, $attributes)) {
0 ignored issues
show
Bug introduced by
The function path_match was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

375
            if (!/** @scrutinizer ignore-call */ path_match($route->getPath(), $requestPath, $attributes)) {
Loading history...
Comprehensibility Best Practice introduced by
The variable $attributes seems to be never defined.
Loading history...
376 13
                continue;
377
            }
378
379 13
            $routeMethods = array_flip($route->getMethods());
380 13
            $allowedMethods += $routeMethods;
381
382 13
            if (!isset($routeMethods[$requestMethod])) {
383 4
                continue;
384
            }
385
386 9
            return $route->withAddedAttributes($attributes);
387
        }
388
389 9
        if (!empty($allowedMethods)) {
390 4
            throw new MethodNotAllowedException('Method Not Allowed', [
391
                'method' => $requestMethod,
392 4
                'allowed' => array_keys($allowedMethods),
393
            ]);
394
        }
395
396 5
        throw new PageNotFoundException('Page Not Found');
397
    }
398
399
    /**
400
     * Runs the router
401
     *
402
     * @param ServerRequestInterface $request
403
     *
404
     * @return ResponseInterface
405
     *
406
     * @since 2.8.0
407
     */
408 4
    public function run(ServerRequestInterface $request) : ResponseInterface
409
    {
410
        // lazy resolving of the given request...
411 4
        $routing = new CallableRequestHandler(function (ServerRequestInterface $request) : ResponseInterface {
412 4
            $route = $this->match($request);
413 2
            $this->matchedRoute = $route;
414
415 2
            if (isset($this->eventDispatcher)) {
416 1
                $event = new RouteEvent($route, $request);
417 1
                $this->eventDispatcher->dispatch($event, RouteEvent::NAME);
0 ignored issues
show
Unused Code introduced by
The call to Symfony\Contracts\EventD...erInterface::dispatch() has too many arguments starting with Sunrise\Http\Router\Event\RouteEvent::NAME. ( Ignorable by Annotation )

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

417
                $this->eventDispatcher->/** @scrutinizer ignore-call */ 
418
                                        dispatch($event, RouteEvent::NAME);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
Bug introduced by
The method dispatch() does not exist on null. ( Ignorable by Annotation )

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

417
                $this->eventDispatcher->/** @scrutinizer ignore-call */ 
418
                                        dispatch($event, RouteEvent::NAME);

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
418 1
                $request = $event->getRequest();
419
            }
420
421 2
            return $route->handle($request);
422
        });
423
424 4
        $middlewares = $this->getMiddlewares();
425 4
        if (empty($middlewares)) {
426 2
            return $routing->handle($request);
427
        }
428
429 2
        $handler = new QueueableRequestHandler($routing);
430 2
        $handler->add(...$middlewares);
431
432 2
        return $handler->handle($request);
433
    }
434
435
    /**
436
     * {@inheritdoc}
437
     */
438 9
    public function handle(ServerRequestInterface $request) : ResponseInterface
439
    {
440 9
        $route = $this->match($request);
441 5
        $this->matchedRoute = $route;
442
443 5
        if (isset($this->eventDispatcher)) {
444 1
            $event = new RouteEvent($route, $request);
445 1
            $this->eventDispatcher->dispatch($event, RouteEvent::NAME);
0 ignored issues
show
Unused Code introduced by
The call to Symfony\Contracts\EventD...erInterface::dispatch() has too many arguments starting with Sunrise\Http\Router\Event\RouteEvent::NAME. ( Ignorable by Annotation )

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

445
            $this->eventDispatcher->/** @scrutinizer ignore-call */ 
446
                                    dispatch($event, RouteEvent::NAME);

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
446 1
            $request = $event->getRequest();
447
        }
448
449 5
        $middlewares = $this->getMiddlewares();
450 5
        if (empty($middlewares)) {
451 3
            return $route->handle($request);
452
        }
453
454 2
        $handler = new QueueableRequestHandler($route);
455 2
        $handler->add(...$middlewares);
456
457 2
        return $handler->handle($request);
458
    }
459
460
    /**
461
     * {@inheritdoc}
462
     */
463 3
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
464
    {
465
        try {
466 3
            return $this->handle($request);
467 2
        } catch (MethodNotAllowedException|PageNotFoundException $e) {
468 2
            $request = $request->withAttribute(self::ATTR_NAME_FOR_ROUTING_ERROR, $e);
469
470 2
            return $handler->handle($request);
471
        }
472
    }
473
474
    /**
475
     * Loads routes through the given loaders
476
     *
477
     * @param LoaderInterface ...$loaders
478
     *
479
     * @return void
480
     */
481 3
    public function load(LoaderInterface ...$loaders) : void
482
    {
483 3
        foreach ($loaders as $loader) {
484 3
            $this->addRoute(...$loader->load()->all());
485
        }
486
    }
487
488
    /**
489
     * Compares the given route host and the given request host
490
     *
491
     * @param string|null $routeHost
492
     * @param string $requestHost
493
     *
494
     * @return bool
495
     */
496 17
    private function compareHosts(?string $routeHost, string $requestHost) : bool
497
    {
498 17
        if (null === $routeHost || $requestHost === $routeHost) {
499 17
            return true;
500
        }
501
502 1
        if (!empty($this->hosts[$routeHost])) {
503 1
            foreach ($this->hosts[$routeHost] as $hostname) {
504 1
                if ($hostname === $requestHost) {
505 1
                    return true;
506
                }
507
            }
508
        }
509
510 1
        return false;
511
    }
512
}
513