Router::compileRoute()   A
last analyzed

Complexity

Conditions 1
Paths 1

Size

Total Lines 4
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 2
c 1
b 0
f 0
nc 1
nop 1
dl 0
loc 4
ccs 3
cts 3
cp 1
crap 1
rs 10
1
<?php
2
3
/**
4
 * It's free open-source software released under the MIT License.
5
 *
6
 * @author Anatoly Nekhay <[email protected]>
7
 * @copyright Copyright (c) 2018, Anatoly Nekhay
8
 * @license https://github.com/sunrise-php/http-router/blob/master/LICENSE
9
 * @link https://github.com/sunrise-php/http-router
10
 */
11
12
declare(strict_types=1);
13
14
namespace Sunrise\Http\Router;
15
16
use InvalidArgumentException;
17
use LogicException;
18
use Psr\EventDispatcher\EventDispatcherInterface;
19
use Psr\Http\Message\ResponseInterface;
20
use Psr\Http\Message\ServerRequestInterface;
21
use Psr\Http\Server\RequestHandlerInterface;
22
use Sunrise\Http\Router\Dictionary\HeaderName;
23
use Sunrise\Http\Router\Dictionary\PlaceholderCode;
24
use Sunrise\Http\Router\Event\RoutePostRunEvent;
25
use Sunrise\Http\Router\Event\RoutePreRunEvent;
26
use Sunrise\Http\Router\Exception\HttpException;
27
use Sunrise\Http\Router\Exception\HttpExceptionFactory;
28
use Sunrise\Http\Router\Helper\RouteBuilder;
29
use Sunrise\Http\Router\Helper\RouteCompiler;
30
use Sunrise\Http\Router\Helper\RouteMatcher;
31
use Sunrise\Http\Router\Loader\LoaderInterface;
32
use Sunrise\Http\Router\RequestHandler\CallableRequestHandler;
33
use Sunrise\Http\Router\RequestHandler\QueueableRequestHandler;
34
use UnexpectedValueException;
35
36
use function array_flip;
37
use function array_keys;
38
use function array_merge;
39
use function rawurldecode;
40
use function sprintf;
41
42
final class Router implements RouterInterface
43
{
44
    /** @var array<string, RouteInterface> */
45
    private array $routes = [];
46
47
    /** @var array<string, non-empty-string> */
0 ignored issues
show
Documentation Bug introduced by
The doc comment array<string, non-empty-string> at position 4 could not be parsed: Unknown type name 'non-empty-string' at position 4 in array<string, non-empty-string>.
Loading history...
48
    private array $routePatterns = [];
49
50
    /** @var array<string, RequestHandlerInterface> */
51
    private array $routeRequestHandlers = [];
52
53
    private ?RequestHandlerInterface $requestHandler = null;
54
55
    private bool $isLoaded = false;
56
57
    /**
58
     * @since 3.0.0
59
     */
60 45
    public function __construct(
61
        private readonly ReferenceResolverInterface $referenceResolver,
62
        /** @var array<array-key, LoaderInterface> */
63
        private readonly array $loaders,
64
        /** @var array<array-key, mixed> */
65
        private readonly array $middlewares = [],
66
        /** @var array<array-key, mixed> */
67
        private readonly array $routeMiddlewares = [],
68
        private readonly ?EventDispatcherInterface $eventDispatcher = null,
69
    ) {
70 45
    }
71
72
    /**
73
     * @inheritDoc
74
     *
75
     * @throws InvalidArgumentException
76
     */
77 22
    public function getRoutes(): array
78
    {
79 22
        $this->isLoaded or $this->load();
80
81 21
        return $this->routes;
82
    }
83
84
    /**
85
     * @inheritDoc
86
     *
87
     * @throws InvalidArgumentException
88
     */
89 2
    public function getRoute(string $name): RouteInterface
90
    {
91 2
        $routes = $this->getRoutes();
92
93 2
        if (!isset($routes[$name])) {
94 1
            throw new InvalidArgumentException(sprintf('The route "%s" does not exist.', $name));
95
        }
96
97 1
        return $routes[$name];
98
    }
99
100
    /**
101
     * @inheritDoc
102
     *
103
     * @throws InvalidArgumentException
104
     */
105 2
    public function hasRoute(string $name): bool
106
    {
107 2
        $routes = $this->getRoutes();
108
109 2
        return isset($routes[$name]);
110
    }
111
112
    /**
113
     * @inheritDoc
114
     *
115
     * @throws HttpException
116
     * @throws InvalidArgumentException
117
     * @throws LogicException
118
     */
119 1
    public function handle(ServerRequestInterface $request): ResponseInterface
120
    {
121 1
        return $this->getRequestHandler()->handle($request);
122
    }
123
124
    /**
125
     * @inheritDoc
126
     *
127
     * @throws HttpException
128
     * @throws InvalidArgumentException
129
     * @throws LogicException
130
     */
131 13
    public function match(ServerRequestInterface $request): RouteInterface
132
    {
133 13
        $routes = $this->getRoutes();
134
135 13
        if ($routes === []) {
136 1
            throw new LogicException('The router does not contain any routes.');
137
        }
138
139 12
        $requestPath = rawurldecode($request->getUri()->getPath());
140 12
        $requestMethod = $request->getMethod();
141 12
        $allowedMethods = [];
142
143 12
        foreach ($routes as $route) {
144
            try {
145 12
                if (!RouteMatcher::matchRoute($this->compileRoute($route), $requestPath, $matches)) {
146 7
                    continue;
147
                }
148 5
            } catch (UnexpectedValueException $e) {
149 1
                throw HttpExceptionFactory::malformedUri(previous: $e);
150
            }
151
152 6
            $routeMethods = array_flip($route->getMethods());
153 6
            if (!isset($routeMethods[$requestMethod])) {
154 1
                $allowedMethods += $routeMethods;
155 1
                continue;
156
            }
157
158 5
            return $matches === [] ? $route : $route->withAddedAttributes($matches);
159
        }
160
161 2
        if ($allowedMethods !== []) {
162 1
            throw HttpExceptionFactory::methodNotAllowed()
163 1
                ->addMessagePlaceholder(PlaceholderCode::REQUEST_METHOD, $requestMethod)
164 1
                ->addHeaderField(HeaderName::ALLOW, ...array_keys($allowedMethods));
165
        }
166
167 1
        throw HttpExceptionFactory::resourceNotFound()
168 1
            ->addMessagePlaceholder(PlaceholderCode::REQUEST_URI, $requestPath);
169
    }
170
171
    /**
172
     * @inheritDoc
173
     *
174
     * @throws InvalidArgumentException
175
     */
176 6
    public function runRoute(RouteInterface $route, ServerRequestInterface $request): ResponseInterface
177
    {
178 6
        foreach ($route->getAttributes() as $name => $value) {
179 2
            $request = $request->withAttribute($name, $value);
180
        }
181
182 6
        $request = $request->withAttribute(RouteInterface::class, $route);
183
184 6
        if ($this->eventDispatcher !== null) {
185 6
            $event = new RoutePreRunEvent($route, $request);
186 6
            $this->eventDispatcher->dispatch($event);
187 6
            $request = $event->request;
188
        }
189
190 6
        $response = $this->getRouteRequestHandler($route)->handle($request);
191
192 6
        if ($this->eventDispatcher !== null) {
193 6
            $event = new RoutePostRunEvent($route, $request, $response);
194 6
            $this->eventDispatcher->dispatch($event);
195 6
            $response = $event->response;
196
        }
197
198 6
        return $response;
199
    }
200
201
    /**
202
     * @inheritDoc
203
     *
204
     * @throws InvalidArgumentException
205
     */
206 16
    public function buildRoute(RouteInterface $route, array $values = [], bool $strictly = false): string
207
    {
208 16
        $result = RouteBuilder::buildRoute($route->getPath(), $values + $route->getAttributes());
209
210 11
        if ($strictly && !RouteMatcher::matchRoute($this->compileRoute($route), $result)) {
211 1
            throw new InvalidArgumentException(sprintf(
212 1
                'The route "%s" could not be built because one of the values does not match its pattern.',
213 1
                $route->getName(),
214 1
            ));
215
        }
216
217 10
        return $result;
218
    }
219
220
    /**
221
     * @throws InvalidArgumentException
222
     *
223
     * @return non-empty-string
0 ignored issues
show
Documentation Bug introduced by
The doc comment non-empty-string at position 0 could not be parsed: Unknown type name 'non-empty-string' at position 0 in non-empty-string.
Loading history...
224
     */
225 14
    private function compileRoute(RouteInterface $route): string
226
    {
227 14
        return $this->routePatterns[$route->getName()] ??= $route->getPattern()
228 12
            ?? RouteCompiler::compileRoute($route->getPath(), $route->getPatterns());
229
    }
230
231
    /**
232
     * @internal
233
     *
234
     * @throws InvalidArgumentException
235
     */
236 7
    public function getRouteRequestHandler(RouteInterface $route): RequestHandlerInterface
237
    {
238 7
        $name = $route->getName();
239 7
        if (isset($this->routeRequestHandlers[$name])) {
240 1
            return $this->routeRequestHandlers[$name];
241
        }
242
243 7
        $this->routeRequestHandlers[$name] = $this->referenceResolver
244 7
            ->resolveRequestHandler($route->getRequestHandler());
245
246 7
        $middlewares = array_merge($this->routeMiddlewares, $route->getMiddlewares());
247 7
        if ($middlewares !== []) {
248 2
            $this->routeRequestHandlers[$name] = new QueueableRequestHandler($this->routeRequestHandlers[$name]);
249 2
            foreach ($middlewares as $middleware) {
250 2
                $this->routeRequestHandlers[$name]->enqueue($this->referenceResolver->resolveMiddleware($middleware));
251
            }
252
        }
253
254 7
        return $this->routeRequestHandlers[$name];
255
    }
256
257
    /**
258
     * @internal
259
     *
260
     * @throws InvalidArgumentException
261
     */
262 2
    public function getRequestHandler(): RequestHandlerInterface
263
    {
264 2
        if ($this->requestHandler !== null) {
265 1
            return $this->requestHandler;
266
        }
267
268 2
        $this->requestHandler = new CallableRequestHandler(
269 2
            fn(ServerRequestInterface $request): ResponseInterface =>
270 2
                $this->runRoute($this->match($request), $request),
271 2
        );
272
273 2
        if ($this->middlewares !== []) {
274 1
            $this->requestHandler = new QueueableRequestHandler($this->requestHandler);
0 ignored issues
show
Bug introduced by
$this->requestHandler of type null is incompatible with the type Psr\Http\Server\RequestHandlerInterface expected by parameter $endpoint of Sunrise\Http\Router\Requ...tHandler::__construct(). ( Ignorable by Annotation )

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

274
            $this->requestHandler = new QueueableRequestHandler(/** @scrutinizer ignore-type */ $this->requestHandler);
Loading history...
275 1
            foreach ($this->middlewares as $middleware) {
276 1
                $this->requestHandler->enqueue($this->referenceResolver->resolveMiddleware($middleware));
0 ignored issues
show
Bug introduced by
The method enqueue() 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

276
                $this->requestHandler->/** @scrutinizer ignore-call */ 
277
                                       enqueue($this->referenceResolver->resolveMiddleware($middleware));

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...
277
            }
278
        }
279
280 2
        return $this->requestHandler;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->requestHandler returns the type null which is incompatible with the type-hinted return Psr\Http\Server\RequestHandlerInterface.
Loading history...
281
    }
282
283
    /**
284
     * @throws InvalidArgumentException
285
     */
286 22
    private function load(): void
287
    {
288 22
        foreach ($this->loaders as $loader) {
289 19
            foreach ($loader->load() as $route) {
290 19
                $name = $route->getName();
291
292 19
                if (isset($this->routes[$name])) {
293 1
                    throw new InvalidArgumentException(sprintf('The route "%s" already exists.', $name));
294
                }
295
296 19
                $this->routes[$name] = $route;
297
            }
298
        }
299
300 21
        $this->isLoaded = true;
301
    }
302
}
303