Passed
Push — master ( f5a1ef...a14569 )
by butschster
29:16 queued 19:38
created

Router::configure()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 14
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4

Importance

Changes 0
Metric Value
eloc 7
c 0
b 0
f 0
dl 0
loc 14
ccs 7
cts 7
cp 1
rs 10
cc 4
nc 4
nop 1
crap 4
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
    private readonly TracerInterface $tracer;
48
49 402
    public function __construct(
50
        string $basePath,
51
        private readonly UriHandler $uriHandler,
52
        private readonly ContainerInterface $container,
53
        private readonly ?EventDispatcherInterface $eventDispatcher = null,
54
        ?TracerInterface $tracer = null,
55
    ) {
56 402
        $this->tracer = $tracer ?? new NullTracer();
0 ignored issues
show
Bug introduced by
The property tracer is declared read-only in Spiral\Router\Router.
Loading history...
57 402
        $this->basePath = '/' . \ltrim($basePath, '/');
58
    }
59
60
    /**
61
     * @throws RouteNotFoundException
62
     * @throws RouterException
63
     */
64 67
    public function handle(ServerRequestInterface $request): ResponseInterface
65
    {
66 67
        $this->eventDispatcher?->dispatch(new Routing($request));
67
68 67
        return $this->tracer->trace(
69 67
            name: 'Routing',
70 67
            callback: function (SpanInterface $span) use ($request) {
71
                try {
72 67
                    $route = $this->matchRoute($request, $routeName);
73
                } catch (RouteException $e) {
74
                    throw new RouterException('Invalid route definition', $e->getCode(), $e);
75
                }
76
77 67
                if ($route === null) {
78 12
                    $this->eventDispatcher?->dispatch(new RouteNotFound($request));
79 12
                    throw new RouteNotFoundException($request->getUri());
80
                }
81
82 56
                $span
83 56
                    ->setAttribute('request.uri', (string)$request->getUri())
84 56
                    ->setAttribute('route.name', $routeName)
85 56
                    ->setAttribute('route.matches', $route->getMatches() ?? []);
86
87 56
                $request = $request
88 56
                    ->withAttribute(self::ROUTE_ATTRIBUTE, $route)
89 56
                    ->withAttribute(self::ROUTE_NAME, $routeName)
90 56
                    ->withAttribute(self::ROUTE_MATCHES, $route->getMatches() ?? []);
91
92 56
                $this->eventDispatcher?->dispatch(new RouteMatched($request, $route));
93
94 56
                return $route->handle($request);
95 67
            }
96 67
        );
97
    }
98
99 345
    public function setRoute(string $name, RouteInterface $route): void
100
    {
101
        // each route must inherit basePath prefix
102 345
        $this->routes[$name] = $this->configure($route);
103
    }
104
105 294
    public function setDefault(RouteInterface $route): void
106
    {
107 294
        $this->default = $this->configure($route);
108
    }
109
110 22
    public function getRoute(string $name): RouteInterface
111
    {
112 22
        if (isset($this->routes[$name])) {
113 18
            return $this->routes[$name];
114
        }
115
116 9
        throw new UndefinedRouteException(\sprintf('Undefined route `%s`', $name));
117
    }
118
119 3
    public function getRoutes(): array
120
    {
121 3
        if (!empty($this->default)) {
122 2
            return $this->routes + [null => $this->default];
123
        }
124
125 1
        return $this->routes;
126
    }
127
128 10
    public function uri(string $route, iterable $parameters = []): UriInterface
129
    {
130
        try {
131 10
            return $this->getRoute($route)->uri($parameters);
132 9
        } catch (UndefinedRouteException) {
133
            //In some cases route name can be provided as controller:action pair, we can try to
134
            //generate such route automatically based on our default/fallback route
135 9
            return $this->castRoute($route)->uri($parameters);
136
        }
137
    }
138
139 294
    public function import(RoutingConfigurator $routes): void
