Passed
Push — master ( 70bf0a...4ee951 )
by Marwan
01:37
created

Router::echoResponse()   B

Complexity

Conditions 9
Paths 33

Size

Total Lines 52
Code Lines 39

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 39
CRAP Score 9

Importance

Changes 4
Bugs 0 Features 0
Metric Value
cc 9
eloc 39
c 4
b 0
f 0
nc 33
nop 3
dl 0
loc 52
ccs 39
cts 39
cp 1
crap 9
rs 7.7404

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
            $code   = $pathMatchFound ? 405 : 404;
444 2
            $path   = static::$path;
445 2
            $method = static::getRequestMethod();
446
447 2
            $title = $code === 404
448 1
                ? sprintf('%d Not Found', $code)
449 2
                : sprintf('%d Not Allowed', $code);
450 2
            $message = $code === 404
451 1
                ? sprintf('The "%s" route is not found!', $path)
452 2
                : sprintf('The "%s" route is found, but the request method "%s" is not allowed!', $path, $method);
453
454 2
            $result = (new HTML())
455 2
                ->node('<!DOCTYPE html>')
456 2
                ->open('html', ['lang' => 'en'])
457 2
                    ->open('head')
458 2
                        ->title($title)
459 2
                        ->link(null, [
460 2
                            'href' => 'https://cdn.jsdelivr.net/npm/[email protected]/css/bulma.min.css',
461
                            'rel' => 'stylesheet'
462
                        ])
463 2
                    ->close()
464 2
                    ->open('body')
465 2
                        ->open('section', ['class' => 'section is-large has-text-centered'])
466 2
                            ->hr(null)
467 2
                            ->h1($title, ['class' => 'title is-1 is-spaced has-text-danger'])
468 2
                            ->h4($message, ['class' => 'subtitle'])
469 2
                            ->hr(null)
470 2
                            ->a('Home', ['class' => 'button is-success is-light', 'href' => '/'])
471 2
                            ->hr(null)
472 2
                        ->close()
473 2
                    ->close()
474 2
                ->close()
475 2
            ->return();
476
477 2
            if ($code === 404 && static::$routeNotFoundCallback !== null) {
478 1
                $result = (static::$routeNotFoundCallback)($path);
479
            }
480
481 2
            if ($code === 405 && static::$methodNotAllowedCallback !== null) {
482 1
                $result = (static::$methodNotAllowedCallback)($path, $method);
483
            }
484
485 2
            App::log("Responded with {$code} to the request for '{$path}' with method '{$method}'", null, 'system');
486
        }
487
488 4
        http_response_code($code);
489 4
        echo($result);
490 4
    }
491
492
    /**
493
     * Returns query parameters.
494
     *
495
     * @return array
496
     */
497 1
    public static function getParsedQuery(): array
498
    {
499 1
        $url = static::getParsedUrl();
500
501 1
        parse_str($url['query'] ?? '', $query);
502
503 1
        return $query;
504
    }
505
506
    /**
507
     * Returns components of the current URL.
508
     *
509
     * @return array
510
     */
511 7
    public static function getParsedUrl(): array
512
    {
513 7
        $uri = Globals::getServer('REQUEST_URI');
514
515
        // remove double slashes as they make parse_url() fail
516 7
        $url = preg_replace('/(\/+)/', '/', $uri);
517 7
        $url = parse_url($url);
518
519 7
        return $url;
520
    }
521
522 5
    protected static function getRequestMethod(): string
523
    {
524 5
        $method = Globals::getPost('_method') ?? '';
525 5
        $methods = static::SUPPORTED_METHODS;
526 5
        $methodAllowed = in_array(
527 5
            strtoupper($method),
528 5
            array_map('strtoupper', $methods)
529
        );
530
531 5
        if ($methodAllowed) {
532 3
            Globals::setServer('REQUEST_METHOD', $method);
533
        }
534
535 5
        return Globals::getServer('REQUEST_METHOD');
536
    }
537
538
    /**
539
     * Returns all registered routes with their `expression`, `handler`, `arguments`, and `method`.
540
     *
541
     * @return array
542
     */
543 2
    public static function getRegisteredRoutes(): array
544
    {
545 2
        return static::$routes;
546
    }
547
548
549
    /**
550
     * Class constructor.
551
     */
552 18
    final public function __construct()
553
    {
554
        // prevent overwriting constructor in subclasses to allow to use
555
        // "return new static()" without caring about dependencies.
556
557
        // start the router if it's not started by the user
558 18
        static $isStarted = false;
559 18
        if (Config::get('router.allowAutoStart') && !$isStarted) {
560 18
            register_shutdown_function(static function () use (&$isStarted) {
561
                // @codeCoverageIgnoreStart
562
                $isStarted = true;
563
                // $params should be an array if the router has been started
564
                if (self::$params === null && PHP_SAPI !== 'cli') {
565
                    try {
566
                        static::start();
567
                    } catch (\Throwable $exception) {
568
                        App::handleException($exception);
0 ignored issues
show
Bug introduced by
The method handleException() does not exist on MAKS\Velox\App. Since you implemented __callStatic, consider adding a @method annotation. ( Ignorable by Annotation )

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

568
                        App::/** @scrutinizer ignore-call */ 
569
                             handleException($exception);
Loading history...
569
                    }
570
                }
571
                // @codeCoverageIgnoreEnd
572 18
            });
573
        }
574 18
    }
575
576
    /**
577
     * Aliases `self::handle()` method with common HTTP verbs.
578
     */
579 1
    public static function __callStatic(string $method, array $arguments)
580
    {
581 1
        $methods = static::SUPPORTED_METHODS;
582 1
        $methodAllowed = in_array(
583 1
            strtoupper($method),
584 1
            array_map('strtoupper', ['ANY', ...$methods])
585
        );
586
587 1
        if (!$methodAllowed) {
588 1
            $class = static::class;
589 1
            throw new \Exception("Call to undefined method {$class}::{$method}()");
590
        }
591
592 1
        if (count($arguments) > 2) {
593 1
            $arguments = array_slice($arguments, 0, 2);
594
        }
595
596 1
        if (strtoupper($method) === 'ANY') {
597 1
            array_push($arguments, $methods);
598
        } else {
599 1
            array_push($arguments, $method);
600
        }
601
602 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

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