Completed
Push — master ( 2f6f54...c29b50 )
by Arman
16s queued 15s
created

Router::getParamName()   A

Complexity

Conditions 4
Paths 6

Size

Total Lines 13
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Importance

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