Passed
Pull Request — master (#108)
by Arman
03:08
created

Router::normalizePattern()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 11
Code Lines 6

Duplication

Lines 0
Ratio 0 %

Importance

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