Passed
Push — master ( d0f9e3...c3e906 )
by Divine Niiquaye
08:38
created

Router::loadAnnotation()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 3

Importance

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

382
                $listener->onRoute($request, $route, $this->resolver->getCallableResolver()->resolve(/** @scrutinizer ignore-type */ $handler));
Loading history...
383
            }
384
385
            try {
386 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

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