Passed
Push — master ( 9d4b70...966dd2 )
by Marwan
04:50 queued 03:21
created

Router::getRouteArguments()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 2

Importance

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

642
        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...
643
    }
644
645
    /**
646
     * Allows static methods handled by `self::__callStatic()` to be accessible via object operator `->`.
647
     */
648 1
    public function __call(string $method, array $arguments)
649
    {
650 1
        return static::__callStatic($method, $arguments);
651
    }
652
}
653