Passed
Push — master ( 13f5d0...38311f )
by Anatoly
04:34 queued 02:11
created

Router::hasRoute()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 1
Metric Value
eloc 1
c 1
b 0
f 1
dl 0
loc 3
ccs 2
cts 2
cp 1
rs 10
cc 1
nc 1
nop 1
crap 1
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 Sunrise\Http\Router\path_build;
0 ignored issues
show
introduced by
The function Sunrise\Http\Router\path_build was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
36
use function Sunrise\Http\Router\path_match;
0 ignored issues
show
introduced by
The function Sunrise\Http\Router\path_match was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
37
use function array_keys;
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's host table
69
     *
70
     * @var array<string, string[]>
71
     *      The key is a host alias and values are hostnames.
72
     */
73
    private $hosts = [];
74
75
    /**
76
     * The router's routes
77
     *
78
     * @var array<string, RouteInterface>
79
     *      The key is a route name.
80
     */
81
    private $routes = [];
82
83
    /**
84
     * The router's middlewares
85
     *
86
     * @var array<string, MiddlewareInterface>
87
     *      The keys is an object hash.
88
     */
89
    private $middlewares = [];
90
91
    /**
92
     * The router's matched route
93
     *
94
     * @var RouteInterface|null
95
     */
96
    private $matchedRoute = null;
97
98
    /**
99
     * The router's event dispatcher
100
     *
101
     * @var EventDispatcherInterface|null
102
     *
103
     * @since 2.13.0
104
     */
105
    private $eventDispatcher = null;
106
107
    /**
108
     * Gets the router's host table
109
     *
110
     * @return array<string, string[]>
111
     *
112
     * @since 2.6.0
113
     */
114 3
    public function getHosts() : array
115
    {
116 3
        return $this->hosts;
117
    }
118
119
    /**
120
     * Resolves the given hostname
121
     *
122
     * @param string $hostname
123
     *
124
     * @return string|null
125
     *
126
     * @since 2.14.0
127
     */
128 19
    public function resolveHostname(string $hostname) : ?string
129
    {
130 19
        foreach ($this->hosts as $alias => $hostnames) {
131 3
            foreach ($hostnames as $value) {
132 3
                if ($hostname === $value) {
133 3
                    return $alias;
134
                }
135
            }
136
        }
137
138 19
        return null;
139
    }
140
141
    /**
142
     * Gets all routes
143
     *
144
     * @return RouteInterface[]
145
     */
146 6
    public function getRoutes() : array
147
    {
148 6
        $routes = [];
149 6
        foreach ($this->routes as $route) {
150 4
            $routes[] = $route;
151
        }
152
153 6
        return $routes;
154
    }
155
156
    /**
157
     * Gets routes by the given hostname
158
     *
159
     * @param string $hostname
160
     *
161
     * @return RouteInterface[]
162
     *
163
     * @since 2.14.0
164
     */
165 18
    public function getRoutesByHostname(string $hostname) : array
166
    {
167
        // the hostname's alias.
168 18
        $alias = $this->resolveHostname($hostname);
169
170 18
        $routes = [];
171 18
        foreach ($this->routes as $route) {
172 18
            $host = $route->getHost();
173 18
            if ($host === null || $host === $alias) {
174 18
                $routes[] = $route;
175
            }
176
        }
177
178 18
        return $routes;
179
    }
180
181
    /**
182
     * Gets the router's middlewares
183
     *
184
     * @return MiddlewareInterface[]
185
     */
186 11
    public function getMiddlewares() : array
187
    {
188 11
        $middlewares = [];
189 11
        foreach ($this->middlewares as $middleware) {
190 6
            $middlewares[] = $middleware;
191
        }
192
193 11
        return $middlewares;
194
    }
195
196
    /**
197
     * Gets the router's matched route
198
     *
199
     * @return RouteInterface|null
200
     */
201 2
    public function getMatchedRoute() : ?RouteInterface
202
    {
203 2
        return $this->matchedRoute;
204
    }
205
206
    /**
207
     * Gets the router's event dispatcher
208
     *
209
     * @return EventDispatcherInterface|null
210
     *
211
     * @since 2.13.0
212
     */
213 2
    public function getEventDispatcher() : ?EventDispatcherInterface
214
    {
215 2
        return $this->eventDispatcher;
216
    }
217
218
    /**
219
     * Adds the given patterns to the router
220
     *
221
     * ```php
222
     * $router->addPatterns([
223
     *   '@digit' => '\d+',
224
     *   '@word' => '\w+',
225
     * ]);
226
     * ```
227
     *
228
     * ```php
229
     * $route->setPath('/{foo<@digit>}/{bar<@word>}');
230
     * ```
231
     *
232
     * @param array<string, string> $patterns
233
     *
234
     * @return void
235
     *
236
     * @since 2.11.0
237
     */
238 2
    public function addPatterns(array $patterns) : void
239
    {
240 2
        foreach ($patterns as $alias => $pattern) {
241 2
            self::$patterns[$alias] = $pattern;
242
        }
243
    }
