Passed
Push — master ( e09ad1...6923e5 )
by Marwan
02:02
created

Router   C

Complexity

Total Complexity 55

Size/Duplication

Total Lines 563
Duplicated Lines 0 %

Test Coverage

Coverage 98.19%

Importance

Changes 26
Bugs 0 Features 1
Metric Value
eloc 184
c 26
b 0
f 1
dl 0
loc 563
ccs 163
cts 166
cp 0.9819
rs 6
wmc 55

19 Methods

Rating   Name   Duplication   Size   Complexity  
A getRegisteredRoutes() 0 3 1
A getParsedUrl() 0 9 1
B start() 0 53 9
A redirect() 0 18 3
B getRouteRegex() 0 23 7
A handle() 0 3 1
A getValidParameters() 0 14 1
A sort() 0 12 5
A __construct() 0 22 6
A getRoutePath() 0 11 4
A getRequestMethod() 0 14 2
A registerRoute() 0 15 1
A doEchoResponse() 0 32 4
A __call() 0 3 1
A getParsedQuery() 0 7 1
A __callStatic() 0 24 4
A middleware() 0 3 1
A getRouteArguments() 0 11 2
A forward() 0 12 1

How to fix   Complexity   

Complex Class

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
2
3
/**
4
 * @author Marwan Al-Soltany <[email protected]>
5
 * @copyright Marwan Al-Soltany 2021
6
 * For the full copyright and license information, please view
7
 * the LICENSE file that was distributed with this source code.
8
 */
9
10
declare(strict_types=1);
11
12
namespace MAKS\Velox\Backend;
13
14
use MAKS\Velox\App;
15
use MAKS\Velox\Backend\Event;
16
use MAKS\Velox\Backend\Config;
17
use MAKS\Velox\Backend\Globals;
18
19
/**
20
 * A class that serves as a router and an entry point for the application.
21
 *
22
 * Example:
23
 * ```
24
 * // register a middleware
25
 * Router::middleware('/pages/{pageId}', function ($path, $match, $previous) {
26
 *      return 'I am working as expected!';
27
 * }, 'POST');
28
 *
29
 * // register a route handler
30
 * Router::handle('/pages/{pageId}', function ($path, $match, $previous) {
31
 *      return sprintf('Hi from "%s" handler, Page ID is: %s, also the middleware said: %s', $path, $match, $previous ?? 'Nothing!');
32
 * }, ['GET', 'POST']);
33
 *
34
 * // register a route handler using an HTTP verb
35
 * Router::get('/another-page', function () {
36
 *      return View::render('another-page');
37
 * });
38
 *
39
 * // start the application
40
 * Router::start();
41
 * ```
42
 *
43
 * @package Velox\Backend
44
 * @since 1.0.0
45
 * @api
46
 *
47
 * @method static self get(string $expression, callable $handler) Handles a `GET` request method.
48
 * @method static self head(string $expression, callable $handler) Handles a `HEAD` request method.
49
 * @method static self post(string $expression, callable $handler) Handles a `POST` request method.
50
 * @method static self put(string $expression, callable $handler) Handles a `PUT` request method.
51
 * @method static self patch(string $expression, callable $handler) Handles a `PATCH` request method.
52
 * @method static self delete(string $expression, callable $handler) Handles a `DELETE` request method.
53
 * @method static self connect(string $expression, callable $handler) Handles a `CONNECT` request method.
54
 * @method static self options(string $expression, callable $handler) Handles a `OPTIONS` request method.
55
 * @method static self trace(string $expression, callable $handler) Handles a `TRACE` request method.
56
 * @method static self any(string $expression, callable $handler) Handles any request method.
57
 */
