Passed
Pull Request — master (#182)
by Arman
12:44 queued 09:39
created

Router::getParamName()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 13
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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