Completed
Push — 5.0 ( d49403...9430f0 )
by Marc André
01:47
created

Router::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 9
Code Lines 5

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 9
rs 9.6666
c 0
b 0
f 0
cc 1
eloc 5
nc 1
nop 1
1
<?php
2
3
4
/**
5
 *
6
 * Copyright (c) 2010-2018 Nevraxe inc. & Marc André Audet <[email protected]>. All rights reserved.
7
 *
8
 * Redistribution and use in source and binary forms, with or without modification, are
9
 * permitted provided that the following conditions are met:
10
 *
11
 *   1. Redistributions of source code must retain the above copyright notice, this list of
12
 *       conditions and the following disclaimer.
13
 *
14
 *   2. Redistributions in binary form must reproduce the above copyright notice, this list
15
 *       of conditions and the following disclaimer in the documentation and/or other materials
16
 *       provided with the distribution.
17
 *
18
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
19
 * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
20
 * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
21
 * DISCLAIMED. IN NO EVENT SHALL NEVRAXE INC. & MARC ANDRÉ AUDET BE LIABLE FOR ANY
22
 * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
23
 * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
24
 * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
25
 * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
26
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
27
 * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
28
 *
29
 */
30
31
32
namespace Cervo;
33
34
35
use Cervo\Exceptions\Router\InvalidMiddlewareException;
36
use Cervo\Exceptions\Router\MethodNotAllowedException;
37
use Cervo\Exceptions\Router\RouteMiddlewareFailedException;
38
use Cervo\Exceptions\Router\RouteNotFoundException;
39
use Cervo\Interfaces\MiddlewareInterface;
40
use Cervo\Interfaces\SingletonInterface;
41
use FastRoute\RouteCollector;
42
use FastRoute\RouteParser;
43
use FastRoute\DataGenerator;
44
use FastRoute\Dispatcher as Dispatcher;
45
46
47
/**
48
 * Routes manager for Cervo.
49
 *
50
 * @author Marc André Audet <[email protected]>
51
 */
