Passed
Push — 5.0 ( c16c04...6887da )
by Marc André
02:29
created

Router::group()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 8
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
cc 1
eloc 4
nc 1
nop 2
dl 0
loc 8
rs 10
c 0
b 0
f 0
1
<?php
2
3
/**
4
 * This file is part of the Cervo package.
5
 *
6
 * Copyright (c) 2010-2019 Nevraxe inc. & Marc André Audet <[email protected]>.
7
 *
8
 * @package   Cervo
9
 * @author    Marc André Audet <[email protected]>
10
 * @copyright 2010 - 2019 Nevraxe inc. & Marc André Audet
11
 * @license   See LICENSE.md  MIT
12
 * @link      https://github.com/Nevraxe/Cervo
13
 * @since     5.0.0
14
 */
15
16
declare(strict_types=1);
17
18
namespace Cervo;
19
20
use Cervo\Exceptions\ConfigurationNotFoundException;
21
use Cervo\Exceptions\Router\InvalidMiddlewareException;
22
use Cervo\Exceptions\Router\InvalidRouterCacheException;
23
use Cervo\Exceptions\Router\MethodNotAllowedException;
24
use Cervo\Exceptions\Router\RouteMiddlewareFailedException;
25
use Cervo\Exceptions\Router\RouteNotFoundException;
26
use Cervo\Interfaces\MiddlewareInterface;
27
use Cervo\Interfaces\SingletonInterface;
28
use Cervo\Utils\ClassUtils;
29
use Cervo\Utils\PathUtils;
30
use FastRoute\RouteCollector;
31
use FastRoute\RouteParser;
32
use FastRoute\DataGenerator;
33
use FastRoute\Dispatcher as Dispatcher;
34
35
/**
36
 * Routes manager for Cervo.
37
 *
38
 * @author Marc André Audet <[email protected]>
39
 */
