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