Passed
Pull Request — master (#178)
by
unknown
03:01
created

Router::checkParamName()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 5
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Importance

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