Passed
Push — master ( 973b18...e09ad1 )
by Marwan
02:04
created

Router::__construct()   A

Complexity

Conditions 6
Paths 2

Size

Total Lines 23
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 10.5

Importance

Changes 3
Bugs 0 Features 1
Metric Value
cc 6
eloc 9
c 3
b 0
f 1
nc 2
nop 0
dl 0
loc 23
ccs 3
cts 6
cp 0.5
crap 10.5
rs 9.2222
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
 * // register handler for 404
40
 * Router::handleRouteNotFound(function ($path) {
41
 *      // forward the request to some route.
42
 *      Router::forward('/');
43
 * });
44
 *
45
 * // register handler for 405
46
 * Router::handleMethodNotAllowed(function ($path, $method) {
47
 *      // redirect the request to some URL.
48
 *      Router::redirect('/some-page');
49
 * });
50
 *
51
 * // start the application
52
 * Router::start();
53
 * ```
54
 *
55
 * @package Velox\Backend
56
 * @since 1.0.0
57
 * @api
58
 *
59
 * @method static self get(string $expression, callable $handler) Handles a `GET` request method.
60
 * @method static self head(string $expression, callable $handler) Handles a `HEAD` request method.
61
 * @method static self post(string $expression, callable $handler) Handles a `POST` request method.
62
 * @method static self put(string $expression, callable $handler) Handles a `PUT` request method.
63
 * @method static self patch(string $expression, callable $handler) Handles a `PATCH` request method.
64
 * @method static self delete(string $expression, callable $handler) Handles a `DELETE` request method.
65
 * @method static self connect(string $expression, callable $handler) Handles a `CONNECT` request method.
66
 * @method static self options(string $expression, callable $handler) Handles a `OPTIONS` request method.
67
 * @method static self trace(string $expression, callable $handler) Handles a `TRACE` request method.
68
 * @method static self any(string $expression, callable $handler) Handles any request method.
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
     * @deprecated Since `v1.4.1`, will be removed in `v1.5.0`. Use `{global.errorPages.404}` config value instead.
296
     */
297 1
    public static function handleRouteNotFound(callable $handler)
298
    {
299 1
        static::$routeNotFoundCallback = $handler;
300
301 1
        return new static();
302
    }
303
304
    /**
305
     * Registers 405 handler.
306
     *
307
     * @param callable $handler The handler to use. It will be passed the current `$path`.
308
     *
309
     * @return static
310
     *
311
     * @deprecated Since `v1.4.1`, will be removed in `v1.5.0`. Use `{global.errorPages.405}` config value instead.
312
     */
313 1
    public static function handleMethodNotAllowed(callable $handler)
314
    {
315 1
        static::$methodNotAllowedCallback = $handler;
316
317 1
        return new static();
318
    }
319
320
    /**
321
     * Starts the router.
322
     *
323
     * @param string|null [optional] $base App base path, this will prefix all routes.
324
     * @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.
325
     * @param bool|null [optional] $caseMatters Whether the route matching should be case sensitive or not.
326
     * @param bool|null [optional] $slashMatters Whether trailing slash should be taken in consideration with route matching or not.
327
     *
328
     * @return void
329
     *
330
     * @throws \Exception If route handler failed or returned false.
331
     */
332 6
    public static function start(?string $base = null, ?bool $allowMultiMatch = null, ?bool $caseMatters = null, ?bool $slashMatters = null): void
333
    {
334 6
        self::$params = func_get_args();
335
336 6
        Session::csrf()->check();
337
338 6
        Event::dispatch(self::ON_START, [&self::$params]);
339
340 6
        [$base, $allowMultiMatch, $caseMatters, $slashMatters] = static::getValidParameters($base, $allowMultiMatch, $caseMatters, $slashMatters);
341
342 6
        static::$base = $base = '/' . trim($base, '/');
343 6
        static::$path = $path = static::getRoutePath($slashMatters);
344
345 6
        $routeMatchFound = false;
346 6
        $pathMatchFound  = false;
347 6
        $result = null;
348
349 6
        static::sort();
350
351 6
        foreach (static::$routes as &$route) {
352 5
            $expression = $base === '/' ? $route['expression'] : sprintf('%s/%s', $base, ltrim($route['expression'], '/'));
353
354 5
            $regex = static::getRouteRegex($expression, $slashMatters, $caseMatters);
355 5
            if (preg_match($regex, $path, $matches, PREG_UNMATCHED_AS_NULL)) {
356 4
                $pathMatchFound = true;
357
358 4
                $currentMethod  = static::getRequestMethod();
359 4
                $allowedMethods = (array)$route['method'];
360 4
                foreach ($allowedMethods as $allowedMethod) {
361 4
                    if (strtoupper($currentMethod) !== strtoupper($allowedMethod)) {
362 2
                        continue;
363
                    }
364
365 3
                    $routeMatchFound = true;
366
367 3
                    $route['arguments'] = static::getRouteArguments($route['arguments'], $matches, $result);
368
369 3
                    $result = call_user_func_array($route['handler'], $route['arguments']);
370
371 3
                    if ($result === false) {
372 1
                        throw new \Exception("Something went wrong when trying to respond to '{$path}'! Check the handler for this route");
373
                    }
374
                }
375
            }
376
377 5
            if ($routeMatchFound && !$allowMultiMatch) {
378 1
                break;
379
            }
380
        }
381
382 5
        unset($route);
383
384 5
        static::doEchoResponse($result, $routeMatchFound, $pathMatchFound);
385 4
    }
386
387
    /**
388
     * Sorts registered routes to make middlewares come before handlers.
389
     *
390
     * @return void
391
     */
392 6
    private static function sort(): void
393
    {
394 6
        usort(static::$routes, function ($routeA, $routeB) {
395 3
            if ($routeA['type'] === 'middleware' && $routeB['type'] !== 'middleware') {
396 1
                return -1;
397
            }
398
399 2
            if ($routeA['type'] !== 'middleware' && $routeB['type'] === 'middleware') {
400
                return 1;
401
            }
402
403 2
            return 0;
404 6
        });
405 6
    }
406
407
    /**
408
     * Returns valid parameters for `self::start()` by validating the passed parameters and adding the deficiency from router config.
409
     *
410
     * @param string|null $base
411
     * @param bool|null $allowMultiMatch
412
     * @param bool|null $caseMatters
413
     * @param bool|null $slashMatters
414
     *
415
     * @return array
416
     */
417 6
    private static function getValidParameters(?string $base, ?bool $allowMultiMatch, ?bool $caseMatters, ?bool $slashMatters): array
418
    {
419 6
        $routerConfig = Config::get('router');
420
421 6
        $base            ??= $routerConfig['base'];
422 6
        $allowMultiMatch ??= $routerConfig['allowMultiMatch'];
423 6
        $caseMatters     ??= $routerConfig['caseMatters'];
424 6
        $slashMatters    ??= $routerConfig['slashMatters'];
425
426
        return [
427 6
            $base,
428 6
            $allowMultiMatch,
429 6
            $caseMatters,
430 6
            $slashMatters,
431
        ];
432
    }
433
434
    /**
435
     * Returns a valid decoded route path.
436
     *
437
     * @param string $base
438
     *
439
     * @return string
440
     */
441 6
    private static function getRoutePath(bool $slashMatters): string
442
    {
443 6
        $url = static::getParsedUrl();
444
445 6
        $path = '/';
446 6
        if (isset($url['path'])) {
447 6
            $path = $url['path'];
448 6
            $path = !$slashMatters && $path !== '/' ? rtrim($path, '/') : $path;
449
        }
450
451 6
        return urldecode($path);
452
    }
453
454
    /**
455
     * Returns a valid route regex.
456
     *
457
     * @param string $expression
458
     * @param bool $slashMatters
459
     * @param bool $caseMatters
460
     *
461
     * @return string
462
     */
463 5
    private static function getRouteRegex(string $expression, bool $slashMatters, bool $caseMatters): string
464
    {
465 5
        $asteriskRegex    = '/(?<!\()\*(?!\))/';
466 5
        $placeholderRegex = '/{([a-zA-Z0-9_\-\.?]+)}/';
467
468
        // replace asterisk only if it's not a part of a regex capturing group
469 5
        $expression = preg_replace($asteriskRegex, '.*?', $expression);
470
471
        // replace placeholders with their corresponding regex
472 5
        if (preg_match_all($placeholderRegex, $expression, $matches, PREG_SET_ORDER)) {
473 1
            foreach ($matches as $match) {
474 1
                $placeholder = $match[0];
475 1
                $replacement = strpos($placeholder, '?') !== false ? '(.*)?' : '(.+)';
476 1
                $expression  = strtr($expression, [
477 1
                    $placeholder => $replacement
478
                ]);
479
            }
480
        }
481
482 5
        return sprintf(
483 5
            '<^%s$>%s',
484 5
            (!$slashMatters && $expression !== '/' ? rtrim($expression, '/') : $expression),
485 5
            (!$caseMatters ? 'iu' : 'u')
486
        );
487
    }
488
489
    /**
490
     * Returns valid arguments for route handler in the order that the handler expect.
491
     *
492
     * @param array $current
493
     * @param array $matches
494
     * @param mixed $result
495
     *
496
     * @return array
497
     */
498 3
    private static function getRouteArguments(array $current, array $matches, $result): array
499
    {
500 3
        $arguments = array_merge($current, $matches);
501 3
        $arguments = array_filter($arguments);
502 3
        if (count($arguments) > 1) {
503 1
            array_push($arguments, $result);
504
        } else {
505 2
            array_push($arguments, null, $result);
506
        }
507
508 3
        return $arguments;
509
    }
510
511
    /**
512
     * Echos the response according to the passed parameters.
513
     *
514
     * @param mixed $result
515
     * @param bool $routeMatchFound
516
     * @param bool $pathMatchFound
517
     *
518
     * @return void
519
     */
520 5
    private static function doEchoResponse($result, bool $routeMatchFound, bool $pathMatchFound): void
521
    {
522 5
        $code = 200;
523
524 5
        if (!$routeMatchFound) {
525 3
            $code   = $pathMatchFound ? 405 : 404;
526 3
            $path   = static::$path;
527 3
            $method = static::getRequestMethod();
528
529 3
            App::log("Responded with {$code} to the request for '{$path}' with method '{$method}'", null, 'system');
530
531
            $responses = [
532
                404 => [
533 3
                    'func' => &static::$routeNotFoundCallback,
534 3
                    'args' => [$path],
535
                    'html' => [
536 3
                        'title'   => sprintf('%d Not Found', $code),
537 3
                        'message' => sprintf('The "%s" route is not found!', $path),
538
                    ]
539
                ],
540
                405 => [
541 3
                    'func' => &static::$methodNotAllowedCallback,
542 3
                    'args' => [$path, $method],
543
                    'html' => [
544 3
                        'title'   => sprintf('%d Not Allowed', $code),
545 3
                        'message' => sprintf('The "%s" route is found, but the request method "%s" is not allowed!', $path, $method),
546
                    ]
547
                ],
548
            ];
549
550 3
            http_response_code($code);
551
552 3
            if (!isset($responses[$code]['func'])) {
553
                // this function will exit the script
554 1
                App::abort(
555 1
                    $code,
556 1
                    $responses[$code]['html']['title'],
557 1
                    $responses[$code]['html']['message']
558
                );
559
            }
560
561 2
            $result = ($responses[$code]['func'])(...$responses[$code]['args']);
562
        }
563
564 4
        http_response_code() || http_response_code($code);
565 4
        echo $result;
566 4
    }
567
568
    /**
569
     * Returns query parameters.
570
     *
571
     * @return array
572
     */
573 1
    public static function getParsedQuery(): array
574
    {
575 1
        $url = static::getParsedUrl();
576
577 1
        parse_str($url['query'] ?? '', $query);
578
579 1
        return $query;
580
    }
581
582
    /**
583
     * Returns components of the current URL.
584
     *
585
     * @return array
586
     */
587 8
    public static function getParsedUrl(): array
588
    {
589 8
        $uri = Globals::getServer('REQUEST_URI');
590
591
        // remove double slashes as they make parse_url() fail
592 8
        $url = preg_replace('/(\/+)/', '/', $uri);
593 8
        $url = parse_url($url);
594
595 8
        return $url;
596
    }
597
598 6
    protected static function getRequestMethod(): string
599
    {
600 6
        $method = Globals::cutPost('_method') ?? '';
601 6
        $methods = static::SUPPORTED_METHODS;
602 6
        $methodAllowed = in_array(
603 6
            strtoupper($method),
604 6
            array_map('strtoupper', $methods)
605
        );
606
607 6
        if ($methodAllowed) {
608 1
            Globals::setServer('REQUEST_METHOD', $method);
609
        }
610
611 6
        return Globals::getServer('REQUEST_METHOD');
612
    }
613
614
    /**
615
     * Returns all registered routes with their `expression`, `handler`, `arguments`, and `method`.
616
     *
617
     * @return array
618
     */
619 2
    public static function getRegisteredRoutes(): array
620
    {
621 2
        return static::$routes;
622
    }
623
624
625
    /**
626
     * Class constructor.
627
     */
628 24
    final public function __construct()
629
    {
630
        // prevent overwriting constructor in subclasses to allow to use
631
        // "return new static()" without caring about dependencies.
632
633 24
        static $isListening = false;
634
635
        // start the router if it's not started by the user
636 24
        if (Config::get('router.allowAutoStart') && !$isListening) {
637
            Event::listen(App::ON_SHUTDOWN, static function () {
638
                // @codeCoverageIgnoreStart
639
                // $params should be an array if the router has been started
640
                if (self::$params === null && PHP_SAPI !== 'cli') {
641
                    try {
642
                        static::start();
643
                    } catch (\Throwable $exception) {
644
                        App::handleException($exception);
645
                    }
646
                }
647
                // @codeCoverageIgnoreEnd
648
            });
649
650
            $isListening = true;
651
        }
652 24
    }
653
654
    /**
655
     * Aliases `self::handle()` method with common HTTP verbs.
656
     */
657 1
    public static function __callStatic(string $method, array $arguments)
658
    {
659 1
        $methods = static::SUPPORTED_METHODS;
660 1
        $methodAllowed = in_array(
661 1
            strtoupper($method),
662 1
            array_map('strtoupper', ['ANY', ...$methods])
663
        );
664
665 1
        if (!$methodAllowed) {
666 1
            $class = static::class;
667 1
            throw new \Exception("Call to undefined method {$class}::{$method}()");
668
        }
669
670 1
        if (count($arguments) > 2) {
671 1
            $arguments = array_slice($arguments, 0, 2);
672
        }
673
674 1
        if (strtoupper($method) === 'ANY') {
675 1
            array_push($arguments, $methods);
676
        } else {
677 1
            array_push($arguments, $method);
678
        }
679
680 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

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