Router::checkParamName()   A
last analyzed

Complexity

Conditions 3
Paths 3

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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