Router::checkCollision()   A
last analyzed

Complexity

Conditions 5
Paths 5

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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