Passed
Push — master ( 228c08...e198b9 )
by Marwan
01:18
created

Router::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 0

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 1
eloc 0
c 1
b 0
f 0
nc 1
nop 0
dl 0
loc 2
rs 10
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\Backend\Config;
15
use MAKS\Velox\Helper\Misc;
16
17
/**
18
 * A class that serves as a router and an entry point for the application.
19
 *
20
 * Example:
21
 * ```
22
 * // register a middleware
23
 * Router::middleware('/pages/{pageId}', function ($path, $match, $previous) {
24
 *      return 'I am working as expected!';
25
 * }, 'POST');
26
 *
27
 * // register a route handler
28
 * Router::route('/pages/{pageId}', function ($path, $match, $previous) {
29
 *      return sprintf('Hi from "%s" handler, Page ID is: %s, also the middleware said: %s', $path, $match, $previous ?? 'Nothing!');
30
 * }, ['GET', 'POST']);
31
 *
32
 * // register a route handler using an HTTP verb
33
 * Router::get('/another-page', function () {
34
 *      return View::render('another-page');
35
 * });
36
 *
37
 * // register handler for 404
38
 * Router::handleRouteNotFound(function ($path) {
39
 *      // forward the request to some route.
40
 *      Router::forward('/');
41
 * });
42
 *
43
 * // register handler for 405
44
 * Router::handleMethodNotAllowed(function ($path, $method) {
45
 *      // redirect the request to some URL.
46
 *      Router::redirect('/some-page');
47
 * });
48
 *
49
 * // start the application
50
 * Router::start();
51
 * ```
52
 *
53
 * @method static self get(string $expression, callable $handler)
54
 * @method static self head(string $expression, callable $handler)
55
 * @method static self post(string $expression, callable $handler)
56
 * @method static self put(string $expression, callable $handler)
57
 * @method static self patch(string $expression, callable $handler)
58
 * @method static self delete(string $expression, callable $handler)
59
 * @method static self connect(string $expression, callable $handler)
60
 * @method static self options(string $expression, callable $handler)
61
 * @method static self trace(string $expression, callable $handler)
62
 * @method static self any(string $expression, callable $handler)
63
 *
64
 * @since 1.0.0
65
 * @api
66
 */
