kodeart /
koded
| 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; |
|
|
0 ignored issues
–
show
|
|||
| 35 | } |
||
| 36 | |||
| 37 | 55 | public function __destruct() |
|
| 38 | { |
||
| 39 | // Saves the routes index in the cache |
||
| 40 | 55 | if (false === $this->cached && $this->index) { |
|
|
0 ignored issues
–
show
The expression
$this->index of type array is implicitly converted to a boolean; are you sure this is intended? If so, consider using ! empty($expr) instead to make it clear that you intend to check for an array without elements.
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 Loading history...
|
|||
| 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.