Passed
Push — master ( 6c6d7e...b48c4f )
by Marwan
01:39
created

Router::doEchoResponse()   B

Complexity

Conditions 6
Paths 18

Size

Total Lines 51
Code Lines 32

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 26
CRAP Score 6

Importance

Changes 3
Bugs 0 Features 0
Metric Value
cc 6
eloc 32
c 3
b 0
f 0
nc 18
nop 3
dl 0
loc 51
ccs 26
cts 26
cp 1
crap 6
rs 8.7857

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\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
     * 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   = preg_replace('/(\/+)/', '/', static::$base . '/' . $to);
210 1
            $base   = Config::get('global.baseUrl', $scheme . '://' . $host);
211
212 1
            $header = sprintf('Location: %s/%s', trim($base, '/'), trim($path, '/'));
213
        }
214
215 2
        header($header, true, 302);
216
217
        App::terminate(); // @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
        App::terminate(); // @codeCoverageIgnore
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
        Session::csrf()->check();
287
288 6
        Event::dispatch('router.on.start', [&self::$params]);
289
290 6
        [$base, $allowMultiMatch, $caseMatters, $slashMatters] = static::getValidParameters($base, $allowMultiMatch, $caseMatters, $slashMatters);
291
292 6
        static::$base = $base = '/' . trim($base, '/');
293 6
        static::$path = $path = static::getRoutePath($slashMatters);
294
295 6
        $routeMatchFound = false;
296 6
        $pathMatchFound  = false;
297 6
        $result = null;
298
299 6
        foreach (static::$routes as &$route) {
300 5
            $expression = $base === '/' ? $route['expression'] : sprintf('%s/%s', $base, ltrim($route['expression'], '/'));
301
302 5
            $regex = static::getRouteRegex($expression, $slashMatters, $caseMatters);
303 5
            if (preg_match($regex, $path, $matches, PREG_UNMATCHED_AS_NULL)) {
304 4
                $pathMatchFound = true;
305
306 4
                $currentMethod  = static::getRequestMethod();
307 4
                $allowedMethods = (array)$route['method'];
308 4
                foreach ($allowedMethods as $allowedMethod) {
309 4
                    if (strtoupper($currentMethod) !== strtoupper($allowedMethod)) {
310 2
                        continue;
311
                    }
312
313 3
                    $routeMatchFound = true;
314
315 3
                    $route['arguments'] = static::getRouteArguments($route['arguments'], $matches, $result);
316
317 3
                    $result = call_user_func_array($route['handler'], $route['arguments']);
318
319 3
                    if ($result === false) {
320 1
                        throw new \Exception("Something went wrong when trying to respond to '{$path}'! Check the handler for this route");
321
                    }
322
                }
323
            }
324
325 5
            if ($routeMatchFound && !$allowMultiMatch) {
326 1
                break;
327
            }
328
        }
329
330 5
        unset($route);
331
332 5
        static::doEchoResponse($result, $routeMatchFound, $pathMatchFound);
333 4
    }
334
335
    /**
336
     * Returns valid parameters for `self::start()` by validating the passed parameters and adding the deficiency from router config.
337
     *
338
     * @param string|null $base
339
     * @param bool|null $allowMultiMatch
340
     * @param bool|null $caseMatters
341
     * @param bool|null $slashMatters
342
     *
343
     * @return array
344
     */
345 6
    private static function getValidParameters(?string $base, ?bool $allowMultiMatch, ?bool $caseMatters, ?bool $slashMatters): array
346
    {
347 6
        $routerConfig = Config::get('router');
348
349 6
        $base            ??= $routerConfig['base'];
350 6
        $allowMultiMatch ??= $routerConfig['allowMultiMatch'];
351 6
        $caseMatters     ??= $routerConfig['caseMatters'];
352 6
        $slashMatters    ??= $routerConfig['slashMatters'];
353
354
        return [
355 6
            $base,
356 6
            $allowMultiMatch,
357 6
            $caseMatters,
358 6
            $slashMatters,
359
        ];
360
    }
361
362
    /**
363
     * Returns a valid decoded route path.
364
     *
365
     * @param string $base
366
     *
367
     * @return string
368
     */
