Passed
Push — master ( 17cf9a...3eae2e )
by Marwan
01:24
created

Router::__call()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 1
c 1
b 0
f 0
nc 1
nop 2
dl 0
loc 3
ccs 2
cts 2
cp 1
crap 1
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\HTML;
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
     * The default values of class parameters.
74
     *
75
     * @var array
76
     */
77
    public const DEFAULTS = [
78
        'base' => '/',
79
        'allowMultiMatch' => true,
80
        'caseMatters' => false,
81
        'slashMatters' => true,
82
        'allowAutoStart' => true,
83
    ];
84
85
    /**
86
     * Supported HTTP methods.
87
     *
88
     * @var array
89
     */
90
    public const SUPPORTED_METHODS = [
91
        'GET',
92
        'HEAD',
93
        'POST',
94
        'PUT',
95
        'PATCH',
96
        'DELETE',
97
        'CONNECT',
98
        'OPTIONS',
99
        'TRACE'
100
    ];
101
102
103
    /**
104
     * The parameters the application started with.
105
     */
106
    private static ?array $params = null;
107
108
    /**
109
     * The current base URL of the application.
110
     */
111
    protected static ?string $base = null;
112
113
    /**
114
     * The currently requested path.
115
     */
116
    protected static ?string $path = null;
117
118
    /**
119
     * The currently registered routes.
120
     */
121
    protected static array $routes = [];
122
123
    /**
124
     * @var callable|null
125
     */
126
    protected static $routeNotFoundCallback = null;
127
128
    /**
129
     * @var callable|null
130
     */
131
    protected static $methodNotAllowedCallback = null;
132
133
134
    /**
135
     * Registers a route.
136
     *
137
     * @param string $type
138
     * @param string $expression
139
     * @param callable $handler
140
     * @param array $arguments
141
     * @param string|array $method
142
     *
143
     * @return static
144
     */
145 7
    private static function registerRoute(string $type, string $expression, callable $handler, array $arguments, $method)
146
    {
147
        $route = [
148 7
            'type' => $type,
149 7
            'expression' => $expression,
150 7
            'handler' => $handler,
151 7
            'arguments' => $arguments,
152 7
            'method' => $method
153
        ];
154
155 7
        static::$routes[] = &$route;
156
157 7
        Event::dispatch('router.on.register' . ucfirst($type), [&$route]);
158
159 7
        return new static();
160
    }
161
162
    /**
163
     * Registers a handler for a route.
164
     *
165
     * @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).
166
     * @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`.
167
     * @param string|string[] $method [optional] Either a string or an array of the allowed method.
168
     *
169
     * @return static
170
     */
171 7
    public static function handle(string $expression, callable $handler, $method = 'GET')
172
    {
173 7
        return static::registerRoute('handler', $expression, $handler, [], $method);
174
    }
175
176
    /**
177
     * Registers a middleware for a route. This method has no effect if `$allowMultiMatch` is set to `false`.
178
     * Note that middlewares must be registered before routes in order to work correctly.
179
     * This method is just an alias for `self::handle()`.
180
     *
181
     * @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).
182
     * @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`.
183
     * @param string|string[] $method [optional] Either a string or an array of the allowed method.
184
     *
185
     * @return static
186
     */
187 1
    public static function middleware(string $expression, callable $handler, $method = 'GET')
188
    {
189 1
        return static::registerRoute('middleware', $expression, $handler, [], $method);
190
    }
191
192
    /**
193
     * Redirects the request to another route.
194
     * Note that this function will exit the script (code that comes after it will not be executed).
195
     *
196
     * @param string $to A route like `/page` or a URL like `http://domain.tld`.
197
     *
198
     * @return void
199
     */
200 2
    public static function redirect(string $to): void
201
    {
202 2
        Event::dispatch('router.before.redirect', [$to]);
203
204 2
        if (filter_var($to, FILTER_VALIDATE_URL)) {
205 1
            $header = sprintf('Location: %s', $to);
206
        } else {
207 1
            $scheme = Globals::getServer('HTTPS') == 'on' ? 'https' : 'http';
208 1
            $host   = Globals::getServer('HTTP_HOST');
209 1
            $path   = static::$base . '/' . $to;
210 1
            $path   = trim(preg_replace('/(\/+)/', '/', $path), '/');
211
212 1
            $header = sprintf('Location: %s://%s/%s', $scheme, $host, $path);
213
        }
214
215 2
        header($header, true, 302);
216
217
        exit; // @codeCoverageIgnore
218
    }
219
220
    /**
221
     * Forwards the request to another route.
222
     * Note that this function will exit the script (code that comes after it will not be executed).
223
     *
224
     * @param string $to A route like `/page`.
225
     *
226
     * @return void
227
     */
