Passed
Push — master ( b1a631...fb75b4 )
by Divine Niiquaye
03:00
created

Router::marshalMatchedRoute()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 21
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3

Importance

Changes 2
Bugs 0 Features 0
Metric Value
cc 3
eloc 10
c 2
b 0
f 0
nc 3
nop 1
dl 0
loc 21
ccs 11
cts 11
cp 1
crap 3
rs 9.9332
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
     * Get the profiled routes
215
     *
216
     * @return null|ProfileRoute
217
     */
218
    public function getProfile(): ?ProfileRoute
219
    {
220
        return $this->profiler;
221
    }
222
223
    /**
224
     * Generate a URI from the named route.
225
     *
226
     * Takes the named route and any parameters, and attempts to generate a
227
     * URI from it. Additional router-dependent query may be passed.
228
     *
229
     * Once there are no missing parameters in the URI we will encode
230
     * the URI and prepare it for returning to the user. If the URI is supposed to
231
     * be absolute, we will return it as-is. Otherwise we will remove the URL's root.
232
     *
233
     * @param string                       $routeName   route name
234
     * @param array<string,string>         $parameters  key => value option pairs to pass to the
235
     *                                                  router for purposes of generating a URI; takes precedence over options
236
     *                                                  present in route used to generate URI
237
     * @param array<int|string,int|string> $queryParams Optional query string parameters
238
     *
239
     * @throws UrlGenerationException if the route name is not known
240
     *                                or a parameter value does not match its regex
241
     *
242
     * @return UriInterface of fully qualified URL for named route
243
     */
244 3
    public function generateUri(string $routeName, array $parameters = [], array $queryParams = []): UriInterface
245
    {
246
        try {
247 3
            $route = $this->getRoute($routeName);
248 1
        } catch (RouteNotFoundException $e) {
249 1
            throw new UrlGenerationException(
250 1
                \sprintf(
251 1
                    'Unable to generate a URL for the named route "%s" as such route does not exist.',
252 1
                    $routeName
253
                ),
254 1
                404
255
            );
256
        }
257
258 2
        $prefix     = '.'; // Append missing "." at the beginning of the $uri.
259 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...
260
261
        // Making routing on sub-folders easier
262 2
        if (\strpos($createdUri = $this->matcher->buildPath($route, $parameters), '/') !== 0) {
263 2
            $prefix .= '/';
264
        }
265
266
        // Incase query is added to uri.
267 2
        if (!empty($queryParams)) {
268 1
            $createdUri .= '?' . \http_build_query($queryParams);
269
        }
270
271 2
        $createdUri = \rtrim(\strpos($createdUri, '://') !== false ? $createdUri : $prefix . $createdUri, '/');
272
273 2
        return $this->uriFactory->createUri($createdUri);
274
    }
275
276
    /**
277
     * Looks for a route that matches the given request
278
     *
279
     * @param ServerRequestInterface $request
280
     *
281
     * @throws MethodNotAllowedException
282
     * @throws UriHandlerException
283
     * @throws RouteNotFoundException
284
     *
285
     * @return RouteHandler
286
     */
287 48
    public function match(ServerRequestInterface &$request): RouteHandler
288
    {
289 48
        $requestUri  = $request->getUri();
290 48
        $basePath    = \dirname($request->getServerParams()['SCRIPT_NAME'] ?? '');
291 48
        $requestPath = \substr($requestUri->getPath(), \strlen($basePath)) ?: '/';
292
293 48
        if ('cli' === PHP_SAPI) {
294 48
            $requestPath = $requestUri->getPath();
295
        }
296
297
        // Get the request matching format.
298 48
        $route = $this->marshalMatchedRoute(
299
            [
300 48
                $request->getMethod(),
301 48
                $requestUri->getScheme(),
302 48
                $requestUri->getHost(),
303 48
                \rawurldecode(\strlen($requestPath) > 1 ? \rtrim($requestPath, '/') : $requestPath),
304
            ]
305
        );
306
307 44
        if ($route instanceof RouteInterface) {
308 42
            if (null !== $this->profiler) {
309
                $this->profiler->setMatched($route->getName());
310
            }
311 42
            $request = $request->withAttribute(Route::class, $route);
312
313 42
            return new RouteHandler($this->generateResponse($route), ($this->response)());
314
        }
315
316 2
        throw new RouteNotFoundException(
317 2
            \sprintf(
318 2
                'Unable to find the controller for path "%s". The route is wrongly configured.',
319 2
                $requestPath
320
            )
321
        );
322
    }
323
324
    /**
325
     * {@inheritDoc}
326
     */
327 22
    public function handle(ServerRequestInterface $request): ResponseInterface
328
    {
329
        // Get the Route Handler ready for dispatching
330 22
        $routingResults = $this->match($request);
331
332
        /** @var RouteInterface $route */
333 18
        $route = $request->getAttribute(Route::class);
334
335
        // Add Middlewares on route ...
336 18
        $pipeline = new RoutePipeline($this->resolver->getContainer());
337 18
        $pipeline->addMiddleware(...$route->getMiddlewares());
338
339 18
        if (\count($pipeline->getMiddlewares()) > 0) {
340
            // This middleware is in the priority map; but, this is the first middleware we have
341
            // encountered from the map thus far. We'll save its current index plus its index
342
            // from the priority map so we can compare against them on the next iterations.
343 6
            return $pipeline->process($request, $routingResults);
344
        }
345
346 12
        return $routingResults->handle($request);
347
    }
348
349
    /**
350
     * Generate the response so it can be served
351
     *
352
     * @param RouteInterface $route
353
     *
354
     * @return callable
355
     */
356 42
    private function generateResponse(RouteInterface $route): callable
357
    {
358 42
        return function (ServerRequestInterface $request, ResponseInterface $response) use ($route) {
359 35
            $handler   = $this->resolveController($request, $route);
360 35
            $arguments = [\get_class($request) => $request, \get_class($response) => $response];
361
362 35
            if ($handler instanceof ResponseInterface) {
363 11
                return $handler;
364
            }
365
366 24
            foreach ($this->listeners as $listener) {
367
                // Only allow default Invoker class
368 1
                if ($this->resolver instanceof Invoker) {
369 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

369
                    $listener->onRoute($request, $route, $this->resolver->getCallableResolver()->resolve(/** @scrutinizer ignore-type */ $handler));
Loading history...
370
                }
371
            }
372
373
            try {
374 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

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