Test Failed
Push — master ( d3660e...c7a4a9 )
by Divine Niiquaye
10:08
created

Router::handle()   B

Complexity

Conditions 6
Paths 3

Size

Total Lines 39
Code Lines 22

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 0
CRAP Score 42

Importance

Changes 5
Bugs 0 Features 0
Metric Value
eloc 22
c 5
b 0
f 0
dl 0
loc 39
rs 8.9457
ccs 0
cts 0
cp 0
cc 6
nc 3
nop 1
crap 42
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\Handlers\RouteHandler;
27
use Flight\Routing\Interfaces\MatcherDumperInterface;
28
use Flight\Routing\Interfaces\RouteMatcherInterface;
29
use Flight\Routing\Interfaces\RouterInterface;
30
use Laminas\Stratigility\MiddlewarePipe;
31
use Laminas\Stratigility\MiddlewarePipeInterface;
32
use Psr\Http\Message\ResponseFactoryInterface;
33
use Psr\Http\Message\ResponseInterface;
34
use Psr\Http\Message\ServerRequestInterface;
35
use Psr\Http\Message\UriFactoryInterface;
36
use Psr\Http\Message\UriInterface;
37
use Psr\Http\Server\MiddlewareInterface;
38
use Psr\Http\Server\RequestHandlerInterface;
39
40
/**
41
 * Aggregate routes for matching and Dispatching.
42
 *
43
 * Internally, the class performs some checks for duplicate routes when
44
 * attaching via one of the exposed methods, and will raise an exception when a
45
 * collision occurs.
46
 *
47
 * @author Divine Niiquaye Ibok <[email protected]>
48
 */
