Passed
Pull Request — master (#150)
by Arman
03:43
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
    const VALID_PARAM_NAME_PATTERN = '/^[a-zA-Z]+$/';
38
39
    /**
40
     * Parameter types
41
     */
42
    const PARAM_TYPES = [
43
        ':alpha' => '[a-zA-Z]',
44
        ':num' => '[0-9]',
45
        ':any' => '[^\/]'
46
    ];
47
48
    /**
49
     * Request instance
50
     * @var Request;
51
     */
52
    private $request;
53
54
    /**
55
     * Response instance
56
     * @var Response;
57
     */
58
    private $response;
59
60
    /**
61
     * List of routes
62
     * @var array
63
     */
64
    protected static $routes = [];
65
66
    /**
67
     * matched routes
68
     * @var array
69
     */
70
    private $matchedRoutes = [];
71
72
    /**
73
     * Router constructor.
74
     * @param Request $request
75
     * @param Response $response
76
     */
77
    public function __construct(Request $request, Response $response)
78
    {
79
        $this->request = $request;
80
        $this->response = $response;
81
    }
82
83
    /**
84
     * Finds the current route
85
     * @throws StopExecutionException
86
     * @throws ViewException
87
     * @throws DiException
88
     * @throws LoaderError
89
     * @throws RuntimeError
90
     * @throws SyntaxError
91
     * @throws RouteException
92
     * @throws ReflectionException
93
     */
94
    public function findRoute()
95
    {
96
        $uri = $this->request->getUri();
97
98
        if (!$uri) {
99
            throw RouteException::notFound();
100
        }
101
102
        $this->matchedRoutes = $this->findMatches($uri);
103
104
        if (empty($this->matchedRoutes)) {
105
            stop(function () {
106
                $this->handleNotFound();
107
            });
108
        }
109
110
        if (count($this->matchedRoutes) > 1) {
111
            $this->checkCollision();
112
        }
113
114
        $currentRoute = $this->currentRoute();
115
116
        if (!$currentRoute) {
117
            throw RouteException::incorrectMethod($this->request->getMethod());
118
        }
119
120
        self::setCurrentRoute($currentRoute);
121
122
        if (filter_var(config()->get(Debugger::DEBUG_ENABLED), FILTER_VALIDATE_BOOLEAN)) {
123
            $this->collectDebugData($currentRoute);
124
        }
125
    }
126
127
    /**
128
     * Resets the routes
129
     */
130
    public function resetRoutes()
131
    {
132
        parent::$currentRoute = null;
133
        $this->matchedRoutes = [];
134
    }
135
136
    /**
137
     * Gets the current route
138
     * @return array|null
139
     */
140
    private function currentRoute(): ?array
141
    {
142
        foreach ($this->matchedRoutes as $matchedRoute) {
143
            if ($this->checkMethod($matchedRoute)) {
144
                return $matchedRoute;
145
            }
146
        }
147
148
        return null;
149
    }
150
151
    /**
152
     * Collects debug data
153
     * @param array $currentRoute
154
     * @return void
155
     */
156
    private function collectDebugData(array $currentRoute)
157
    {
158
        $routeInfo = [];
159
160
        foreach ($currentRoute as $key => $value) {
161
            $routeInfo[ucfirst($key)] = json_encode($value);
162
        }
163
164
        Debugger::addToStore(Debugger::ROUTES, LogLevel::INFO, $routeInfo);
165
    }
166
167
    /**
168
     * Finds matches by pattern
169
     * @param string $uri
170
     * @return array
171
     * @throws RouteException
172
     */
173
    private function findMatches(string $uri)
174
    {
175
        $requestUri = urldecode(parse_url($uri, PHP_URL_PATH));
176
177
        $matches = [];
178
179
        foreach (self::$routes as $route) {
180
            list($pattern, $params) = $this->handleRoutePattern($route);
181
182
            if (preg_match("/^" . $this->escape($pattern) . "$/u", $requestUri, $matchedParams)) {
183
                $route['uri'] = $uri;
184
                $route['params'] = $this->routeParams($params, $matchedParams);
185
                $route['pattern'] = $pattern;
186
                $matches[] = $route;
187
            }
188
        }
189
190
        return $matches;
191
    }
192
193
    /**
194
     * Handles the route pattern
195
     * @param array $route
196
     * @return array
197
     * @throws RouteException
198
     */
199
    private function handleRoutePattern(array $route): array
200
    {
201
        $routeSegments = explode('/', trim($route['route'], '/'));
202
203
        $routePattern = '(\/)?';
204
        $routeParams = [];
205
206
        $lastIndex = (int)array_key_last($routeSegments);
207
208
        foreach ($routeSegments as $index => $segment) {
209
            $segmentParam = $this->checkSegment($segment, $index, $lastIndex);
210
211
            if (!empty($segmentParam)) {
212
                $this->checkParamName($routeParams, $segmentParam['name']);
213
214
                $routeParams[] = [
215
                    'route_pattern' => $segment,
216
                    'pattern' => $segmentParam['pattern'],
217
                    'name' => $segmentParam['name']
218
                ];
219
220
                $routePattern = $this->normalizePattern($routePattern, $segmentParam, $index, $lastIndex);
221
            } else {
222
                $routePattern .= $segment . ($index != $lastIndex ? '(\/)' : '');
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
        $name = $this->getParamName($match, $index);
319
320
        $pattern = '(?<' . $name . '>' . $expr;
321
322
        if (isset($match[4]) && is_numeric($match[4])) {
323
            $pattern .= (isset($match[5]) && $match[5] == '?') ? '{0,' . $match[4] . '})' : '{' . $match[4] . '})';
324
        } else {
325
            $pattern .= (isset($match[5]) && $match[5] == '?') ? '*)' : '+)';
326
        }
327
328
        if (isset($match[5]) && $match[5] == '?') {
329
            $pattern = ($index == $lastIndex ? '(\/)?' . $pattern : $pattern . '(\/)?');
330
        } else {
331
            $pattern = ($index == $lastIndex ? '(\/)' . $pattern : $pattern . '(\/)');
332
        }
333
334
        return [
335
            'name' => $name,
336
            'pattern' => $pattern
337
        ];
338
    }
339
340
    /**
341
     * Gets the parameter name
342
     * @param array $match
343
     * @param int $index
344
     * @return string
345
     * @throws RouteException
346
     */
347
    private function getParamName(array $match, int $index): string
348
    {
349
        $name = $match[1] ? rtrim($match[1], '=') : null;
350
351
        if ($name === null) {
352
            return '_segment' . $index;
353
        }
354
355
        if (!preg_match(self::VALID_PARAM_NAME_PATTERN, $name)) {
356
            throw RouteException::paramNameNotValid();
357
        }
358
359
        return $name;
360
    }
361
362
    /**
363
     * Checks the route collisions
364
     * @throws RouteException
365
     */
366
    private function checkCollision()
367
    {
368
        $length = count($this->matchedRoutes);
369
370
        for ($i = 0; $i < $length - 1; $i++) {
371
            for ($j = $i + 1; $j < $length; $j++) {
372
                if ($this->matchedRoutes[$i]['method'] == $this->matchedRoutes[$j]['method']) {
373
                    throw RouteException::repetitiveRouteSameMethod($this->matchedRoutes[$j]['method']);
374
                }
375
                if ($this->matchedRoutes[$i]['module'] != $this->matchedRoutes[$j]['module']) {
376
                    throw RouteException::repetitiveRouteDifferentModules();
377
                }
378
            }
379
        }
380
    }
381
382
    /**
383
     * Checks the request method against defined route method
384
     * @param array $matchedRoute
385
     * @return bool
386
     */
387
    private function checkMethod(array $matchedRoute): bool
388
    {
389
        $allowedMethods = explode('|', $matchedRoute['method']);
390
391
        return in_array($this->request->getMethod(), $allowedMethods, true);
392
    }
393
394
    /**
395
     * Escapes the slashes
396
     * @param string $str
397
     * @return string
398
     */
399
    private function escape(string $str): string
400
    {
401
        return str_replace('/', '\/', stripslashes($str));
402
    }
403
404
    /**
405
     * Handles page not found
406
     * @throws DiException
407
     * @throws ViewException
408
     * @throws ReflectionException
409
     * @throws LoaderError
410
     * @throws RuntimeError
411
     * @throws SyntaxError
412
     */
413
    private function handleNotFound()
414
    {
415
        $acceptHeader = $this->request->getHeader('Accept');
416
        $isJson = $acceptHeader === 'application/json';
417
418
        if ($isJson) {
419
            $this->response->json(
420
                ['status' => 'error', 'message' => 'Page not found'],
421
                404
422
            );
423
        } else {
424
            $this->response->html(
425
                partial('errors/404'),
426
                404
427
            );
428
        }
429
    }
430
431
}
432