1 | <?php |
||
2 | namespace Lead\Router; |
||
3 | |||
4 | use Closure; |
||
5 | use Psr\Http\Message\RequestInterface; |
||
6 | use Lead\Router\ParseException; |
||
7 | use Lead\Router\RouterException; |
||
8 | |||
9 | /** |
||
10 | * The Router class. |
||
11 | */ |
||
12 | class Router extends \Lead\Collection\Collection |
||
13 | { |
||
14 | /** |
||
15 | * Class dependencies. |
||
16 | * |
||
17 | * @var array |
||
18 | */ |
||
19 | protected $_classes = []; |
||
20 | |||
21 | /** |
||
22 | * Hosts. |
||
23 | * |
||
24 | * @var array |
||
25 | */ |
||
26 | protected $_hosts = []; |
||
27 | |||
28 | /** |
||
29 | * Routes. |
||
30 | * |
||
31 | * @var array |
||
32 | */ |
||
33 | protected $_routes = []; |
||
34 | |||
35 | /** |
||
36 | * Scopes stack. |
||
37 | * |
||
38 | * @var array |
||
39 | */ |
||
40 | protected $_scopes = []; |
||
41 | |||
42 | /** |
||
43 | * Base path. |
||
44 | * |
||
45 | * @param string |
||
46 | */ |
||
47 | protected $_basePath = ''; |
||
48 | |||
49 | /** |
||
50 | * Dispatching strategies. |
||
51 | * |
||
52 | * @param array |
||
53 | */ |
||
54 | protected $_strategies = []; |
||
55 | |||
56 | /** |
||
57 | * Defaults parameters to use when generating URLs in a dispatching context. |
||
58 | * |
||
59 | * @var array |
||
60 | */ |
||
61 | protected $_defaults = []; |
||
62 | |||
63 | /** |
||
64 | * Constructor |
||
65 | * |
||
66 | * @param array $config |
||
67 | */ |
||
68 | public function __construct($config = []) |
||
69 | { |
||
70 | $defaults = [ |
||
71 | 'basePath' => '', |
||
72 | 'scope' => [], |
||
73 | 'strategies' => [], |
||
74 | 'classes' => [ |
||
75 | 'parser' => 'Lead\Router\Parser', |
||
76 | 'host' => 'Lead\Router\Host', |
||
77 | 'route' => 'Lead\Router\Route', |
||
78 | 'scope' => 'Lead\Router\Scope' |
||
79 | ] |
||
80 | 31 | ]; |
|
81 | 31 | $config += $defaults; |
|
82 | 31 | $this->_classes = $config['classes']; |
|
83 | 31 | $this->_strategies = $config['strategies']; |
|
84 | 31 | $this->basePath($config['basePath']); |
|
85 | |||
86 | 31 | $scope = $this->_classes['scope']; |
|
87 | 31 | $this->_scopes[] = new $scope(['router' => $this]); |
|
88 | } |
||
89 | |||
90 | /** |
||
91 | * Returns the current router scope. |
||
92 | * |
||
93 | * @return object The current scope instance. |
||
94 | */ |
||
95 | public function scope() |
||
96 | { |
||
97 | 15 | return end($this->_scopes); |
|
98 | } |
||
99 | |||
100 | /** |
||
101 | * Pushes a new router scope context. |
||
102 | * |
||
103 | * @param object $scope A scope instance. |
||
104 | * @return self |
||
105 | */ |
||
106 | public function pushScope($scope) |
||
107 | { |
||
108 | 15 | $this->_scopes[] = $scope; |
|
109 | 15 | return $this; |
|
110 | } |
||
111 | |||
112 | /** |
||
113 | * Pops the current router scope context. |
||
114 | * |
||
115 | * @return object The poped scope instance. |
||
116 | */ |
||
117 | public function popScope() |
||
118 | { |
||
119 | 15 | return array_pop($this->_scopes); |
|
120 | } |
||
121 | |||
122 | /** |
||
123 | * Gets/sets the base path of the router. |
||
124 | * |
||
125 | * @param string $basePath The base path to set or none to get the setted one. |
||
126 | * @return string|self |
||
127 | */ |
||
128 | public function basePath($basePath = null) |
||
129 | { |
||
130 | if (!func_num_args()) { |
||
131 | 6 | return $this->_basePath; |
|
132 | } |
||
133 | 31 | $basePath = trim($basePath, '/'); |
|
134 | 31 | $this->_basePath = $basePath ? '/' . $basePath : ''; |
|
135 | 31 | return $this; |
|
136 | } |
||
137 | |||
138 | /** |
||
139 | * Adds a route. |
||
140 | * |
||
141 | * @param string|array $pattern The route's pattern. |
||
142 | * @param Closure|array $options An array of options or the callback handler. |
||
143 | * @param Closure|null $handler The callback handler. |
||
144 | * @return self |
||
145 | */ |
||
146 | public function bind($pattern, $options = [], $handler = null) |
||
147 | { |
||
148 | if (!is_array($options)) { |
||
149 | 18 | $handler = $options; |
|
150 | 18 | $options = []; |
|
151 | } |
||
152 | if (!$handler instanceof Closure && !method_exists($handler, '__invoke')) { |
||
153 | 2 | throw new RouterException("The handler needs to be an instance of `Closure` or implements the `__invoke()` magic method."); |
|
154 | } |
||
155 | |||
156 | if (isset($options['method'])) { |
||
157 | 2 | throw new RouterException("Use the `'methods'` option to limit HTTP verbs on a route binding definition."); |
|
158 | } |
||
159 | |||
160 | 25 | $scope = end($this->_scopes); |
|
161 | 25 | $options = $scope->scopify($options); |
|
162 | 25 | $options['pattern'] = $pattern; |
|
163 | 25 | $options['handler'] = $handler; |
|
164 | 25 | $options['scope'] = $scope; |
|
165 | |||
166 | 25 | $scheme = $options['scheme']; |
|
167 | 25 | $host = $options['host']; |
|
168 | |||
169 | if (isset($this->_hosts[$scheme][$host])) { |
||
170 | 25 | $options['host'] = $this->_hosts[$scheme][$host]; |
|
171 | } |
||
172 | |||
173 | 25 | $route = $this->_classes['route']; |
|
174 | 25 | $instance = new $route($options); |
|
175 | 25 | $this->_hosts[$scheme][$host] = $instance->host(); |
|
176 | 25 | $methods = $options['methods'] ? (array) $options['methods'] : []; |
|
177 | |||
178 | if (!isset($this->_routes[$scheme][$host])) { |
||
179 | 25 | $this->_routes[$scheme][$host]['HEAD'] = []; |
|
180 | } |
||
181 | |||
182 | foreach ($methods as $method) { |
||
183 | 25 | $this->_routes[$scheme][$host][strtoupper($method)][] = $instance; |
|
184 | } |
||
185 | |||
186 | if (isset($options['name'])) { |
||
187 | 25 | $this->_data[$options['name']] = $instance; |
|
188 | } |
||
189 | 25 | return $instance; |
|
190 | } |
||
191 | |||
192 | /** |
||
193 | * Groups some routes inside a new scope. |
||
194 | * |
||
195 | * @param string|array $prefix The group's prefix pattern or the options array. |
||
196 | * @param Closure|array $options An array of options or the callback handler. |
||
197 | * @param Closure|null $handler The callback handler. |
||
198 | * @return object The newly created scope instance. |
||
199 | */ |
||
200 | public function group($prefix, $options, $handler = null) |
||
201 | { |
||
202 | if (!is_array($options)) { |
||
203 | 14 | $handler = $options; |
|
204 | if (is_string($prefix)) { |
||
205 | 12 | $options = []; |
|
206 | } else { |
||
207 | 2 | $options = $prefix; |
|
208 | 2 | $prefix = ''; |
|
209 | } |
||
210 | } |
||
211 | if (!$handler instanceof Closure && !method_exists($handler, '__invoke')) { |
||
212 | 2 | throw new RouterException("The handler needs to be an instance of `Closure` or implements the `__invoke()` magic method."); |
|
213 | } |
||
214 | |||
215 | 15 | $options['prefix'] = isset($options['prefix']) ? $options['prefix'] : $prefix; |
|
216 | |||
217 | 15 | $scope = $this->scope(); |
|
218 | |||
219 | 15 | $this->pushScope($scope->seed($options)); |
|
220 | |||
221 | 15 | $handler($this); |
|
222 | |||
223 | 15 | return $this->popScope(); |
|
224 | } |
||
225 | |||
226 | /** |
||
227 | * Routes a Request. |
||
228 | * |
||
229 | * @param mixed $request The request to route. |
||
230 | * @return object A route matching the request or a "route not found" route. |
||
231 | */ |
||
232 | public function route($request) |
||
233 | { |
||
234 | $defaults = [ |
||
235 | 'path' => '', |
||
236 | 'method' => 'GET', |
||
237 | 'host' => '*', |
||
238 | 'scheme' => '*' |
||
239 | 24 | ]; |
|
240 | |||
241 | 24 | $this->_defaults = []; |
|
242 | |||
243 | if ($request instanceof RequestInterface) { |
||
244 | 2 | $uri = $request->getUri(); |
|
245 | $r = [ |
||
246 | 'scheme' => $uri->getScheme(), |
||
247 | 'host' => $uri->getHost(), |
||
248 | 'method' => $request->getMethod(), |
||
249 | 'path' => $uri->getPath() |
||
250 | 2 | ]; |
|
251 | if (method_exists($request, 'basePath')) { |
||
252 | 2 | $this->basePath($request->basePath()); |
|
253 | } |
||
254 | } elseif (!is_array($request)) { |
||
255 | 24 | $r = array_combine(array_keys($defaults), func_get_args() + array_values($defaults)); |
|
256 | } else { |
||
257 | 2 | $r = $request + $defaults; |
|
258 | } |
||
259 | 24 | $r = $this->_normalizeRequest($r); |
|
260 | |||
261 | if ($route = $this->_route($r)) { |
||
262 | 24 | $route->request = is_object($request) ? $request : $r; |
|
263 | foreach ($route->persist as $key) { |
||
264 | if (isset($route->params[$key])) { |
||
265 | 2 | $this->_defaults[$key] = $route->params[$key]; |
|
266 | } |
||
267 | } |
||
268 | } else { |
||
269 | 14 | $route = $this->_classes['route']; |
|
270 | 14 | $error = $route::NOT_FOUND; |
|
271 | 14 | $message = "No route found for `{$r['scheme']}:{$r['host']}:{$r['method']}:/{$r['path']}`."; |
|
272 | 14 | $route = new $route(compact('error', 'message')); |
|
273 | } |
||
274 | |||
275 | 24 | return $route; |
|
276 | } |
||
277 | |||
278 | /** |
||
279 | * Normalizes a request. |
||
280 | * |
||
281 | * @param array $request The request to normalize. |
||
282 | * @return array The normalized request. |
||
283 | */ |
||
284 | protected function _normalizeRequest($request) |
||
285 | { |
||
286 | if (preg_match('~^(?:[a-z]+:)?//~i', $request['path'])) { |
||
287 | 6 | $parsed = array_intersect_key(parse_url($request['path']), $request); |
|
288 | 6 | $request = $parsed + $request; |
|
289 | } |
||
290 | 24 | $request['path'] = (ltrim(strtok($request['path'], '?'), '/')); |
|
291 | 24 | $request['method'] = strtoupper($request['method']); |
|
292 | 24 | return $request; |
|
293 | } |
||
294 | |||
295 | /** |
||
296 | * Routes a request. |
||
297 | * |
||
298 | * @param array $request The request to route. |
||
299 | */ |
||
300 | protected function _route($request) |
||
301 | { |
||
302 | 24 | $path = $request['path']; |
|
0 ignored issues
–
show
Unused Code
introduced
by
![]() |
|||
303 | 24 | $httpMethod = $request['method']; |
|
304 | 24 | $host = $request['host']; |
|
0 ignored issues
–
show
|
|||
305 | 24 | $scheme = $request['scheme']; |
|
306 | |||
307 | 24 | $allowedSchemes = array_unique([$scheme => $scheme, '*' => '*']); |
|
308 | 24 | $allowedMethods = array_unique([$httpMethod => $httpMethod, '*' => '*']); |
|
309 | |||
310 | if ($httpMethod === 'HEAD') { |
||
311 | 2 | $allowedMethods += ['GET' => 'GET']; |
|
312 | } |
||
313 | |||
314 | foreach ($this->_routes as $scheme => $hostBasedRoutes) { |
||
315 | if (!isset($allowedSchemes[$scheme])) { |
||
316 | 2 | continue; |
|
317 | } |
||
318 | foreach ($hostBasedRoutes as $routeHost => $methodBasedRoutes) { |
||
319 | foreach ($methodBasedRoutes as $method => $routes) { |
||
320 | if (!isset($allowedMethods[$method]) && $httpMethod !== '*') { |
||
321 | 24 | continue; |
|
322 | } |
||
323 | foreach ($routes as $route) { |
||
324 | if (!$route->match($request, $variables, $hostVariables)) { |
||
325 | if ($hostVariables === null) { |
||
326 | 4 | continue 3; |
|
327 | } |
||
328 | 10 | continue; |
|
329 | } |
||
330 | 24 | return $route; |
|
331 | } |
||
332 | } |
||
333 | } |
||
334 | } |
||
335 | } |
||
336 | |||
337 | /** |
||
338 | * Middleware generator. |
||
339 | * |
||
340 | * @return callable |
||
341 | */ |
||
342 | public function middleware() |
||
343 | { |
||
344 | foreach ($this->_scopes[0]->middleware() as $middleware) { |
||
345 | 2 | yield $middleware; |
|
346 | } |
||
347 | } |
||
348 | |||
349 | /** |
||
350 | * Adds a middleware to the list of middleware. |
||
351 | * |
||
352 | * @param object|Closure A callable middleware. |
||
353 | */ |
||
354 | public function apply($middleware) |
||
355 | { |
||
356 | foreach (func_get_args() as $mw) { |
||
357 | 4 | $this->_scopes[0]->apply($mw); |
|
358 | } |
||
359 | 4 | return $this; |
|
360 | } |
||
361 | |||
362 | /** |
||
363 | * Gets/sets router's strategies. |
||
364 | * |
||
365 | * @param string $name A routing strategy name. |
||
366 | * @param mixed $handler The strategy handler or none to get the setted one. |
||
367 | * @return mixed The strategy handler (or `null` if not found) on get or `$this` on set. |
||
368 | */ |
||
369 | public function strategy($name, $handler = null) |
||
370 | { |
||
371 | if (func_num_args() === 1) { |
||
372 | if (!isset($this->_strategies[$name])) { |
||
373 | 6 | return; |
|
374 | } |
||
375 | 2 | return $this->_strategies[$name]; |
|
376 | } |
||
377 | if ($handler === false) { |
||
378 | 2 | unset($this->_strategies[$name]); |
|
379 | 2 | return; |
|
380 | } |
||
381 | if (!$handler instanceof Closure && !method_exists($handler, '__invoke')) { |
||
382 | 2 | throw new RouterException("The handler needs to be an instance of `Closure` or implements the `__invoke()` magic method."); |
|
383 | } |
||
384 | 2 | $this->_strategies[$name] = $handler; |
|
385 | 2 | return $this; |
|
386 | } |
||
387 | |||
388 | /** |
||
389 | * Adds a route based on a custom HTTP verb. |
||
390 | * |
||
391 | * @param string $name The HTTP verb to define a route on. |
||
392 | * @param array $params The route's parameters. |
||
393 | */ |
||
394 | public function __call($name, $params) |
||
395 | { |
||
396 | if ($strategy = $this->strategy($name)) { |
||
397 | 2 | array_unshift($params, $this); |
|
398 | 2 | return call_user_func_array($strategy, $params); |
|
399 | } |
||
400 | if (is_callable($params[1])) { |
||
401 | 4 | $params[2] = $params[1]; |
|
402 | 4 | $params[1] = []; |
|
403 | } |
||
404 | 4 | $params[1]['methods'] = [$name]; |
|
405 | 4 | return call_user_func_array([$this, 'bind'], $params); |
|
406 | } |
||
407 | |||
408 | /** |
||
409 | * Returns a route's link. |
||
410 | * |
||
411 | * @param string $name A route name. |
||
412 | * @param array $params The route parameters. |
||
413 | * @param array $options Options for generating the proper prefix. Accepted values are: |
||
414 | * - `'absolute'` _boolean_: `true` or `false`. |
||
415 | * - `'scheme'` _string_ : The scheme. |
||
416 | * - `'host'` _string_ : The host name. |
||
417 | * - `'basePath'` _string_ : The base path. |
||
418 | * - `'query'` _string_ : The query string. |
||
419 | * - `'fragment'` _string_ : The fragment string. |
||
420 | * @return string The link. |
||
421 | */ |
||
422 | public function link($name, $params = [], $options = []) |
||
423 | { |
||
424 | $defaults = [ |
||
425 | 'basePath' => $this->basePath() |
||
426 | 2 | ]; |
|
427 | 2 | $options += $defaults; |
|
428 | |||
429 | 2 | $params += $this->_defaults; |
|
430 | |||
431 | if (!isset($this[$name])) { |
||
432 | 2 | throw new RouterException("No binded route defined for `'{$name}'`, bind it first with `bind()`."); |
|
433 | } |
||
434 | 2 | $route = $this[$name]; |
|
435 | 2 | return $route->link($params, $options); |
|
436 | } |
||
437 | |||
438 | /** |
||
439 | * Clears the router. |
||
440 | */ |
||
441 | public function clear() |
||
442 | { |
||
443 | 2 | $this->_basePath = ''; |
|
444 | 2 | $this->_strategies = []; |
|
445 | 2 | $this->_defaults = []; |
|
446 | 2 | $this->_routes = []; |
|
447 | 2 | $scope = $this->_classes['scope']; |
|
448 | 2 | $this->_scopes = [new $scope(['router' => $this])]; |
|
449 | } |
||
450 | } |
||
451 |