140
    {
141
        /** @var GroupRegistry $groups */
142 294
        $groups = $this->container->get(GroupRegistry::class);
143
144 294
        foreach ($routes->getCollection() as $name => $configurator) {
145 288
            $target = $configurator->target;
146 288
            if ($configurator->core !== null && $target instanceof AbstractTarget) {
147 288
                $target = $target->withCore($configurator->core);
148
            }
149
150 288
            $route = new Route(\ltrim($configurator->pattern, '/'), $target, $configurator->defaults);
151
152 288
            if ($configurator->middleware !== null) {
153 288
                $route = $route->withMiddleware(...$configurator->middleware);
154
            }
155
156 288
            if ($configurator->methods !== null) {
157
                $route = $route->withVerbs(...$configurator->methods);
158
            }
159
160 288
            if (!isset($this->routes[$name]) && $name !== RoutingConfigurator::DEFAULT_ROUTE_NAME) {
161 288
                $group = $groups->getGroup($configurator->group ?? $groups->getDefaultGroup());
162 288
                $group->setPrefix($configurator->prefix);
163 288
                $group->addRoute($name, $route);
164
            }
165
166 288
            if ($name === RoutingConfigurator::DEFAULT_ROUTE_NAME) {
167 288
                $this->setDefault($route);
168
            }
169
        }
170
    }
171
172
    /**
173
     * Find route matched for given request.
174
     */
175 67
    protected function matchRoute(ServerRequestInterface $request, string &$routeName = null): ?RouteInterface
176
    {
177 67
        foreach ($this->routes as $name => $route) {
178
            // Matched route will return new route instance with matched parameters
179 63
            $matched = $route->match($request);
180
181 63
            if ($matched !== null) {
182 42
                $routeName = $name;
183 42
                return $matched;
184
            }
185
        }
186
187 26
        if ($this->default !== null) {
188 15
            return $this->default->match($request);
189
        }
190
191
        // unable to match any route
192 11
        return null;
193
    }
194
195
    /**
196
     * Configure route with needed dependencies.
197
     */
198 350
    protected function configure(RouteInterface $route): RouteInterface
199
    {
200 350
        if ($route instanceof ContainerizedInterface && !$route->hasContainer()) {
201
            // isolating route in a given container
202 345
            $route = $route->withContainer($this->container);
203
        }
204
205
        try {
206 348
            $uriHandler = $route->getUriHandler();
207 333
        } catch (\Throwable) {
208 333
            $uriHandler = $this->uriHandler;
209
        }
210
211 348
        return $route->withUriHandler($uriHandler->withBasePath($this->basePath));
212
    }
213
214
    /**
215
     * Locates appropriate route by name. Support dynamic route allocation using following pattern:
216
     * Named route:   `name/controller:action`
217
     * Default route: `controller:action`
218
     * Only action:   `name/action`
219
     *
220
     * @throws UndefinedRouteException
221
     */
222 9
    protected function castRoute(string $route): RouteInterface
223
    {
224
        if (
225 9
            !\preg_match(
226 9
                '/^(?:(?P<name>[^\/]+)\/)?(?:(?P<controller>[^:]+):+)?(?P<action>[a-z_\-]+)$/i',
227 9
                $route,
228 9
                $matches
229 9
            )
230
        ) {
231 1
            throw new UndefinedRouteException(
232 1
                "Unable to locate route or use default route with 'name/controller:action' pattern"
233 1
            );
234
        }
235
236
        /**
237
         * @var Matches $matches
238
         */
239 8
        if (!empty($matches['name'])) {
240 5
            $routeObject = $this->getRoute($matches['name']);
241 3
        } elseif ($this->default !== null) {
242 2
            $routeObject = $this->default;
243
        } else {
244 1
            throw new UndefinedRouteException(\sprintf('Unable to locate route candidate for `%s`', $route));
245
        }
246
247 7
        return $routeObject->withDefaults(
248 7
            [
249 7
                'controller' => $matches['controller'],
250 7
                'action' => $matches['action'],
251 7
            ]
252 7
        );
253
    }
254
}
255