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
|
527 |
|
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
|
527 |
|
$this->tracer = $tracer ?? new NullTracer(); |
|
|
|
|
57
|
527 |
|
$this->basePath = '/' . \ltrim($basePath, '/'); |
58
|
|
|
} |
59
|
|
|
|
60
|
|
|
/** |
61
|
|
|
* @throws RouteNotFoundException |
62
|
|
|
* @throws RouterException |
63
|
|
|
*/ |
64
|
66 |
|
public function handle(ServerRequestInterface $request): ResponseInterface |
65
|
|
|
{ |
66
|
66 |
|
$this->eventDispatcher?->dispatch(new Routing($request)); |
67
|
|
|
|
68
|
66 |
|
return $this->tracer->trace( |
69
|
66 |
|
name: 'Routing', |
70
|
66 |
|
callback: function (SpanInterface $span) use ($request): ResponseInterface { |
71
|
|
|
try { |
72
|
66 |
|
$route = $this->matchRoute($request, $routeName); |
73
|
|
|
} catch (RouteException $e) { |
74
|
|
|
throw new RouterException('Invalid route definition', $e->getCode(), $e); |
75
|
|
|
} |
76
|
|
|
|
77
|
66 |
|
if ($route === null) { |
78
|
12 |
|
$this->eventDispatcher?->dispatch(new RouteNotFound($request)); |
79
|
12 |
|
throw new RouteNotFoundException($request->getUri()); |
80
|
|
|
} |
81
|
|
|
|
82
|
55 |
|
$span |
83
|
55 |
|
->setAttribute('request.uri', (string) $request->getUri()) |
84
|
55 |
|
->setAttribute('route.name', $routeName) |
85
|
55 |
|
->setAttribute('route.matches', $route->getMatches() ?? []); |
86
|
|
|
|
87
|
55 |
|
$request = $request |
88
|
55 |
|
->withAttribute(self::ROUTE_ATTRIBUTE, $route) |
89
|
55 |
|
->withAttribute(self::ROUTE_NAME, $routeName) |
90
|
55 |
|
->withAttribute(self::ROUTE_MATCHES, $route->getMatches() ?? []); |
91
|
|
|
|
92
|
55 |
|
$this->eventDispatcher?->dispatch(new RouteMatched($request, $route)); |
93
|
|
|
|
94
|
55 |
|
return $route->handle($request); |
95
|
66 |
|
}, |
96
|
66 |
|
); |
97
|
|
|
} |
98
|
|
|
|
99
|
469 |
|
public function setRoute(string $name, RouteInterface $route): void |
100
|
|
|
{ |
101
|
|
|
// each route must inherit basePath prefix |
102
|
469 |
|
$this->routes[$name] = $this->configure($route); |
103
|
|
|
} |
104
|
|
|
|
105
|
364 |
|
public function setDefault(RouteInterface $route): void |
106
|
|
|
{ |
107
|
364 |
|
$this->default = $this->configure($route); |
108
|
|
|
} |
109
|
|
|
|
110
|
73 |
|
public function getRoute(string $name): RouteInterface |
111
|
|
|
{ |
112
|
73 |
|
if (isset($this->routes[$name])) { |
113
|
69 |
|
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
|
12 |
|
public function uri(string $route, iterable $parameters = []): UriInterface |
129
|
|
|
{ |
130
|
|
|
try { |
131
|
12 |
|
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
|
385 |
|
public function import(RoutingConfigurator $routes): void |
140
|
|
|
{ |
141
|
|
|
/** @var GroupRegistry $groups */ |
142
|
385 |
|
$groups = $this->container->get(GroupRegistry::class); |
143
|
|
|
|
144
|
385 |
|
foreach ($routes->getCollection() as $name => $configurator) { |
145
|
360 |
|
$target = $configurator->target; |
146
|
360 |
|
if ($configurator->core !== null && $target instanceof AbstractTarget) { |
147
|
358 |
|
$target = $target->withCore($configurator->core); |
|
|
|
|
148
|
|
|
} |
149
|
|
|
|
150
|
360 |
|
$pattern = \str_starts_with($configurator->pattern, '//') |
151
|
1 |
|
? $configurator->pattern |
152
|
359 |
|
: \ltrim($configurator->pattern, '/'); |
153
|
360 |
|
$route = new Route($pattern, $target, $configurator->defaults); |
154
|
|
|
|
155
|
360 |
|
if ($configurator->middleware !== null) { |
156
|
358 |
|
$route = $route->withMiddleware(...$configurator->middleware); |
157
|
|
|
} |
158
|
|
|
|
159
|
360 |
|
if ($configurator->methods !== null) { |
160
|
1 |
|
$route = $route->withVerbs(...$configurator->methods); |
161
|
|
|
} |
162
|
|
|
|
163
|
360 |
|
if (!isset($this->routes[$name]) && $name !== RoutingConfigurator::DEFAULT_ROUTE_NAME) { |
164
|
360 |
|
$group = $groups->getGroup($configurator->group ?? $groups->getDefaultGroup()); |
165
|
360 |
|
if ($configurator->prefix !== '') { |
166
|
358 |
|
$group->setPrefix($configurator->prefix); |
167
|
|
|
} |
168
|
360 |
|
$group->addRoute($name, $route); |
169
|
|
|
} |
170
|
|
|
|
171
|
360 |
|
if ($name === RoutingConfigurator::DEFAULT_ROUTE_NAME) { |
172
|
358 |
|
$this->setDefault($route); |
173
|
|
|
} |
174
|
|
|
} |
175
|
|
|
} |
176
|
|
|
|
177
|
|
|
/** |
178
|
|
|
* Find route matched for given request. |
179
|
|
|
*/ |
180
|
66 |
|
protected function matchRoute(ServerRequestInterface $request, ?string &$routeName = null): ?RouteInterface |
181
|
|
|
{ |
182
|
66 |
|
foreach ($this->routes as $name => $route) { |
183
|
|
|
// Matched route will return new route instance with matched parameters |
184
|
62 |
|
$matched = $route->match($request); |
185
|
|
|
|
186
|
62 |
|
if ($matched !== null) { |
187
|
41 |
|
$routeName = $name; |
188
|
41 |
|
return $matched; |
189
|
|
|
} |
190
|
|
|
} |
191
|
|
|
|
192
|
26 |
|
if ($this->default !== null) { |
193
|
15 |
|
return $this->default->match($request); |
194
|
|
|
} |
195
|
|
|
|
196
|
|
|
// unable to match any route |
197
|
11 |
|
return null; |
198
|
|
|
} |
199
|
|
|
|
200
|
|
|
/** |
201
|
|
|
* Configure route with needed dependencies. |
202
|
|
|
*/ |
203
|
474 |
|
protected function configure(RouteInterface $route): RouteInterface |
204
|
|
|
{ |
205
|
474 |
|
if ($route instanceof ContainerizedInterface && !$route->hasContainer()) { |
206
|
|
|
// isolating route in a given container |
207
|
469 |
|
$route = $route->withContainer($this->container); |
208
|
|
|
} |
209
|
|
|
|
210
|
|
|
try { |
211
|
472 |
|
$uriHandler = $route->getUriHandler(); |
212
|
447 |
|
} catch (\Throwable) { |
213
|
447 |
|
$uriHandler = $this->uriHandler; |
214
|
|
|
} |
215
|
|
|
|
216
|
472 |
|
return $route->withUriHandler($uriHandler->withBasePath($this->basePath)); |
217
|
|
|
} |
218
|
|
|
|
219
|
|
|
/** |
220
|
|
|
* Locates appropriate route by name. Support dynamic route allocation using following pattern: |
221
|
|
|
* Named route: `name/controller:action` |
222
|
|
|
* Default route: `controller:action` |
223
|
|
|
* Only action: `name/action` |
224
|
|
|
* |
225
|
|
|
* @throws UndefinedRouteException |
226
|
|
|
*/ |
227
|
9 |
|
protected function castRoute(string $route): RouteInterface |
228
|
|
|
{ |
229
|
|
|
if ( |
230
|
9 |
|
!\preg_match( |
231
|
9 |
|
'/^(?:(?P<name>[^\/]+)\/)?(?:(?P<controller>[^:]+):+)?(?P<action>[a-z_\-]+)$/i', |
232
|
9 |
|
$route, |
233
|
9 |
|
$matches, |
234
|
9 |
|
) |
235
|
|
|
) { |
236
|
1 |
|
throw new UndefinedRouteException( |
237
|
1 |
|
"Unable to locate route or use default route with 'name/controller:action' pattern", |
238
|
1 |
|
); |
239
|
|
|
} |
240
|
|
|
|
241
|
|
|
/** |
242
|
|
|
* @var Matches $matches |
243
|
|
|
*/ |
244
|
8 |
|
if (!empty($matches['name'])) { |
245
|
5 |
|
$routeObject = $this->getRoute($matches['name']); |
246
|
3 |
|
} elseif ($this->default !== null) { |
247
|
2 |
|
$routeObject = $this->default; |
248
|
|
|
} else { |
249
|
1 |
|
throw new UndefinedRouteException(\sprintf('Unable to locate route candidate for `%s`', $route)); |
250
|
|
|
} |
251
|
|
|
|
252
|
7 |
|
return $routeObject->withDefaults( |
253
|
7 |
|
[ |
254
|
7 |
|
'controller' => $matches['controller'], |
255
|
7 |
|
'action' => $matches['action'], |
256
|
7 |
|
], |
257
|
7 |
|
); |
258
|
|
|
} |
259
|
|
|
} |
260
|
|
|
|