244
245
    /**
246
     * Adds aliases for hostnames to the router's host table
247
     *
248
     * ```php
249
     * $router->addHosts([
250
     *   'local' => ['127.0.0.1', 'localhost'],
251
     * ]);
252
     * ```
253
     *
254
     * ```php
255
     * // will be available at 127.0.0.1
256
     * $route->setHost('local');
257
     * ```
258
     *
259
     * @param array<string, string[]> $hosts
260
     *
261
     * @return void
262
     *
263
     * @since 2.11.0
264
     */
265 2
    public function addHosts(array $hosts) : void
266
    {
267 2
        foreach ($hosts as $alias => $hostnames) {
268 2
            $this->addHost($alias, ...$hostnames);
269
        }
270
    }
271
272
    /**
273
     * Adds the given alias for the given hostname(s) to the router's host table
274
     *
275
     * @param string $alias
276
     * @param string ...$hostnames
277
     *
278
     * @return void
279
     *
280
     * @since 2.6.0
281
     */
282 6
    public function addHost(string $alias, string ...$hostnames) : void
283
    {
284 6
        $this->hosts[$alias] = $hostnames;
285
    }
286
287
    /**
288
     * Adds the given route(s) to the router
289
     *
290
     * @param RouteInterface ...$routes
291
     *
292
     * @return void
293
     *
294
     * @throws InvalidArgumentException
295
     *         if one of the given routes already exists.
296
     */
297 28
    public function addRoute(RouteInterface ...$routes) : void
298
    {
299 28
        foreach ($routes as $route) {
300 28
            $name = $route->getName();
301 28
            if (isset($this->routes[$name])) {
302 1
                throw new InvalidArgumentException(sprintf(
303
                    'The route "%s" already exists.',
304
                    $name
305
                ));
306
            }
307
308 28
            $this->routes[$name] = $route;
309
        }
310
    }
311
312
    /**
313
     * Adds the given middleware(s) to the router
314
     *
315
     * @param MiddlewareInterface ...$middlewares
316
     *
317
     * @return void
318
     *
319
     * @throws InvalidArgumentException
320
     *         if one of the given middlewares already exists.
321
     */
322 7
    public function addMiddleware(MiddlewareInterface ...$middlewares) : void
323
    {
324 7
        foreach ($middlewares as $middleware) {
325 7
            $hash = spl_object_hash($middleware);
326 7
            if (isset($this->middlewares[$hash])) {
327 1
                throw new InvalidArgumentException(sprintf(
328
                    'The middleware "%s" already exists.',
329 1
                    get_class($middleware)
330
                ));
331
            }
332
333 7
            $this->middlewares[$hash] = $middleware;
334
        }
335
    }
336
337
    /**
338
     * Sets the given event dispatcher to the router
339
     *
340
     * @param EventDispatcherInterface|null $eventDispatcher
341
     *
342
     * @return void
343
     *
344
     * @since 2.13.0
345
     */
346 4
    public function setEventDispatcher(?EventDispatcherInterface $eventDispatcher) : void
347
    {
348 4
        $this->eventDispatcher = $eventDispatcher;
349
    }
350
351
    /**
352
     * Gets allowed methods
353
     *
354
     * @return string[]
355
     */
356 1
    public function getAllowedMethods() : array
357
    {
358 1
        $methods = [];
359 1
        foreach ($this->routes as $route) {
360 1
            foreach ($route->getMethods() as $method) {
361 1
                $methods[$method] = true;
362
            }
363
        }
364
365 1
        return array_keys($methods);
366
    }
367
368
    /**
369
     * Checks if a route exists by the given name
370
     *
371
     * @return bool
372
     */
373 1
    public function hasRoute(string $name) : bool
374
    {
375 1
        return isset($this->routes[$name]);
376
    }
377
378
    /**
379
     * Gets a route for the given name
380
     *
381
     * @param string $name
382
     *
383
     * @return RouteInterface
384
     *
385
     * @throws RouteNotFoundException
386
     */
387 3
    public function getRoute(string $name) : RouteInterface
388
    {
389 3
        if (!isset($this->routes[$name])) {
390 1
            throw new RouteNotFoundException(sprintf(
391
                'No route found for the name "%s".',
392
                $name
393
            ));
394
        }
395
396 2
        return $this->routes[$name];
397
    }
398
399
    /**
400
     * Generates a URI for the given named route
401
     *
402
     * @param string $name
403
     * @param array $attributes
404
     * @param bool $strict
405
     *
406
     * @return string
407
     *
408
     * @throws RouteNotFoundException
409
     *         If the given named route wasn't found.
410
     *
411
     * @throws Exception\InvalidAttributeValueException
412
     *         It can be thrown in strict mode, if an attribute value is not valid.
413
     *
414
     * @throws Exception\MissingAttributeValueException
415
     *         If a required attribute value is not given.
416
     */
417 1
    public function generateUri(string $name, array $attributes = [], bool $strict = false) : string
418
    {
419 1
        $route = $this->getRoute($name);
420
421 1
        $attributes += $route->getAttributes();
422
423 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

423
        return /** @scrutinizer ignore-call */ path_build($route->getPath(), $attributes, $strict);
Loading history...
424
    }