58
class Router
59
{
60
    /**
61
     * This event will be dispatched when a handler is registered.
62
     * This event will be passed a reference to the route config array.
63
     *
64
     * @var string
65
     */
66
    public const ON_REGISTER_HANDLER = 'router.on.registerHandler';
67
68
    /**
69
     * This event will be dispatched when a middleware is registered.
70
     * This event will be passed a reference to the route config array.
71
     *
72
     * @var string
73
     */
74
    public const ON_REGISTER_MIDDLEWARE = 'router.on.registerMiddleware';
75
76
    /**
77
     * This event will be dispatched when the router is started.
78
     * This event will be passed a reference to the router parameters.
79
     *
80
     * @var string
81
     */
82
    public const ON_START = 'router.on.start';
83
84
    /**
85
     * This event will be dispatched when a redirect is attempted.
86
     * This event will be passed the redirection path/URL and the status code.
87
     *
88
     * @var string
89
     */
90
    public const BEFORE_REDIRECT = 'router.before.redirect';
91
92
    /**
93
     * This event will be dispatched when a forward is attempted.
94
     * This event will be passed the forward path.
95
     *
96
     * @var string
97
     */
98
    public const BEFORE_FORWARD = 'router.before.forward';
99
100
101
    /**
102
     * The default values of class parameters.
103
     *
104
     * @var array
105
     */
106
    public const DEFAULTS = [
107
        'base' => '/',
108
        'allowMultiMatch' => true,
109
        'caseMatters' => false,
110
        'slashMatters' => true,
111
        'allowAutoStart' => true,
112
    ];
113
114
    /**
115
     * Supported HTTP methods.
116
     *
117
     * @var array
118
     */
119
    public const SUPPORTED_METHODS = [
120
        'GET',
121
        'HEAD',
122
        'POST',
123
        'PUT',
124
        'PATCH',
125
        'DELETE',
126
        'CONNECT',
127
        'OPTIONS',
128
        'TRACE'
129
    ];
130
131
132
    /**
133
     * The parameters the application started with.
134
     */
135
    private static ?array $params = null;
136
137
    /**
138
     * The current base URL of the application.
139
     */
140
    protected static ?string $base = null;
141
142
    /**
143
     * The currently requested path.
144
     */
145
    protected static ?string $path = null;
146
147
    /**
148
     * The currently registered routes.
149
     */
150
    protected static array $routes = [];
151
152
153
    /**
154
     * Registers a route.
155
     *
156
     * @param string $type
157
     * @param string $expression
158
     * @param callable $handler
159
     * @param array $arguments
160
     * @param string|array $method
161
     *
162
     * @return static
163
     */
164 6
    private static function registerRoute(string $type, string $expression, callable $handler, array $arguments, $method)
165
    {
166
        $route = [
167 6
            'type' => $type,
168 6
            'expression' => $expression,
169 6
            'handler' => $handler,
170 6
            'arguments' => $arguments,
171 6
            'method' => $method
172
        ];
173
174 6
        static::$routes[] = &$route;
175
176 6
        Event::dispatch('router.on.register' . ucfirst(strtolower($type)), [&$route]);
177
178 6
        return new static();
179
    }
180
181
    /**
182
     * Registers a handler for a route.
183
     *
184
     * @param string $expression A route like `/page`, `/page/{id}` (`id` is required), or `/page/{id?}` (`id` is optional), or `page*` (`*` is a wildcard for anything).
185
     *      For more flexibility, pass an expression like `/page/([\d]+|[0-9]*)` (regex capture group).
186
     * @param callable $handler A function to call if route has matched.
187
     *      It will be passed the current `$path`, the `$match` or `...$match` from the expression if there was any, and lastly the `$previous` result
188
     *      (the return of the last middleware or route with a matching expression) if `$allowMultiMatch` is set to `true`.
189
     * @param string|string[] $method [optional] Either a string or an array of the allowed method.
190
     *
191
     * @return static
192
     */
193 6
    public static function handle(string $expression, callable $handler, $method = 'GET')
194
    {
195 6
        return static::registerRoute('handler', $expression, $handler, [], $method);
196
    }
197
198
    /**
199
     * Registers a middleware for a route. This method has no effect if `$allowMultiMatch` is set to `false`.
200
     *
201
     * @param string $expression A route like `/page`, `/page/{id}` (`id` is required), or `/page/{id?}` (`id` is optional), or `page*` (`*` is a wildcard for anything).
202
     *      For more flexibility, pass an expression like `/page/([\d]+|[0-9]*)` (regex capture group).
203
     * @param callable $handler A function to call if route has matched.
204
     *      It will be passed the current `$path`, the `$match` or `...$match` from the expression if there was any, and lastly the `$previous` result
205
     *      (the return of the last middleware or route with a matching expression) if `$allowMultiMatch` is set to `true`.
206
     * @param string|string[] $method [optional] Either a string or an array of the allowed method.
207
     *
208
     * @return static
209
     */
210 1
    public static function middleware(string $expression, callable $handler, $method = 'GET')
211
    {
212 1
        return static::registerRoute('middleware', $expression, $handler, [], $method);
213
    }
214
215
    /**
216
     * Redirects the request to another route.
217
     * Note that this function will exit the script (code that comes after it will not be executed).
218
     *
219
     * @param string $to A route like `/page` or a URL like `http://domain.tld`.
220
     * @param int $status [optional] The HTTP status code to send.
221
     *
222
     * @return void
223
     */
224 2
    public static function redirect(string $to, int $status = 302): void
225
    {
226 2
        Event::dispatch(self::BEFORE_REDIRECT, [$to, $status]);
227
228 2
        if (filter_var($to, FILTER_VALIDATE_URL)) {
229 1
            $header = sprintf('Location: %s', $to);
230
        } else {
231 1
            $scheme = Globals::getServer('HTTPS') == 'on' ? 'https' : 'http';
232 1
            $host   = Globals::getServer('HTTP_HOST');
233 1
            $path   = preg_replace('/(\/+)/', '/', static::$base . '/' . $to);
234 1
            $base   = Config::get('global.baseUrl', $scheme . '://' . $host);
235
236 1
            $header = sprintf('Location: %s/%s', trim($base, '/'), trim($path, '/'));
237
        }
238
239 2
        header($header, false, $status);
240
241
        App::terminate(); // @codeCoverageIgnore
242
    }
243
244
    /**
245
     * Forwards the request to another route.
246
     * Note that this function will exit the script (code that comes after it will not be executed).
247
     *
248
     * @param string $to A route like `/page`.
249
     *
250
     * @return void
251
     */
252 1
    public static function forward(string $to): void
253
    {
254 1
        Event::dispatch(self::BEFORE_FORWARD, [$to]);
255
256 1
        $base = static::$base ?? '';
257 1
        $path = trim($base, '/') . '/' . ltrim($to, '/');
258
259 1
        Globals::setServer('REQUEST_URI', $path);
260
261 1
        static::start(...self::$params);
262
263
        App::terminate(); // @codeCoverageIgnore
264
    }
265
266
    /**
267
     * Starts the router.
268
     *
269
     * @param string|null [optional] $base App base path, this will prefix all routes.
270
     * @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.
271
     * @param bool|null [optional] $caseMatters Whether the route matching should be case sensitive or not.
272
     * @param bool|null [optional] $slashMatters Whether trailing slash should be taken in consideration with route matching or not.
273
     *
274
     * @return void
275
     *
276
     * @throws \Exception If route handler failed or returned false.
277
     */
278 4
    public static function start(?string $base = null, ?bool $allowMultiMatch = null, ?bool $caseMatters = null, ?bool $slashMatters = null): void
279
    {
280 4
        self::$params = func_get_args();
281
282 4
        Session::csrf()->check();
283
284 4
        Event::dispatch(self::ON_START, [&self::$params]);
285
286 4
        [$base, $allowMultiMatch, $caseMatters, $slashMatters] = static::getValidParameters($base, $allowMultiMatch, $caseMatters, $slashMatters);
287
288 4
        static::$base = $base = '/' . trim($base, '/');
289 4
        static::$path = $path = static::getRoutePath($slashMatters);
290
291 4
        $routeMatchFound = false;
292 4
        $pathMatchFound  = false;
293 4
        $result = null;
294
295 4
        static::sort();
296
297 4
        foreach (static::$routes as &$route) {
298 3
            $expression = $base === '/' ? $route['expression'] : sprintf('%s/%s', $base, ltrim($route['expression'], '/'));
299
300 3
            $regex = static::getRouteRegex($expression, $slashMatters, $caseMatters);
301 3
            if (preg_match($regex, $path, $matches, PREG_UNMATCHED_AS_NULL)) {
302 3
                $pathMatchFound = true;
303
304 3
                $currentMethod  = static::getRequestMethod();
305 3
                $allowedMethods = (array)$route['method'];
306 3
                foreach ($allowedMethods as $allowedMethod) {
307 3
                    if (strtoupper($currentMethod) !== strtoupper($allowedMethod)) {
308 1
                        continue;
309
                    }
310
311 3
                    $routeMatchFound = true;
312
313 3
                    $route['arguments'] = static::getRouteArguments($route['arguments'], $matches, $result);
314
315 3
                    $result = call_user_func_array($route['handler'], $route['arguments']);
316
317 3
                    if ($result === false) {
318 1
                        throw new \Exception("Something went wrong when trying to respond to '{$path}'! Check the handler for this route");
319
                    }
320
                }
321
            }
322
323 3
            if ($routeMatchFound && !$allowMultiMatch) {
324 1
                break;
325
            }
326
        }
327
328 3
        unset($route);
329
330 3
        static::doEchoResponse($result, $routeMatchFound, $pathMatchFound);
331 2
    }
332
333
    /**
334
     * Sorts registered routes to make middlewares come before handlers.
335
     *
336
     * @return void
337
     */
338 4
    private static function sort(): void
339
    {
340 4
        usort(static::$routes, function ($routeA, $routeB) {
341 3
            if ($routeA['type'] === 'middleware' && $routeB['type'] !== 'middleware') {
342 1
                return -1;
343
            }
344
345 2
            if ($routeA['type'] !== 'middleware' && $routeB['type'] === 'middleware') {
346
                return 1;
347
            }
348
349 2
            return 0;
350 4
        });
351 4
    }
352
353
    /**
354
     * Returns valid parameters for `self::start()` by validating the passed parameters and adding the deficiency from router config.
355
     *
356
     * @param string|null $base
357
     * @param bool|null $allowMultiMatch
358
     * @param bool|null $caseMatters
359
     * @param bool|null $slashMatters
360
     *
361
     * @return array
362
     */
363 4
    private static function getValidParameters(?string $base, ?bool $allowMultiMatch, ?bool $caseMatters, ?bool $slashMatters): array
364
    {
365 4
        $routerConfig = Config::get('router');
366
367 4
        $base            ??= $routerConfig['base'];
368 4
        $allowMultiMatch ??= $routerConfig['allowMultiMatch'];
369 4
        $caseMatters     ??= $routerConfig['caseMatters'];
370 4
        $slashMatters    ??= $routerConfig['slashMatters'];
371
372
        return [
373 4
            $base,
374 4
            $allowMultiMatch,
375 4
            $caseMatters,
376 4
            $slashMatters,
377
        ];
378
    }
379
380
    /**
381
     * Returns a valid decoded route path.
382
     *
383
     * @param string $base
384
     *
385
     * @return string
386
     */
387 4
    private static function getRoutePath(bool $slashMatters): string
388
    {
389 4
        $url = static::getParsedUrl();
390
391 4
        $path = '/';
392 4
        if (isset($url['path'])) {
393 4
            $path = $url['path'];
394 4
            $path = !$slashMatters && $path !== '/' ? rtrim($path, '/') : $path;
395
        }
396
397 4
        return urldecode($path);
398
    }
399
400
    /**
401
     * Returns a valid route regex.
402
     *
403
     * @param string $expression
404
     * @param bool $slashMatters
405
     * @param bool $caseMatters
406
     *
407
     * @return string
408
     */
409 3
    private static function getRouteRegex(string $expression, bool $slashMatters, bool $caseMatters): string
410
    {
411 3
        $asteriskRegex    = '/(?<!\()\*(?!\))/';
412 3
        $placeholderRegex = '/{([a-zA-Z0-9_\-\.?]+)}/';
413
414
        // replace asterisk only if it's not a part of a regex capturing group
415 3
        $expression = preg_replace($asteriskRegex, '.*?', $expression);
416
417
        // replace placeholders with their corresponding regex
418 3
        if (preg_match_all($placeholderRegex, $expression, $matches, PREG_SET_ORDER)) {
419 1
            foreach ($matches as $match) {
420 1
                $placeholder = $match[0];
421 1
                $replacement = strpos($placeholder, '?') !== false ? '(.*)?' : '(.+)';
422 1
                $expression  = strtr($expression, [
423 1
                    $placeholder => $replacement
424
                ]);
425
            }
426
        }
427
428 3
        return sprintf(
429 3
            '<^%s$>%s',
430 3
            (!$slashMatters && $expression !== '/' ? rtrim($expression, '/') : $expression),
431 3
            (!$caseMatters ? 'iu' : 'u')
432
        );
433
    }
434
435
    /**
436
     * Returns valid arguments for route handler in the order that the handler expect.
437
     *
438
     * @param array $current
439
     * @param array $matches
440
     * @param mixed $result
441
     *
442
     * @return array
443
     */
444 3
    private static function getRouteArguments(array $current, array $matches, $result): array
445
    {
446 3
        $arguments = array_merge($current, $matches);
447 3
        $arguments = array_filter($arguments);
448 3
        if (count($arguments) > 1) {
449 1
            array_push($arguments, $result);
450
        } else {
451 2
            array_push($arguments, null, $result);
452
        }
453
454 3
        return $arguments;
455
    }
456
457
    /**
458
     * Echos the response according to the passed parameters.
459
     *
460
     * @param mixed $result
461
     * @param bool $routeMatchFound
462
     * @param bool $pathMatchFound
463
     *
464
     * @return void
465
     */
466 3
    private static function doEchoResponse($result, bool $routeMatchFound, bool $pathMatchFound): void
467
    {
468 3
        $code = 200;
469
470 3
        if (!$routeMatchFound) {
471 1
            $code   = $pathMatchFound ? 405 : 404;
472 1
            $path   = static::$path;
473 1
            $method = static::getRequestMethod();
474
475
            $responses = [
476
                404 => [
477 1
                    'title'   => sprintf('%d Not Found', $code),
478 1
                    'message' => sprintf('The "%s" route is not found!', $path),
479
                ],
480
                405 => [
481 1
                    'title'   => sprintf('%d Not Allowed', $code),
482 1
                    'message' => sprintf('The "%s" route is found, but the request method "%s" is not allowed!', $path, $method),
483
                ],
484
            ];
485
486 1
            App::log("Responded with {$code} to the request for '{$path}' with method '{$method}'", null, 'system');
487
488
            // this function will exit the script
489 1
            App::abort(
490 1
                $code,
491 1
                $responses[$code]['title'],
492 1
                $responses[$code]['message']
493
            );
494
        }
495
496 2
        http_response_code() || http_response_code($code);
497 2
        echo $result;
498 2
    }
499
500
    /**
501
     * Returns query parameters.
502
     *
503
     * @return array
504
     */
505 1
    public static function getParsedQuery(): array
506
    {
507 1
        $url = static::getParsedUrl();
508
509 1
        parse_str($url['query'] ?? '', $query);
510
511 1
        return $query;
512
    }
513
514
    /**
515
     * Returns components of the current URL.
516
     *
517
     * @return array
518
     */
519 6
    public static function getParsedUrl(): array
520
    {
521 6
        $uri = Globals::getServer('REQUEST_URI');
522
523
        // remove double slashes as they make parse_url() fail
524 6
        $url = preg_replace('/(\/+)/', '/', $uri);
525 6
        $url = parse_url($url);
526
527 6
        return $url;
528
    }
529
530 4
    protected static function getRequestMethod(): string
531
    {
532 4
        $method = Globals::cutPost('_method') ?? '';
533 4
        $methods = static::SUPPORTED_METHODS;
534 4
        $methodAllowed = in_array(
535 4
            strtoupper($method),
536 4
            array_map('strtoupper', $methods)
537
        );
538
539 4
        if ($methodAllowed) {
540 1
            Globals::setServer('REQUEST_METHOD', $method);
541
        }
542
543 4
        return Globals::getServer('REQUEST_METHOD');
544
    }
545
546
    /**
547
     * Returns all registered routes with their `expression`, `handler`, `arguments`, and `method`.
548
     *
549
     * @return array
550
     */
551 2
    public static function getRegisteredRoutes(): array
552
    {
553 2
        return static::$routes;
554
    }
555
556
557
    /**
558
     * Class constructor.
559
     */
560 22
    final public function __construct()
561
    {
562
        // prevent overwriting constructor in subclasses to allow to use
563
        // "return new static()" without caring about dependencies.
564
565 22
        static $isListening = false;
566
567
        // start the router if it's not started explicitly
568
        // @codeCoverageIgnoreStart
569
        if (Config::get('router.allowAutoStart') && !$isListening) {
570
            Event::listen(App::ON_SHUTDOWN, static function () {
571
                // $params should be an array if the router has been started
572
                if (self::$params === null && PHP_SAPI !== 'cli') {
573
                    try {
574
                        static::start();
575
                    } catch (\Throwable $exception) {
576
                        App::handleException($exception);
577
                    }
578
                }
579
            });
580
581
            $isListening = true;
582
        }
583
        // @codeCoverageIgnoreEnd
584 22
    }
585
586
    /**
587
     * Aliases `self::handle()` method with common HTTP verbs.
588
     */
589 1
    public static function __callStatic(string $method, array $arguments)
590
    {
591 1
        $methods = static::SUPPORTED_METHODS;
592 1
        $methodAllowed = in_array(
593 1
            strtoupper($method),
594 1
            array_map('strtoupper', ['ANY', ...$methods])
595
        );
596
597 1
        if (!$methodAllowed) {
598 1
            $class = static::class;
599 1
            throw new \Exception("Call to undefined method {$class}::{$method}()");
600
        }
601
602 1
        if (count($arguments) > 2) {
603 1
            $arguments = array_slice($arguments, 0, 2);
604
        }
605
606 1
        if (strtoupper($method) === 'ANY') {
607 1
            array_push($arguments, $methods);
608
        } else {
609 1
            array_push($arguments, $method);
610
        }
611
612 1
        return static::handle(...$arguments);
0 ignored issues
show
Bug introduced by
The call to MAKS\Velox\Backend\Router::handle() has too few arguments starting with handler. ( Ignorable by Annotation )

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

612
        return static::/** @scrutinizer ignore-call */ handle(...$arguments);

This check compares calls to functions or methods with their respective definitions. If the call has less arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress. Please note the @ignore annotation hint above.

Loading history...
613
    }
614
615
    /**
616
     * Allows static methods handled by `self::__callStatic()` to be accessible via object operator `->`.
617
     */
618 1
    public function __call(string $method, array $arguments)
619
    {
620 1
        return static::__callStatic($method, $arguments);
621
    }
622
}
623