49
class Router implements RouterInterface, RequestHandlerInterface
50 77
{
51
    use Traits\RouterTrait;
52
53
    /** @var MiddlewarePipeInterface */
54
    private $pipeline;
55
56 77
    /**
57 77
     * @param array<string,mixed> $options
58 77
     */
59 77
    public function __construct(
60 77
        ResponseFactoryInterface $responseFactory,
61
        UriFactoryInterface $uriFactory,
62
        ?InvokerInterface $resolver = null,
63
        array $options = []
64
    ) {
65
        $this->uriFactory      = $uriFactory;
66
        $this->responseFactory = $responseFactory;
67
        $this->resolver        = new RouteResolver($resolver ?? new Invoker());
68
69 62
        $this->setOptions($options);
70
        $this->routes   = new RouteCollection();
71 62
        $this->pipeline = new MiddlewarePipe();
72 62
    }
73
74 62
    /**
75 1
     * Sets options.
76 1
     *
77
     * Available options:
78
     *
79
     *   * cache_dir:              The cache directory (or null to disable caching)
80 62
     *   * debug:                  Whether to enable debugging or not (false by default)
81
     *   * namespace:              Set Namespace for route handlers/controllers
82 62
     *   * matcher_class:          The name of a RouteMatcherInterface implementation
83 1
     *   * matcher_dumper_class:   The name of a MatcherDumperInterface implementation
84
     *   * options_skip:           Whether to serve a response on HTTP request OPTIONS method (false by default)
85
     *
86 62
     * @throws \InvalidArgumentException When unsupported option is provided
87
     */
88
    public function setOptions(array $options): void
89
    {
90
        $this->options = [
91
            'cache_dir'            => null,
92
            'debug'                => false,
93 57
            'options_skip'         => false,
94
            'namespace'            => null,
95 57
            'matcher_class'        => Matchers\SimpleRouteMatcher::class,
96 57
            'matcher_dumper_class' => Matchers\SimpleRouteDumper::class,
97
        ];
98 57
99
        // check option names and live merge, if errors are encountered Exception will be thrown
100
        $invalid = [];
101
102
        foreach ($options as $key => $value) {
103
            if (\array_key_exists($key, $this->options)) {
104
                $this->options[$key] = $value;
105
            } else {
106
                $invalid[] = $key;
107
            }
108
        }
109
110
        if (!empty($invalid)) {
111
            throw new \InvalidArgumentException(
112
                \sprintf('The Router does not support the following options: "%s".', \implode('", "', $invalid))
113
            );
114
        }
115
116
        if ($this->options['debug']) {
117
            $this->debug = new DebugRoute();
118
        }
119
120
        // Set the cache_file for caching compiled routes.
121
        if (isset($this->options['cache_dir'])) {
122 4
            $this->options['cache_file'] = $this->options['cache_dir'] . '/compiled_routes.php';
123
        }
124
    }
125 4
126 1
    /**
127 1
     * This is true if debug mode is false and cached routes exists.
128 1
     */
129 1
    public function isFrozen(): bool
130 1
    {
131
        if ($this->options['debug']) {
132 1
            return false;
133
        }
134
135
        return \file_exists($this->options['cache_file'] ?? '');
136 3
    }
137
138
    /**
139
     * Adds the given route(s) to the router
140
     *
141
     * @param Route ...$routes
142
     *
143
     * @throws DuplicateRouteException
144
     */
145
    public function addRoute(Route ...$routes): void
146
    {
147
        // Guess you off debug mode and would not need to add new routes.
148
        if ($this->isFrozen()) {
149
            return;
150 51
        }
151
152
        foreach ($routes as $route) {
153 51
            if (null === $name = $route->getName()) {
154
                $route->bind($name = $route->generateRouteName(''));
155 47
            }
156 4
157 4
            if (null !== $this->routes->find($name)) {
158 4
                throw new DuplicateRouteException(
159 4
                    \sprintf('A route with the name "%s" already exists.', $name)
160
                );
161
            }
162
163
            $this->routes->add($route);
164 43
        }
165
    }
166 43
167 1
    /**
168
     * Attach middleware to the pipeline.
169
     *
170 43
     * @param array<string,mixed>|callable|MiddlewareInterface|RequestHandlerInterface|string $middleware
171
     */
172
    public function pipe($middleware): void
173
    {
174
        if (!$middleware instanceof MiddlewareInterface) {
175
            $middleware = $this->resolveMiddleware($middleware);
176 44
        }
177
178
        $this->pipeline->pipe($middleware);
179 44
    }
180 43
181
    /**
182
     * {@inheritdoc}
183 38
     */
184
    public function getCollection(): RouteCollection
185 38
    {
186 38
        return $this->routes;
187 38
    }
188 38
189
    /**
190 38
     * {@inheritdoc}
191 38
     *
192 38
     * @return UriInterface of fully qualified URL for named route
193
     */
194 38
    public function generateUri(string $routeName, array $parameters = [], array $queryParams = []): UriInterface
195
    {
196 38
        $createUri = (string) $this->getMatcher()->generateUri($routeName, $parameters, $queryParams);
197 38
198 1
        return $this->uriFactory->createUri($createUri);
199
    }
200
201
    /**
202 38
     * Looks for a route that matches the given request
203
     *
204 38
     * @throws MethodNotAllowedException
205
     * @throws UriHandlerException
206
     * @throws RouteNotFoundException
207
     */
208
    public function match(ServerRequestInterface $request): Route
209
    {
210
        // Get the request matching format.
211
        $route = $this->getMatcher()->match($request);
212
213 54
        if (!$route instanceof Route) {
214
            throw new RouteNotFoundException(
215 54
                \sprintf(
216
                    'Unable to find the controller for path "%s". The route is wrongly configured.',
217 54
                    $request->getUri()->getPath()
218 1
                )
219
            );
220
        }
221 54
222 2
        $this->mergeDefaults($route);
223
224
        if ($this->options['debug']) {
225 54
            $this->debug->setMatched(new DebugRoute($route->getName(), $route));
0 ignored issues
show
Bug introduced by
It seems like $route->getName() can also be of type null; however, parameter $name of Flight\Routing\DebugRoute::__construct() does only seem to accept 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

225
            $this->debug->setMatched(new DebugRoute(/** @scrutinizer ignore-type */ $route->getName(), $route));
Loading history...
Bug introduced by
The method setMatched() 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

225
            $this->debug->/** @scrutinizer ignore-call */ 
226
                          setMatched(new DebugRoute($route->getName(), $route));

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...
226
        }
227
228
        return $this->route = clone $route;
229
    }
230
231
    /**
232
     * {@inheritDoc}
233
     */
234
    public function handle(ServerRequestInterface $request): ResponseInterface
235
    {
236
        // This is to aid request made from javascript using cors, eg: using axios.
237
        // Midddlware support is added, so it make it easier to add "cors" settings to the response and request
238
        if ($this->options['options_skip']) {
239
            return $this->handleOptionsResponse($request);
240
        }
241
242
        $route   = $this->match($request);
243
        $handler = $this->route->getController();
0 ignored issues
show
Bug introduced by
The method getController() 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

243
        /** @scrutinizer ignore-call */ 
244
        $handler = $this->route->getController();

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...
244
245
        if (!$handler instanceof RequestHandlerInterface) {
246
            $handler = new RouteHandler(
247
                function (ServerRequestInterface $request, ResponseInterface $response) use ($route) {
248
                    if (isset($this->options['namespace'])) {
249
                        $this->resolver->setNamespace($this->options['namespace']);
250
                    }
251
252
                    return ($this->resolver)($request, $response, $route);
253
                },
254
                $this->responseFactory
255
            );
256
        }
257
258
        try {
259
            $middleware = $this->resolveMiddlewares(new MiddlewarePipe(), $route);
260
261
            return $this->pipeline->process(
262
                $request->withAttribute(Route::class, $route),
263
                new Handlers\CallbackHandler(
264
                    static function (ServerRequestInterface $request) use ($middleware, $handler): ResponseInterface {
265
                        return $middleware->process($request, $handler);
266
                    }
267
                )
268
            );
269
        } finally {
270
            if ($this->options['debug']) {
271
                foreach ($this->debug->getProfiles() as $profiler) {
272
                    $profiler->leave();
273
                }
274
            }
275
        }
276
    }
277
278
    /**
279
     * Gets the RouteMatcherInterface instance associated with this Router.
280
     */
281
    public function getMatcher(): RouteMatcherInterface
282
    {
283
        if (null !== $this->matcher) {
284
            return $this->matcher;
285
        }
286
287
        $routes    = $this->getCollection();
288
        $cacheFile = $this->options['cache_file'] ?? null;
289
290
        if ($this->options['debug'] || null === $cacheFile) {
291
            /** @var RouteMatcherInterface $matcher */
292
            $matcher = new $this->options['matcher_class']($routes);
293
294
            return $this->matcher = $matcher;
295
        } elseif ($this->isFrozen()) {
296
            return $this->matcher = $this->getDumper($cacheFile);
297
        }
298
299
        $dumper = $this->getDumper($routes);
300
301
        if ($dumper instanceof MatcherDumperInterface) {
302
            $cacheDir = $this->options['cache_dir'];
303
304
            if (!\file_exists($cacheDir)) {
305
                @\mkdir($cacheDir);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for mkdir(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

305
                /** @scrutinizer ignore-unhandled */ @\mkdir($cacheDir);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
306
            }
307
            \file_put_contents($cacheFile, $dumper->dump());
308
309
            if (
310
                \function_exists('opcache_invalidate') &&
311
                \filter_var(\ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN)
312
            ) {
313
                @opcache_invalidate($cacheFile, true);
0 ignored issues
show
Security Best Practice introduced by
It seems like you do not handle an error condition for opcache_invalidate(). This can introduce security issues, and is generally not recommended. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unhandled  annotation

313
                /** @scrutinizer ignore-unhandled */ @opcache_invalidate($cacheFile, true);

If you suppress an error, we recommend checking for the error condition explicitly:

// For example instead of
@mkdir($dir);

// Better use
if (@mkdir($dir) === false) {
    throw new \RuntimeException('The directory '.$dir.' could not be created.');
}
Loading history...
314
            }
315
        }
316
317
        return $this->matcher = $dumper;
318
    }
319
320
    /**
321
     * @param RouteCollection|string $routes
322
     */
323
    private function getDumper($routes): RouteMatcherInterface
324
    {
325
        return new $this->options['matcher_dumper_class']($routes);
326
    }
327
328
    /**
329
     * We have allowed middleware from router to run on response due to
330
     *
331
     * @return \Psr\Http\Message\ResponseInterface
332
     */
333
    private function handleOptionsResponse(ServerRequestInterface $request): ResponseInterface
334
    {
335
        return $this->pipeline->process(
336
            $request,
337
            new Handlers\CallbackHandler(
338
                function (ServerRequestInterface $request): ResponseInterface {
0 ignored issues
show
Unused Code introduced by
The parameter $request is not used and could be removed. ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-unused  annotation

338
                function (/** @scrutinizer ignore-unused */ ServerRequestInterface $request): ResponseInterface {

This check looks for parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
339
                    return $this->responseFactory->createResponse();
340
                }
341
            )
342
        );
343
    }
344
}
345