Passed
Push — master ( 6b7eb7...b4b84b )
by Marwan
01:21
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::route('/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 6
    private static function registerRoute(string $type, string $expression, callable $handler, array $arguments, $method)
146
    {
147
        $route = [
148 6
            'type' => $type,
149 6
            'expression' => $expression,
150 6
            'handler' => $handler,
151 6
            'arguments' => $arguments,
152 6
            'method' => $method
153
        ];
154
155 6
        static::$routes[] = &$route;
156
157 6
        Event::dispatch('router.on.register' . ucfirst($type), [&$route]);
158
159 6
        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 6
    public static function handle(string $expression, callable $handler, $method = 'GET')
172
    {
173 6
        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 5
    public static function start(?string $base = null, ?bool $allowMultiMatch = null, ?bool $caseMatters = null, ?bool $slashMatters = null): void
283
    {
284 5
        self::$params = func_get_args();
285
286 5
        Event::dispatch('router.on.start', [&self::$params]);
287
288 5
        [$base, $allowMultiMatch, $caseMatters, $slashMatters] = static::getValidParameters($base, $allowMultiMatch, $caseMatters, $slashMatters);
289
290 5
        static::$base = $base = '/' . trim($base, '/');
291 5
        static::$path = $path = static::getRoutePath($slashMatters);
292
293 5
        $routeMatchFound = false;
294 5
        $pathMatchFound  = false;
295 5
        $result = null;
296
297 5
        foreach (static::$routes as &$route) {
298 5
            $expression = $base === '/' ? $route['expression'] : sprintf('%s/%s', $base, ltrim($route['expression'], '/'));
299
300 5
            $regex = static::getRouteRegex($expression, $slashMatters, $caseMatters);
301 5
            if (preg_match($regex, $path, $matches, PREG_UNMATCHED_AS_NULL)) {
302 4
                $pathMatchFound = true;
303
304 4
                $currentMethod  = static::getRequestMethod();
305 4
                $allowedMethods = (array)$route['method'];
306 4
                foreach ($allowedMethods as $allowedMethod) {
307 4
                    if (strtoupper($currentMethod) !== strtoupper($allowedMethod)) {
308 2
                        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 5
            if ($routeMatchFound && !$allowMultiMatch) {
324 1
                break;
325
            }
326
        }
327
328 4
        unset($route);
329
330 4
        static::echoResponse($routeMatchFound, $pathMatchFound, $result);
331 4
    }
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 5
    private static function getValidParameters(?string $base, ?bool $allowMultiMatch, ?bool $caseMatters, ?bool $slashMatters): array
344
    {
345 5
        $routerConfig = Config::get('router');
346
347 5
        $base            ??= $routerConfig['base'];
348 5
        $allowMultiMatch ??= $routerConfig['allowMultiMatch'];
349 5
        $caseMatters     ??= $routerConfig['caseMatters'];
350 5
        $slashMatters    ??= $routerConfig['slashMatters'];
351
352
        return [
353 5
            $base,
354 5
            $allowMultiMatch,
355 5
            $caseMatters,
356 5
            $slashMatters,
357
        ];
358
    }
359
360
    /**
361
     * Returns a valid decoded route path.
362
     *
363
     * @param string $base
364
     *
365
     * @return string
366
     */
367 5
    private static function getRoutePath(bool $slashMatters): string
368
    {
369 5
        $url = static::getParsedUrl();
370
371 5
        $path = '/';
372 5
        if (isset($url['path'])) {
373 5
            $path = $url['path'];
374 5
            $path = !$slashMatters && $path !== '/' ? rtrim($path, '/') : $path;
375
        }
376
377 5
        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 5
    private static function getRouteRegex(string $expression, bool $slashMatters, bool $caseMatters): string
390
    {
391 5
        $routePlaceholderRegex = '/{([a-z0-9_\-\.?]+)}/i';
392 5
        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 5
        return sprintf(
401 5
            '<^%s$>%s',
402 5
            (!$slashMatters && $expression !== '/' ? rtrim($expression, '/') : $expression),
403 5
            (!$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 bool $routeMatchFound
433
     * @param bool $pathMatchFound
434
     * @param mixed $result
435
     *
436
     * @return void
437
     */
438 4
    private static function echoResponse(bool $routeMatchFound, bool $pathMatchFound, $result): void
439
    {
440 4
        $code = 200;
441
442 4
        if (!$routeMatchFound) {
443 2
            $path    = static::$path;
444 2
            $method  = static::getRequestMethod();
445 2
            $title   = '';
446 2
            $message = '';
447
448 2
            if ($pathMatchFound === false) {
449 1
                if (static::$routeNotFoundCallback) {
450 1
                    $result = (static::$routeNotFoundCallback)($path);
451
                }
452
453 1
                $code    = 404;
454 1
                $title   = sprintf('%d Not Found', $code);
455 1
                $message = sprintf('The "%s" route is not found!', $path);
456
            }
457
458 2
            if ($pathMatchFound === true) {
459 1
                if (static::$methodNotAllowedCallback) {
460 1
                    $result = (static::$methodNotAllowedCallback)($path, $method);
461
                }
462
463 1
                $code    = 405;
464 1
                $title   = sprintf('%d Not Allowed', $code);
465 1
                $message = sprintf('The "%s" route is found, but the request method "%s" is not allowed!', $path, $method);
466
            }
467
468 2
            $result = $result ?? (new HTML())
469
                ->node('<!DOCTYPE html>')
470
                ->open('html', ['lang' => 'en'])
471
                    ->open('head')
472
                        ->title($title)
473
                        ->link(null, [
474
                            'href' => 'https://cdn.jsdelivr.net/npm/[email protected]/css/bulma.min.css',
475
                            'rel' => 'stylesheet'
476
                        ])
477
                    ->close()
478
                    ->open('body')
479
                        ->open('section', ['class' => 'section is-large has-text-centered'])
480
                            ->hr(null)
481
                            ->h1($title, ['class' => 'title is-1 is-spaced has-text-danger'])
482
                            ->h4($message, ['class' => 'subtitle'])
483
                            ->hr(null)
484
                            ->a('Home', ['class' => 'button is-success is-light', 'href' => '/'])
485
                            ->hr(null)
486
                        ->close()
487
                    ->close()
488
                ->close()
489 2
            ->return();
490
491 2
            App::log("Responded with {$code} to the request for '{$path}' with method '{$method}'", null, 'system');
492
        }
493
494 4
        http_response_code($code);
495 4
        echo($result);
496 4
    }
497
498
    /**
499
     * Returns query parameters.
500
     *
501
     * @return array
502
     */
503 1
    public static function getParsedQuery(): array
504
    {
505 1
        $url = static::getParsedUrl();
506
507 1
        parse_str($url['query'] ?? '', $query);
508
509 1
        return $query;
510
    }
511
512
    /**
513
     * Returns components of the current URL.
514
     *
515
     * @return array
516
     */
517 7
    public static function getParsedUrl(): array
518
    {
519 7
        $uri = Globals::getServer('REQUEST_URI');
520
521
        // remove double slashes as they make parse_url() fail
522 7
        $url = preg_replace('/(\/+)/', '/', $uri);
523 7
        $url = parse_url($url);
524
525 7
        return $url;
526
    }
527
528 5
    protected static function getRequestMethod(): string
529
    {
530 5
        $method = Globals::getPost('_method') ?? '';
531 5
        $methods = static::SUPPORTED_METHODS;
532 5
        $methodAllowed = in_array(
533 5
            strtoupper($method),
534 5
            array_map('strtoupper', $methods)
535
        );
536
537 5
        if ($methodAllowed) {
538 3
            Globals::setServer('REQUEST_METHOD', $method);
539
        }
540
541 5
        return Globals::getServer('REQUEST_METHOD');
542
    }
543
544
    /**
545
     * Returns all registered routes with their `expression`, `handler`, `arguments`, and `method`.
546
     *
547
     * @return array
548
     */
549 2
    public static function getRegisteredRoutes(): array
550
    {
551 2
        return static::$routes;
552
    }
553
554
555
    /**
556
     * Class constructor.
557
     */
558 18
    final public function __construct()
559
    {
560
        // prevent overwriting constructor in subclasses to allow to use
561
        // "return new static()" without caring about dependencies.
562
563
        // start the router if it's not started by the user
564 18
        static $isStarted = false;
565 18
        if (Config::get('router.allowAutoStart') && !$isStarted) {
566 18
            register_shutdown_function(static function () use (&$isStarted) {
567
                // @codeCoverageIgnoreStart
568
                $isStarted = true;
569
                // $params should be an array if the router has been started
570
                if (self::$params === null && PHP_SAPI !== 'cli') {
571
                    try {
572
                        static::start();
573
                    } catch (\Throwable $exception) {
574
                        App::handleException($exception);
575
                    }
576
                }
577
                // @codeCoverageIgnoreEnd
578 18
            });
579
        }
580 18
    }
581
582
    /**
583
     * Aliases `self::handle()` method with common HTTP verbs.
584
     */
585 1
    public static function __callStatic(string $method, array $arguments)
586
    {
587 1
        $methods = static::SUPPORTED_METHODS;
588 1
        $methodAllowed = in_array(
589 1
            strtoupper($method),
590 1
            array_map('strtoupper', ['ANY', ...$methods])
591
        );
592
593 1
        if (!$methodAllowed) {
594 1
            $class = static::class;
595 1
            throw new \Exception("Call to undefined method {$class}::{$method}()");
596
        }
597
598 1
        if (count($arguments) > 2) {
599 1
            $arguments = array_slice($arguments, 0, 2);
600
        }
601
602 1
        if (strtoupper($method) === 'ANY') {
603 1
            array_push($arguments, $methods);
604
        } else {
605 1
            array_push($arguments, $method);
606
        }
607
608 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

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