Passed
Push — master ( 57cfb9...a2aa4e )
by Marwan
10:09
created

Router::sort()   A

Complexity

Conditions 5
Paths 1

Size

Total Lines 12
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 6
CRAP Score 5.0729

Importance

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

674
        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...
675
    }
676
677
    /**
678
     * Allows static methods handled by `self::__callStatic()` to be accessible via object operator `->`.
679
     */
680 1
    public function __call(string $method, array $arguments)
681
    {
682 1
        return static::__callStatic($method, $arguments);
683
    }
684
}
685