1
|
|
|
<?php declare(strict_types=1); |
2
|
|
|
|
3
|
|
|
namespace Koded\Framework; |
4
|
|
|
|
5
|
|
|
use Koded\Http\HTTPConflict; |
6
|
|
|
use Koded\Stdlib\UUID; |
7
|
|
|
use Psr\SimpleCache\CacheInterface; |
8
|
|
|
use Throwable; |
9
|
|
|
use function array_filter; |
10
|
|
|
use function array_keys; |
11
|
|
|
use function assert; |
12
|
|
|
use function crc32; |
13
|
|
|
use function explode; |
14
|
|
|
use function is_object; |
15
|
|
|
use function Koded\Stdlib\json_serialize; |
16
|
|
|
use function Koded\Stdlib\json_unserialize; |
17
|
|
|
use function preg_match; |
18
|
|
|
use function preg_match_all; |
19
|
|
|
use function str_contains; |
20
|
|
|
use function str_replace; |
21
|
|
|
|
22
|
|
|
class Router |
23
|
|
|
{ |
24
|
|
|
private const INDEX = 'router.index'; |
25
|
|
|
|
26
|
|
|
private bool $cached; |
27
|
|
|
private array $index; |
28
|
|
|
private array $identity = []; |
29
|
|
|
private array $callback = []; |
30
|
|
|
|
31
|
61 |
|
public function __construct(private CacheInterface $cache) |
32
|
|
|
{ |
33
|
61 |
|
$this->index = $cache->get(self::INDEX, []); |
34
|
61 |
|
$this->cached = $cache->has(self::INDEX) && $this->index; |
|
|
|
|
35
|
|
|
} |
36
|
|
|
|
37
|
55 |
|
public function __destruct() |
38
|
|
|
{ |
39
|
|
|
// Saves the routes index in the cache |
40
|
55 |
|
if (false === $this->cached && $this->index) { |
|
|
|
|
41
|
47 |
|
$this->cached = !$this->cache->set(self::INDEX, $this->index); |
42
|
|
|
} |
43
|
|
|
} |
44
|
|
|
|
45
|
57 |
|
public function route(string $template, object|string $resource): void |
46
|
|
|
{ |
47
|
57 |
|
assert('/' === ($template[0] ?? ''), __('koded.router.noSlash')); |
48
|
57 |
|
assert(false === str_contains($template, '//'), __('koded.router.duplicateSlashes')); |
49
|
|
|
|
50
|
57 |
|
$id = $this->id($template); |
51
|
57 |
|
if ($this->index[$id] ?? false) { |
52
|
|
|
if (empty($this->index[$id]['resource'])) { |
53
|
|
|
$this->callback[$id] = ['resource' => $resource] + $this->index[$id]; |
54
|
|
|
} |
55
|
|
|
return; |
56
|
|
|
} |
57
|
57 |
|
$this->cache->set($id, $this->compileRoute($template, $resource, $id)); |
58
|
|
|
} |
59
|
|
|
|
60
|
49 |
|
public function match(string $path): array |
61
|
|
|
{ |
62
|
49 |
|
$id = $this->id($path); |
63
|
49 |
|
if ($route = $this->callback[$id] ?? $this->index[$id] ?? false) { |
64
|
36 |
|
return $route; |
65
|
|
|
} |
66
|
15 |
|
foreach ($this->index as $id => $route) { |
67
|
14 |
|
if (preg_match($route['regex'], $path, $params)) { |
68
|
13 |
|
return $this->normalizeParams($this->callback[$id] ?? $route, $params); |
69
|
|
|
} |
70
|
|
|
} |
71
|
2 |
|
return empty($this->index) ? ['resource' => 'no_app_routes'] : []; |
72
|
|
|
} |
73
|
|
|
|
74
|
13 |
|
private function normalizeParams(array $route, array $params): array |
75
|
|
|
{ |
76
|
13 |
|
$route['params'] = json_unserialize(json_serialize( |
77
|
13 |
|
array_filter($params, 'is_string', ARRAY_FILTER_USE_KEY), |
78
|
13 |
|
JSON_NUMERIC_CHECK) |
79
|
13 |
|
); |
80
|
13 |
|
return $route; |
81
|
|
|
} |
82
|
|
|
|
83
|
58 |
|
private function id(string $value): string |
84
|
|
|
{ |
85
|
58 |
|
return 'r.' . crc32($value); |
86
|
|
|
} |
87
|
|
|
|
88
|
57 |
|
private function compileRoute( |
89
|
|
|
string $template, |
90
|
|
|
object|string $resource, |
91
|
|
|
string $id): array |
92
|
|
|
{ |
93
|
57 |
|
$route = $this->compileTemplate($template) + [ |
94
|
57 |
|
'resource' => is_object($resource) ? '' : $resource, |
95
|
57 |
|
'template' => $template, |
96
|
57 |
|
]; |
97
|
52 |
|
$this->index[$id] = $route; |
98
|
52 |
|
if (empty($route['resource'])) { |
99
|
30 |
|
$this->callback[$id] = ['resource' => $resource] + $route; |
100
|
|
|
} |
101
|
52 |
|
return $route; |
102
|
|
|
} |
103
|
|
|
|
104
|
57 |
|
private function compileTemplate(string $template): array |
105
|
|
|
{ |
106
|
|
|
// Test for direct URI |
107
|
57 |
|
if (false === str_contains($template, '{') && |
108
|
57 |
|
false === str_contains($template, '<')) { |
109
|
40 |
|
$this->identity[$template] = $template; |
110
|
40 |
|
return [ |
111
|
40 |
|
'regex' => "~^$template\$~ui", |
112
|
40 |
|
'identity' => $template |
113
|
40 |
|
]; |
114
|
|
|
} |
115
|
19 |
|
$options = ''; |
116
|
19 |
|
$regex = $identity = $template; |
117
|
19 |
|
$types = [ |
118
|
19 |
|
'str' => '.+?', // non-greedy (stop at first match) |
119
|
19 |
|
'int' => '\-?\d+', |
120
|
19 |
|
'float' => '(\-?\d*\.\d+)', |
121
|
19 |
|
'path' => '.+', // greedy |
122
|
19 |
|
'regex' => '', |
123
|
19 |
|
'uuid' => UUID::PATTERN, |
124
|
19 |
|
]; |
125
|
|
|
|
126
|
|
|
// https://regex101.com/r/xeuMU3/2 |
127
|
19 |
|
preg_match_all('~{((?:[^{}]*|(?R))*)}~mx', |
128
|
19 |
|
$template, |
129
|
19 |
|
$parameters, |
130
|
19 |
|
PREG_SET_ORDER); |
131
|
|
|
|
132
|
19 |
|
foreach ($parameters as [$parameter, $param]) { |
133
|
19 |
|
[$name, $type, $filter] = explode(':', $param, 3) + [1 => 'str', 2 => '']; |
134
|
19 |
|
$this->assertSupportedType($template, $types, $type, $filter); |
135
|
16 |
|
$regex = str_replace($parameter, '(?P<' . $name .'>' . ($filter ?: $types[$type]) . ')', $regex); |
136
|
16 |
|
$identity = str_replace($parameter, $types[$type] ? ":$type" : $filter, $identity); |
137
|
16 |
|
('str' === $type || 'path' === $type) && $options = 'ui'; |
138
|
|
|
} |
139
|
|
|
/* |
140
|
|
|
* [NOTE]: Replace :path with :str. The concept of "path" is irrelevant |
141
|
|
|
* because the single parameters are matched as non-greedy (first occurrence) |
142
|
|
|
* and the path is greedy matched (as many as possible). The implementation |
143
|
|
|
* cannot distinguish between both types, therefore limit the types to :str |
144
|
|
|
* and disallow routes with multiple :path types. |
145
|
|
|
*/ |
146
|
16 |
|
$identity = str_replace(':path', ':str', $identity, $paths); |
147
|
16 |
|
$this->assertIdentityAndPaths($template, $identity, $paths); |
148
|
15 |
|
$this->identity[$identity] = $template; |
149
|
|
|
|
150
|
|
|
try { |
151
|
15 |
|
$regex = "~^$regex\$~$options"; |
152
|
|
|
// TODO: Quick test for duplicate subpattern names |
153
|
15 |
|
preg_match($regex, '/'); |
154
|
14 |
|
return [ |
155
|
14 |
|
'regex' => $regex, |
156
|
14 |
|
'identity' => $identity |
157
|
14 |
|
]; |
158
|
1 |
|
} catch (Throwable $ex) { |
159
|
1 |
|
throw new HTTPConflict( |
160
|
1 |
|
title: __('koded.router.pcre.compilation', [$ex->getMessage()]), |
161
|
1 |
|
detail: $ex->getMessage(), |
162
|
1 |
|
instance: $template, |
163
|
1 |
|
); |
164
|
|
|
} |
165
|
|
|
} |
166
|
|
|
|
167
|
19 |
|
private function assertSupportedType( |
168
|
|
|
string $template, |
169
|
|
|
array $types, |
170
|
|
|
string $type, |
171
|
|
|
string $filter): void |
172
|
|
|
{ |
173
|
19 |
|
('regex' === $type and empty($filter)) and throw new HTTPConflict( |
174
|
19 |
|
title: __('koded.router.invalidRoute.title'), |
175
|
19 |
|
detail: __('koded.router.invalidRoute.detail'), |
176
|
19 |
|
instance: $template, |
177
|
19 |
|
); |
178
|
17 |
|
isset($types[$type]) or throw (new HTTPConflict( |
179
|
17 |
|
title: __('koded.router.invalidParam.title', [$type]), |
180
|
17 |
|
detail: __('koded.router.invalidParam.detail'), |
181
|
17 |
|
instance: $template, |
182
|
17 |
|
))->setMember('supported-types', array_keys($types)); |
183
|
|
|
} |
184
|
|
|
|
185
|
16 |
|
private function assertIdentityAndPaths( |
186
|
|
|
string $template, |
187
|
|
|
string $identity, |
188
|
|
|
int $paths): void |
189
|
|
|
{ |
190
|
16 |
|
isset($this->identity[$identity]) and throw (new HTTPConflict( |
191
|
16 |
|
instance: $template, |
192
|
16 |
|
title: __('koded.router.duplicateRoute.title'), |
193
|
16 |
|
detail: __('koded.router.duplicateRoute.detail', [$template, $this->identity[$identity]]) |
194
|
16 |
|
))->setMember('conflict-route', [$template => $this->identity[$identity]]); |
195
|
|
|
|
196
|
16 |
|
$paths > 1 and throw new HTTPConflict( |
197
|
16 |
|
title: __('koded.router.multiPaths.title'), |
198
|
16 |
|
detail: __('koded.router.multiPaths.detail'), |
199
|
16 |
|
instance: $template, |
200
|
16 |
|
); |
201
|
|
|
} |
202
|
|
|
} |
203
|
|
|
|
This check marks implicit conversions of arrays to boolean values in a comparison. While in PHP an empty array is considered to be equal (but not identical) to false, this is not always apparent.
Consider making the comparison explicit by using
empty(..)
or! empty(...)
instead.