Passed
Pull Request — master (#178)
by
unknown
02:51
created

Router::findRoute()   B

Complexity

Conditions 8
Paths 25

Size

Total Lines 34
Code Lines 17

Duplication

Lines 0
Ratio 0 %

Importance

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