228 1
    public static function forward(string $to): void
229
    {
230 1
        Event::dispatch('router.before.forward', [$to]);
231
232 1
        $base = static::$base ?? '';
233 1
        $path = trim($base, '/') . '/' . ltrim($to, '/');
234
235 1
        Globals::setServer('REQUEST_URI', $path);
236
237 1
        static::start(...self::$params);
238
239
        exit; // @codeCoverageIgnore
0 ignored issues
show
Best Practice introduced by
Using exit here is not recommended.

In general, usage of exit should be done with care and only when running in a scripting context like a CLI script.

Loading history...
240
    }
241
242
    /**
243
     * Registers 404 handler.
244
     *
245
     * @param callable $handler The handler to use. It will be passed the current `$path` and the current `$method`.
246
     *
247
     * @return static
248
     */
249 1
    public static function handleRouteNotFound(callable $handler)
250
    {
251 1
        static::$routeNotFoundCallback = $handler;
252
253 1
        return new static();
254
    }
255
256
    /**
257
     * Registers 405 handler.
258
     *
259
     * @param callable $handler The handler to use. It will be passed the current `$path`.
260
     *
261
     * @return static
262
     */
263 1
    public static function handleMethodNotAllowed(callable $handler)
264
    {
265 1
        static::$methodNotAllowedCallback = $handler;
266
267 1
        return new static();
268
    }
269
270
    /**
271
     * Starts the router.
272
     *
273
     * @param string|null [optional] $base App base path, this will prefix all routes.
274
     * @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.
275
     * @param bool|null [optional] $caseMatters Whether the route matching should be case sensitive or not.
276
     * @param bool|null [optional] $slashMatters Whether trailing slash should be taken in consideration with route matching or not.
277
     *
278
     * @return void
279
     *
280
     * @throws \Exception If route handler failed or returned false.
281
     */
282 6
    public static function start(?string $base = null, ?bool $allowMultiMatch = null, ?bool $caseMatters = null, ?bool $slashMatters = null): void
283
    {
284 6
        self::$params = func_get_args();
285
286 6
        Event::dispatch('router.on.start', [&self::$params]);
287
288 6
        [$base, $allowMultiMatch, $caseMatters, $slashMatters] = static::getValidParameters($base, $allowMultiMatch, $caseMatters, $slashMatters);
289
290 6
        static::$base = $base = '/' . trim($base, '/');
291 6
        static::$path = $path = static::getRoutePath($slashMatters);
292
293 6
        $routeMatchFound = false;
294 6
        $pathMatchFound  = false;
295 6
        $result = null;
296
297 6
        foreach (static::$routes as &$route) {
298 6
            $expression = $base === '/' ? $route['expression'] : sprintf('%s/%s', $base, ltrim($route['expression'], '/'));
299
300 6
            $regex = static::getRouteRegex($expression, $slashMatters, $caseMatters);
301 6
            if (preg_match($regex, $path, $matches, PREG_UNMATCHED_AS_NULL)) {
302 5
                $pathMatchFound = true;
303
304 5
                $currentMethod  = static::getRequestMethod();
305 5
                $allowedMethods = (array)$route['method'];
306 5
                foreach ($allowedMethods as $allowedMethod) {
307 5
                    if (strtoupper($currentMethod) !== strtoupper($allowedMethod)) {
308 3
                        continue;
309
                    }
310
311 3
                    $routeMatchFound = true;
312
313 3
                    $route['arguments'] = static::getRouteArguments($route['arguments'], $matches, $result);
314
315 3
                    $result = call_user_func_array($route['handler'], $route['arguments']);
316
317 3
                    if ($result === false) {
318 1
                        throw new \Exception("Something went wrong when trying to respond to '{$path}'! Check the handler for this route");
319
                    }
320
                }
321
            }
322
323 6
            if ($routeMatchFound && !$allowMultiMatch) {
324 1
                break;
325
            }
326
        }
327
328 5
        unset($route);
329
330 5
        static::doEchoResponse($result, $routeMatchFound, $pathMatchFound);
331 5
    }
332
333
    /**
334
     * Returns valid parameters for `self::start()` by validating the passed parameters and adding the deficiency from router config.
335
     *
336
     * @param string|null $base
337
     * @param bool|null $allowMultiMatch
338
     * @param bool|null $caseMatters
339
     * @param bool|null $slashMatters
340
     *
341
     * @return array
342
     */
343 6
    private static function getValidParameters(?string $base, ?bool $allowMultiMatch, ?bool $caseMatters, ?bool $slashMatters): array
