Passed
Push — master ( 27f4fd...94a6c3 )
by Marwan
01:16
created

Router::getValidParameters()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 14
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

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

571
        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...
572
    }
573
574
    /**
575
     * Allows static methods handled by self::__callStatic() to be accessible via object operator `->`.
576
     */
577
    public function __call(string $method, array $arguments)
578
    {
579
        return self::__callStatic($method, $arguments);
580
    }
581
}
582