Passed
Push — master ( 8fb003...dae8ba )
by Divine Niiquaye
11:39
created

Router::setOptions()   B

Complexity

Conditions 6
Paths 15

Size

Total Lines 35
Code Lines 20

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 6.3541

Importance

Changes 0
Metric Value
eloc 20
c 0
b 0
f 0
dl 0
loc 35
ccs 11
cts 14
cp 0.7856
rs 8.9777
cc 6
nc 15
nop 1
crap 6.3541
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
{
51
    use Traits\RouterTrait;
52
53
    /** @var MiddlewarePipeInterface */
54
    private $pipeline;
55
56
    /**
57
     * @param array<string,mixed> $options
58
     */
59 77
    public function __construct(
60
        ResponseFactoryInterface $responseFactory,
61
        UriFactoryInterface $uriFactory,
62
        ?InvokerInterface $resolver = null,
63
        array $options = []
64
    ) {
65 77
        $this->uriFactory      = $uriFactory;
66 77
        $this->responseFactory = $responseFactory;
67 77
        $this->resolver        = new RouteResolver($resolver ?? new Invoker());
68
69 77
        $this->setOptions($options);
70 77
        $this->routes   = new RouteCollection(false);
71 77
        $this->pipeline = new MiddlewarePipe();
72 77
    }
73
74
    /**
75
     * Sets options.
76
     *
77
     * Available options:
78
     *
79
     *   * cache_dir:              The cache directory (or null to disable caching)
80
     *   * debug:                  Whether to enable debugging or not (false by default)
81
     *   * namespace:              Set Namespace for route handlers/controllers
82
     *   * matcher_class:          The name of a RouteMatcherInterface implementation
83
     *   * 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
     * @throws \InvalidArgumentException When unsupported option is provided
87
     */
88 77
    public function setOptions(array $options): void
89
    {
90 77
        $this->options = [
91
            'cache_dir'            => null,
92
            'debug'                => false,
93
            'options_skip'         => false,
94
            'namespace'            => null,
95
            'matcher_class'        => Matchers\SimpleRouteMatcher::class,
96
            'matcher_dumper_class' => Matchers\SimpleRouteDumper::class,
97
        ];
98
99
        // check option names and live merge, if errors are encountered Exception will be thrown
100 77
        $invalid = [];
101
102 77
        foreach ($options as $key => $value) {
103 77
            if (\array_key_exists($key, $this->options)) {
104 77
                $this->options[$key] = $value;
105
            } else {
106
                $invalid[] = $key;
107
            }
108
        }
109
110 77
        if (!empty($invalid)) {
111
            throw new \InvalidArgumentException(
112
                \sprintf('The Router does not support the following options: "%s".', \implode('", "', $invalid))
113
            );
114
        }
115
116 77
        if ($this->options['debug']) {
117 3
            $this->debug = new DebugRoute();
118
        }
119
120
        // Set the cache_file for caching compiled routes.
121 77
        if (isset($this->options['cache_dir'])) {
122 4
            $this->options['cache_file'] = $this->options['cache_dir'] . '/compiled_routes.php';
123
        }
124 77
    }
125
126
    /**
127
     * This is true if debug mode is false and cached routes exists.
128
     */
129 59
    public function isFrozen(): bool
130
    {
131 59
        if ($this->options['debug']) {
132 3
            return false;
133
        }
134
135 56
        return \file_exists($this->options['cache_file'] ?? '');
136
    }
137
138
    /**
139
     * Adds the given route(s) to the router
140
     *
141
     * @param Route ...$routes
142
     *
143
     * @throws DuplicateRouteException
144
     */
145 60
    public function addRoute(Route ...$routes): void
146
    {
147 60
        foreach ($routes as $route) {
148 60
            if (null === $name = $route->get('name')) {
149 29
                $route->bind($name = $route->generateRouteName(''));
150
            }
151
152 60
            if (null !== $this->routes->find($name)) {
153 1
                throw new DuplicateRouteException(
154 1
                    \sprintf('A route with the name "%s" already exists.', $name)
155
                );
156
            }
157
158 60
            $this->routes->add($route);
159
        }
160 60
    }
161
162
    /**
163
     * Attach middleware to the pipeline.
164
     *
165
     * @param array<string,mixed>|callable|MiddlewareInterface|RequestHandlerInterface|string $middleware
166
     */
167 24
    public function pipe($middleware): void
168
    {
169 24
        if (!$middleware instanceof MiddlewareInterface) {
170 2
            $middleware = $this->resolveMiddleware($middleware);
171
        }
172
173 24
        $this->pipeline->pipe($middleware);
174 24
    }
175
176
    /**
177
     * {@inheritdoc}
178
     */
179 12
    public function getCollection(): RouteCollection
180
    {
181 12
        return $this->routes;
182
    }
183
184
    /**
185
     * {@inheritdoc}
186
     *
187
     * @return UriInterface of fully qualified URL for named route
188
     */
189 4
    public function generateUri(string $routeName, array $parameters = [], array $queryParams = []): UriInterface
190
    {
191 4
        $createUri = (string) $this->getMatcher()->generateUri($routeName, $parameters, $queryParams);
192
193 4
        return $this->uriFactory->createUri($createUri);
194
    }
195
196
    /**
197
     * Looks for a route that matches the given request
198
     *
199
     * @throws MethodNotAllowedException
200
     * @throws UriHandlerException
201
     * @throws RouteNotFoundException
202
     */
203 55
    public function match(ServerRequestInterface $request): Route
204
    {
205
        // Get the request matching format.
206 55
        $route = $this->getMatcher()->match($request);
207
208 50
        if (!$route instanceof Route) {
209 4
            throw new RouteNotFoundException(
210 4
                \sprintf(
211 4
                    'Unable to find the controller for path "%s". The route is wrongly configured.',
212 4
                    $request->getUri()->getPath()
213
                )
214
            );
215
        }
216
217 46
        $this->mergeDefaults($route);
218
219 46
        if ($this->options['debug']) {
220 3
            $this->debug->setMatched(new DebugRoute($route->get('name'), $route));
0 ignored issues
show
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

220
            $this->debug->/** @scrutinizer ignore-call */ 
221
                          setMatched(new DebugRoute($route->get('name'), $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...
Bug introduced by
It seems like $route->get('name') 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

220
            $this->debug->setMatched(new DebugRoute(/** @scrutinizer ignore-type */ $route->get('name'), $route));
Loading history...
221
        }
222
223 46
        return $this->route = clone $route;
224
    }
225
226
    /**
227
     * {@inheritDoc}
228
     */
229 48
    public function handle(ServerRequestInterface $request): ResponseInterface
230
    {
231
        // This is to aid request made from javascript using cors, eg: using axios.
232
        // Midddlware support is added, so it make it easier to add "cors" settings to the response and request
233 48
        if ($this->options['options_skip'] && \strtolower($request->getMethod()) === 'options') {
234
            return $this->handleOptionsResponse($request);
235
        }
236
237 48
        $route   = $this->match($request);
238 41
        $handler = $this->route->get('controller');
0 ignored issues
show
Bug introduced by
The method get() 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

238
        /** @scrutinizer ignore-call */ 
239
        $handler = $this->route->get('controller');

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...
239
240 41
        if (!$handler instanceof RequestHandlerInterface) {
241 31
            $handler = new RouteHandler(
242 31
                function (ServerRequestInterface $request, ResponseInterface $response) use ($route) {
243 30
                    if (isset($this->options['namespace'])) {
244 4
                        $this->resolver->setNamespace($this->options['namespace']);
245
                    }
246
247 30
                    return \call_user_func_array($this->resolver, [$request, $response, $route]);
248 31
                },
249 31
                $this->responseFactory
250
            );
251
        }
252
253
        // Add routes middleware to MiddlewarePipe
254 41
        $this->addMiddleware(...$this->resolveMiddlewares($route));
255
256
        try {
257 39
            return $this->pipeline->process($request->withAttribute(Route::class, $route), $handler);
258
        } finally {
259 39
            if ($this->options['debug']) {
260 39
                foreach ($this->debug->getProfiles() as $profiler) {
261
                    $profiler->leave();
262
                }
263
            }
264
        }
265
    }
266
267
    /**
268
     * Gets the RouteMatcherInterface instance associated with this Router.
269
     */
270 59
    public function getMatcher(): RouteMatcherInterface
271
    {
272 59
        if (null !== $this->matcher) {
273 7
            return $this->matcher;
274 59
        } elseif ($this->isFrozen()) {
275
            return $this->matcher = $this->getDumper($this->options['cache_file']);
276
        }
277
278 59
        if (!$this->options['debug'] && isset($this->options['cache_file'])) {
279 2
            $dumper = $this->getDumper($this->routes);
280
281 2
            if ($dumper instanceof MatcherDumperInterface) {
282 2
                $cacheDir = $this->options['cache_dir'];
283 2
                $cacheFile = $this->options['cache_file'];
284
285 2
                if (!\file_exists($cacheDir)) {
286
                    @\mkdir($cacheDir, 0777, true);
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

286
                    /** @scrutinizer ignore-unhandled */ @\mkdir($cacheDir, 0777, 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...
287
                }
288
289 2
                \file_put_contents($cacheFile, $dumper->dump());
290
291
                if (
292 2
                    \function_exists('opcache_invalidate') &&
293 2
                    \filter_var(\ini_get('opcache.enable'), \FILTER_VALIDATE_BOOLEAN)
294
                ) {
295 2
                    @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

295
                    /** @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...
296
                }
297
            }
298
299 2
            return $this->matcher = $dumper;
300
        }
301
302
        /** @var RouteMatcherInterface $matcher */
303 57
        $matcher = new $this->options['matcher_class']($this->routes);
304
305 57
        return $this->matcher = $matcher;
306
    }
307
308
    /**
309
     * @param RouteCollection|string $routes
310
     */
311 2
    private function getDumper($routes): RouteMatcherInterface
312
    {
313 2
        return new $this->options['matcher_dumper_class']($routes);
314
    }
315
316
    /**
317
     * We have allowed middleware from router to run on response due to
318
     *
319
     * @return \Psr\Http\Message\ResponseInterface
320
     */
321
    private function handleOptionsResponse(ServerRequestInterface $request): ResponseInterface
322
    {
323
        return $this->pipeline->process(
324
            $request,
325
            new Handlers\CallbackHandler(
326
                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

326
                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...
327
                    return $this->responseFactory->createResponse();
328
                }
329
            )
330
        );
331
    }
332
}
333