Test Failed
Push — master ( 00affb...1204ff )
by Divine Niiquaye
02:07
created

Router::fetchOptions()   A

Complexity

Conditions 6
Paths 5

Size

Total Lines 21
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 6
eloc 9
c 1
b 0
f 0
nc 5
nop 2
dl 0
loc 21
rs 9.2222
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 BiuradPHP\Support\BoundMethod;
21
use Closure;
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\CallableResolverInterface;
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\RequestHandlerInterface;
36
37
/**
38
 * Router
39
 */
40
class Router implements RequestHandlerInterface
41
{
42
    use RouteValidation;
43
44
    public const TYPE_REQUIREMENT = 1;
45
46
    public const TYPE_DEFAULT = 0;
47
48
    /** @var RouteMatcherInterface */
49
    private $matcher;
50
51
    /** @var CallableResolverInterface */
52
    private $resolver;
53
54
    /** @var MiddlewareDispatcher */
55
    private $pipeline;
56
57
    /** @var ContainerInterface */
58
    private $container;
59
60
    /** @var callable */
61
    private $response;
62
63
    /** @var string */
64
    private $namespace;
65
66
    /** @var RouteInterface[] */
67
    private $routes = [];
68
69
    /** @var MiddlewareInterface[] */
70
    private $middlewares = [];
71
72
    /** @var array<int,array<string,mixed>> */
73
    private $attributes = [];
74
75
    public function __construct(
76
        ResponseFactoryInterface $responseFactory,
77
        ?RouteMatcherInterface $matcher = null,
78
        ?CallableResolverInterface $resolver = null,
79
        ?ContainerInterface $container = null
80
    ) {
81
        $this->container = $container;
82
        $this->pipeline  = new MiddlewareDispatcher($container);
83
        $this->resolver  = $resolver ?? new CallableResolver($container);
84
        $this->matcher   = $matcher ?? new Services\SimpleRouteMatcher();
85
86
        $this->response = [$responseFactory, 'createResponse'];
87
    }
88
89
    /**
90
     * Gets the router routes
91
     *
92
     * @return RouteInterface[]
93
     */
94
    public function getRoutes(): array
95
    {
96
        return \array_values($this->routes);
97
    }
98
99
    /**
100
     * Gets the router middlewares
101
     *
102
     * @return MiddlewareInterface[]
103
     */
104
    public function getMiddlewares(): array
105
    {
106
        return \array_values($this->middlewares);
107
    }
108
109
    public function setNamespace(string $namespace): void
110
    {
111
        $this->namespace = $namespace;
112
    }
113
114
    /**
115
     * Adds the given route(s) to the router
116
     *
117
     * @param RouteInterface ...$routes
118
     *
119
     * @throws DuplicateRouteException
120
     */
121
    public function addRoute(RouteInterface ...$routes): void
122
    {
123
        foreach ($routes as $route) {
124
            $name = $route->getName();
125
126
            if (isset($this->routes[$name])) {
127
                throw new DuplicateRouteException(
128
                    \sprintf('A route with the name "%s" already exists.', $name)
129
                );
130
            }
131
132
            $this->routes[$name] = $route;
133
        }
134
    }
135
136
    /**
137
     * Adds the given middleware(s) to the router
138
     *
139
     * @param array<string,mixed>|MiddlewareInterface|string ...$middlewares
0 ignored issues
show
Bug introduced by
The type Flight\Routing\MiddlewareInterface was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
140
     *
141
     * @throws DuplicateRouteException
142
     */
143
    public function addMiddleware(...$middlewares): void
144
    {
145
        foreach ($middlewares as $middleware) {
146
            if (\is_array($middleware)) {
147
                $this->pipeline->add($middleware);
148
149
                continue;
150
            }
151
            $hash = \spl_object_hash($middleware);
0 ignored issues
show
Bug introduced by
It seems like $middleware can also be of type string; however, parameter $obj of spl_object_hash() does only seem to accept object, 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

151
            $hash = \spl_object_hash(/** @scrutinizer ignore-type */ $middleware);
Loading history...
152
153
            if (isset($this->middlewares[$hash])) {
154
                throw new DuplicateRouteException(\sprintf('A middleware with the hash "%s" already exists.', $hash));
155
            }
156
157
            $this->middlewares[$hash] = $middleware;
158
        }
159
    }
160
161
    /**
162
     * Adds parameters.
163
     *
164
     * This method implements a fluent interface.
165
     *
166
     * @param array<string,mixed> $parameters The parameters
167
     * @param int                 $type
168
     */
169
    public function addParameters(array $parameters, int $type = self::TYPE_REQUIREMENT): void
170
    {
171
        foreach ($parameters as $key => $regex) {
172
            if (self::TYPE_DEFAULT === $type) {
173
                $this->attributes[self::TYPE_DEFAULT] = [$key => $regex];
174
175
                continue;
176
            }
177
178
            $this->attributes[self::TYPE_REQUIREMENT] = [$key => $regex];
179
        }
180
    }
181
182
    /**
183
     * Gets allowed methods
184
     *
185
     * @return string[]
186
     */
187
    public function getAllowedMethods(): array
188
    {
189
        $methods = [];
190
191
        foreach ($this->routes as $route) {
192
            foreach ($route->getMethods() as $method) {
193
                $methods[$method] = true;
194
            }
195
        }
196
197
        return \array_keys($methods);
198
    }
199
200
    /**
201
     * Gets a route for the given name
202
     *
203
     * @param string $name
204
     *
205
     * @throws RouteNotFoundException
206
     *
207
     * @return RouteInterface
208
     */
209
    public function getRoute(string $name): RouteInterface
210
    {
211
        if (!isset($this->routes[$name])) {
212
            throw new RouteNotFoundException(\sprintf('No route found for the name "%s".', $name));
213
        }
214
215
        return $this->routes[$name];
216
    }
217
218
    /**
219
     * Generate a URI from the named route.
220
     *
221
     * Takes the named route and any parameters, and attempts to generate a
222
     * URI from it. Additional router-dependent query may be passed.
223
     *
224
     * Once there are no missing parameters in the URI we will encode
225
     * the URI and prepare it for returning to the user. If the URI is supposed to
226
     * be absolute, we will return it as-is. Otherwise we will remove the URL's root.
227
     *
228
     * @param string                       $routeName   route name
229
     * @param array<string,string>         $parameters  key => value option pairs to pass to the
230
     *                                                  router for purposes of generating a URI; takes precedence over options
231
     *                                                  present in route used to generate URI
232
     * @param array<int|string,int|string> $queryParams Optional query string parameters
233
     *
234
     * @throws UrlGenerationException if the route name is not known
235
     *                                or a parameter value does not match its regex
236
     *
237
     * @return string of fully qualified URL for named route
238
     */
239
    public function generateUri(string $routeName, array $parameters = [], array $queryParams = []): string
240
    {
241
        try {
242
            $route = $this->getRoute($routeName);
243
        } catch (RouteNotFoundException $e) {
244
            throw new UrlGenerationException(
245
                \sprintf(
246
                    'Unable to generate a URL for the named route "%s" as such route does not exist.',
247
                    $routeName
248
                ),
249
                404
250
            );
251
        }
252
253
        $prefix     = '.'; // Append missing "." at the beginning of the $uri.
254
        $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...
255
256
        // Making routing on sub-folders easier
257
        if (\strpos($createdUri = $this->matcher->buildPath($route, $parameters), '/') !== 0) {
258
            $prefix .= '/';
259
        }
260
261
        // Incase query is added to uri.
262
        if (!empty($queryParams)) {
263
            $createdUri .= '?' . \http_build_query($queryParams);
264
        }
265
266
        return \rtrim(\strpos($createdUri, '://') !== false ? $createdUri : $prefix . $createdUri, '/');
267
    }
268
269
    /**
270
     * Looks for a route that matches the given request
271
     *
272
     * @param ServerRequestInterface $request
273
     *
274
     * @throws MethodNotAllowedException
275
     * @throws UriHandlerException
276
     * @throws RouteNotFoundException
277
     *
278
     * @return RouteHandler
279
     */
280
    public function match(ServerRequestInterface &$request): RouteHandler
281
    {
282
        $requestUri  = $request->getUri();
283
        $basePath    = \dirname($request->getServerParams()['SCRIPT_NAME'] ?? '');
284
        $requestPath = \substr($requestUri->getPath(), \strlen($basePath)) ?? '/';
285
286
        // Get the request matching format.
287
        $route = $this->marshalMatchedRoute(
288
            [
289
                $request->getMethod(),
290
                $requestUri->getScheme(),
291
                $requestUri->getHost(),
292
                \rawurldecode($requestPath),
293
            ]
294
        );
295
        $request = $request->withAttribute(Route::class, $route);
296
297
        return new RouteHandler($this->generateResponse($route), ($this->response)());
298
    }
299
300
    /**
301
     * {@inheritDoc}
302
     */
303
    public function handle(ServerRequestInterface $request): ResponseInterface
304
    {
305
        $routingResults = $this->match($request);
306
        $route          = $request->getAttribute(Route::class);
307
308
        // Get all available middlewares
309
        $middlewares = $route instanceof Route ? $route->getMiddlewares() : [];
310
311
        if (\count($middlewares = $this->mergeMiddlewares($middlewares)) > 0) {
312
            $middleware = $this->pipeline->pipeline(...$middlewares);
313
314
            // This middleware is in the priority map; but, this is the first middleware we have
315
            // encountered from the map thus far. We'll save its current index plus its index
316
            // from the priority map so we can compare against them on the next iterations.
317
            return $middleware->process($request, $routingResults);
318
        }
319
320
        return $routingResults->handle($request);
321
    }
322
323
    /**
324
     * Merge route middlewares with Router Middlewares.
325
     *
326
     * @param mixed[] $middlewares
327
     *
328
     * @return MiddlewareInterface[]|mixed[]
329
     */
330
    private function mergeMiddlewares(array $middlewares): array
331
    {
332
        $this->pipeline->add(...$this->getMiddlewares());
333
334
        return \array_merge($middlewares, $this->pipeline->getMiddlewareStack());
335
    }
336
337
    /**
338
     * Merge Router attributes in route default and patterns.
339
     *
340
     * @param RouteInterface $route
341
     *
342
     * @return RouteInterface
343
     */
344
    private function mergeAttributes(RouteInterface $route): RouteInterface
345
    {
346
        foreach ($this->attributes as $type => $attributes) {
347
            if (self::TYPE_DEFAULT === $type) {
348
                $route->setDefaults($attributes);
349
350
                continue;
351
            }
352
353
            $route->setPatterns($attributes);
354
        }
355
356
        return $route;
357
    }
358
359
    /**
360
     * Generate the response so it can be served
361
     *
362
     * @param RouteInterface $route
363
     *
364
     * @return callable
365
     */
366
    private function generateResponse(RouteInterface $route): callable
367
    {
368
        return function (ServerRequestInterface $request, ResponseInterface $response) use ($route) {
369
            $handler   = $this->resolver->resolve($route->getController(), $this->namespace);
370
            $arguments = \array_merge(
371
                $route->getArguments(),
372
                $this->container ? [$request] : [$request, $response]
373
            );
374
375
            // If controller is instance of RequestHandlerInterface
376
            if (!$handler instanceof Closure && $handler[0] instanceof RequestHandlerInterface) {
377
                return $handler($request);
378
            }
379
380
            return BoundMethod::call($this->container, $handler, $arguments);
381
        };
382
    }
383
384
    /**
385
     * Marshals a route result based on the results of matching URL from set of routes.
386
     *
387
     * @param string[] $process
388
     *
389
     * @throws MethodNotAllowedException
390
     * @throws UriHandlerException
391
     * @throws RouteNotFoundException
392
     *
393
     * @return RouteInterface
394
     */
395
    private function marshalMatchedRoute(array $process): RouteInterface
396
    {
397
        [, , $host, $path] = $process;
398
399
        foreach ($this->routes as $route) {
400
            // Let's match the routes
401
            $match      = $this->matcher->compileRoute($this->mergeAttributes($route));
402
            $parameters = $hostParameters = [];
403
404
            // https://tools.ietf.org/html/rfc7231#section-6.5.5
405
            if (!$this->compareUri($match->getRegex(), $path, $parameters)) {
406
                continue;
407
            }
408
409
            if (!$this->compareDomain($match->getRegex(true), $host, $hostParameters)) {
410
                throw new UriHandlerException(
411
                    \sprintf('Unfortunately current domain "%s" is not allowed on requested uri [%s]', $host, $path),
412
                    400
413
                );
414
            }
415
416
            $this->assertRoute($route, $process);
417
418
            return $route->setArguments($this->mergeDefaults(
419
                \array_replace($parameters, $hostParameters) ?? $match->getVariables(),
420
                $route->getDefaults()
421
            ));
422
        }
423
424
        throw new  RouteNotFoundException(
425
            \sprintf(
426
                'Unable to find the controller for path "%s". The route is wrongly configured.',
427
                $path
428
            )
429
        );
430
    }
431
432
    /**
433
     * Asserts the Route's method and domain scheme.
434
     *
435
     * @param RouteInterface $route
436
     * @param string[]       $attributes
437
     */
438
    private function assertRoute(RouteInterface $route, array $attributes): void
439
    {
440
        [$method, $scheme, , $path] = $attributes;
441
442
        if (!$this->compareMethod($route->getMethods(), $method)) {
443
            throw new MethodNotAllowedException($route->getMethods(), $path, $method);
444
        }
445
446
        if (!$this->compareScheme($route->getSchemes(), $scheme)) {
447
            throw new UriHandlerException(
448
                \sprintf('Unfortunately current scheme "%s" is not allowed on requested uri [%s]', $scheme, $path),
449
                400
450
            );
451
        }
452
    }
453
}
454