1 | <?php declare(strict_types=1); |
||
2 | |||
3 | /* |
||
4 | * This file is part of Flight Routing. |
||
5 | * |
||
6 | * PHP version 8.0 and above required |
||
7 | * |
||
8 | * @author Divine Niiquaye Ibok <[email protected]> |
||
9 | * @copyright 2019 Divine Niiquaye Ibok (https://divinenii.com/) |
||
10 | * @license https://opensource.org/licenses/BSD-3-Clause License |
||
11 | * |
||
12 | * For the full copyright and license information, please view the LICENSE |
||
13 | * file that was distributed with this source code. |
||
14 | */ |
||
15 | |||
16 | namespace Flight\Routing; |
||
17 | |||
18 | use Flight\Routing\Exceptions\{UriHandlerException, UrlGenerationException}; |
||
19 | use Flight\Routing\Interfaces\RouteCompilerInterface; |
||
20 | |||
21 | /** |
||
22 | * RouteCompiler compiles Route instances to regex. |
||
23 | * |
||
24 | * provides ability to match and generate uris based on given parameters. |
||
25 | * |
||
26 | * @author Divine Niiquaye Ibok <[email protected]> |
||
27 | */ |
||
28 | final class RouteCompiler implements RouteCompilerInterface |
||
29 | { |
||
30 | private const DEFAULT_SEGMENT = '[^\/]+'; |
||
31 | |||
32 | /** |
||
33 | * This string defines the characters that are automatically considered separators in front of |
||
34 | * optional placeholders (with default and no static text following). Such a single separator |
||
35 | * can be left out together with the optional placeholder from matching and generating URLs. |
||
36 | */ |
||
37 | private const PATTERN_REPLACES = ['/[' => '/?(?:', '[' => '(?:', ']' => ')?', '.' => '\.', '/$' => '/?$']; |
||
38 | |||
39 | /** |
||
40 | * Using the strtr function is faster than the preg_quote function. |
||
41 | */ |
||
42 | private const SEGMENT_REPLACES = ['/' => '\\/', '.' => '\.']; |
||
43 | |||
44 | /** |
||
45 | * This regex is used to match a certain rule of pattern to be used for routing. |
||
46 | * |
||
47 | * List of string patterns that regex matches: |
||
48 | * - /{var} - A required variable pattern |
||
49 | * - /[{var}] - An optional variable pattern |
||
50 | * - /foo[/{var}] - A path with an optional sub variable pattern |
||
51 | * - /foo[/{var}[.{format}]] - A path with optional nested variables |
||
52 | * - /{var:[a-z]+} - A required variable with lowercase rule |
||
53 | * - /{var=foo} - A required variable with default value |
||
54 | * - /{var}[.{format:(html|php)=html}] - A required variable with an optional variable, a rule & default |
||
55 | */ |
||
56 | private const COMPILER_REGEX = '~\{(\w+)(?:\:(.*?[\}=]?))?(?:\=(.*?))?\}~i'; |
||
57 | |||
58 | /** |
||
59 | * This regex is used to reverse a pattern path, matching required and options vars. |
||
60 | */ |
||
61 | private const REVERSED_REGEX = '#(?|\<(\w+)\>|\[(.*?\])\]|\[(.*?)\])#'; |
||
62 | |||
63 | /** |
||
64 | * A matching requirement helper, to ease matching route pattern when found. |
||
65 | */ |
||
66 | private const SEGMENT_TYPES = [ |
||
67 | 'int' => '[0-9]+', |
||
68 | 'lower' => '[a-z]+', |
||
69 | 'upper' => '[A-Z]+', |
||
70 | 'alpha' => '[A-Za-z]+', |
||
71 | 'hex' => '[[:xdigit:]]+', |
||
72 | 'md5' => '[a-f0-9]{32}+', |
||
73 | 'sha1' => '[a-f0-9]{40}+', |
||
74 | 'year' => '[0-9]{4}', |
||
75 | 'month' => '0[1-9]|1[012]+', |
||
76 | 'day' => '0[1-9]|[12][0-9]|3[01]+', |
||
77 | 'date' => '[0-9]{4}-(?:0[1-9]|1[012])-(?:0[1-9]|[12][0-9]|(?<!02-)3[01])', // YYYY-MM-DD |
||
78 | 'slug' => '[A-Za-z0-9]+(?:-[A-Za-z0-9]+)*', |
||
79 | 'port' => '[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5]', |
||
80 | 'UID_BASE32' => '[0-9A-HJKMNP-TV-Z]{26}', |
||
81 | 'UID_BASE58' => '[1-9A-HJ-NP-Za-km-z]{22}', |
||
82 | 'UID_RFC4122' => '[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}', |
||
83 | 'ULID' => '[0-7][0-9A-HJKMNP-TV-Z]{25}', |
||
84 | 'UUID' => '[0-9a-f]{8}-[0-9a-f]{4}-[1-6][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', |
||
85 | 'UUID_V1' => '[0-9a-f]{8}-[0-9a-f]{4}-1[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', |
||
86 | 'UUID_V3' => '[0-9a-f]{8}-[0-9a-f]{4}-3[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', |
||
87 | 'UUID_V4' => '[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', |
||
88 | 'UUID_V5' => '[0-9a-f]{8}-[0-9a-f]{4}-5[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', |
||
89 | 'UUID_V6' => '[0-9a-f]{8}-[0-9a-f]{4}-6[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}', |
||
90 | ]; |
||
91 | |||
92 | /** |
||
93 | * A helper in reversing route pattern to URI. |
||
94 | */ |
||
95 | private const URI_FIXERS = [ |
||
96 | '[]' => '', |
||
97 | '[/]' => '', |
||
98 | '[' => '', |
||
99 | ']' => '', |
||
100 | '://' => '://', |
||
101 | '//' => '/', |
||
102 | '/..' => '/%2E%2E', |
||
103 | '/.' => '/%2E', |
||
104 | ]; |
||
105 | |||
106 | /** |
||
107 | * The maximum supported length of a PCRE subpattern name |
||
108 | * http://pcre.org/current/doc/html/pcre2pattern.html#SEC16. |
||
109 | * |
||
110 | * @internal |
||
111 | */ |
||
112 | private const VARIABLE_MAXIMUM_LENGTH = 32; |
||
113 | |||
114 | /** |
||
115 | * {@inheritdoc} |
||
116 | */ |
||
117 | 115 | public function compile(string $route, array $placeholders = [], bool $reversed = false): array |
|
118 | { |
||
119 | 115 | $variables = $replaces = []; |
|
120 | |||
121 | 115 | if (\strpbrk($route, '{')) { |
|
122 | // Match all variables enclosed in "{}" and iterate over them... |
||
123 | 78 | \preg_match_all(self::COMPILER_REGEX, $route, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL); |
|
124 | |||
125 | 78 | foreach ($matches as [$placeholder, $varName, $segment, $default]) { |
|
126 | 78 | if (1 === \preg_match('/\A\d+/', $varName)) { |
|
127 | 1 | throw new UriHandlerException(\sprintf('Variable name "%s" cannot start with a digit in route pattern "%s". Use a different name.', $varName, $route)); |
|
128 | } |
||
129 | |||
130 | 77 | if (\strlen($varName) > self::VARIABLE_MAXIMUM_LENGTH) { |
|
131 | 1 | throw new UriHandlerException(\sprintf('Variable name "%s" cannot be longer than %s characters in route pattern "%s".', $varName, self::VARIABLE_MAXIMUM_LENGTH, $route)); |
|
132 | } |
||
133 | |||
134 | 76 | if (\array_key_exists($varName, $variables)) { |
|
135 | 1 | throw new UriHandlerException(\sprintf('Route pattern "%s" cannot reference variable name "%s" more than once.', $route, $varName)); |
|
136 | } |
||
137 | |||
138 | 76 | $segment = self::SEGMENT_TYPES[$segment] ?? self::prepareSegment($varName, $placeholders[$varName] ?? $segment); |
|
139 | 69 | [$variables[$varName], $replaces[$placeholder]] = !$reversed ? [$default, '(?P<'.$varName.'>'.$segment.')'] : [[$segment, $default], '<'.$varName.'>']; |
|
140 | } |
||
141 | } |
||
142 | |||
143 | 105 | return !$reversed ? [\strtr('{^'.$route.'$}', $replaces + self::PATTERN_REPLACES), $variables] : [\strtr($route, $replaces), $variables]; |
|
144 | } |
||
145 | |||
146 | /** |
||
147 | * {@inheritdoc} |
||
148 | */ |
||
149 | 14 | public function generateUri(array $route, array $parameters, int $referenceType = RouteUri::ABSOLUTE_PATH): RouteUri |
|
150 | { |
||
151 | 14 | [$pathRegex, $pathVars] = $this->compile($route['path'], reversed: true); |
|
152 | |||
153 | 14 | $defaults = $route['defaults'] ?? []; |
|
154 | 14 | $createUri = new RouteUri(self::interpolate($pathRegex, $pathVars, $parameters + $defaults), $referenceType); |
|
155 | |||
156 | 12 | foreach (($route['hosts'] ?? []) as $host => $exists) { |
|
157 | 7 | [$hostRegex, $hostVars] = $this->compile($host, reversed: true); |
|
158 | 7 | $createUri->withHost(self::interpolate($hostRegex, $hostVars, $parameters + $defaults)); |
|
159 | 7 | break; |
|
160 | } |
||
161 | |||
162 | 12 | if (!empty($schemes = $route['schemes'] ?? [])) { |
|
163 | 7 | $createUri->withScheme(isset($schemes['https']) ? 'https' : \array_key_last($schemes) ?? 'http'); |
|
164 | } |
||
165 | |||
166 | 12 | return $createUri; |
|
167 | } |
||
168 | |||
169 | /** |
||
170 | * Check for mandatory parameters then interpolate $uriRoute with given $parameters. |
||
171 | * |
||
172 | * @param array<string,array<int,string>> $uriVars |
||
173 | * @param array<int|string,string> $parameters |
||
174 | */ |
||
175 | 14 | private static function interpolate(string $uriRoute, array $uriVars, array $parameters): string |
|
176 | { |
||
177 | 14 | $required = []; // Parameters required which are missing. |
|
178 | 14 | $replaces = self::URI_FIXERS; |
|
179 | |||
180 | // Fetch and merge all possible parameters + route defaults ... |
||
181 | 14 | \preg_match_all(self::REVERSED_REGEX, $uriRoute, $matches, \PREG_SET_ORDER | \PREG_UNMATCHED_AS_NULL); |
|
182 | |||
183 | 14 | if (isset($uriVars['*'])) { |
|
184 | 1 | [$defaultPath, $required, $optional] = $uriVars['*']; |
|
185 | 1 | $replaces = []; |
|
186 | } |
||
187 | |||
188 | 14 | foreach ($matches as $i => [$matched, $varName]) { |
|
189 | 13 | if ('[' !== $matched[0]) { |
|
190 | 13 | [$segment, $default] = $uriVars[$varName]; |
|
191 | 13 | $value = $parameters[$varName] ?? (isset($optional) ? $default : ($parameters[$i] ?? $default)); |
|
192 | |||
193 | 13 | if (!empty($value)) { |
|
194 | 12 | if (1 !== \preg_match("~^{$segment}\$~", (string) $value)) { |
|
195 | 1 | throw new UriHandlerException( |
|
196 | 1 | \sprintf('Expected route path "%s" placeholder "%s" value "%s" to match "%s".', $uriRoute, $varName, $value, $segment) |
|
197 | ); |
||
198 | } |
||
199 | 11 | $optional = isset($optional) ? false : null; |
|
200 | 11 | $replaces[$matched] = $value; |
|
201 | 2 | } elseif (isset($optional) && $optional) { |
|
202 | 1 | $replaces[$matched] = ''; |
|
203 | } else { |
||
204 | 1 | $required[] = $varName; |
|
205 | } |
||
206 | 12 | continue; |
|
207 | } |
||
208 | 1 | $replaces[$matched] = self::interpolate($varName, $uriVars + ['*' => [$uriRoute, $required, true]], $parameters); |
|
209 | } |
||
210 | |||
211 | 13 | if (!empty($required)) { |
|
212 | 1 | throw new UrlGenerationException(\sprintf( |
|
213 | 'Some mandatory parameters are missing ("%s") to generate a URL for route path "%s".', |
||
214 | 1 | \implode('", "', $required), |
|
0 ignored issues
–
show
Bug
introduced
by
![]() |
|||
215 | $defaultPath ?? $uriRoute |
||
216 | )); |
||
217 | } |
||
218 | |||
219 | 12 | return !empty(\array_filter($replaces)) ? \strtr($uriRoute, $replaces) : ''; |
|
220 | } |
||
221 | |||
222 | 24 | private static function sanitizeRequirement(string $key, string $regex): string |
|
223 | { |
||
224 | 24 | if ('' !== $regex) { |
|
225 | 23 | if ('^' === $regex[0]) { |
|
226 | 2 | $regex = \substr($regex, 1); |
|
227 | 21 | } elseif (\str_starts_with($regex, '\\A')) { |
|
228 | 2 | $regex = \substr($regex, 2); |
|
229 | } |
||
230 | |||
231 | 23 | if (\str_ends_with($regex, '$')) { |
|
232 | 2 | $regex = \substr($regex, 0, -1); |
|
233 | 21 | } elseif (\strlen($regex) - 2 === \strpos($regex, '\\z')) { |
|
234 | 2 | $regex = \substr($regex, 0, -2); |
|
235 | } |
||
236 | } |
||
237 | |||
238 | 24 | if ('' === $regex) { |
|
239 | 7 | throw new UriHandlerException(\sprintf('Routing requirement for "%s" cannot be empty.', $key)); |
|
240 | } |
||
241 | |||
242 | 17 | return \strtr($regex, self::SEGMENT_REPLACES); |
|
243 | } |
||
244 | |||
245 | /** |
||
246 | * Prepares segment pattern with given constrains. |
||
247 | * |
||
248 | * @param null|array<int,string>|string $segment |
||
249 | */ |
||
250 | 72 | private static function prepareSegment(string $name, string|array|null $segment): string |
|
251 | { |
||
252 | 72 | return null === $segment ? self::DEFAULT_SEGMENT : (!\is_array($segment) ? self::sanitizeRequirement($name, $segment) : \implode('|', $segment)); |
|
0 ignored issues
–
show
|
|||
253 | } |
||
254 | } |
||
255 |