Passed
Push — master ( e92a23...70bf0a )
by Marwan
08:52
created

Router   B

Complexity

Total Complexity 47

Size/Duplication

Total Lines 500
Duplicated Lines 0 %

Test Coverage

Coverage 91.72%

Importance

Changes 10
Bugs 0 Features 0
Metric Value
eloc 165
c 10
b 0
f 0
dl 0
loc 500
ccs 144
cts 157
cp 0.9172
rs 8.64
wmc 47

20 Methods

Rating   Name   Duplication   Size   Complexity  
B start() 0 47 9
A redirect() 0 16 3
A getRouteRegex() 0 15 6
A handle() 0 3 1
A getValidParameters() 0 14 1
A getRegisteredRoutes() 0 3 1
A __construct() 0 2 1
A getRoutePath() 0 11 4
A getRequestMethod() 0 14 2
A registerRoute() 0 13 1
A handleMethodNotAllowed() 0 5 1
A echoResponse() 0 38 5
A handleRouteNotFound() 0 5 1
A __call() 0 3 1
A getParsedQuery() 0 7 1
A __callStatic() 0 24 4
A middleware() 0 3 1
A getRouteArguments() 0 11 2
A forward() 0 10 1
A getParsedUrl() 0 9 1

How to fix   Complexity   

Complex Class

Complex classes like Router often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Router, and based on these observations, apply Extract Interface, too.

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\Config;
16
use MAKS\Velox\Backend\Globals;
17
18
/**
19
 * A class that serves as a router and an entry point for the application.
20
 *
21
 * Example:
22
 * ```
23
 * // register a middleware
24
 * Router::middleware('/pages/{pageId}', function ($path, $match, $previous) {
25
 *      return 'I am working as expected!';
26
 * }, 'POST');
27
 *
28
 * // register a route handler
29
 * Router::route('/pages/{pageId}', function ($path, $match, $previous) {
30
 *      return sprintf('Hi from "%s" handler, Page ID is: %s, also the middleware said: %s', $path, $match, $previous ?? 'Nothing!');
31
 * }, ['GET', 'POST']);
32
 *
33
 * // register a route handler using an HTTP verb
34
 * Router::get('/another-page', function () {
35
 *      return View::render('another-page');
36
 * });
37
 *
38
 * // register handler for 404
39
 * Router::handleRouteNotFound(function ($path) {
40
 *      // forward the request to some route.
41
 *      Router::forward('/');
42
 * });
43
 *
44
 * // register handler for 405
45
 * Router::handleMethodNotAllowed(function ($path, $method) {
46
 *      // redirect the request to some URL.
47
 *      Router::redirect('/some-page');
48
 * });
49
 *
50
 * // start the application
51
 * Router::start();
52
 * ```
53
 *
54
 * @method static self get(string $expression, callable $handler)
55
 * @method static self head(string $expression, callable $handler)
56
 * @method static self post(string $expression, callable $handler)
57
 * @method static self put(string $expression, callable $handler)
58
 * @method static self patch(string $expression, callable $handler)
59
 * @method static self delete(string $expression, callable $handler)
60
 * @method static self connect(string $expression, callable $handler)
61
 * @method static self options(string $expression, callable $handler)
62
 * @method static self trace(string $expression, callable $handler)
63
 * @method static self any(string $expression, callable $handler)
64
 *
65
 * @since 1.0.0
66
 * @api
67
 */
