Total Complexity | 54 |
Total Lines | 531 |
Duplicated Lines | 0 % |
Changes | 4 | ||
Bugs | 0 | Features | 0 |
Complex classes like Router often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.
Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.
While breaking up the class, it is a good idea to analyze how other classes use Router, and based on these observations, apply Extract Interface, too.
1 | <?php |
||
67 | class Router |
||
68 | { |
||
69 | /** |
||
70 | * The default values of class parameters. |
||
71 | * |
||
72 | * @var array |
||
73 | */ |
||
74 | public const DEFAULTS = [ |
||
75 | 'base' => '/', |
||
76 | 'allowMultiMatch' => true, |
||
77 | 'caseMatters' => false, |
||
78 | 'slashMatters' => true, |
||
79 | ]; |
||
80 | |||
81 | /** |
||
82 | * The default values of class parameters. |
||
83 | * |
||
84 | * @var array |
||
85 | */ |
||
86 | public const SUPPORTED_METHODS = [ |
||
87 | 'GET', |
||
88 | 'HEAD', |
||
89 | 'POST', |
||
90 | 'PUT', |
||
91 | 'PATCH', |
||
92 | 'DELETE', |
||
93 | 'CONNECT', |
||
94 | 'OPTIONS', |
||
95 | 'TRACE' |
||
96 | ]; |
||
97 | |||
98 | |||
99 | /** |
||
100 | * The parameters the application started with. |
||
101 | */ |
||
102 | private static ?array $params = null; |
||
103 | |||
104 | /** |
||
105 | * The current base URL of the application. |
||
106 | */ |
||
107 | protected static ?string $base = null; |
||
108 | |||
109 | /** |
||
110 | * The currently requested path. |
||
111 | */ |
||
112 | protected static ?string $path = null; |
||
113 | |||
114 | /** |
||
115 | * The currently registered routes. |
||
116 | */ |
||
117 | protected static array $routes = []; |
||
118 | |||
119 | /** |
||
120 | * @var callable|null |
||
121 | */ |
||
122 | protected static $routeNotFoundCallback = null; |
||
123 | |||
124 | /** |
||
125 | * @var callable|null |
||
126 | */ |
||
127 | protected static $methodNotAllowedCallback = null; |
||
128 | |||
129 | |||
130 | /** |
||
131 | * Registers a handler for a route. |
||
132 | * |
||
133 | * @param string $expression A route like `/page`, `/page/{id}` (`id` is required), or `/page/{id?}` (`id` is optional). For more flexibility, pass en expression like `/page/([\d]+|[0-9]*)` (regex capture group). |
||
134 | * @param callable $handler A function to call if route has matched. It will be passed the current `$path`, the `$match` or `...$match` from the expression if there was any, and lastly the `$previous` result (the return of the last middleware or route with a matching expression) if `$allowMultiMatch` is set to `true`. |
||
135 | * @param string|string[] $method Either a string or an array of the allowed method. |
||
136 | * |
||
137 | * @return static |
||
138 | */ |
||
139 | public static function handle(string $expression, callable $handler, $method = 'GET') |
||
140 | { |
||
141 | static::$routes[] = [ |
||
142 | 'expression' => $expression, |
||
143 | 'handler' => $handler, |
||
144 | 'arguments' => [], |
||
145 | 'method' => $method |
||
146 | ]; |
||
147 | |||
148 | return new static(); |
||
149 | } |
||
150 | |||
151 | /** |
||
152 | * Registers a middleware for a route. This method has no effect if `$allowMultiMatch` is set to `false`. |
||
153 | * Note that middlewares must be registered before routes in order to work correctly. |
||
154 | * This method is just an alias for `self::handle()`. |
||
155 | * |
||
156 | * @param string $expression A route like `/page`, `/page/{id}` (`id` is required), or `/page/{id?}` (`id` is optional). For more flexibility, pass en expression like `/page/([\d]+|[0-9]*)` (regex capture group). |
||
157 | * @param callable $handler A function to call if route has matched. It will be passed the current `$path`, the `$match` or `...$match` from the expression if there was any, and lastly the `$previous` result (the return of the last middleware or route with a matching expression) if `$allowMultiMatch` is set to `true`. |
||
158 | * @param string|string[] $method Either a string or an array of the allowed method. |
||
159 | * |
||
160 | * @return static |
||
161 | */ |
||
162 | public static function middleware(string $expression, callable $handler, $method = 'GET') |
||
163 | { |
||
164 | return static::handle($expression, $handler, $method); |
||
165 | } |
||
166 | |||
167 | /** |
||
168 | * Redirects the request to another route. |
||
169 | * Note that this function will exit the script (code that comes after it will not be executed). |
||
170 | * |
||
171 | * @param string $to A route like `/page`. |
||
172 | * |
||
173 | * @return void |
||
174 | */ |
||
175 | public static function redirect(string $to): void |
||
176 | { |
||
177 | if (filter_var($to, FILTER_VALIDATE_URL)) { |
||
178 | $header = sprintf('Location: %s', $to); |
||
179 | } else { |
||
180 | $scheme = static::isHttpsCompliant() ? 'https' : 'http'; |
||
181 | $host = static::getServerHost(); |
||
182 | $path = static::$base . '/' . $to; |
||
183 | $path = trim(preg_replace('/(\/+)/', '/', $path), '/'); |
||
184 | |||
185 | $header = sprintf('Location: %s://%s/%s', $scheme, $host, $path); |
||
186 | } |
||
187 | |||
188 | header($header, true, 302); |
||
189 | |||
190 | exit; |
||
191 | } |
||
192 | |||
193 | /** |
||
194 | * Forwards the request to another route. |
||
195 | * Note that this function will exit the script (code that comes after it will not be executed). |
||
196 | * |
||
197 | * @param string $to A route like `/page`. |
||
198 | * |
||
199 | * @return void |
||
200 | */ |
||
201 | public static function forward(string $to): void |
||
202 | { |
||
203 | $base = static::$base ?? ''; |
||
204 | $path = trim($base, '/') . '/' . ltrim($to, '/'); |
||
205 | |||
206 | static::setRequestUri($path); |
||
207 | static::start(...self::$params); |
||
208 | |||
209 | exit; |
||
|
|||
210 | } |
||
211 | |||
212 | /** |
||
213 | * Registers 404 handler. |
||
214 | * |
||
215 | * @param callable $handler The handler to use. It will be passed the current `$path` and the current `$method`. |
||
216 | * |
||
217 | * @return static |
||
218 | */ |
||
219 | public static function handleRouteNotFound(callable $handler) |
||
220 | { |
||
221 | static::$routeNotFoundCallback = $handler; |
||
222 | |||
223 | return new static(); |
||
224 | } |
||
225 | |||
226 | /** |
||
227 | * Registers 405 handler. |
||
228 | * |
||
229 | * @param callable $handler The handler to use. It will be passed the current `$path`. |
||
230 | * |
||
231 | * @return static |
||
232 | */ |
||
233 | public static function handleMethodNotAllowed(callable $handler) |
||
234 | { |
||
235 | static::$methodNotAllowedCallback = $handler; |
||
236 | |||
237 | return new static(); |
||
238 | } |
||
239 | |||
240 | /** |
||
241 | * Starts the router. |
||
242 | * |
||
243 | * @param string|null [optional] $base App base path, this will prefix all routes. |
||
244 | * @param bool|null [optional] $allowMultiMatch Whether the router should execute handlers of all matches. Useful to make middleware-like functionality, the first match will act as a middleware. |
||
245 | * @param bool|null [optional] $caseMatters Whether the route matching should be case sensitive or not. |
||
246 | * @param bool|null [optional] $slashMatters Whether trailing slash should be taken in consideration with route matching or not. |
||
247 | * |
||
248 | * @return void |
||
249 | * |
||
250 | * @throws \Exception If route handler failed or returned false. |
||
251 | */ |
||
252 | public static function start(?string $base = null, ?bool $allowMultiMatch = null, ?bool $caseMatters = null, ?bool $slashMatters = null): void |
||
301 | } |
||
302 | |||
303 | /** |
||
304 | * Returns valid parameters for `self::start()` by validating the passed parameters and adding the deficiency from router config. |
||
305 | * |
||
306 | * @param string|null $base |
||
307 | * @param bool|null $allowMultiMatch |
||
308 | * @param bool|null $caseMatters |
||
309 | * @param bool|null $slashMatters |
||
310 | * |
||
311 | * @return array |
||
312 | */ |
||
313 | private static function getValidParameters(?string $base, ?bool $allowMultiMatch, ?bool $caseMatters, ?bool $slashMatters): array |
||
314 | { |
||
315 | $routerConfig = Config::get('router'); |
||
316 | |||
317 | $base ??= $routerConfig['base']; |
||
318 | $allowMultiMatch ??= $routerConfig['allowMultiMatch']; |
||
319 | $caseMatters ??= $routerConfig['caseMatters']; |
||
320 | $slashMatters ??= $routerConfig['slashMatters']; |
||
321 | |||
322 | return [ |
||
323 | $base, |
||
324 | $allowMultiMatch, |
||
325 | $caseMatters, |
||
326 | $slashMatters, |
||
327 | ]; |
||
328 | } |
||
329 | |||
330 | /** |
||
331 | * Returns the a valid decoded route path. |
||
332 | * |
||
333 | * @param string $base |
||
334 | * @param bool $slashMatters |
||
335 | * |
||
336 | * @return string |
||
337 | */ |
||
338 | private static function getRoutePath(string $base, bool $slashMatters): string |
||
339 | { |
||
340 | $url = static::getParsedUrl(); |
||
341 | |||
342 | $path = '/'; |
||
343 | if (isset($url['path'])) { |
||
344 | $path = $url['path']; |
||
345 | if (!$slashMatters && $path !== $base . '/') { |
||
346 | $path = rtrim($path, '/'); |
||
347 | } |
||
348 | } |
||
349 | |||
350 | return urldecode($path); |
||
351 | } |
||
352 | |||
353 | /** |
||
354 | * Returns a valid route regex. |
||
355 | * |
||
356 | * @param string $expression |
||
357 | * @param bool $caseMatters |
||
358 | * |
||
359 | * @return string |
||
360 | */ |
||
361 | private static function getRouteRegex(string $expression, bool $caseMatters): string |
||
362 | { |
||
363 | $routePlaceholderRegex = '/{([a-z0-9_\-\.?]+)}/i'; |
||
364 | if (preg_match($routePlaceholderRegex, $expression)) { |
||
365 | $routeMatchRegex = strpos($expression, '?}') !== false ? '(.*)?' : '(.+)'; |
||
366 | $expression = preg_replace( |
||
367 | $routePlaceholderRegex, |
||
368 | $routeMatchRegex, |
||
369 | $expression |
||
370 | ); |
||
371 | } |
||
372 | |||
373 | return sprintf('<^%s$>%s', $expression, ($caseMatters ? 'iu' : 'u')); |
||
374 | } |
||
375 | |||
376 | /** |
||
377 | * Returns valid arguments for route handler with the order thy expect. |
||
378 | * |
||
379 | * @param array $current |
||
380 | * @param array $matches |
||
381 | * @param mixed $result |
||
382 | * |
||
383 | * @return array |
||
384 | */ |
||
385 | private static function getRouteArguments(array $current, array $matches, $result): array |
||
386 | { |
||
387 | $arguments = array_merge($current, $matches); |
||
388 | $arguments = array_filter($arguments); |
||
389 | if (count($arguments) > 1) { |
||
390 | array_push($arguments, $result); |
||
391 | } else { |
||
392 | array_push($arguments, null, $result); |
||
393 | } |
||
394 | |||
395 | return $arguments; |
||
396 | } |
||
397 | |||
398 | /** |
||
399 | * Echos the response according to the passed parameters. |
||
400 | * |
||
401 | * @param bool $routeMatchFound |
||
402 | * @param bool $pathMatchFound |
||
403 | * @param mixed $result |
||
404 | * |
||
405 | * @return void |
||
406 | */ |
||
407 | private static function echoResponse(bool $routeMatchFound, bool $pathMatchFound, $result): void |
||
408 | { |
||
409 | $protocol = static::getServerProtocol(); |
||
410 | $method = static::getRequestMethod(); |
||
411 | |||
412 | if (!$routeMatchFound) { |
||
413 | $result = 'The route is not found, or the request method is not allowed!'; |
||
414 | |||
415 | if ($pathMatchFound) { |
||
416 | if (static::$methodNotAllowedCallback) { |
||
417 | $result = call_user_func(static::$methodNotAllowedCallback, static::$path, $method); |
||
418 | |||
419 | header("{$protocol} 405 Method Not Allowed", true, 405); |
||
420 | } |
||
421 | |||
422 | Misc::log( |
||
423 | 'Responded with 405 to the request for "{path}" with method "{method}"', |
||
424 | ['path' => static::$path, 'method' => $method], |
||
425 | 'system' |
||
426 | ); |
||
427 | } else { |
||
428 | if (static::$routeNotFoundCallback) { |
||
429 | $result = call_user_func(static::$routeNotFoundCallback, static::$path); |
||
430 | |||
431 | header("{$protocol} 404 Not Found", true, 404); |
||
432 | } |
||
433 | |||
434 | Misc::log( |
||
435 | 'Responded with 404 to the request for "{path}"', |
||
436 | ['path' => static::$path], |
||
437 | 'system' |
||
438 | ); |
||
439 | } |
||
440 | } else { |
||
441 | header("{$protocol} 200 OK", false, 200); |
||
442 | } |
||
443 | |||
444 | echo $result; |
||
445 | } |
||
446 | |||
447 | /** |
||
448 | * Returns query parameters. |
||
449 | * |
||
450 | * @return array |
||
451 | */ |
||
452 | public static function getParsedQuery(): array |
||
453 | { |
||
454 | $url = static::getParsedUrl(); |
||
455 | |||
456 | parse_str($url['query'] ?? '', $query); |
||
457 | |||
458 | return $query; |
||
459 | } |
||
460 | |||
461 | /** |
||
462 | * Returns components of the current URL. |
||
463 | * |
||
464 | * @return array |
||
465 | */ |
||
466 | public static function getParsedUrl(): array |
||
467 | { |
||
468 | $uri = static::getRequestUri(); |
||
469 | |||
470 | // remove double slashes as they make parse_url() fail |
||
471 | $url = preg_replace('/(\/+)/', '/', $uri); |
||
472 | $url = parse_url($url); |
||
473 | |||
474 | return $url; |
||
475 | } |
||
476 | |||
477 | /** |
||
478 | * Returns `php://input`. |
||
479 | * |
||
480 | * @return string |
||
481 | */ |
||
482 | public static function getInput(): string |
||
483 | { |
||
484 | return file_get_contents('php://input') ?: ''; |
||
485 | } |
||
486 | |||
487 | /** |
||
488 | * Returns the currently requested route. |
||
489 | * |
||
490 | * @return string |
||
491 | */ |
||
492 | public static function getCurrent(): string |
||
493 | { |
||
494 | return static::getRequestUri(); |
||
495 | } |
||
496 | |||
497 | protected static function getServerHost(): string |
||
498 | { |
||
499 | return $_SERVER['HTTP_HOST']; |
||
500 | } |
||
501 | |||
502 | protected static function getServerProtocol(): string |
||
503 | { |
||
504 | return $_SERVER['SERVER_PROTOCOL']; |
||
505 | } |
||
506 | |||
507 | protected static function getRequestMethod(): string |
||
508 | { |
||
509 | $method = $_POST['_method'] ?? ''; |
||
510 | $methods = static::SUPPORTED_METHODS; |
||
511 | $methodAllowed = in_array( |
||
512 | strtoupper($method), |
||
513 | array_map('strtoupper', $methods) |
||
514 | ); |
||
515 | |||
516 | if ($methodAllowed) { |
||
517 | static::setRequestMethod($method); |
||
518 | } |
||
519 | |||
520 | return $_SERVER['REQUEST_METHOD']; |
||
521 | } |
||
522 | |||
523 | protected static function setRequestMethod(string $method): void |
||
526 | } |
||
527 | |||
528 | protected static function getRequestUri(): string |
||
529 | { |
||
530 | return $_SERVER['REQUEST_URI']; |
||
531 | } |
||
532 | |||
533 | protected static function setRequestUri(string $uri): void |
||
534 | { |
||
535 | $_SERVER['REQUEST_URI'] = $uri; |
||
536 | } |
||
537 | |||
538 | protected static function isHttpsCompliant(): bool |
||
541 | } |
||
542 | |||
543 | /** |
||
544 | * Returns all registered routes with their `expression`, `handler`, `arguments`, and `method`. |
||
545 | * |
||
546 | * @return array |
||
547 | */ |
||
548 | public static function getRegisteredRoutes(): array |
||
549 | { |
||
550 | return static::$routes; |
||
551 | } |
||
552 | |||
553 | |||
554 | /** |
||
555 | * Class constructor. |
||
556 | */ |
||
557 | final public function __construct() |
||
559 | // prevent overwriting constructor in subclasses to allow to use |
||
560 | // "return new static()" without caring about dependencies. |
||
561 | } |
||
562 | |||
563 | /** |
||
564 | * Aliases `self::handle()` method with common HTTP verbs. |
||
565 | */ |
||
566 | public static function __callStatic(string $method, array $arguments) |
||
567 | { |
||
568 | $methods = static::SUPPORTED_METHODS; |
||
569 | $methodAllowed = in_array( |
||
570 | strtoupper($method), |
||
571 | array_map('strtoupper', ['ANY', ...$methods]) |
||
572 | ); |
||
573 | |||
574 | if (!$methodAllowed) { |
||
575 | $class = static::class; |
||
576 | throw new \Exception("Call to undefined method {$class}::{$method}()"); |
||
577 | } |
||
578 | |||
579 | if (count($arguments) > 2) { |
||
580 | $arguments = array_slice($arguments, 0, 2); |
||
581 | } |
||
582 | |||
583 | if (strtoupper($method) === 'ANY') { |
||
584 | array_push($arguments, $methods); |
||
585 | } else { |
||
586 | array_push($arguments, $method); |
||
587 | } |
||
588 | |||
589 | return static::handle(...$arguments); |
||
590 | } |
||
591 | |||
592 | /** |
||
593 | * Allows static methods handled by self::__callStatic() to be accessible via object operator `->`. |
||
594 | */ |
||
595 | public function __call(string $method, array $arguments) |
||
598 | } |
||
599 | } |
||
600 |
In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.