divineniiquaye /
flight-routing
| 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
Loading history...
|
|||
| 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)); |
|
| 253 | } |
||
| 254 | } |
||
| 255 |