Passed
Branch master (5a7b25)
by Arman
03:56
created

Router::currentRoute()   A

Complexity

Conditions 3
Paths 3

Size

Total Lines 9
Code Lines 4

Duplication

Lines 0
Ratio 0 %

Importance

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