344
    {
345 6
        $routerConfig = Config::get('router');
346
347 6
        $base            ??= $routerConfig['base'];
348 6
        $allowMultiMatch ??= $routerConfig['allowMultiMatch'];
349 6
        $caseMatters     ??= $routerConfig['caseMatters'];
350 6
        $slashMatters    ??= $routerConfig['slashMatters'];
351
352
        return [
353 6
            $base,
354 6
            $allowMultiMatch,
355 6
            $caseMatters,
356 6
            $slashMatters,
357
        ];
358
    }
359
360
    /**
361
     * Returns a valid decoded route path.
362
     *
363
     * @param string $base
364
     *
365
     * @return string
366
     */
367 6
    private static function getRoutePath(bool $slashMatters): string
368
    {
369 6
        $url = static::getParsedUrl();
370
371 6
        $path = '/';
372 6
        if (isset($url['path'])) {
373 6
            $path = $url['path'];
374 6
            $path = !$slashMatters && $path !== '/' ? rtrim($path, '/') : $path;
375
        }
376
377 6
        return urldecode($path);
378
    }
379
380
    /**
381
     * Returns a valid route regex.
382
     *
383
     * @param string $expression
384
     * @param bool $slashMatters
385
     * @param bool $caseMatters
386
     *
387
     * @return string
388
     */
389 6
    private static function getRouteRegex(string $expression, bool $slashMatters, bool $caseMatters): string
390
    {
391 6
        $routePlaceholderRegex = '/{([a-z0-9_\-\.?]+)}/i';
392 6
        if (preg_match($routePlaceholderRegex, $expression)) {
393 1
            $routeMatchRegex = strpos($expression, '?}') !== false ? '(.*)?' : '(.+)';
394 1
            $expression = preg_replace(
395 1
                $routePlaceholderRegex,
396
                $routeMatchRegex,
397
                $expression
398
            );
399
        }
400 6
        return sprintf(
401 6
            '<^%s$>%s',
402 6
            (!$slashMatters && $expression !== '/' ? rtrim($expression, '/') : $expression),
403 6
            (!$caseMatters ? 'iu' : 'u')
404
        );
405
    }
406
407
    /**
408
     * Returns valid arguments for route handler in the order that the handler expect.
409
     *
410
     * @param array $current
411
     * @param array $matches
412
     * @param mixed $result
413
     *
414
     * @return array
415
     */
416 3
    private static function getRouteArguments(array $current, array $matches, $result): array
417
    {
418 3
        $arguments = array_merge($current, $matches);
419 3
        $arguments = array_filter($arguments);
420 3
        if (count($arguments) > 1) {
421 1
            array_push($arguments, $result);
422
        } else {
423 2
            array_push($arguments, null, $result);
424
        }
425
426 3
        return $arguments;
427
    }
428
429
    /**
430
     * Echos the response according to the passed parameters.
431
     *
432
     * @param mixed $result
433
     * @param bool $routeMatchFound
434
     * @param bool $pathMatchFound
435
     *
436
     * @return void
437
     */
438 5
    private static function doEchoResponse($result, bool $routeMatchFound, bool $pathMatchFound): void
439
    {
440 5
        $code = 200;
441
442 5
        if (!$routeMatchFound) {
443 3
            $code   = $pathMatchFound ? 405 : 404;
444 3
            $path   = static::$path;
445 3
            $method = static::getRequestMethod();
446
447
            $responses = [
448
                404 => [
449 3
                    'func' => &static::$routeNotFoundCallback,
450 3
                    'args' => [$path],
451
                    'html' => [
452 3
                        'title'   => sprintf('%d Not Found', $code),
453 3
                        'message' => sprintf('The "%s" route is not found!', $path),
454
                    ]
455
                ],
456
                405 => [
457 3
                    'func' => &static::$methodNotAllowedCallback,
458 3
                    'args' => [$path, $method],
459
                    'html' => [
460 3
                        'title'   => sprintf('%d Not Allowed', $code),
461 3
                        'message' => sprintf('The "%s" route is found, but the request method "%s" is not allowed!', $path, $method),
462
                    ]
463
                ],
464
            ];
465
466 3
            if (isset($responses[$code]['func'])) {
467 2
                $result = ($responses[$code]['func'])(...$responses[$code]['args']);
468
            } else {
469 1
                $result = (new HTML())
470 1
                    ->node('<!DOCTYPE html>')
471 1
                    ->open('html', ['lang' => 'en'])
472 1
                        ->open('head')
473 1
                            ->title($responses[$code]['html']['title'])
474 1
                            ->link(null, [
475 1
                                'href' => 'https://cdn.jsdelivr.net/npm/[email protected]/css/bulma.min.css',
476
                                'rel' => 'stylesheet'
477
                            ])
478 1
                        ->close()
479 1
                        ->open('body')
480 1
                            ->open('section', ['class' => 'section is-large has-text-centered'])
481 1
                                ->hr(null)
482 1
                                ->h1($responses[$code]['html']['title'], ['class' => 'title is-1 is-spaced has-text-danger'])
483 1
                                ->h4($responses[$code]['html']['message'], ['class' => 'subtitle'])
484 1
                                ->hr(null)
485 1
                                ->a('Home', ['class' => 'button is-success is-light', 'href' => '/'])
486 1
                                ->hr(null)
487 1
                            ->close()
488 1
                        ->close()
489 1
                    ->close()
490 1
                ->return();
491
            }
492
493 3
            App::log("Responded with {$code} to the request for '{$path}' with method '{$method}'", null, 'system');
494
        }
495
496 5
        http_response_code($code);
497 5
        echo($result);
498 5
    }
