Passed
Pull Request — master (#182)
by Arman
05:01 queued 02:11
created

Router::findRoute()   A

Complexity

Conditions 5
Paths 9

Size

Total Lines 31
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Importance

Changes 6
Bugs 0 Features 0
Metric Value
cc 5
eloc 15
nc 9
nop 0
dl 0
loc 31
rs 9.4555
c 6
b 0
f 0
1
<?php
2
3
/**
4
 * Quantum PHP Framework
5
 *
6
 * An open source software development framework for PHP
7
 *
8
 * @package Quantum
9
 * @author Arman Ag. <[email protected]>
10
 * @copyright Copyright (c) 2018 Softberg LLC (https://softberg.org)
11
 * @link http://quantum.softberg.org/
12
 * @since 2.9.5
13
 */
14
15
namespace Quantum\Router;
16
17
use Quantum\Libraries\ResourceCache\ViewCache;
18
use Quantum\Libraries\Config\ConfigException;
19
use Quantum\Exceptions\StopExecutionException;
20
use Quantum\Exceptions\RouteException;
21
use Quantum\Exceptions\ViewException;
22
use Quantum\Exceptions\DiException;
23
use Quantum\Logger\LoggerException;
24
use Quantum\Debugger\Debugger;
25
use Twig\Error\RuntimeError;
26
use Twig\Error\SyntaxError;
27
use Twig\Error\LoaderError;
28
use Quantum\Http\Request;
29
use ReflectionException;
30
31
/**
32
 * Class Router
33
 * @package Quantum\Router
34
 */
35
class Router extends RouteController
36
{
37
38
    const VALID_PARAM_NAME_PATTERN = '/^[a-zA-Z]+$/';
39
40
    /**
41
     * Parameter types
42
     */
43
    const PARAM_TYPES = [
44
        ':alpha' => '[a-zA-Z]',
45
        ':num' => '[0-9]',
46
        ':any' => '[^\/]'
47
    ];
48
49
    /**
50
     * Request instance
51
     * @var Request;
52
     */
53
    private $request;
54
55
    /**
56
     * List of routes
57
     * @var array
58
     */
59
    protected static $routes = [];
60
61
    /**
62
     * matched routes
63
     * @var array
64
     */
65
    private $matchedRoutes = [];
66
67
    /**
68
     * Router constructor.
69
     * @param Request $request
70
     */
71
    public function __construct(Request $request)
72
    {
73
        $this->request = $request;
74
    }
75
76
    /**
77
     * Finds the current route
78
     * @throws DiException
79
     * @throws LoaderError
80
     * @throws ReflectionException
81
     * @throws RouteException
82
     * @throws RuntimeError
83
     * @throws StopExecutionException
84
     * @throws SyntaxError
85
     * @throws ViewException
86
     * @throws ConfigException
87
     * @throws LoggerException
88
     */
89
    public function findRoute()
90
    {
91
        $uri = $this->request->getUri();
92
93
        if (!$uri) {
94
            throw RouteException::notFound();
95
        }
96
97
        $this->matchedRoutes = $this->findMatches($uri);
98
99
        if (empty($this->matchedRoutes)) {
100
            stop(function () {
101
                page_not_found();
102
            });
103
        }
104
105
        if (count($this->matchedRoutes) > 1) {
106
            $this->checkCollision();
107
        }
108
109
        $currentRoute = $this->currentRoute();
110
111
        if (!$currentRoute) {
112
            throw RouteException::incorrectMethod($this->request->getMethod());
113
        }
114
115
        $this->handleCaching($currentRoute);
116
117
        self::setCurrentRoute($currentRoute);
118
119
        info($this->collectDebugData($currentRoute), ['tab' => Debugger::ROUTES]);
120
    }
121
122
    /**
123
     * Resets the routes
124
     */
125
    public function resetRoutes()
126
    {
127
        parent::$currentRoute = null;
128
        $this->matchedRoutes = [];
129
    }
130
131
    /**
132
     * @param array $route
133
     * @return void
134
     */
135
    private function handleCaching(array $route): void
136
    {
137
        $viewCache = ViewCache::getInstance();
138
139
        $defaultCaching = $viewCache->isEnabled();
140
141
        $shouldCacheForRoute = $route['cache_settings']['shouldCache'] ?? $defaultCaching;
142
143
        $viewCache->enableCaching($shouldCacheForRoute);
144
145
        if ($shouldCacheForRoute && !empty($route['cache_settings']['ttl'])) {
146
            $viewCache->setTtl($route['cache_settings']['ttl']);
147
        }
148
    }
149
150
    /**
151
     * Gets the current route
152
     * @return array|null
153
     */
154
    private function currentRoute(): ?array
155
    {
156
        foreach ($this->matchedRoutes as $matchedRoute) {
157
            if ($this->checkMethod($matchedRoute)) {
158
                return $matchedRoute;
159
            }
160
        }
161
162
        return null;
163
    }
164
165
    /**
166
     * Collects debug data
167
     * @param array $currentRoute
168
     * @return array
169
     */
170
    private function collectDebugData(array $currentRoute): array
171
    {
172
        $routeInfo = [];
173
174
        foreach ($currentRoute as $key => $value) {
175
            $routeInfo[ucfirst($key)] = json_encode($value);
176
        }
177
178
        return $routeInfo;
179
    }
180
181
    /**
182
     * Finds matches by pattern
183
     * @param string $uri
184
     * @return array
185
     * @throws RouteException
186
     */
187
    private function findMatches(string $uri): array
188
    {
189
        $requestUri = urldecode(parse_url($uri, PHP_URL_PATH));
190
191
        $matches = [];
192
193
        foreach (self::$routes as $route) {
194
            list($pattern, $params) = $this->handleRoutePattern($route);
195
196
            if (preg_match("/^" . $this->escape($pattern) . "$/u", $requestUri, $matchedParams)) {
197
                $route['uri'] = $uri;
198
                $route['params'] = $this->routeParams($params, $matchedParams);
199
                $route['pattern'] = $pattern;
200
                $matches[] = $route;
201
            }
202
        }
203
204
        return $matches;
205
    }
206
207
    /**
208
     * Handles the route pattern
209
     * @param array $route
210
     * @return array
211
     * @throws RouteException
212
     */
213
    private function handleRoutePattern(array $route): array
214
    {
215
        $routeSegments = explode('/', trim($route['route'], '/'));
216
217
        $routePattern = '(\/)?';
218
        $routeParams = [];
219
220
        $lastIndex = (int)array_key_last($routeSegments);
221
222
        foreach ($routeSegments as $index => $segment) {
223
            $segmentParam = $this->getSegmentParam($segment, $index, $lastIndex);
224
225
            if (!empty($segmentParam)) {
226
                $this->checkParamName($routeParams, $segmentParam['name']);
227
228
                $routeParams[] = [
229
                    'route_pattern' => $segment,
230
                    'pattern' => $segmentParam['pattern'],
231
                    'name' => $segmentParam['name']
232
                ];
233
234
                $routePattern = $this->normalizePattern($routePattern, $segmentParam, $index, $lastIndex);
235
            } else {
236
                $routePattern .= $segment . ($index != $lastIndex ? '(\/)' : '');
237
            }
238
        }
239
240
        return [
241
            $routePattern,
242
            $routeParams
243
        ];
244
    }
245
246
    /**
247
     * Normalize the pattern
248
     * @param string $routePattern
249
     * @param array $segmentParam
250
     * @param int $index
251
     * @param int $lastIndex
252
     * @return string
253
     */
254
    private function normalizePattern(string $routePattern, array $segmentParam, int $index, int $lastIndex): string
255
    {
256
        if ($index == $lastIndex) {
257
            if (mb_substr($routePattern, -5) == '(\/)?') {
258
                $routePattern = mb_substr($routePattern, 0, mb_strlen($routePattern) - 5);
259
            } elseif (mb_substr($routePattern, -4) == '(\/)') {
260
                $routePattern = mb_substr($routePattern, 0, mb_strlen($routePattern) - 4);
261
            }
262
        }
263
264
        return $routePattern . $segmentParam['pattern'];
265
    }
266
267
    /**
268
     * Gets the route parameters
269
     * @param array $params
270
     * @param array $arguments
271
     * @return array
272
     */
273
    private function routeParams(array $params, array $arguments): array
274
    {
275
        $arguments = array_diff($arguments, ['', '/']);
276
277
        foreach ($params as &$param) {
278
            $param['value'] = $arguments[$param['name']] ?? null;
279
            if (mb_substr($param['name'], 0, 1) == '_') {
280
                $param['name'] = null;
281
            }
282
        }
283
284
        return $params;
285
    }
286
287
    /**
288
     * Checks the segment for parameter
289
     * @param string $segment
290
     * @param int $index
291
     * @param int $lastIndex
292
     * @return array
293
     * @throws RouteException
294
     */
295
    private function getSegmentParam(string $segment, int $index, int $lastIndex): array
296
    {
297
        foreach (self::PARAM_TYPES as $type => $expr) {
298
            if (preg_match('/\[(.*=)*(' . $type . ')(:([0-9]+))*\](\?)?/', $segment, $match)) {
299
                return $this->getParamPattern($match, $expr, $index, $lastIndex);
300
            }
301
        }
302
303
        return [];
304
    }
305
306
    /**
307
     * Checks the parameter name availability
308
     * @param array $routeParams
309
     * @param string $name
310
     * @throws RouteException
311
     */
312
    private function checkParamName(array $routeParams, string $name)
313
    {
314
        foreach ($routeParams as $param) {
315
            if ($param['name'] == $name) {
316
                throw RouteException::paramNameNotAvailable($name);
317
            }
318
        }
319
    }
320
321
    /**
322
     * Finds pattern for parameter
323
     * @param array $match
324
     * @param string $expr
325
     * @param int $index
326
     * @param int $lastIndex
327
     * @return array
328
     * @throws RouteException
329
     */
330
    private function getParamPattern(array $match, string $expr, int $index, int $lastIndex): array
331
    {
332
        $name = $this->getParamName($match, $index);
333
334
        $pattern = '(?<' . $name . '>' . $expr;
335
336
        if (isset($match[4]) && is_numeric($match[4])) {
337
            $pattern .= (isset($match[5]) && $match[5] == '?') ? '{0,' . $match[4] . '})' : '{' . $match[4] . '})';
338
        } else {
339
            $pattern .= (isset($match[5]) && $match[5] == '?') ? '*)' : '+)';
340
        }
341
342
        if (isset($match[5]) && $match[5] == '?') {
343
            $pattern = ($index == $lastIndex ? '(\/)?' . $pattern : $pattern . '(\/)?');
344
        } else {
345
            $pattern = ($index == $lastIndex ? '(\/)' . $pattern : $pattern . '(\/)');
346
        }
347
348
        return [
349
            'name' => $name,
350
            'pattern' => $pattern
351
        ];
352
    }
353
354
    /**
355
     * Gets the parameter name
356
     * @param array $match
357
     * @param int $index
358
     * @return string
359
     * @throws RouteException
360
     */
361
    private function getParamName(array $match, int $index): string
362
    {
363
        $name = $match[1] ? rtrim($match[1], '=') : null;
364
365
        if ($name === null) {
366
            return '_segment' . $index;
367
        }
368
369
        if (!preg_match(self::VALID_PARAM_NAME_PATTERN, $name)) {
370
            throw RouteException::paramNameNotValid();
371
        }
372
373
        return $name;
374
    }
375
376
    /**
377
     * Checks the route collisions
378
     * @throws RouteException
379
     */
380
    private function checkCollision()
381
    {
382
        $length = count($this->matchedRoutes);
383
384
        for ($i = 0; $i < $length - 1; $i++) {
385
            for ($j = $i + 1; $j < $length; $j++) {
386
                if ($this->matchedRoutes[$i]['method'] == $this->matchedRoutes[$j]['method']) {
387
                    throw RouteException::repetitiveRouteSameMethod($this->matchedRoutes[$j]['method']);
388
                }
389
                if ($this->matchedRoutes[$i]['module'] != $this->matchedRoutes[$j]['module']) {
390
                    throw RouteException::repetitiveRouteDifferentModules();
391
                }
392
            }
393
        }
394
    }
395
396
    /**
397
     * Checks the request method against defined route method
398
     * @param array $matchedRoute
399
     * @return bool
400
     */
401
    private function checkMethod(array $matchedRoute): bool
402
    {
403
        $allowedMethods = explode('|', $matchedRoute['method']);
404
405
        return in_array($this->request->getMethod(), $allowedMethods, true);
406
    }
407
408
    /**
409
     * Escapes the slashes
410
     * @param string $str
411
     * @return string
412
     */
413
    private function escape(string $str): string
414
    {
415
        return str_replace('/', '\/', stripslashes($str));
416
    }
417
}
418