Passed
Push — 5.0 ( 2a6b21...7f67c6 )
by Marc André
04:22 queued 02:31
created

Router::put()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Importance

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