67
class Router
68
{
69
    /**
70
     * The default values of class parameters.
71
     *
72
     * @var array
73
     */
74
    public const DEFAULTS = [
75
        'base' => '/',
76
        'allowMultiMatch' => true,
77
        'caseMatters' => false,
78
        'slashMatters' => true,
79
    ];
80
81
    /**
82
     * The default values of class parameters.
83
     *
84
     * @var array
85
     */
86
    public const SUPPORTED_METHODS = [
87
        'GET',
88
        'HEAD',
89
        'POST',
90
        'PUT',
91
        'PATCH',
92
        'DELETE',
93
        'CONNECT',
94
        'OPTIONS',
95
        'TRACE'
96
    ];
97
98
99
    /**
100
     * The parameters the application started with.
101
     */
102
    private static ?array $params = null;
103
104
    /**
105
     * The current base URL of the application.
106
     */
107
    protected static ?string $base = null;
108
109
    /**
110
     * The currently requested path.
111
     */
112
    protected static ?string $path = null;
113
114
    /**
115
     * The currently registered routes.
116
     */
117
    protected static array $routes = [];
118
119
    /**
120
     * @var callable|null
121
     */
122
    protected static $routeNotFoundCallback = null;
123
124
    /**
125
     * @var callable|null
126
     */
127
    protected static $methodNotAllowedCallback = null;
128
129
130
    /**
131
     * Registers a handler for a route.
132
     *
133
     * @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).
134
     * @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`.
135
     * @param string|string[] $method Either a string or an array of the allowed method.
136
     *
137
     * @return static
138
     */
139
    public static function handle(string $expression, callable $handler, $method = 'GET')
140
    {
141
        static::$routes[] = [
142
            'expression' => $expression,
143
            'handler' => $handler,
144
            'arguments' => [],
145
            'method' => $method
146
        ];
147
148
        return new static();
149
    }
150
151
    /**
152
     * Registers a middleware for a route. This method has no effect if `$allowMultiMatch` is set to `false`.
153
     * Note that middlewares must be registered before routes in order to work correctly.
154
     * This method is just an alias for `self::handle()`.
155
     *
156
     * @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).
157
     * @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`.
158
     * @param string|string[] $method Either a string or an array of the allowed method.
159
     *
160
     * @return static
161
     */
162
    public static function middleware(string $expression, callable $handler, $method = 'GET')
163
    {
164
        return static::handle($expression, $handler, $method);
165
    }
166
167
    /**
168
     * Redirects the request to another route.
169
     * Note that this function will exit the script (code that comes after it will not be executed).
170
     *
171
     * @param string $to A route like `/page`.
172
     *
173
     * @return void
174
     */
175
    public static function redirect(string $to): void
176
    {
177
        if (filter_var($to, FILTER_VALIDATE_URL)) {
178
            $header = sprintf('Location: %s', $to);
179
        } else {
180
            $scheme = static::isHttpsCompliant() ? 'https' : 'http';
181
            $host   = static::getServerHost();
182
            $path   = static::$base . '/' . $to;
183
            $path   = trim(preg_replace('/(\/+)/', '/', $path), '/');
184
185
            $header = sprintf('Location: %s://%s/%s', $scheme, $host, $path);
186
        }
187
188
        header($header, true, 302);
189
190
        exit;
191
    }
192
193
    /**
194
     * Forwards the request to another route.
195
     * Note that this function will exit the script (code that comes after it will not be executed).
196
     *
197
     * @param string $to A route like `/page`.
198
     *
199
     * @return void
200
     */
201
    public static function forward(string $to): void
202
    {
203
        $base = static::$base ?? '';
204
        $path = trim($base, '/') . '/' . ltrim($to, '/');
205
206
        static::setRequestUri($path);
207
        static::start(...self::$params);
208
209
        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...
210
    }
211
212
    /**
213
     * Registers 404 handler.
214
     *
215
     * @param callable $handler The handler to use. It will be passed the current `$path` and the current `$method`.
216
     *
217
     * @return static
218
     */
219
    public static function handleRouteNotFound(callable $handler)
220
    {
221
        static::$routeNotFoundCallback = $handler;
222
223
        return new static();
224
    }
225
226
    /**
227
     * Registers 405 handler.
228
     *
229
     * @param callable $handler The handler to use. It will be passed the current `$path`.
230
     *
231
     * @return static
232
     */
233
    public static function handleMethodNotAllowed(callable $handler)
234
    {
235
        static::$methodNotAllowedCallback = $handler;
236
237
        return new static();
238
    }
239
240
    /**
241
     * Starts the router.
242
     *
243
     * @param string|null [optional] $base App base path, this will prefix all routes.
244
     * @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.
245
     * @param bool|null [optional] $caseMatters Whether the route matching should be case sensitive or not.
246
     * @param bool|null [optional] $slashMatters Whether trailing slash should be taken in consideration with route matching or not.
247
     *
248
     * @return void
249
     *
250
     * @throws \Exception If route handler failed or returned false.
251
     */
252
    public static function start(?string $base = null, ?bool $allowMultiMatch = null, ?bool $caseMatters = null, ?bool $slashMatters = null): void
253
    {
254
        self::$params = func_get_args();
255
256
        [$base, $allowMultiMatch, $caseMatters, $slashMatters] = static::getValidParameters($base, $allowMultiMatch, $caseMatters, $slashMatters);
257
258
        static::$base = $base = trim($base, '/');
259
        static::$path = $path = static::getRoutePath($base, $slashMatters);
260
261
        $routeMatchFound = false;
262
        $pathMatchFound  = false;
263
        $result = null;
264
265
        foreach (static::$routes as &$route) {
266
            if ($base !== '') {
267
                $route['expression'] = $base . $route['expression'];
268
            }
269
270
            $regex = static::getRouteRegex($route['expression'], $caseMatters);
271
            if (preg_match($regex, $path, $matches, PREG_UNMATCHED_AS_NULL)) {
272
                $pathMatchFound = true;
273
274
                $currentMethod  = static::getRequestMethod();
275
                $allowedMethods = (array)$route['method'];
276
                foreach ($allowedMethods as $allowedMethod) {
277
                    if (strtoupper($currentMethod) !== strtoupper($allowedMethod)) {
278
                        continue;
279
                    }
280
281
                    $routeMatchFound = true;
282
283
                    $route['arguments'] = static::getRouteArguments($route['arguments'], $matches, $result);
284
285
                    $result = call_user_func_array($route['handler'], $route['arguments']);
286
287
                    if ($result === false) {
288
                        throw new \Exception("Something went wrong when trying to respond to '{$path}'! Check the handler for this route");
289
                    }
290
                }
291
            }
292
293
            if ($routeMatchFound && !$allowMultiMatch) {
294
                break;
295
            }
296
        }
297
298
        unset($route);
299
300
        static::echoResponse($routeMatchFound, $pathMatchFound, $result);
301
    }
302
303
    /**
304
     * Returns valid parameters for `self::start()` by validating the passed parameters and adding the deficiency from router config.
305
     *
306
     * @param string|null $base
307
     * @param bool|null $allowMultiMatch
308
     * @param bool|null $caseMatters
309
     * @param bool|null $slashMatters
310
     *
311
     * @return array
312
     */
313
    private static function getValidParameters(?string $base, ?bool $allowMultiMatch, ?bool $caseMatters, ?bool $slashMatters): array
314
    {
315
        $routerConfig = Config::get('router');
316
317
        $base            ??= $routerConfig['base'];
318
        $allowMultiMatch ??= $routerConfig['allowMultiMatch'];
319
        $caseMatters     ??= $routerConfig['caseMatters'];
320
        $slashMatters    ??= $routerConfig['slashMatters'];
321
322
        return [
323
            $base,
324
            $allowMultiMatch,
325
            $caseMatters,
326
            $slashMatters,
327
        ];
328
    }
329
330
    /**
331
     * Returns the a valid decoded route path.
332
     *
333
     * @param string $base
334
     * @param bool $slashMatters
335
     *
336
     * @return string
337
     */
338
    private static function getRoutePath(string $base, bool $slashMatters): string
339
    {
340
        $url = static::getParsedUrl();
341
342
        $path = '/';
343
        if (isset($url['path'])) {
344
            $path = $url['path'];
345
            if (!$slashMatters && $path !== $base . '/') {
346
                $path = rtrim($path, '/');
347
            }
348
        }
349
350
        return urldecode($path);
351
    }
352
353
    /**
354
     * Returns a valid route regex.
355
     *
356
     * @param string $expression
357
     * @param bool $caseMatters
358
     *
359
     * @return string
360
     */
361
    private static function getRouteRegex(string $expression, bool $caseMatters): string
362
    {
363
        $routePlaceholderRegex = '/{([a-z0-9_\-\.?]+)}/i';
364
        if (preg_match($routePlaceholderRegex, $expression)) {
365
            $routeMatchRegex = strpos($expression, '?}') !== false ? '(.*)?' : '(.+)';
366
            $expression = preg_replace(
367
                $routePlaceholderRegex,
368
                $routeMatchRegex,
369
                $expression
370
            );
371
        }
372
373
        return sprintf('<^%s$>%s', $expression, ($caseMatters ? 'iu' : 'u'));
374
    }
375
376
    /**
377
     * Returns valid arguments for route handler with the order thy expect.
378
     *
379
     * @param array $current
380
     * @param array $matches
381
     * @param mixed $result
382
     *
383
     * @return array
384
     */
385
    private static function getRouteArguments(array $current, array $matches, $result): array
386
    {
387
        $arguments = array_merge($current, $matches);
388
        $arguments = array_filter($arguments);
389
        if (count($arguments) > 1) {
390
            array_push($arguments, $result);
391
        } else {
392
            array_push($arguments, null, $result);
393
        }
394
395
        return $arguments;
396
    }
397
398
    /**
399
     * Echos the response according to the passed parameters.
400
     *
401
     * @param bool $routeMatchFound
402
     * @param bool $pathMatchFound
403
     * @param mixed $result
404
     *
405
     * @return void
406
     */
407
    private static function echoResponse(bool $routeMatchFound, bool $pathMatchFound, $result): void
408
    {
409
        $protocol = static::getServerProtocol();
410
        $method   = static::getRequestMethod();
411
412
        if (!$routeMatchFound) {
413
            $result = 'The route is not found, or the request method is not allowed!';
414
415
            if ($pathMatchFound) {
416
                if (static::$methodNotAllowedCallback) {
417
                    $result = call_user_func(static::$methodNotAllowedCallback, static::$path, $method);
418
419
                    header("{$protocol} 405 Method Not Allowed", true, 405);
420
                }
421
422
                Misc::log(
423
                    'Responded with 405 to the request for "{path}" with method "{method}"',
424
                    ['path' => static::$path, 'method' => $method],
425
                    'system'
426
                );
427
            } else {
428
                if (static::$routeNotFoundCallback) {
429
                    $result = call_user_func(static::$routeNotFoundCallback, static::$path);
430
431
                    header("{$protocol} 404 Not Found", true, 404);
432
                }
433
434
                Misc::log(
435
                    'Responded with 404 to the request for "{path}"',
436
                    ['path' => static::$path],
437
                    'system'
438
                );
439
            }
440
        } else {
441
            header("{$protocol} 200 OK", false, 200);
442
        }
443
444
        echo $result;
445
    }
446
447
    /**
448
     * Returns query parameters.
449
     *
450
     * @return array
451
     */
452
    public static function getParsedQuery(): array
453
    {
454
        $url = static::getParsedUrl();
455
456
        parse_str($url['query'] ?? '', $query);
457
458
        return $query;
459
    }
460
461
    /**
462
     * Returns components of the current URL.
463
     *
464
     * @return array
465
     */
466
    public static function getParsedUrl(): array
467
    {
468
        $uri = static::getRequestUri();
469
470
        // remove double slashes as they make parse_url() fail
471
        $url = preg_replace('/(\/+)/', '/', $uri);
472
        $url = parse_url($url);
473
474
        return $url;
475
    }
476
477
    /**
478
     * Returns `php://input`.
479
     *
480
     * @return string
481
     */
482
    public static function getInput(): string
483
    {
484
        return file_get_contents('php://input') ?: '';
485
    }
486
487
    /**
488
     * Returns the currently requested route.
489
     *
490
     * @return string
491
     */
492
    public static function getCurrent(): string
493
    {
494
        return static::getRequestUri();
495
    }
496
497
    protected static function getServerHost(): string
498
    {
499
        return $_SERVER['HTTP_HOST'];
500
    }
501
502
    protected static function getServerProtocol(): string
503
    {
504
        return $_SERVER['SERVER_PROTOCOL'];
505
    }
506
507
    protected static function getRequestMethod(): string
508
    {
509
        $method = $_POST['_method'] ?? '';
510
        $methods = static::SUPPORTED_METHODS;
511
        $methodAllowed = in_array(
512
            strtoupper($method),
513
            array_map('strtoupper', $methods)
514
        );
515
516
        if ($methodAllowed) {
517
            static::setRequestMethod($method);
518
        }
519
520
        return $_SERVER['REQUEST_METHOD'];
521
    }
522
523
    protected static function setRequestMethod(string $method): void
524
    {
525
        $_SERVER['REQUEST_METHOD'] = $method;
526
    }
527
528
    protected static function getRequestUri(): string
529
    {
530
        return $_SERVER['REQUEST_URI'];
531
    }
532
533
    protected static function setRequestUri(string $uri): void
534
    {
535
        $_SERVER['REQUEST_URI'] = $uri;
536
    }
537
538
    protected static function isHttpsCompliant(): bool
539
    {
540
        return isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on';
541
    }
542
543
    /**
544
     * Returns all registered routes with their `expression`, `handler`, `arguments`, and `method`.
545
     *
546
     * @return array
547
     */
548
    public static function getRegisteredRoutes(): array
549
    {
550
        return static::$routes;
551
    }
552
553
554
    /**
555
     * Class constructor.
556
     */
557
    final public function __construct()
558
    {
559
        // prevent overwriting constructor in subclasses to allow to use
560
        // "return new static()" without caring about dependencies.
561
    }
562
563
    /**
564
     * Aliases `self::handle()` method with common HTTP verbs.
565
     */
566
    public static function __callStatic(string $method, array $arguments)
567
    {
568
        $methods = static::SUPPORTED_METHODS;
569
        $methodAllowed = in_array(
570
            strtoupper($method),
571
            array_map('strtoupper', ['ANY', ...$methods])
572
        );
573
574
        if (!$methodAllowed) {
575
            $class = static::class;
576
            throw new \Exception("Call to undefined method {$class}::{$method}()");
577
        }
578
579
        if (count($arguments) > 2) {
580
            $arguments = array_slice($arguments, 0, 2);
581
        }
582
583
        if (strtoupper($method) === 'ANY') {
584
            array_push($arguments, $methods);
585
        } else {
586
            array_push($arguments, $method);
587
        }
588
589
        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

589
        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...
590
    }
591
592
    /**
593
     * Allows static methods handled by self::__callStatic() to be accessible via object operator `->`.
594
     */
595
    public function __call(string $method, array $arguments)
596
    {
597
        return self::__callStatic($method, $arguments);
598
    }
599
}
600