Passed
Push — master ( 65407b...cfe0c1 )
by Divine Niiquaye
02:21
created

Router::getRoutes()   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 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
rs 10
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 Closure;
21
use DivineNii\Invoker\Interfaces\InvokerInterface;
22
use DivineNii\Invoker\Invoker;
23
use Flight\Routing\Exceptions\DuplicateRouteException;
24
use Flight\Routing\Exceptions\MethodNotAllowedException;
25
use Flight\Routing\Exceptions\RouteNotFoundException;
26
use Flight\Routing\Exceptions\UriHandlerException;
27
use Flight\Routing\Exceptions\UrlGenerationException;
28
use Flight\Routing\Interfaces\RouteInterface;
29
use Flight\Routing\Interfaces\RouteMatcherInterface;
30
use Flight\Routing\Middlewares\MiddlewareDispatcher;
31
use Psr\Container\ContainerInterface;
32
use Psr\Http\Message\ResponseFactoryInterface;
33
use Psr\Http\Message\ResponseInterface;
34
use Psr\Http\Message\ServerRequestInterface;
35
use Psr\Http\Server\MiddlewareInterface;
36
use Psr\Http\Server\RequestHandlerInterface;
37
38
/**
39
 * Router
40
 */
41
class Router implements RequestHandlerInterface
42
{
43 1
    use RouteValidation;
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 MiddlewareDispatcher */
56
    private $pipeline;
57
58
    /** @var null|ContainerInterface */
59
    private $container;
60
61
    /** @var callable */
62
    private $response;
63
64
    /** @var string */
65
    private $namespace;
66
67
    /** @var RouteInterface[] */
68
    private $routes = [];
69
70
    /** @var array<string,mixed> */
71
    private $middlewares = [];
72
73
    /** @var array<int,array<string,mixed>> */
74
    private $attributes = [];
75
76 41
    public function __construct(
77
        ResponseFactoryInterface $responseFactory,
78
        ?RouteMatcherInterface $matcher = null,
79
        ?InvokerInterface $resolver = null,
80
        ?ContainerInterface $container = null
81
    ) {
82 41
        $this->container = $container;
83 41
        $this->pipeline  = new MiddlewareDispatcher($container);
84 41
        $this->resolver  = $resolver ?? new Invoker([], $container);
85 41
        $this->matcher   = $matcher ?? new Services\SimpleRouteMatcher();
86
87 41
        $this->response = [$responseFactory, 'createResponse'];
88 41
    }
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
     * Gets the router middlewares
102
     *
103
     * @return array<int,array<array<string,mixed>|MiddlewareInterface|string>>
104
     */
105 23
    public function getMiddlewares(): array
106
    {
107 23
        return \array_values($this->middlewares);
108
    }
109
110 4
    public function setNamespace(string $namespace): void
111
    {
112 4
        $this->namespace = $namespace;
113 4
    }
114
115
    /**
116
     * Adds the given route(s) to the router
117
     *
118
     * @param RouteInterface ...$routes
119
     *
120
     * @throws DuplicateRouteException
121
     */
122 37
    public function addRoute(RouteInterface ...$routes): void
123
    {
124 37
        foreach ($routes as $route) {
125 37
            $name = $route->getName();
126
127 37
            if (isset($this->routes[$name])) {
128 1
                throw new DuplicateRouteException(
129 1
                    \sprintf('A route with the name "%s" already exists.', $name)
130
                );
131
            }
132
133 37
            $this->routes[$name] = $route;
134
        }
135 37
    }
136
137
    /**
138
     * Adds the given middleware(s) to the router
139
     *
140
     * @param array<string,mixed>|MiddlewareInterface|string ...$middlewares
141
     *
142
     * @throws DuplicateRouteException
143
     */
144 14
    public function addMiddleware(...$middlewares): void
145
    {
146 14
        foreach ($middlewares as $middleware) {
147 14
            if (\is_array($middleware) || \is_callable($middleware)) {
148 2
                $this->pipeline->add($middleware);
149
150 2
                continue;
151
            }
152 14
            $hash = \is_object($middleware) ? \spl_object_hash($middleware) : \md5($middleware);
153
154 14
            if (isset($this->middlewares[$hash])) {
155 1
                throw new DuplicateRouteException(\sprintf('A middleware with the hash "%s" already exists.', $hash));
156
            }
157
158 14
            $this->middlewares[$hash] = $middleware;
159
        }
160 14
    }
161
162
    /**
163
     * Adds parameters.
164
     *
165
     * This method implements a fluent interface.
166
     *
167
     * @param array<string,mixed> $parameters The parameters
168
     * @param int                 $type
169
     */
170 1
    public function addParameters(array $parameters, int $type = self::TYPE_REQUIREMENT): void
171
    {
172 1
        foreach ($parameters as $key => $regex) {
173 1
            if (self::TYPE_DEFAULT === $type) {
174 1
                $this->attributes[self::TYPE_DEFAULT] = [$key => $regex];
175
176 1
                continue;
177
            }
178
179 1
            $this->attributes[self::TYPE_REQUIREMENT] = [$key => $regex];
180
        }
181 1
    }
182
183
    /**
184
     * Gets allowed methods
185
     *
186
     * @return string[]
187
     */
188 1
    public function getAllowedMethods(): array
189
    {
190 1
        $methods = [];
191
192 1
        foreach ($this->routes as $route) {
193 1
            foreach ($route->getMethods() as $method) {
194 1
                $methods[$method] = true;
195
            }
196
        }
197
198 1
        return \array_keys($methods);
199
    }
200
201
    /**
202
     * Gets a route for the given name
203
     *
204
     * @param string $name
205
     *
206
     * @throws RouteNotFoundException
207
     *
208
     * @return RouteInterface
209
     */
210 5
    public function getRoute(string $name): RouteInterface
211
    {
212 5
        if (!isset($this->routes[$name])) {
213 2
            throw new RouteNotFoundException(\sprintf('No route found for the name "%s".', $name));
214
        }
215
216 3
        return $this->routes[$name];
217
    }
218
219
    /**
220
     * Generate a URI from the named route.
221
     *
222
     * Takes the named route and any parameters, and attempts to generate a
223
     * URI from it. Additional router-dependent query may be passed.
224
     *
225
     * Once there are no missing parameters in the URI we will encode
226
     * the URI and prepare it for returning to the user. If the URI is supposed to
227
     * be absolute, we will return it as-is. Otherwise we will remove the URL's root.
228
     *
229
     * @param string                       $routeName   route name
230
     * @param array<string,string>         $parameters  key => value option pairs to pass to the
231
     *                                                  router for purposes of generating a URI; takes precedence over options
232
     *                                                  present in route used to generate URI
233
     * @param array<int|string,int|string> $queryParams Optional query string parameters
234
     *
235
     * @throws UrlGenerationException if the route name is not known
236
     *                                or a parameter value does not match its regex
237
     *
238
     * @return string of fully qualified URL for named route
239
     */
240 3
    public function generateUri(string $routeName, array $parameters = [], array $queryParams = []): string
241
    {
242
        try {
243 3
            $route = $this->getRoute($routeName);
244 1
        } catch (RouteNotFoundException $e) {
245 1
            throw new UrlGenerationException(
246 1
                \sprintf(
247 1
                    'Unable to generate a URL for the named route "%s" as such route does not exist.',
248 1
                    $routeName
249
                ),
250 1
                404
251
            );
252
        }
253
254 2
        $prefix     = '.'; // Append missing "." at the beginning of the $uri.
255 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...
256
257
        // Making routing on sub-folders easier
258 2
        if (\strpos($createdUri = $this->matcher->buildPath($route, $parameters), '/') !== 0) {
259 2
            $prefix .= '/';
260
        }
261
262
        // Incase query is added to uri.
263 2
        if (!empty($queryParams)) {
264 1
            $createdUri .= '?' . \http_build_query($queryParams);
265
        }
266
267 2
        return \rtrim(\strpos($createdUri, '://') !== false ? $createdUri : $prefix . $createdUri, '/');
268
    }
269
270
    /**
271
     * Looks for a route that matches the given request
272
     *
273
     * @param ServerRequestInterface $request
274
     *
275
     * @throws MethodNotAllowedException
276
     * @throws UriHandlerException
277
     * @throws RouteNotFoundException
278
     *
279
     * @return RouteHandler
280
     */
281 30
    public function match(ServerRequestInterface &$request): RouteHandler
282
    {
283 30
        $requestUri  = $request->getUri();
284 30
        $basePath    = \dirname($request->getServerParams()['SCRIPT_NAME'] ?? '');
285 30
        $requestPath = \substr($requestUri->getPath(), \strlen($basePath)) ?? '/';
286
287
        // Get the request matching format.
288 30
        $route = $this->marshalMatchedRoute(
289
            [
290 30
                $request->getMethod(),
291 30
                $requestUri->getScheme(),
292 30
                $requestUri->getHost(),
293 30
                \rawurldecode($requestPath),
294
            ]
295
        );
296 24
        $request = $request->withAttribute(Route::class, $route);
297
298 24
        return new RouteHandler($this->generateResponse($route), ($this->response)());
299
    }
300
301
    /**
302
     * {@inheritDoc}
303
     */
304 26
    public function handle(ServerRequestInterface $request): ResponseInterface
305
    {
306 26
        $routingResults = $this->match($request);
307 22
        $route          = $request->getAttribute(Route::class);
308
309
        // Get all available middlewares
310 22
        $middlewares = $route instanceof Route ? $route->getMiddlewares() : [];
311
312 22
        if (\count($middlewares = $this->mergeMiddlewares($middlewares)) > 0) {
313 14
            $middleware = $this->pipeline->pipeline(...$middlewares);
314
315
            // This middleware is in the priority map; but, this is the first middleware we have
316
            // encountered from the map thus far. We'll save its current index plus its index
317
            // from the priority map so we can compare against them on the next iterations.
318 13
            return $middleware->process($request, $routingResults);
319
        }
320
321 8
        return $routingResults->handle($request);
322
    }
323
324
    /**
325
     * Merge route middlewares with Router Middlewares.
326
     *
327
     * @param mixed[] $middlewares
328
     *
329
     * @return MiddlewareInterface[]|mixed[]
330
     */
331 22
    private function mergeMiddlewares(array $middlewares): array
332
    {
333 22
        $this->pipeline->add(...$this->getMiddlewares());
334
335 22
        return \array_merge($middlewares, $this->pipeline->getMiddlewareStack());
336
    }
337
338
    /**
339
     * Merge Router attributes in route default and patterns.
340
     *
341
     * @param RouteInterface $route
342
     *
343
     * @return RouteInterface
344
     */
345 30
    private function mergeAttributes(RouteInterface $route): RouteInterface
346
    {
347 30
        foreach ($this->attributes as $type => $attributes) {
348 1
            if (self::TYPE_DEFAULT === $type) {
349 1
                $route->setDefaults($attributes);
350
351 1
                continue;
352
            }
353
354 1
            $route->setPatterns($attributes);
355
        }
356
357 30
        return $route;
358
    }
359
360
    /**
361
     * Generate the response so it can be served
362
     *
363
     * @param RouteInterface $route
364
     *
365
     * @return callable
366
     */
367 24
    private function generateResponse(RouteInterface $route): callable
368
    {
369 24
        return function (ServerRequestInterface $request, ResponseInterface $response) use ($route) {
370 21
            $controller = $route->getController();
371
372
            // Disable or enable HTTP request method prefix for action.
373 21
            if (is_array($controller) && false !== strpos($route->getName(), '__restful')) {
374 3
                $controller[1] = \strtolower($request->getMethod()) . \ucfirst($controller[1]);
375
            }
376
377 21
            $handler   = $this->resolveController($controller);
378 21
            $arguments = [ServerRequestInterface::class => $request, ResponseInterface::class => $response];
379
380
            // For a class that implements RequestHandlerInterface, we will call handle()
381
            // if no method has been specified explicitly
382 21
            if (\is_string($handler) && \is_a($handler, RequestHandlerInterface::class, true)) {
383 11
                $handler = [$handler, 'handle'];
384
            }
385
386
            // If controller is instance of RequestHandlerInterface
387 21
            if ($handler instanceof RequestHandlerInterface) {
388 2
                return $handler->handle($request);
389
            }
390
391 19
            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

391
            return $this->resolver->call(/** @scrutinizer ignore-type */ $handler, \array_merge($route->getArguments(), $arguments));
Loading history...
392 24
        };
393
    }
394
395
    /**
396
     * Marshals a route result based on the results of matching URL from set of routes.
397
     *
398
     * @param string[] $process
399
     *
400
     * @throws MethodNotAllowedException
401
     * @throws UriHandlerException
402
     * @throws RouteNotFoundException
403
     *
404
     * @return RouteInterface
405
     */
406 30
    private function marshalMatchedRoute(array $process): RouteInterface
407
    {
408 30
        foreach ($this->routes as $route) {
409
            // Let's match the routes
410 30
            $match      = $this->matcher->compileRoute($this->mergeAttributes($route));
411 30
            $parameters = $hostParameters = [];
412
413
            // https://tools.ietf.org/html/rfc7231#section-6.5.5
414 30
            if (!$this->compareUri($match->getRegex(), $process[3], $parameters)) {
415 10
                continue;
416
            }
417
418 28
            $this->assertRoute($route, $match->getRegex(true), $hostParameters, $process);
419
420 24
            return $route->setArguments($this->mergeDefaults(
421 24
                \array_replace($parameters, $hostParameters) ?? $match->getVariables(),
422 24
                $route->getDefaults()
423
            ));
424
        }
425
426 2
        throw new  RouteNotFoundException(
427 2
            \sprintf(
428 2
                'Unable to find the controller for path "%s". The route is wrongly configured.',
429 2
                $process[3]
430
            )
431
        );
432
    }
433
434
    /**
435
     * Asserts the Route's method and domain scheme.
436
     *
437
     * @param RouteInterface           $route
438
     * @param string                   $domain
439
     * @param array<int|string,string> $parameters
440
     * @param array<int,mixed>         $attributes
441
     */
442 28
    private function assertRoute(RouteInterface $route, string $domain, array &$parameters, array $attributes): void
443
    {
444 28
        [$method, $scheme, $host, $path] = $attributes;
445 28
        $parameters                      = [];
446
447 28
        if (!$this->compareMethod($route->getMethods(), $method)) {
448 2
            throw new MethodNotAllowedException($route->getMethods(), $path, $method);
449
        }
450
451 26
        if (!$this->compareDomain($domain, $host, $parameters)) {
452 1
            throw new UriHandlerException(
453 1
                \sprintf('Unfortunately current domain "%s" is not allowed on requested uri [%s]', $host, $path),
454 1
                400
455
            );
456
        }
457
458 25
        if (!$this->compareScheme($route->getSchemes(), $scheme)) {
459 1
            throw new UriHandlerException(
460 1
                \sprintf('Unfortunately current scheme "%s" is not allowed on requested uri [%s]', $scheme, $path),
461 1
                400
462
            );
463
        }
464 24
    }
465
466
    /**
467
     * @param callable|object|string|string[] $controller
468
     *
469
     * @return callable|object|string|string[]
470
     */
471 21
    private function resolveController($controller)
472
    {
473 21
        if (null !== $this->namespace && (\is_string($controller) || !$controller instanceof Closure)) {
474
            if (
475 4
                \is_string($controller) &&
476 4
                !\class_exists($controller) &&
477 4
                false === \stripos($controller, $this->namespace)
478
            ) {
479 3
                $controller = \is_callable($controller) ? $controller : $this->namespace . $controller;
480
            }
481
482 4
            if (\is_array($controller) && (!\is_object($controller[0]) && !\class_exists($controller[0]))) {
483 1
                $controller[0] = $this->namespace . $controller[0];
484
            }
485
        }
486
487 21
        return $controller;
488
    }
489
}
490