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

Router::echoResponse()   B

Complexity

Conditions 6
Paths 10

Size

Total Lines 58
Code Lines 42

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 24
CRAP Score 8.8343

Importance

Changes 5
Bugs 0 Features 0
Metric Value
cc 6
eloc 42
c 5
b 0
f 0
nc 10
nop 3
dl 0
loc 58
ccs 24
cts 42
cp 0.5714
crap 8.8343
rs 8.6257

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\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