369 6
    private static function getRoutePath(bool $slashMatters): string
370
    {
371 6
        $url = static::getParsedUrl();
372
373 6
        $path = '/';
374 6
        if (isset($url['path'])) {
375 6
            $path = $url['path'];
376 6
            $path = !$slashMatters && $path !== '/' ? rtrim($path, '/') : $path;
377
        }
378
379 6
        return urldecode($path);
380
    }
381
382
    /**
383
     * Returns a valid route regex.
384
     *
385
     * @param string $expression
386
     * @param bool $slashMatters
387
     * @param bool $caseMatters
388
     *
389
     * @return string
390
     */
391 5
    private static function getRouteRegex(string $expression, bool $slashMatters, bool $caseMatters): string
392
    {
393 5
        $routePlaceholderRegex = '/{([a-z0-9_\-\.?]+)}/i';
394 5
        if (preg_match($routePlaceholderRegex, $expression)) {
395 1
            $routeMatchRegex = strpos($expression, '?}') !== false ? '(.*)?' : '(.+)';
396 1
            $expression = preg_replace(
397 1
                $routePlaceholderRegex,
398
                $routeMatchRegex,
399
                $expression
400
            );
401
        }
402 5
        return sprintf(
403 5
            '<^%s$>%s',
404 5
            (!$slashMatters && $expression !== '/' ? rtrim($expression, '/') : $expression),
405 5
            (!$caseMatters ? 'iu' : 'u')
406
        );
407
    }
408
409
    /**
410
     * Returns valid arguments for route handler in the order that the handler expect.
411
     *
412
     * @param array $current
413
     * @param array $matches
414
     * @param mixed $result
415
     *
416
     * @return array
417
     */
418 3
    private static function getRouteArguments(array $current, array $matches, $result): array
419
    {
420 3
        $arguments = array_merge($current, $matches);
421 3
        $arguments = array_filter($arguments);
422 3
        if (count($arguments) > 1) {
423 1
            array_push($arguments, $result);
424
        } else {
425 2
            array_push($arguments, null, $result);
426
        }
427
428 3
        return $arguments;
429
    }
430
431
    /**
432
     * Echos the response according to the passed parameters.
433
     *
434
     * @param mixed $result
435
     * @param bool $routeMatchFound
436
     * @param bool $pathMatchFound
437
     *
438
     * @return void
439
     */
440 5
    private static function doEchoResponse($result, bool $routeMatchFound, bool $pathMatchFound): void
441
    {
442 5
        $code = 200;
443
444 5
        if (!$routeMatchFound) {
445 3
            $code   = $pathMatchFound ? 405 : 404;
446 3
            $path   = static::$path;
447 3
            $method = static::getRequestMethod();
448
449 3
            App::log("Responded with {$code} to the request for '{$path}' with method '{$method}'", null, 'system');
450
451
            $responses = [
452
                404 => [
453 3
                    'func' => &static::$routeNotFoundCallback,
454 3
                    'args' => [$path],
455
                    'html' => [
456 3
                        'title'   => sprintf('%d Not Found', $code),
457 3
                        'message' => sprintf('The "%s" route is not found!', $path),
458
                    ]
459
                ],
460
                405 => [
461 3
                    'func' => &static::$methodNotAllowedCallback,
462 3
                    'args' => [$path, $method],
463
                    'html' => [
464 3
                        'title'   => sprintf('%d Not Allowed', $code),
465 3
                        'message' => sprintf('The "%s" route is found, but the request method "%s" is not allowed!', $path, $method),
466
                    ]
467
                ],
468
            ];
469
470 3
            http_response_code($code);
471
472 3
            if (!isset($responses[$code]['func'])) {
473
                try {
474 1
                    echo View::render(Config::get('global.errorPages.' . $code), compact('path', 'method'));
0 ignored issues
show
Bug introduced by
It seems like MAKS\Velox\Backend\Confi...l.errorPages.' . $code) can also be of type null; however, parameter $page of MAKS\Velox\Frontend\View::render() does only seem to accept string, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

474
                    echo View::render(/** @scrutinizer ignore-type */ Config::get('global.errorPages.' . $code), compact('path', 'method'));
Loading history...
475
                    App::terminate(); // @codeCoverageIgnore
476 1
                } catch (\Throwable $e) {
477
                    // this function will exit the script
478 1
                    App::abort(
479 1
                        $code,
480 1
                        $responses[$code]['html']['title'],
481 1
                        $responses[$code]['html']['message']
482
                    );
483
                }
484
            }
485
486 2
            $result = ($responses[$code]['func'])(...$responses[$code]['args']);
487
        }
488
489 4
        http_response_code() || http_response_code($code);
490 4
        echo($result);
491 4
    }
