Passed
Push — master ( 3ebf33...973b18 )
by Marwan
09:59
created

Router::doEchoResponse()   A

Complexity

Conditions 5
Paths 10

Size

Total Lines 46
Code Lines 28

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 24
CRAP Score 5

Importance

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

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