Router::start()   B
last analyzed

Complexity

Conditions 9
Paths 9

Size

Total Lines 57
Code Lines 32

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 31
CRAP Score 9

Importance

Changes 14
Bugs 0 Features 0
Metric Value
cc 9
eloc 32
c 14
b 0
f 0
nc 9
nop 4
dl 0
loc 57
ccs 31
cts 31
cp 1
crap 9
rs 8.0555

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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

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