492
493
    /**
494
     * Returns query parameters.
495
     *
496
     * @return array
497
     */
498 1
    public static function getParsedQuery(): array
499
    {
500 1
        $url = static::getParsedUrl();
501
502 1
        parse_str($url['query'] ?? '', $query);
503
504 1
        return $query;
505
    }
506
507
    /**
508
     * Returns components of the current URL.
509
     *
510
     * @return array
511
     */
512 8
    public static function getParsedUrl(): array
513
    {
514 8
        $uri = Globals::getServer('REQUEST_URI');
515
516
        // remove double slashes as they make parse_url() fail
517 8
        $url = preg_replace('/(\/+)/', '/', $uri);
518 8
        $url = parse_url($url);
519
520 8
        return $url;
521
    }
522
523 6
    protected static function getRequestMethod(): string
524
    {
525 6
        $method = Globals::cutPost('_method') ?? '';
526 6
        $methods = static::SUPPORTED_METHODS;
527 6
        $methodAllowed = in_array(
528 6
            strtoupper($method),
529 6
            array_map('strtoupper', $methods)
530
        );
531
532 6
        if ($methodAllowed) {
533 1
            Globals::setServer('REQUEST_METHOD', $method);
534
        }
535
536 6
        return Globals::getServer('REQUEST_METHOD');
537
    }
538
539
    /**
540
     * Returns all registered routes with their `expression`, `handler`, `arguments`, and `method`.
541
     *
542
     * @return array
543
     */
544 2
    public static function getRegisteredRoutes(): array
545
    {
546 2
        return static::$routes;
547
    }
548
549
550
    /**
551
     * Class constructor.
552
     */
553 21
    final public function __construct()
554
    {
555
        // prevent overwriting constructor in subclasses to allow to use
556
        // "return new static()" without caring about dependencies.
557
558
        // start the router if it's not started by the user
559 21
        static $isStarted = false;
560 21
        if (Config::get('router.allowAutoStart') && !$isStarted) {
561 21
            register_shutdown_function(static function () use (&$isStarted) {
562
                // @codeCoverageIgnoreStart
563
                $isStarted = true;
564
                // $params should be an array if the router has been started
565
                if (self::$params === null && PHP_SAPI !== 'cli') {
566
                    try {
567
                        static::start();
568
                    } catch (\Throwable $exception) {
569
                        App::handleException($exception);
570
                    }
571
                }
572
                // @codeCoverageIgnoreEnd
573 21
            });
574
        }
575 21
    }
576
577
    /**
578
     * Aliases `self::handle()` method with common HTTP verbs.
579
     */
580 1
    public static function __callStatic(string $method, array $arguments)
581
    {
582 1
        $methods = static::SUPPORTED_METHODS;
583 1
        $methodAllowed = in_array(
584 1
            strtoupper($method),
585 1
            array_map('strtoupper', ['ANY', ...$methods])
586
        );
587
588 1
        if (!$methodAllowed) {
589 1
            $class = static::class;
590 1
            throw new \Exception("Call to undefined method {$class}::{$method}()");
591
        }
592
593 1
        if (count($arguments) > 2) {
594 1
            $arguments = array_slice($arguments, 0, 2);
595
        }
596
597 1
        if (strtoupper($method) === 'ANY') {
598 1
            array_push($arguments, $methods);
599
        } else {
600 1
            array_push($arguments, $method);
601
        }
602
603 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

603
        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...
604
    }
605
606
    /**
607
     * Allows static methods handled by `self::__callStatic()` to be accessible via object operator `->`.
608
     */
609 1
    public function __call(string $method, array $arguments)
610
    {
611 1
        return static::__callStatic($method, $arguments);
612
    }
613
}
614