1 | <?php |
||
2 | |||
3 | declare(strict_types=1); |
||
4 | |||
5 | namespace Conia\Chuck; |
||
6 | |||
7 | use Closure; |
||
8 | use Conia\Chuck\Di\Resolver; |
||
9 | use Conia\Chuck\Exception\HttpMethodNotAllowed; |
||
10 | use Conia\Chuck\Exception\HttpNotFound; |
||
11 | use Conia\Chuck\Exception\RuntimeException; |
||
12 | use Conia\Chuck\Http\Dispatcher; |
||
13 | use Conia\Chuck\Http\MiddlewareWrapper; |
||
14 | use Conia\Chuck\Http\View; |
||
15 | use Conia\Chuck\Http\ViewHandler; |
||
16 | use Conia\Chuck\Middleware; |
||
17 | use Conia\Chuck\Registry; |
||
18 | use Conia\Chuck\Request; |
||
19 | use Conia\Chuck\Routing\AddsMiddleware; |
||
20 | use Conia\Chuck\Routing\AddsRoutes; |
||
21 | use Conia\Chuck\Routing\RouteAdder; |
||
22 | use Conia\Chuck\Routing\StaticRoute; |
||
23 | use Psr\Http\Message\ResponseInterface as PsrResponse; |
||
24 | use Psr\Http\Server\MiddlewareInterface as PsrMiddleware; |
||
25 | use Throwable; |
||
26 | |||
27 | /** @psalm-api */ |
||
28 | class Router implements RouteAdder |
||
29 | { |
||
30 | use AddsRoutes; |
||
31 | use AddsMiddleware; |
||
32 | |||
33 | protected const ALL = 'ALL'; |
||
34 | |||
35 | protected string $cacheFile = ''; |
||
36 | protected bool $shouldCache = false; |
||
37 | protected ?Route $route = null; |
||
38 | |||
39 | /** @psalm-var array<string, list<Route>> */ |
||
40 | protected array $routes = []; |
||
41 | |||
42 | /** @var array<string, StaticRoute> */ |
||
43 | protected array $staticRoutes = []; |
||
44 | |||
45 | /** @var array<string, Route> */ |
||
46 | protected array $names = []; |
||
47 | |||
48 | 3 | public function getRoute(): Route |
|
49 | { |
||
50 | 3 | if (is_null($this->route)) { |
|
51 | 1 | throw new RuntimeException('Route is not initialized'); |
|
52 | } |
||
53 | |||
54 | 2 | return $this->route; |
|
55 | } |
||
56 | |||
57 | /** @psalm-param Closure(Router $router):void $creator */ |
||
58 | 2 | public function routes(Closure $creator, string $cacheFile = '', bool $shouldCache = true): void |
|
59 | { |
||
60 | 2 | $this->cacheFile = $cacheFile; |
|
61 | 2 | $this->shouldCache = $shouldCache; |
|
62 | |||
63 | 2 | $creator($this); |
|
64 | } |
||
65 | |||
66 | 74 | public function addRoute(Route $route): Route |
|
67 | { |
||
68 | 74 | $name = $route->name(); |
|
69 | 74 | $noMethodGiven = true; |
|
70 | |||
71 | 74 | foreach ($route->methods() as $method) { |
|
72 | 42 | $noMethodGiven = false; |
|
73 | 42 | $this->routes[$method][] = $route; |
|
74 | } |
||
75 | |||
76 | 74 | if ($noMethodGiven) { |
|
77 | 39 | $this->routes[self::ALL][] = $route; |
|
78 | } |
||
79 | |||
80 | 74 | if ($name) { |
|
81 | 24 | if (array_key_exists($name, $this->names)) { |
|
82 | 1 | throw new RuntimeException( |
|
83 | 1 | 'Duplicate route: ' . $name . '. If || you want to use the same ' . |
|
84 | 1 | 'url pattern with different methods, you have to create routes with names.' |
|
85 | 1 | ); |
|
86 | } |
||
87 | |||
88 | 24 | $this->names[$name] = $route; |
|
89 | } |
||
90 | |||
91 | 74 | return $route; |
|
92 | } |
||
93 | |||
94 | 3 | public function addGroup(Group $group): void |
|
95 | { |
||
96 | 3 | $group->create($this); |
|
97 | } |
||
98 | |||
99 | 6 | public function addStatic( |
|
100 | string $prefix, |
||
101 | string $dir, |
||
102 | string $name = '', |
||
103 | ): void { |
||
104 | 6 | if (empty($name)) { |
|
105 | 4 | $name = $prefix; |
|
106 | } |
||
107 | |||
108 | 6 | if (array_key_exists($name, $this->staticRoutes)) { |
|
109 | 2 | throw new RuntimeException( |
|
110 | 2 | 'Duplicate static route: ' . $name . '. If you want to use the same ' . |
|
111 | 2 | 'url prefix you have to create static routes with names.' |
|
112 | 2 | ); |
|
113 | } |
||
114 | |||
115 | 6 | if (is_dir($dir)) { |
|
116 | 5 | $this->staticRoutes[$name] = new StaticRoute( |
|
117 | 5 | prefix: '/' . trim($prefix, '/') . '/', |
|
118 | 5 | dir: $dir, |
|
119 | 5 | ); |
|
120 | } else { |
||
121 | 1 | throw new RuntimeException("The static directory does not exist: {$dir}"); |
|
122 | } |
||
123 | } |
||
124 | |||
125 | 3 | public function staticUrl( |
|
126 | string $name, |
||
127 | string $path, |
||
128 | bool $bust = false, |
||
129 | ?string $host = null |
||
130 | ): string { |
||
131 | 3 | $route = $this->staticRoutes[$name]; |
|
132 | |||
133 | 3 | if ($bust) { |
|
134 | // Check if there is already a query parameter present |
||
135 | 1 | if (strpos($path, '?')) { |
|
136 | 1 | $file = strtok($path, '?'); |
|
137 | 1 | $sep = '&'; |
|
138 | } else { |
||
139 | 1 | $file = $path; |
|
140 | 1 | $sep = '?'; |
|
141 | } |
||
142 | |||
143 | 1 | $buster = $this->getCacheBuster($route->dir, $file); |
|
144 | |||
145 | 1 | if (!empty($buster)) { |
|
146 | 1 | $path .= $sep . 'v=' . $buster; |
|
147 | } |
||
148 | } |
||
149 | |||
150 | 3 | return ($host ? trim($host, '/') : '') . $route->prefix . trim($path, '/'); |
|
151 | } |
||
152 | |||
153 | 14 | public function routeUrl(string $__routeName__, mixed ...$args): string |
|
154 | { |
||
155 | 14 | $route = $this->names[$__routeName__] ?? null; |
|
156 | |||
157 | 14 | if ($route) { |
|
158 | 13 | return $route->url(...$args); |
|
159 | } |
||
160 | |||
161 | 1 | throw new RuntimeException('Route not found: ' . $__routeName__); |
|
162 | } |
||
163 | |||
164 | 61 | public function match(Request $request): Route |
|
165 | { |
||
166 | 61 | $url = rawurldecode($request->uri()->getPath()); |
|
167 | 61 | $requestMethod = $request->method(); |
|
168 | |||
169 | 61 | foreach ([$requestMethod, self::ALL] as $method) { |
|
170 | 61 | foreach ($this->routes[$method] ?? [] as $route) { |
|
171 | 60 | if ($route->match($url)) { |
|
172 | 59 | return $route; |
|
173 | } |
||
174 | } |
||
175 | } |
||
176 | |||
177 | // We know now, that the route does not match. |
||
178 | // Check if it would match one of the remaining methods |
||
179 | 9 | $wrongMethod = false; |
|
180 | 9 | $remainingMethods = array_keys($this->routes); |
|
181 | |||
182 | 9 | foreach ([$requestMethod, self::ALL] as $method) { |
|
183 | 9 | if (($key = array_search($method, $remainingMethods)) !== false) { |
|
184 | 4 | unset($remainingMethods[$key]); |
|
185 | } |
||
186 | } |
||
187 | |||
188 | 9 | foreach ($remainingMethods as $method) { |
|
189 | 7 | foreach ($this->routes[$method] as $route) { |
|
190 | 7 | if ($route->match($url)) { |
|
191 | 7 | $wrongMethod = true; |
|
192 | |||
193 | 7 | break; |
|
194 | } |
||
195 | } |
||
196 | } |
||
197 | |||
198 | 9 | if ($wrongMethod) { |
|
199 | 7 | throw new HttpMethodNotAllowed(); |
|
200 | } |
||
201 | |||
202 | 2 | throw new HttpNotFound(); |
|
203 | } |
||
204 | |||
205 | /** |
||
206 | * Looks up the matching route and generates the response. |
||
207 | */ |
||
208 | 28 | public function dispatch(Request $request, Registry $registry): PsrResponse |
|
209 | { |
||
210 | 28 | $this->route = $this->match($request); |
|
211 | |||
212 | 27 | $view = new View($this->route->view(), $this->route->args(), $registry); |
|
213 | 25 | $queue = $this->collectMiddleware($view, $registry); |
|
214 | 24 | $queue[] = new ViewHandler($view, $registry, $this->route); |
|
215 | |||
216 | 24 | return (new Dispatcher($queue, $registry))->dispatch($request); |
|
217 | } |
||
218 | |||
219 | 1 | protected function getCacheBuster(string $dir, string $path): string |
|
220 | { |
||
221 | 1 | $ds = DIRECTORY_SEPARATOR; |
|
222 | 1 | $file = $dir . $ds . ltrim(str_replace('/', $ds, $path), $ds); |
|
223 | |||
224 | try { |
||
225 | 1 | return hash('xxh32', (string)filemtime($file)); |
|
226 | 1 | } catch (Throwable) { |
|
227 | 1 | return ''; |
|
228 | } |
||
229 | } |
||
230 | |||
231 | 25 | protected function collectMiddleware(View $view, Registry $registry): array |
|
232 | { |
||
233 | 25 | $middlewareAttributes = $view->attributes(Middleware::class); |
|
234 | |||
235 | 25 | return array_map( |
|
236 | /** @psalm-param list{non-falsy-string, ...}|Closure|Middleware|PsrMiddleware $middleware */ |
||
237 | 25 | function ( |
|
238 | 25 | array|Closure|Middleware|PsrMiddleware $middleware |
|
239 | 25 | ) use ($registry): Middleware|PsrMiddleware { |
|
240 | if ( |
||
241 | 10 | ($middleware instanceof Middleware) |
|
242 | 6 | || ($middleware instanceof PsrMiddleware) |
|
0 ignored issues
–
show
introduced
by
![]() |
|||
243 | 10 | || ($middleware instanceof Closure) |
|
0 ignored issues
–
show
|
|||
244 | ) { |
||
245 | 9 | return $middleware; |
|
246 | } |
||
247 | |||
248 | 6 | if (class_exists($middleware[0])) { |
|
249 | 5 | $object = (new Resolver($registry))->autowire( |
|
250 | 5 | $middleware[0], |
|
251 | 5 | array_slice($middleware, 1), |
|
252 | 5 | ); |
|
253 | 5 | assert($object instanceof Middleware || $object instanceof PsrMiddleware); |
|
254 | |||
255 | 5 | return $object; |
|
256 | } |
||
257 | |||
258 | 4 | if (is_callable($middleware[0])) { |
|
259 | 3 | return new MiddlewareWrapper($middleware[0]); |
|
260 | } |
||
261 | |||
262 | 1 | throw new RuntimeException('Invalid middleware: ' . |
|
263 | /** @scrutinizer ignore-type */ |
||
264 | 1 | print_r($middleware[0], true)); |
|
265 | 25 | }, |
|
266 | 25 | array_merge( |
|
267 | 25 | $this->middleware, |
|
268 | 25 | $this->route ? $this->route->getMiddleware() : [], |
|
269 | 25 | $middlewareAttributes, |
|
270 | 25 | ) |
|
271 | 25 | ); |
|
272 | } |
||
273 | } |
||
274 |