52
class Router implements SingletonInterface
53
{
54
    /** @var RouteCollector FastRoute, null if usingCache is set */
55
    private $routeCollector = null;
56
57
    /** @var array List of middlewares called using the middleware() method. */
58
    private $currentMiddlewares = [];
59
60
    /** @var string List of group prefixes called using the group() method. */
61
    private $currentGroupPrefix;
62
63
    /** @var Context The current context */
64
    private $context;
65
66
    /**
67
     * Router constructor.
68
     *
69
     * @param Context $context
70
     */
71
    public function __construct(Context $context)
72
    {
73
        $this->routeCollector = new RouteCollector(
74
            new RouteParser\Std(),
75
            new DataGenerator\GroupCountBased()
76
        );
77
78
        $this->context = $context;
79
    }
80
81
    /**
82
     * Load every PHP Routes files under the directory
83
     *
84
     * @param string $path
85
     */
86
    public function loadPath(string $path) : void
87
    {
88
        if (file_exists($path . \DIRECTORY_SEPARATOR . 'Routes')) {
89
90
            foreach (
91
                PathUtils::getRecursivePHPFilesIterator($path . \DIRECTORY_SEPARATOR . 'Routes')
92
                as $file
93
            ) {
94
95
                $callback = require $file->getPathName();
96
97
                if (is_callable($callback)) {
98
                    $callback($this);
99
                }
100
101
            }
102
103
        }
104
    }
105
106
    /**
107
     * Encapsulate all the routes that are added from $func(Router) with this middleware.
108
     *
109
     * If the return value of the middleware is false, throws a RouteMiddlewareFailedException.
110
     *
111
     * @param string $middleware_class The middleware to use
112
     * @param string $method_name The method of the singleton to call
113
     * @param callable $func
114
     */
115
    public function middleware(string $middleware_class, string $method_name, callable $func) : void
116
    {
117
        // It's easier to cache an array
118
        array_push($this->currentMiddlewares, [
119
            'middleware_class' => $middleware_class,
120
            'method' => $method_name
121
        ]);
122
123
        $func($this);
124
125
        array_pop($this->currentMiddlewares);
126
    }
127
128
    /**
129
     * Adds a prefix in front of all the encapsulated routes.
130
     *
131
     * @param string $prefix The prefix of the group.
132
     * @param callable $func
133
     */
134
    public function group(string $prefix, callable $func) : void
135
    {
136
        $previousGroupPrefix = $this->currentGroupPrefix;
137
        $this->currentGroupPrefix = $previousGroupPrefix . $prefix;
138
139
        $func($this);
140
141
        $this->currentGroupPrefix = $previousGroupPrefix;
142
    }
143
144
    /**
145
     * Dispatch the request to the router.
146
     *
147
     * @return Route
148
     * @throws MethodNotAllowedException if the request method is not supported, but others are for this route.
149
     * @throws RouteNotFoundException if the requested route did not match any routes.
150
     */
151
    public function dispatch() : Route
152
    {
153
        $dispatcher = $this->getDispatcher();
154
155
        if (defined('STDIN')) {
156
            $request_method = 'CLI';
157
        } else {
158
            $request_method = $_SERVER['REQUEST_METHOD'];
159
        }
160
161
        // TODO: Support HEAD call
0 ignored issues
show
Coding Style Best Practice introduced by
Comments for TODO tasks are often forgotten in the code; it might be better to use a dedicated issue tracker.
Loading history...
162
        // TODO: Support OPTIONS call
0 ignored issues
show
Coding Style Best Practice introduced by
Comments for TODO tasks are often forgotten in the code; it might be better to use a dedicated issue tracker.
Loading history...
163
164
        $routeInfo = $dispatcher->dispatch($request_method, $this->detectUri());
165
166
        if ($routeInfo[0] === Dispatcher::FOUND) {
167
168
            $handler = $routeInfo[1];
169
            $arguments = $routeInfo[2];
170
            $middlewares = $handler['middlewares'];
171
172
            $route = new Route($handler['controller_class'], $handler['parameters'], $arguments);
173
174
            if (is_array($middlewares)) {
175
                $this->handleMiddlewares($middlewares, $route);
176
            }
177
178
            return $route;
179
180
        } elseif ($routeInfo[0] === Dispatcher::METHOD_NOT_ALLOWED) {
181
            throw new MethodNotAllowedException($routeInfo[1]);
182
        } else {
183
            throw new RouteNotFoundException;
184
        }
185
    }
186
187
    /**
188
     * Add a new route.
189
     *
190
     * @param string|string[] $http_method The HTTP method, example: GET, HEAD, POST, PATCH, PUT, DELETE, CLI, etc. Can be an array of values.
191
     * @param string $route The route
192
     * @param string $controller_class The Controller's class
193
     * @param array $parameters The parameters to pass
194
     */
195
    public function addRoute($http_method, string $route, string $controller_class, array $parameters = []) : void
196
    {
197
        $route = $this->currentGroupPrefix . $route;
198
199
        $this->routeCollector->addRoute($http_method, $route, [
200
            'controller_class' => $controller_class,
201
            'middlewares' => $this->currentMiddlewares,
202
            'parameters' => $parameters
203
        ]);
204
    }
205
206
    /**
207
     * Add a new route with GET as HTTP method.
208
     *
209
     * @param string $route The route
210
     * @param string $controller_class The Controller's class
211
     * @param array $parameters The parameters to pass
212
     */
213
    public function get(string $route, string $controller_class, array $parameters = []) : void
214
    {
215
        $this->addRoute('GET', $route, $controller_class, $parameters);
216
    }
217
218
    /**
219
     * Add a new route with HEAD as HTTP method.
220
     *
221
     * @param string $route The route
222
     * @param string $controller_class The Controller's class
223
     * @param array $parameters The parameters to pass
224
     */
225
    public function head(string $route, string $controller_class, array $parameters = []) : void
226
    {
227
        $this->addRoute('HEAD', $route, $controller_class, $parameters);
228
    }
229
230
    /**
231
     * Add a new route with POST as HTTP method.
232
     *
233
     * @param string $route The route
234
     * @param string $controller_class The Controller's class
235
     * @param array $parameters The parameters to pass
236
     */
237
    public function post(string $route, string $controller_class, array $parameters = []) : void
238
    {
239
        $this->addRoute('POST', $route, $controller_class, $parameters);
240
    }
241
242
    /**
243
     * Add a new route with PUT as HTTP method.
244
     *
245
     * @param string $route The route
246
     * @param string $controller_class The Controller's class
247
     * @param array $parameters The parameters to pass
248
     */
249
    public function put(string $route, string $controller_class, array $parameters = []) : void
250
    {
251
        $this->addRoute('PUT', $route, $controller_class, $parameters);
252
    }
253
254
    /**
255
     * Add a new route with PATCH as HTTP method.
256
     *
257
     * @param string $route The route
258
     * @param string $controller_class The Controller's class
259
     * @param array $parameters The parameters to pass
260
     */
261
    public function patch(string $route, string $controller_class, array $parameters = []) : void
262
    {
263
        $this->addRoute('PATCH', $route, $controller_class, $parameters);
264
    }
265
266
    /**
267
     * Add a new route with DELETE as HTTP method.
268
     *
269
     * @param string $route The route
270
     * @param string $controller_class The Controller's class
271
     * @param array $parameters The parameters to pass
272
     */
273
    public function delete(string $route, string $controller_class, array $parameters = []) : void
274
    {
275
        $this->addRoute('DELETE', $route, $controller_class, $parameters);
276
    }
277
278
    /**
279
     * Add a new route with CLI as method.
280
     *
281
     * @param string $route The route
282
     * @param string $controller_class The Controller's class
283
     * @param array $parameters The parameters to pass
284
     */
285
    public function cli(string $route, string $controller_class, array $parameters = []) : void
286
    {
287
        $this->addRoute('CLI', $route, $controller_class, $parameters);
288
    }
289
290
    /**
291
     * @return Dispatcher\GroupCountBased
292
     */
293
    private function getDispatcher() : Dispatcher\GroupCountBased
294
    {
295
        return new Dispatcher\GroupCountBased($this->routeCollector->getData());
296
    }
297
298
    /**
299
     * Returns a parsable URI
300
     *
301
     * @return string
302
     */
303
    private function detectUri() : string
304
    {
305
        if (php_sapi_name() == 'cli') {
306
            $args = array_slice($_SERVER['argv'], 1);
307
            return $args ? '/' . implode('/', $args) : '/';
308
        }
309
310
        if (!isset($_SERVER['REQUEST_URI']) || !isset($_SERVER['SCRIPT_NAME'])) {
311
            return '/';
312
        }
313
314
        $parts = preg_split('#\?#i', $this->getBaseUri(), 2);
315
        $uri = $parts[0];
316
317
        if ($uri == '/' || strlen($uri) <= 0) {
318
            return '/';
319
        }
320
321
        $uri = parse_url($uri, PHP_URL_PATH);
322
        return '/' . str_replace(['//', '../', '/..'], '/', trim($uri, '/'));
323
    }
324
325
    /**
326
     * Return the base URI for a request
327
     *
328
     * @return string
329
     */
330
    private function getBaseUri() : string
331
    {
332
        $uri = $_SERVER['REQUEST_URI'];
333
334
        if (strlen($_SERVER['SCRIPT_NAME']) > 0) {
335
336
            if (strpos($uri, $_SERVER['SCRIPT_NAME']) === 0) {
337
                $uri = substr($uri, strlen($_SERVER['SCRIPT_NAME']));
338
            } elseif (strpos($uri, dirname($_SERVER['SCRIPT_NAME'])) === 0) {
339
                $uri = substr($uri, strlen(dirname($_SERVER['SCRIPT_NAME'])));
340
            }
341
342
        }
343
344
        return $uri;
345
    }
346
347
    /**
348
     * Throws an exception or return void.
349
     *
350
     * @param array $middlewares
351
     * @param Route $route
352
     *
353
     * @return void
354
     * @throws RouteMiddlewareFailedException if a route middleware returned false.
355
     * @throws InvalidMiddlewareException if a middleware is invalid.
356
     */
357
    private function handleMiddlewares(array $middlewares, Route $route) : void
358
    {
359
        foreach ($middlewares as $middleware) {
360
361
            if (is_array($middleware) && strlen($middleware['middleware_class']) > 0 && strlen($middleware['method']) > 0) {
362
363
                if (!is_subclass_of($middleware['middleware_class'], MiddlewareInterface::class)) {
0 ignored issues
show
Bug introduced by
Due to PHP Bug #53727, is_subclass_of might return inconsistent results on some PHP versions if \Cervo\Interfaces\MiddlewareInterface::class can be an interface. If so, you could instead use ReflectionClass::implementsInterface.
Loading history...
364
                    throw new InvalidMiddlewareException;
365
                }
366
367
                if (!(new $middleware['middleware_class'])($route)()) {
368
                    throw new RouteMiddlewareFailedException;
369
                }
370
371
            } else {
372
                throw new InvalidMiddlewareException;
373
            }
374
375
        }
376
    }
377
}
378