68
class Router
69
{
70
    /**
71
     * The default values of class parameters.
72
     *
73
     * @var array
74
     */
75
    public const DEFAULTS = [
76
        'base' => '/',
77
        'allowMultiMatch' => true,
78
        'caseMatters' => false,
79
        'slashMatters' => true,
80
    ];
81
82
    /**
83
     * Supported HTTP methods.
84
     *
85
     * @var array
86
     */
87
    public const SUPPORTED_METHODS = [
88
        'GET',
89
        'HEAD',
90
        'POST',
91
        'PUT',
92
        'PATCH',
93
        'DELETE',
94
        'CONNECT',
95
        'OPTIONS',
96
        'TRACE'
97
    ];
98
99
100
    /**
101
     * The parameters the application started with.
102
     */
103
    private static ?array $params = null;
104
105
    /**
106
     * The current base URL of the application.
107
     */
108
    protected static ?string $base = null;
109
110
    /**
111
     * The currently requested path.
112
     */
113
    protected static ?string $path = null;
114
115
    /**
116
     * The currently registered routes.
117
     */
118
    protected static array $routes = [];
119
120
    /**
121
     * @var callable|null
122
     */
123
    protected static $routeNotFoundCallback = null;
124
125
    /**
126
     * @var callable|null
127
     */
128
    protected static $methodNotAllowedCallback = null;
129
130
131
    /**
132
     * Registers a route.
133
     *
134
     * @param string $type
135
     * @param string $expression
136
     * @param callable $handler
137
     * @param array $arguments
138
     * @param string|array $method
139
     *
140
     * @return void
141
     */
142 6
    private static function registerRoute(string $type, string $expression, callable $handler, array $arguments, $method)
143
    {
144
        $route = [
145 6
            'type' => $type,
146 6
            'expression' => $expression,
147 6
            'handler' => $handler,
148 6
            'arguments' => $arguments,
149 6
            'method' => $method
150
        ];
151
152 6
        static::$routes[] = &$route;
153
154 6
        return new static();
155
    }
156
157
    /**
158
     * Registers a handler for a route.
159
     *
160
     * @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).
161
     * @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`.
162
     * @param string|string[] $method [optional] Either a string or an array of the allowed method.
163
     *
164
     * @return static
165
     */
166 6
    public static function handle(string $expression, callable $handler, $method = 'GET')
167
    {
168 6
        return static::registerRoute('handler', $expression, $handler, [], $method);
0 ignored issues
show
Bug introduced by
Are you sure the usage of static::registerRoute('h...dler, array(), $method) targeting MAKS\Velox\Backend\Router::registerRoute() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
169
    }
170
171
    /**
172
     * Registers a middleware for a route. This method has no effect if `$allowMultiMatch` is set to `false`.
173
     * Note that middlewares must be registered before routes in order to work correctly.
174
     * This method is just an alias for `self::handle()`.
175
     *
176
     * @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).
177
     * @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`.
178
     * @param string|string[] $method [optional] Either a string or an array of the allowed method.
179
     *
180
     * @return static
181
     */
182 1
    public static function middleware(string $expression, callable $handler, $method = 'GET')
183
    {
184 1
        return static::registerRoute('middleware', $expression, $handler, [], $method);
0 ignored issues
show
Bug introduced by
Are you sure the usage of static::registerRoute('m...dler, array(), $method) targeting MAKS\Velox\Backend\Router::registerRoute() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
185
    }
186
187
    /**
188
     * Redirects the request to another route.
189
     * Note that this function will exit the script (code that comes after it will not be executed).
190
     *
191
     * @param string $to A route like `/page` or a URL like `http://domain.tld`.
192
     *
193
     * @return void
194
     */
195 2
    public static function redirect(string $to): void
196
    {
197 2
        if (filter_var($to, FILTER_VALIDATE_URL)) {
198 1
            $header = sprintf('Location: %s', $to);
199
        } else {
200 1
            $scheme = Globals::getServer('HTTPS') == 'on' ? 'https' : 'http';
201 1
            $host   = Globals::getServer('HTTP_HOST');
202 1
            $path   = static::$base . '/' . $to;
203 1
            $path   = trim(preg_replace('/(\/+)/', '/', $path), '/');
204
205 1
            $header = sprintf('Location: %s://%s/%s', $scheme, $host, $path);
206
        }
207
208 2
        header($header, true, 302);
209
210
        exit;
211
    }
212
213
    /**
214
     * Forwards the request to another route.
215
     * Note that this function will exit the script (code that comes after it will not be executed).
216
     *
217
     * @param string $to A route like `/page`.
218
     *
219
     * @return void
220
     */
221 1
    public static function forward(string $to): void
222
    {
223 1
        $base = static::$base ?? '';
224 1
        $path = trim($base, '/') . '/' . ltrim($to, '/');
225
226 1
        Globals::setServer('REQUEST_URI', $path);
227
228 1
        static::start(...self::$params);
229
230
        exit;
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...
231
    }
232
233
    /**
234
     * Registers 404 handler.
235
     *
236
     * @param callable $handler The handler to use. It will be passed the current `$path` and the current `$method`.
237
     *
238
     * @return static
239
     */
240 1
    public static function handleRouteNotFound(callable $handler)
241
    {
242 1
        static::$routeNotFoundCallback = $handler;
243
244 1
        return new static();
245
    }
246
247
    /**
248
     * Registers 405 handler.
249
     *
250
     * @param callable $handler The handler to use. It will be passed the current `$path`.
251
     *
252
     * @return static
253
     */
254 1
    public static function handleMethodNotAllowed(callable $handler)
255
    {
256 1
        static::$methodNotAllowedCallback = $handler;
257
258 1
        return new static();
259
    }
260
261
    /**
262
     * Starts the router.
263
     *
264
     * @param string|null [optional] $base App base path, this will prefix all routes.
265
     * @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.
266
     * @param bool|null [optional] $caseMatters Whether the route matching should be case sensitive or not.
267
     * @param bool|null [optional] $slashMatters Whether trailing slash should be taken in consideration with route matching or not.
268
     *
269
     * @return void
270
     *
271
     * @throws \Exception If route handler failed or returned false.
272
     */
273 5
    public static function start(?string $base = null, ?bool $allowMultiMatch = null, ?bool $caseMatters = null, ?bool $slashMatters = null): void
274
    {
275 5
        self::$params = func_get_args();
276
277 5
        [$base, $allowMultiMatch, $caseMatters, $slashMatters] = static::getValidParameters($base, $allowMultiMatch, $caseMatters, $slashMatters);
278
279 5
        static::$base = $base = '/' . trim($base, '/');
280 5
        static::$path = $path = static::getRoutePath($slashMatters);
281
282 5
        $routeMatchFound = false;
283 5
        $pathMatchFound  = false;
284 5
        $result = null;
285
286 5
        foreach (static::$routes as &$route) {
287 5
            $expression = $base === '/' ? $route['expression'] : sprintf('%s/%s', $base, ltrim($route['expression'], '/'));
288
289 5
            $regex = static::getRouteRegex($expression, $slashMatters, $caseMatters);
290 5
            if (preg_match($regex, $path, $matches, PREG_UNMATCHED_AS_NULL)) {
291 4
                $pathMatchFound = true;
292
293 4
                $currentMethod  = static::getRequestMethod();
294 4
                $allowedMethods = (array)$route['method'];
295 4
                foreach ($allowedMethods as $allowedMethod) {
296 4
                    if (strtoupper($currentMethod) !== strtoupper($allowedMethod)) {
297 2
                        continue;
298
                    }
299
300 3
                    $routeMatchFound = true;
301
302 3
                    $route['arguments'] = static::getRouteArguments($route['arguments'], $matches, $result);
303
304 3
                    $result = call_user_func_array($route['handler'], $route['arguments']);
305
306 3
                    if ($result === false) {
307 1
                        throw new \Exception("Something went wrong when trying to respond to '{$path}'! Check the handler for this route");
308
                    }
309
                }
310
            }
311
312 5
            if ($routeMatchFound && !$allowMultiMatch) {
313 1
                break;
314
            }
315
        }
316
317 4
        unset($route);
318
319 4
        static::echoResponse($routeMatchFound, $pathMatchFound, $result);
320
    }
321
322
    /**
323
     * Returns valid parameters for `self::start()` by validating the passed parameters and adding the deficiency from router config.
324
     *
325
     * @param string|null $base
326
     * @param bool|null $allowMultiMatch
327
     * @param bool|null $caseMatters
328
     * @param bool|null $slashMatters
329
     *
330
     * @return array
331
     */
332 5
    private static function getValidParameters(?string $base, ?bool $allowMultiMatch, ?bool $caseMatters, ?bool $slashMatters): array
333
    {
334 5
        $routerConfig = Config::get('router');
335
336 5
        $base            ??= $routerConfig['base'];
337 5
        $allowMultiMatch ??= $routerConfig['allowMultiMatch'];
338 5
        $caseMatters     ??= $routerConfig['caseMatters'];
339 5
        $slashMatters    ??= $routerConfig['slashMatters'];
340
341
        return [
342 5
            $base,
343 5
            $allowMultiMatch,
344 5
            $caseMatters,
345 5
            $slashMatters,
346
        ];
347
    }
348
349
    /**
350
     * Returns a valid decoded route path.
351
     *
352
     * @param string $base
353
     *
354
     * @return string
355
     */
356 5
    private static function getRoutePath(bool $slashMatters): string
357
    {
358 5
        $url = static::getParsedUrl();
359
360 5
        $path = '/';
361 5
        if (isset($url['path'])) {
362 5
            $path = $url['path'];
363 5
            $path = !$slashMatters && $path !== '/' ? rtrim($path, '/') : $path;
364
        }
365
366 5
        return urldecode($path);
367
    }
368
369
    /**
370
     * Returns a valid route regex.
371
     *
372
     * @param string $expression
373
     * @param bool $slashMatters
374
     * @param bool $caseMatters
375
     *
376
     * @return string
377
     */
378 5
    private static function getRouteRegex(string $expression, bool $slashMatters, bool $caseMatters): string
379
    {
380 5
        $routePlaceholderRegex = '/{([a-z0-9_\-\.?]+)}/i';
381 5
        if (preg_match($routePlaceholderRegex, $expression)) {
382 1
            $routeMatchRegex = strpos($expression, '?}') !== false ? '(.*)?' : '(.+)';
383 1
            $expression = preg_replace(
384 1
                $routePlaceholderRegex,
385
                $routeMatchRegex,
386
                $expression
387
            );
388
        }
389 5
        return sprintf(
390 5
            '<^%s$>%s',
391 5
            (!$slashMatters && $expression !== '/' ? rtrim($expression, '/') : $expression),
392 5
            (!$caseMatters ? 'iu' : 'u')
393
        );
394
    }
395
396
    /**
397
     * Returns valid arguments for route handler in the order that the handler expect.
398
     *
399
     * @param array $current
400
     * @param array $matches
401
     * @param mixed $result
402
     *
403
     * @return array
404
     */
405 3
    private static function getRouteArguments(array $current, array $matches, $result): array
406
    {
407 3
        $arguments = array_merge($current, $matches);
408 3
        $arguments = array_filter($arguments);
409 3
        if (count($arguments) > 1) {
410 1
            array_push($arguments, $result);
411
        } else {
412 2
            array_push($arguments, null, $result);
413
        }
414
415 3
        return $arguments;
416
    }
417
418
    /**
419
     * Echos the response according to the passed parameters.
420
     *
421
     * @param bool $routeMatchFound
422
     * @param bool $pathMatchFound
423
     * @param mixed $result
424
     *
425
     * @return void
426
     */
427 4
    private static function echoResponse(bool $routeMatchFound, bool $pathMatchFound, $result): void
428
    {
429 4
        $protocol = Globals::getServer('SERVER_PROTOCOL');
430 4
        $method   = Globals::getServer('REQUEST_METHOD');
431
432 4
        if (!$routeMatchFound) {
433 2
            $result = sprintf('The "%s" route is not found, or the request method is not allowed!', static::$path);
434
435 2
            if ($pathMatchFound) {
436 1
                if (static::$methodNotAllowedCallback) {
437 1
                    $result = call_user_func(static::$methodNotAllowedCallback, static::$path, $method);
438
439 1
                    header("{$protocol} 405 Method Not Allowed", true, 405);
440
                }
441
442
                App::log(
443
                    'Responded with 405 to the request for "{path}" with method "{method}"',
444
                    ['path' => static::$path, 'method' => $method],
445
                    'system'
446
                );
447
            } else {
448 1
                if (static::$routeNotFoundCallback) {
449 1
                    $result = call_user_func(static::$routeNotFoundCallback, static::$path);
450
451 1
                    header("{$protocol} 404 Not Found", true, 404);
452
                }
453
454
                App::log(
455
                    'Responded with 404 to the request for "{path}"',
456
                    ['path' => static::$path],
457
                    'system'
458
                );
459
            }
460
        } else {
461 2
            header("{$protocol} 200 OK", false, 200);
462
        }
463
464
        echo $result;
465
    }
466
467
    /**
468
     * Returns query parameters.
469
     *
470
     * @return array
471
     */
472 1
    public static function getParsedQuery(): array
473
    {
474 1
        $url = static::getParsedUrl();
475
476 1
        parse_str($url['query'] ?? '', $query);
477
478 1
        return $query;
479
    }
480
481
    /**
482
     * Returns components of the current URL.
483
     *
484
     * @return array
485
     */
486 7
    public static function getParsedUrl(): array
487
    {
488 7
        $uri = Globals::getServer('REQUEST_URI');
489
490
        // remove double slashes as they make parse_url() fail
491 7
        $url = preg_replace('/(\/+)/', '/', $uri);
492 7
        $url = parse_url($url);
493
494 7
        return $url;
495
    }
496
497 4
    protected static function getRequestMethod(): string
498
    {
499 4
        $method = Globals::getPost('_method') ?? '';
500 4
        $methods = static::SUPPORTED_METHODS;
501 4
        $methodAllowed = in_array(
502 4
            strtoupper($method),
503 4
            array_map('strtoupper', $methods)
504
        );
505
506 4
        if ($methodAllowed) {
507 2
            Globals::setServer('REQUEST_METHOD', $method);
508
        }
509
510 4
        return Globals::getServer('REQUEST_METHOD');
511
    }
512
513
    /**
514
     * Returns all registered routes with their `expression`, `handler`, `arguments`, and `method`.
515
     *
516
     * @return array
517
     */
518 2
    public static function getRegisteredRoutes(): array
519
    {
520 2
        return static::$routes;
521
    }
522
523
524
    /**
525
     * Class constructor.
526
     */
527 18
    final public function __construct()
528
    {
529
        // prevent overwriting constructor in subclasses to allow to use
530
        // "return new static()" without caring about dependencies.
531 18
    }
532
533
    /**
534
     * Aliases `self::handle()` method with common HTTP verbs.
535
     */
536 1
    public static function __callStatic(string $method, array $arguments)
537
    {
538 1
        $methods = static::SUPPORTED_METHODS;
539 1
        $methodAllowed = in_array(
540 1
            strtoupper($method),
541 1
            array_map('strtoupper', ['ANY', ...$methods])
542
        );
543
544 1
        if (!$methodAllowed) {
545 1
            $class = static::class;
546 1
            throw new \Exception("Call to undefined method {$class}::{$method}()");
547
        }
548
549 1
        if (count($arguments) > 2) {
550 1
            $arguments = array_slice($arguments, 0, 2);
551
        }
552
553 1
        if (strtoupper($method) === 'ANY') {
554 1
            array_push($arguments, $methods);
555
        } else {
556 1
            array_push($arguments, $method);
557
        }
558
559 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

559
        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...
Bug introduced by
Are you sure the usage of static::handle($arguments) targeting MAKS\Velox\Backend\Router::handle() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
560
    }
561
562
    /**
563
     * Allows static methods handled by self::__callStatic() to be accessible via object operator `->`.
564
     */
565 1
    public function __call(string $method, array $arguments)
566
    {
567 1
        return static::__callStatic($method, $arguments);
0 ignored issues
show
Bug introduced by
Are you sure the usage of static::__callStatic($method, $arguments) targeting MAKS\Velox\Backend\Router::__callStatic() seems to always return null.

This check looks for function or method calls that always return null and whose return value is used.

class A
{
    function getObject()
    {
        return null;
    }

}

$a = new A();
if ($a->getObject()) {

The method getObject() can return nothing but null, so it makes no sense to use the return value.

The reason is most likely that a function or method is imcomplete or has been reduced for debug purposes.

Loading history...
568
    }
569
}
570