Completed
Push — master ( 7e48a9...4d4876 )
by Arman
29s queued 13s
created

Router::findRoute()   A

Complexity

Conditions 6
Paths 17

Size

Total Lines 38
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

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