425
426
    /**
427
     * Looks for a route that matches the given request
428
     *
429
     * @param ServerRequestInterface $request
430
     *
431
     * @return RouteInterface
432
     *
433
     * @throws MethodNotAllowedException
434
     * @throws PageNotFoundException
435
     */
436 17
    public function match(ServerRequestInterface $request) : RouteInterface
437
    {
438 17
        $currentHost = $request->getUri()->getHost();
439 17
        $currentPath = $request->getUri()->getPath();
440 17
        $currentMethod = $request->getMethod();
441 17
        $allowedMethods = [];
442
443 17
        $routes = $this->getRoutesByHostname($currentHost);
444
445 17
        foreach ($routes as $route) {
446
            // https://github.com/sunrise-php/http-router/issues/50
447
            // https://tools.ietf.org/html/rfc7231#section-6.5.5
448 17
            if (!path_match($route->getPath(), $currentPath, $attributes)) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $attributes seems to be never defined.
Loading history...
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

448
            if (!/** @scrutinizer ignore-call */ path_match($route->getPath(), $currentPath, $attributes)) {
Loading history...
449 13
                continue;
450
            }
451
452 13
            $routeMethods = [];
453 13
            foreach ($route->getMethods() as $routeMethod) {
454 13
                $routeMethods[$routeMethod] = true;
455 13
                $allowedMethods[$routeMethod] = true;
456
            }
457
458 13
            if (!isset($routeMethods[$currentMethod])) {
459 4
                continue;
460
            }
461
462 9
            return $route->withAddedAttributes($attributes);
463
        }
464
465 8
        if (!empty($allowedMethods)) {
466 4
            throw new MethodNotAllowedException('Method Not Allowed', [
467
                'method' => $currentMethod,
468 4
                'allowed' => array_keys($allowedMethods),
469
            ]);
470
        }
471
472 4
        throw new PageNotFoundException('Page Not Found');
473
    }
474
475
    /**
476
     * Runs the router
477
     *
478
     * @param ServerRequestInterface $request
479
     *
480
     * @return ResponseInterface
481
     *
482
     * @since 2.8.0
483
     */
484 4
    public function run(ServerRequestInterface $request) : ResponseInterface
485
    {
486
        // lazy resolving of the given request...
487 4
        $routing = new CallableRequestHandler(function (ServerRequestInterface $request) : ResponseInterface {
488 4
            $route = $this->match($request);
489 2
            $this->matchedRoute = $route;
490
491 2
            if (isset($this->eventDispatcher)) {
492 1
                $event = new RouteEvent($route, $request);
493
494
                /**
495
                 * @psalm-suppress TooManyArguments
496
                 */
497 1
                $this->eventDispatcher->dispatch($event, RouteEvent::NAME);
0 ignored issues
show
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

497
                $this->eventDispatcher->/** @scrutinizer ignore-call */ 
498
                                        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...
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

497
                $this->eventDispatcher->/** @scrutinizer ignore-call */ 
498
                                        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...
498
499 1
                $request = $event->getRequest();
500
            }
501
502 2
            return $route->handle($request);
503
        });
504
505 4
        $middlewares = $this->getMiddlewares();
506 4
        if (empty($middlewares)) {
507 2
            return $routing->handle($request);
508
        }
509
510 2
        $handler = new QueueableRequestHandler($routing);
511 2
        $handler->add(...$middlewares);
512
513 2
        return $handler->handle($request);
514
    }
515
516
    /**
517
     * {@inheritdoc}
518
     */
519 9
    public function handle(ServerRequestInterface $request) : ResponseInterface
520
    {
521 9
        $route = $this->match($request);
522 5
        $this->matchedRoute = $route;
523
524 5
        if (isset($this->eventDispatcher)) {
525 1
            $event = new RouteEvent($route, $request);
526
527
            /**
528
             * @psalm-suppress TooManyArguments
529
             */
530 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

530
            $this->eventDispatcher->/** @scrutinizer ignore-call */ 
531
                                    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...
531
532 1
            $request = $event->getRequest();
533
        }
534
535 5
        $middlewares = $this->getMiddlewares();
536 5
        if (empty($middlewares)) {
537 3
            return $route->handle($request);
538
        }
539
540 2
        $handler = new QueueableRequestHandler($route);
541 2
        $handler->add(...$middlewares);
542
543 2
        return $handler->handle($request);
544
    }
545
546
    /**
547
     * {@inheritdoc}
548
     */
549 3
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface
550
    {
551
        try {
552 3
            return $this->handle($request);
553 2
        } catch (MethodNotAllowedException|PageNotFoundException $e) {
554 2
            $request = $request->withAttribute(self::ATTR_NAME_FOR_ROUTING_ERROR, $e);
555
556 2
            return $handler->handle($request);
557
        }
558
    }
559
560
    /**
561
     * Loads routes through the given loaders
562
     *
563
     * @param LoaderInterface ...$loaders
564
     *
565
     * @return void
566
     */
567 3
    public function load(LoaderInterface ...$loaders) : void
568
    {
569 3
        foreach ($loaders as $loader) {
570 3
            $this->addRoute(...$loader->load()->all());
571
        }
572
    }
573
}
574