40
class Router implements SingletonInterface
41
{
42
    const CACHE_FILE_NAME = 'router.cache.php';
43
44
    /** @var RouteCollector FastRoute, null if usingCache is set */
45
    private $routeCollector = null;
46
47
    /** @var array List of middlewares called using the middleware() method. */
48
    private $currentMiddlewares = [];
49
50
    /** @var string List of group prefixes called using the group() method. */
51
    private $currentGroupPrefix;
52
53
    /** @var Context The current context */
54
    private $context;
55
56
    /** @var string The path to the router cache file */
57
    private $cacheFilePath;
58
59
    /**
60
     * Router constructor.
61
     *
62
     * @param Context $context
63
     */
64
    public function __construct(Context $context)
65
    {
66
        $this->routeCollector = new RouteCollector(
67
            new RouteParser\Std(),
68
            new DataGenerator\GroupCountBased()
69
        );
70
71
        $this->context = $context;
72
73
        $root_dir = $this->context->getConfig()->get('app/root_dir');
74
        $cache_dir = $this->context->getConfig()->get('app/cache_dir') ?? 'var' . DIRECTORY_SEPARATOR . 'cache';
75
76
        if (!$root_dir || strlen($root_dir) <= 0) {
77
            throw new ConfigurationNotFoundException('app/root_dir');
78
        }
79
80
        $this->cacheFilePath = $root_dir . DIRECTORY_SEPARATOR . $cache_dir . DIRECTORY_SEPARATOR . self::CACHE_FILE_NAME;
81
    }
82
83
    /**
84
     * Load every PHP Routes files under the directory
85
     *
86
     * @param string $path
87
     */
88
    public function loadPath(string $path): void
89
    {
90
        if (file_exists($path . \DIRECTORY_SEPARATOR . 'Routes')) {
91
92
            foreach (PathUtils::getRecursivePHPFilesIterator($path . \DIRECTORY_SEPARATOR . 'Routes') as $file) {
93
94
                $callback = require $file->getPathName();
95
96
                if (is_callable($callback)) {
97
                    $callback($this);
98
                }
99
100
            }
101
102
        }
103
    }
104
105
    /**
106
     * Encapsulate all the routes that are added from $func(Router) with this middleware.
107
     *
108
     * If the return value of the middleware is false, throws a RouteMiddlewareFailedException.
109
     *
110
     * @param string $middlewareClass The middleware to use
111
     * @param callable $func
112
     */
113
    public function middleware(string $middlewareClass, callable $func): void
114
    {
115
        array_push($this->currentMiddlewares, $middlewareClass);
116
117
        $func($this);
118
119
        array_pop($this->currentMiddlewares);
120
    }
121
122
    /**
123
     * Adds a prefix in front of all the encapsulated routes.
124
     *
125
     * @param string $prefix The prefix of the group.
126
     * @param callable $func
127
     */
128
    public function group(string $prefix, callable $func): void
129
    {
130
        $previousGroupPrefix = $this->currentGroupPrefix;
131
        $this->currentGroupPrefix = $previousGroupPrefix . $prefix;
132
133
        $func($this);
134
135
        $this->currentGroupPrefix = $previousGroupPrefix;
136
    }
137
138
    /**
139
     * Dispatch the request to the router.
140
     *
141
     * @return Route
142
     * @throws MethodNotAllowedException if the request method is not supported, but others are for this route.
143
     * @throws RouteNotFoundException if the requested route did not match any routes.
144
     */
145
    public function dispatch(): Route
146
    {
147
        $dispatcher = $this->getDispatcher();
148
149
        if (defined('STDIN')) {
150
            $requestMethod = 'CLI';
151
        } else {
152
            $requestMethod = $_SERVER['REQUEST_METHOD'];
153
        }
154
155
        $routeInfo = $dispatcher->dispatch($requestMethod, $this->detectUri());
156
157
        if ($routeInfo[0] === Dispatcher::FOUND) {
158
159
            $handler = $routeInfo[1];
160
            $arguments = $routeInfo[2];
161
            $middlewares = $handler['middlewares'];
162
163
            $route = new Route($handler['controller_class'], $handler['parameters'], $arguments);
164
165
            if (is_array($middlewares)) {
166
                $this->handleMiddlewares($middlewares, $route);
167
            }
168
169
            return $route;
170
171
        } elseif ($routeInfo[0] === Dispatcher::METHOD_NOT_ALLOWED) {
172
            throw new MethodNotAllowedException;
173
        } else {
174
            throw new RouteNotFoundException;
175
        }
176
    }
177
178
    /**
179
     * Add a new route.
180
     *
181
     * @param string|string[] $httpMethod The HTTP method, example: GET, HEAD, POST, PATCH, PUT, DELETE, CLI, etc.
182
     *  Can be an array of values.
183
     * @param string $route The route
184
     * @param string $controllerClass The Controller's class
185
     * @param array $parameters The parameters to pass
186
     */
187
    public function addRoute($httpMethod, string $route, string $controllerClass, array $parameters = []): void
188
    {
189
        if ($this->context->getConfig()->get('app/production') == true && file_exists($this->cacheFilePath)) {
190
            return;
191
        }
192
193
        $route = $this->currentGroupPrefix . $route;
194
195
        $this->routeCollector->addRoute($httpMethod, $route, [
196
            'controller_class' => $controllerClass,
197
            'middlewares' => $this->currentMiddlewares,
198
            'parameters' => $parameters
199
        ]);
200
    }
201
202
    /**
203
     * Add a new route with GET as HTTP method.
204
     *
205
     * @param string $route The route
206
     * @param string $controllerClass The Controller's class
207
     * @param array $parameters The parameters to pass
208
     */
209
    public function get(string $route, string $controllerClass, array $parameters = []): void
210
    {
211
        $this->addRoute('GET', $route, $controllerClass, $parameters);
212
    }
213
214
    /**
215
     * Add a new route with HEAD as HTTP method.
216
     *
217
     * @param string $route The route
218
     * @param string $controllerClass The Controller's class
219
     * @param array $parameters The parameters to pass
220
     */
221
    public function head(string $route, string $controllerClass, array $parameters = []): void
222
    {
223
        $this->addRoute('HEAD', $route, $controllerClass, $parameters);
224
    }
225
226
    /**
227
     * Add a new route with POST as HTTP method.
228
     *
229
     * @param string $route The route
230
     * @param string $controllerClass The Controller's class
231
     * @param array $parameters The parameters to pass
232
     */
233
    public function post(string $route, string $controllerClass, array $parameters = []): void
234
    {
235
        $this->addRoute('POST', $route, $controllerClass, $parameters);
236
    }
237
238
    /**
239
     * Add a new route with PUT as HTTP method.
240
     *
241
     * @param string $route The route
242
     * @param string $controllerClass The Controller's class
243
     * @param array $parameters The parameters to pass
244
     */
245
    public function put(string $route, string $controllerClass, array $parameters = []): void
246
    {
247
        $this->addRoute('PUT', $route, $controllerClass, $parameters);
248
    }
249
250
    /**
251
     * Add a new route with PATCH as HTTP method.
252
     *
253
     * @param string $route The route
254
     * @param string $controllerClass The Controller's class
255
     * @param array $parameters The parameters to pass
256
     */
257
    public function patch(string $route, string $controllerClass, array $parameters = []): void
258
    {
259
        $this->addRoute('PATCH', $route, $controllerClass, $parameters);
260
    }
261
262
    /**
263
     * Add a new route with DELETE as HTTP method.
264
     *
265
     * @param string $route The route
266
     * @param string $controllerClass The Controller's class
267
     * @param array $parameters The parameters to pass
268
     */
269
    public function delete(string $route, string $controllerClass, array $parameters = []): void
270
    {
271
        $this->addRoute('DELETE', $route, $controllerClass, $parameters);
272
    }
273
274
    /**
275
     * Add a new route with CLI as method.
276
     *
277
     * @param string $route The route
278
     * @param string $controllerClass The Controller's class
279
     * @param array $parameters The parameters to pass
280
     */
281
    public function cli(string $route, string $controllerClass, array $parameters = []): void
282
    {
283
        $this->addRoute('CLI', $route, $controllerClass, $parameters);
284
    }
285
286
    /**
287
     * Force the generation of the cache file. Delete the current cache file if it exists.
288
     */
289
    public function forceGenerateCache() : bool
290
    {
291
        if (file_exists($this->cacheFilePath) && !unlink($this->cacheFilePath)) {
292
            return false;
293
        }
294
295
        return $this->generateCache($this->routeCollector->getData());
296
    }
297
298
    /**
299
     * @param array $dispatchData
300
     *
301
     * @return bool
302
     */
303
    private function generateCache(array $dispatchData) : bool
304
    {
305
        $dir = dirname($this->cacheFilePath);
306
307
        if (!file_exists($this->cacheFilePath) && is_dir($dir) && is_writable($dir)) {
308
309
            return file_put_contents(
310
                $this->cacheFilePath,
311
                '<?php return ' . var_export($dispatchData, true) . ';' . PHP_EOL,
312
                LOCK_EX
313
            ) !== false;
314
315
        } else {
316
            return false;
317
        }
318
    }
319
320
    /**
321
     * @return Dispatcher\GroupCountBased
322
     */
323
    private function getDispatcher(): Dispatcher\GroupCountBased
324
    {
325
        $dispatchData = null;
326
327
        if ($this->context->getConfig()->get('app/production') == true && file_exists($this->cacheFilePath)) {
328
329
            if (!is_array($dispatchData = require $this->cacheFilePath)) {
330
                throw new InvalidRouterCacheException;
331
            }
332
333
        } else {
334
335
            $dispatchData = $this->routeCollector->getData();
336
337
            if ($this->context->getConfig()->get('app/production') == true) {
338
                $this->generateCache($dispatchData);
339
            }
340
341
        }
342
343
        return new Dispatcher\GroupCountBased($dispatchData);
344
    }
345
346
    /**
347
     * Returns a parsable URI
348
     *
349
     * @return string
350
     */
351
    private function detectUri(): string
352
    {
353
        if (php_sapi_name() == 'cli') {
354
            $args = array_slice($_SERVER['argv'], 1);
355
            return count($args) > 0 ? '/' . implode('/', $args) : '/';
356
        }
357
358
        if (!isset($_SERVER['REQUEST_URI']) || !isset($_SERVER['SCRIPT_NAME'])) {
359
            return '/';
360
        }
361
362
        $parts = preg_split('#\?#i', $this->getBaseUri(), 2);
363
        $uri = $parts[0];
364
365
        if ($uri == '/' || strlen($uri) <= 0) {
366
            return '/';
367
        }
368
369
        $uri = parse_url($uri, PHP_URL_PATH);
370
        return '/' . str_replace(['//', '../', '/..'], '/', trim($uri, '/'));
371
    }
372
373
    /**
374
     * Return the base URI for a request
375
     *
376
     * @return string
377
     */
378
    private function getBaseUri(): string
379
    {
380
        $uri = $_SERVER['REQUEST_URI'];
381
382
        if (strlen($_SERVER['SCRIPT_NAME']) > 0) {
383
384
            if (strpos($uri, $_SERVER['SCRIPT_NAME']) === 0) {
385
                $uri = substr($uri, strlen($_SERVER['SCRIPT_NAME']));
386
            } elseif (strpos($uri, dirname($_SERVER['SCRIPT_NAME'])) === 0) {
387
                $uri = substr($uri, strlen(dirname($_SERVER['SCRIPT_NAME'])));
388
            }
389
390
        }
391
392
        return $uri;
393
    }
394
395
    /**
396
     * Throws an exception or return void.
397
     *
398
     * @param array $middlewares
399
     * @param Route $route
400
     *
401
     * @return void
402
     * @throws RouteMiddlewareFailedException if a route middleware returned false.
403
     * @throws InvalidMiddlewareException if a middleware is invalid.
404
     */
405
    private function handleMiddlewares(array $middlewares, Route $route): void
406
    {
407
        foreach ($middlewares as $middleware) {
408
409
            if (strlen($middleware) > 0) {
410
411
                if (!ClassUtils::implements($middleware, MiddlewareInterface::class)) {
412
                    throw new InvalidMiddlewareException;
413
                }
414
415
                if (!(new $middleware)($route)()) {
416
                    throw new RouteMiddlewareFailedException;
417
                }
418
419
            } else {
420
                throw new InvalidMiddlewareException;
421
            }
422
423
        }
424
    }
425
}
426