Passed
Push — master ( 800479...48e2e4 )
by Marwan
01:58
created

Router::respond()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 32
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 4

Importance

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

632
        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...
633
    }
634
635
    /**
636
     * Allows static methods handled by `self::__callStatic()` to be accessible via object operator `->`.
637
     */
638 1
    public function __call(string $method, array $arguments)
639
    {
640 1
        return static::__callStatic($method, $arguments);
641
    }
642
}
643