499
500
    /**
501
     * Returns query parameters.
502
     *
503
     * @return array
504
     */
505 1
    public static function getParsedQuery(): array
506
    {
507 1
        $url = static::getParsedUrl();
508
509 1
        parse_str($url['query'] ?? '', $query);
510
511 1
        return $query;
512
    }
513
514
    /**
515
     * Returns components of the current URL.
516
     *
517
     * @return array
518
     */
519 8
    public static function getParsedUrl(): array
520
    {
521 8
        $uri = Globals::getServer('REQUEST_URI');
522
523
        // remove double slashes as they make parse_url() fail
524 8
        $url = preg_replace('/(\/+)/', '/', $uri);
525 8
        $url = parse_url($url);
526
527 8
        return $url;
528
    }
529
530 6
    protected static function getRequestMethod(): string
531
    {
532 6
        $method = Globals::getPost('_method') ?? '';
533 6
        $methods = static::SUPPORTED_METHODS;
534 6
        $methodAllowed = in_array(
535 6
            strtoupper($method),
536 6
            array_map('strtoupper', $methods)
537
        );
538
539 6
        if ($methodAllowed) {
540 4
            Globals::setServer('REQUEST_METHOD', $method);
541
        }
542
543 6
        return Globals::getServer('REQUEST_METHOD');
544
    }
545
546
    /**
547
     * Returns all registered routes with their `expression`, `handler`, `arguments`, and `method`.
548
     *
549
     * @return array
550
     */
551 2
    public static function getRegisteredRoutes(): array
552
    {
553 2
        return static::$routes;
554
    }
555
556
557
    /**
558
     * Class constructor.
559
     */
560 19
    final public function __construct()
561
    {
562
        // prevent overwriting constructor in subclasses to allow to use
563
        // "return new static()" without caring about dependencies.
564
565
        // start the router if it's not started by the user
566 19
        static $isStarted = false;
567 19
        if (Config::get('router.allowAutoStart') && !$isStarted) {
568 19
            register_shutdown_function(static function () use (&$isStarted) {
569
                // @codeCoverageIgnoreStart
570
                $isStarted = true;
571
                // $params should be an array if the router has been started
572
                if (self::$params === null && PHP_SAPI !== 'cli') {
573
                    try {
574
                        static::start();
575
                    } catch (\Throwable $exception) {
576
                        App::handleException($exception);
577
                    }
578
                }
579
                // @codeCoverageIgnoreEnd
580 19
            });
581
        }
582 19
    }
583
584
    /**
585
     * Aliases `self::handle()` method with common HTTP verbs.
586
     */
587 1
    public static function __callStatic(string $method, array $arguments)
588
    {
589 1
        $methods = static::SUPPORTED_METHODS;
590 1
        $methodAllowed = in_array(
591 1
            strtoupper($method),
592 1
            array_map('strtoupper', ['ANY', ...$methods])
593
        );
594
595 1
        if (!$methodAllowed) {
596 1
            $class = static::class;
597 1
            throw new \Exception("Call to undefined method {$class}::{$method}()");
598
        }
599
600 1
        if (count($arguments) > 2) {
601 1
            $arguments = array_slice($arguments, 0, 2);
602
        }
603
604 1
        if (strtoupper($method) === 'ANY') {
605 1
            array_push($arguments, $methods);
606
        } else {
607 1
            array_push($arguments, $method);
608
        }
609
610 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

610
        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...
611
    }
612
613
    /**
614
     * Allows static methods handled by `self::__callStatic()` to be accessible via object operator `->`.
615
     */
616 1
    public function __call(string $method, array $arguments)
617
    {
618 1
        return static::__callStatic($method, $arguments);
619
    }
620
}
621