Passed
Pull Request — master (#1141)
by Abdul Malik
15:30 queued 03:41
created

Router::matchRoute()   A

Complexity

Conditions 4
Paths 5

Size

Total Lines 18
Code Lines 8

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 4

Importance

Changes 0
Metric Value
cc 4
eloc 8
nc 5
nop 2
dl 0
loc 18
ccs 9
cts 9
cp 1
crap 4
rs 10
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Spiral\Router;
6
7
use Psr\Container\ContainerInterface;
8
use Psr\EventDispatcher\EventDispatcherInterface;
9
use Psr\Http\Message\ResponseInterface;
10
use Psr\Http\Message\ServerRequestInterface;
11
use Psr\Http\Message\UriInterface;
12
use Spiral\Router\Event\RouteMatched;
13
use Spiral\Router\Event\RouteNotFound;
14
use Spiral\Router\Event\Routing;
15
use Spiral\Router\Exception\RouteException;
16
use Spiral\Router\Exception\RouteNotFoundException;
17
use Spiral\Router\Exception\RouterException;
18
use Spiral\Router\Exception\UndefinedRouteException;
19
use Spiral\Router\Loader\Configurator\RoutingConfigurator;
20
use Spiral\Router\Target\AbstractTarget;
21
use Spiral\Telemetry\NullTracer;
22
use Spiral\Telemetry\SpanInterface;
23
use Spiral\Telemetry\TracerInterface;
24
25
/**
26
 * Manages set of routes.
27
 *
28
 * @psalm-import-type Matches from UriHandler
29
 */
30
final class Router implements RouterInterface
31
{
32
    // attribute to store active route in request
33
    public const ROUTE_ATTRIBUTE = 'route';
34
35
    // attribute to store active route in request
36
    public const ROUTE_NAME = 'routeName';
37
38
    // attribute to store active route in request
39
    public const ROUTE_MATCHES = 'matches';
40
41
    private string $basePath = '/';
42
43
    /** @var RouteInterface[] */
44
    private array $routes = [];
45
46
    private ?RouteInterface $default = null;
47
48 487
    public function __construct(
49
        string $basePath,
50
        private readonly UriHandler $uriHandler,
51
        private readonly ContainerInterface $container,
52
        private readonly ?EventDispatcherInterface $eventDispatcher = null,
53
        private readonly TracerInterface $tracer = new NullTracer(),
54
    ) {
55 487
        $this->basePath = '/' . \ltrim($basePath, '/');
56
    }
57
58
    /**
59
     * @throws RouteNotFoundException
60
     * @throws RouterException
61
     */
62 64
    public function handle(ServerRequestInterface $request): ResponseInterface
63
    {
64 64
        $this->eventDispatcher?->dispatch(new Routing($request));
65
66 64
        return $this->tracer->trace(
67 64
            name: 'Routing',
68 64
            callback: function (SpanInterface $span) use ($request) {
69
                try {
70 64
                    $route = $this->matchRoute($request, $routeName);
71
                } catch (RouteException $e) {
72
                    throw new RouterException('Invalid route definition', $e->getCode(), $e);
73
                }
74
75 64
                if ($route === null) {
76 12
                    $this->eventDispatcher?->dispatch(new RouteNotFound($request));
77 12
                    throw new RouteNotFoundException($request->getUri());
78
                }
79
80 53
                $span
81 53
                    ->setAttribute('request.uri', (string)$request->getUri())
82 53
                    ->setAttribute('route.name', $routeName)
83 53
                    ->setAttribute('route.matches', $route->getMatches() ?? []);
84
85 53
                $request = $request
86 53
                    ->withAttribute(self::ROUTE_ATTRIBUTE, $route)
87 53
                    ->withAttribute(self::ROUTE_NAME, $routeName)
88 53
                    ->withAttribute(self::ROUTE_MATCHES, $route->getMatches() ?? []);
89
90 53
                $this->eventDispatcher?->dispatch(new RouteMatched($request, $route));
91
92 53
                return $route->handle($request);
93 64
            }
94 64
        );
95
    }
96
97 429
    public function setRoute(string $name, RouteInterface $route): void
98
    {
99
        // each route must inherit basePath prefix
100 429
        $this->routes[$name] = $this->configure($route);
101
    }
102
103 363
    public function setDefault(RouteInterface $route): void
104
    {
105 363
        $this->default = $this->configure($route);
106
    }
107
108 36
    public function getRoute(string $name): RouteInterface
109
    {
110 36
        if (isset($this->routes[$name])) {
111 32
            return $this->routes[$name];
112
        }
113
114 9
        throw new UndefinedRouteException(\sprintf('Undefined route `%s`', $name));
115
    }
116
117 3
    public function getRoutes(): array
118
    {
119 3
        if (!empty($this->default)) {
120 2
            return $this->routes + [null => $this->default];
121
        }
122
123 1
        return $this->routes;
124
    }
125
126 11
    public function uri(string $route, iterable $parameters = []): UriInterface
127
    {
128
        try {
129 11
            return $this->getRoute($route)->uri($parameters);
130 9
        } catch (UndefinedRouteException) {
131
            //In some cases route name can be provided as controller:action pair, we can try to
132
            //generate such route automatically based on our default/fallback route
133 9
            return $this->castRoute($route)->uri($parameters);
134
        }
135
    }
136
137 363
    public function import(RoutingConfigurator $routes): void
138
    {
139
        /** @var GroupRegistry $groups */
140 363
        $groups = $this->container->get(GroupRegistry::class);
141
142 363
        foreach ($routes->getCollection() as $name => $configurator) {
143 358
            $target = $configurator->target;
144 358
            if ($configurator->core !== null && $target instanceof AbstractTarget) {
145 357
                $target = $target->withCore($configurator->core);
0 ignored issues
show
Deprecated Code introduced by
The function Spiral\Router\Target\AbstractTarget::withCore() has been deprecated: Use {@see withHandler()} instead. ( Ignorable by Annotation )

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

145
                $target = /** @scrutinizer ignore-deprecated */ $target->withCore($configurator->core);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
146
            }
147
148 358
            $pattern = \str_starts_with($configurator->pattern, '//')
149 1
                ? $configurator->pattern
150 357
                : \ltrim($configurator->pattern, '/');
151 358
            $route = new Route($pattern, $target, $configurator->defaults);
152
153 358
            if ($configurator->middleware !== null) {
154 357
                $route = $route->withMiddleware(...$configurator->middleware);
155
            }
156
157 358
            if ($configurator->methods !== null) {
158
                $route = $route->withVerbs(...$configurator->methods);
159
            }
160
161 358
            if (!isset($this->routes[$name]) && $name !== RoutingConfigurator::DEFAULT_ROUTE_NAME) {
162 358
                $group = $groups->getGroup($configurator->group ?? $groups->getDefaultGroup());
163 358
                $group->setPrefix($configurator->prefix);
164 358
                $group->addRoute($name, $route);
165
            }
166
167 358
            if ($name === RoutingConfigurator::DEFAULT_ROUTE_NAME) {
168 357
                $this->setDefault($route);
169
            }
170
        }
171
    }
172
173
    /**
174
     * Find route matched for given request.
175
     */
176 64
    protected function matchRoute(ServerRequestInterface $request, string &$routeName = null): ?RouteInterface
177
    {
178 64
        foreach ($this->routes as $name => $route) {
179
            // Matched route will return new route instance with matched parameters
180 60
            $matched = $route->match($request);
181
182 60
            if ($matched !== null) {
183 39
                $routeName = $name;
184 39
                return $matched;
185
            }
186
        }
187
188 26
        if ($this->default !== null) {
189 15
            return $this->default->match($request);
190
        }
191
192
        // unable to match any route
193 11
        return null;
194
    }
195
196
    /**
197
     * Configure route with needed dependencies.
198
     */
199 434
    protected function configure(RouteInterface $route): RouteInterface
200
    {
201 434
        if ($route instanceof ContainerizedInterface && !$route->hasContainer()) {
202
            // isolating route in a given container
203 429
            $route = $route->withContainer($this->container);
204
        }
205
206
        try {
207 432
            $uriHandler = $route->getUriHandler();
208 408
        } catch (\Throwable) {
209 408
            $uriHandler = $this->uriHandler;
210
        }
211
212 432
        return $route->withUriHandler($uriHandler->withBasePath($this->basePath));
213
    }
214
215
    /**
216
     * Locates appropriate route by name. Support dynamic route allocation using following pattern:
217
     * Named route:   `name/controller:action`
218
     * Default route: `controller:action`
219
     * Only action:   `name/action`
220
     *
221
     * @throws UndefinedRouteException
222
     */
223 9
    protected function castRoute(string $route): RouteInterface
224
    {
225
        if (
226 9
            !\preg_match(
227 9
                '/^(?:(?P<name>[^\/]+)\/)?(?:(?P<controller>[^:]+):+)?(?P<action>[a-z_\-]+)$/i',
228 9
                $route,
229 9
                $matches
230 9
            )
231
        ) {
232 1
            throw new UndefinedRouteException(
233 1
                "Unable to locate route or use default route with 'name/controller:action' pattern"
234 1
            );
235
        }
236
237
        /**
238
         * @var Matches $matches
239
         */
240 8
        if (!empty($matches['name'])) {
241 5
            $routeObject = $this->getRoute($matches['name']);
242 3
        } elseif ($this->default !== null) {
243 2
            $routeObject = $this->default;
244
        } else {
245 1
            throw new UndefinedRouteException(\sprintf('Unable to locate route candidate for `%s`', $route));
246
        }
247
248 7
        return $routeObject->withDefaults(
249 7
            [
250 7
                'controller' => $matches['controller'],
251 7
                'action' => $matches['action'],
252 7
            ]
253 7
        );
254
    }
255
}
256