Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
Total | |
100.00% |
127 / 127 |
|
100.00% |
21 / 21 |
CRAP | |
100.00% |
1 / 1 |
Route | |
100.00% |
127 / 127 |
|
100.00% |
21 / 21 |
50 | |
100.00% |
1 / 1 |
__construct | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
1 | |||
getOrigin | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
setOrigin | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getUrl | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
getOptions | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setOptions | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getName | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setName | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
setPath | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
getPath | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
getAction | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setAction | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
2 | |||
getActionArguments | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
setActionArguments | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
1 | |||
run | |
100.00% |
40 / 40 |
|
100.00% |
1 / 1 |
10 | |||
makeResponse | |
100.00% |
2 / 2 |
|
100.00% |
1 / 1 |
1 | |||
makeResponseBodyPart | |
100.00% |
15 / 15 |
|
100.00% |
1 / 1 |
9 | |||
extractMethodAndArguments | |
100.00% |
31 / 31 |
|
100.00% |
1 / 1 |
8 | |||
onNamedRoutePart | |
100.00% |
3 / 3 |
|
100.00% |
1 / 1 |
2 | |||
jsonSerialize | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
toArrayOfStrings | |
100.00% |
5 / 5 |
|
100.00% |
1 / 1 |
2 |
1 | <?php |
2 | /* |
3 | * This file is part of Aplus Framework Routing Library. |
4 | * |
5 | * (c) Natan Felles <natanfelles@gmail.com> |
6 | * |
7 | * For the full copyright and license information, please view the LICENSE |
8 | * file that was distributed with this source code. |
9 | */ |
10 | namespace Framework\Routing; |
11 | |
12 | use Closure; |
13 | use Framework\HTTP\Response; |
14 | use InvalidArgumentException; |
15 | use JetBrains\PhpStorm\ArrayShape; |
16 | use JetBrains\PhpStorm\Pure; |
17 | use JsonException; |
18 | |
19 | /** |
20 | * Class Route. |
21 | * |
22 | * @package routing |
23 | */ |
24 | class Route implements \JsonSerializable |
25 | { |
26 | protected Router $router; |
27 | protected string $origin; |
28 | protected string $path; |
29 | protected Closure | string $action; |
30 | /** |
31 | * @var array<int,string> |
32 | */ |
33 | protected array $actionArguments = []; |
34 | protected ?string $name = null; |
35 | /** |
36 | * @var array<string,mixed> |
37 | */ |
38 | protected array $options = []; |
39 | |
40 | /** |
41 | * Route constructor. |
42 | * |
43 | * @param Router $router A Router instance |
44 | * @param string $origin URL Origin. A string in the following format: |
45 | * {scheme}://{hostname}[:{port}] |
46 | * @param string $path URL Path. A string starting with '/' |
47 | * @param Closure|string $action The action |
48 | */ |
49 | public function __construct( |
50 | Router $router, |
51 | string $origin, |
52 | string $path, |
53 | Closure | string $action |
54 | ) { |
55 | $this->router = $router; |
56 | $this->setOrigin($origin); |
57 | $this->setPath($path); |
58 | $this->setAction($action); |
59 | } |
60 | |
61 | /** |
62 | * Gets the Route URL origin. |
63 | * |
64 | * @param string ...$arguments Arguments to fill the URL Origin placeholders |
65 | * |
66 | * @return string |
67 | */ |
68 | public function getOrigin(string ...$arguments) : string |
69 | { |
70 | if ($arguments) { |
71 | return $this->router->fillPlaceholders($this->origin, ...$arguments); |
72 | } |
73 | return $this->origin; |
74 | } |
75 | |
76 | /** |
77 | * @param string $origin |
78 | * |
79 | * @return static |
80 | */ |
81 | protected function setOrigin(string $origin) : static |
82 | { |
83 | $this->origin = \ltrim($origin, '/'); |
84 | return $this; |
85 | } |
86 | |
87 | /** |
88 | * Gets the Route URL. |
89 | * |
90 | * Note: Arguments must be passed if placeholders need to be filled. |
91 | * |
92 | * @param array<mixed> $originArgs Arguments to fill the URL Origin placeholders |
93 | * @param array<mixed> $pathArgs Arguments to fill the URL Path placeholders |
94 | * |
95 | * @return string |
96 | */ |
97 | public function getUrl(array $originArgs = [], array $pathArgs = []) : string |
98 | { |
99 | $originArgs = static::toArrayOfStrings($originArgs); |
100 | $pathArgs = static::toArrayOfStrings($pathArgs); |
101 | return $this->getOrigin(...$originArgs) . $this->getPath(...$pathArgs); |
102 | } |
103 | |
104 | /** |
105 | * Gets Route options. |
106 | * |
107 | * @return array<string,mixed> |
108 | */ |
109 | #[Pure] |
110 | public function getOptions() : array |
111 | { |
112 | return $this->options; |
113 | } |
114 | |
115 | /** |
116 | * Sets options to be used in a specific environment application. |
117 | * For example: its possible set Access Control List options, Locations, |
118 | * Middleware filters, etc. |
119 | * |
120 | * @param array<string,mixed> $options |
121 | * |
122 | * @return static |
123 | */ |
124 | public function setOptions(array $options) : static |
125 | { |
126 | $this->options = $options; |
127 | return $this; |
128 | } |
129 | |
130 | /** |
131 | * Gets the Route name. |
132 | * |
133 | * @return string|null |
134 | */ |
135 | #[Pure] |
136 | public function getName() : ?string |
137 | { |
138 | return $this->name; |
139 | } |
140 | |
141 | /** |
142 | * Sets the Route name. |
143 | * |
144 | * @param string $name |
145 | * |
146 | * @return static |
147 | */ |
148 | public function setName(string $name) : static |
149 | { |
150 | $this->name = $name; |
151 | return $this; |
152 | } |
153 | |
154 | /** |
155 | * Sets the Route URL path. |
156 | * |
157 | * @param string $path |
158 | * |
159 | * @return static |
160 | */ |
161 | public function setPath(string $path) : static |
162 | { |
163 | $this->path = '/' . \trim($path, '/'); |
164 | return $this; |
165 | } |
166 | |
167 | /** |
168 | * Gets the Route URL path. |
169 | * |
170 | * @param string ...$arguments Arguments to fill the URL Path placeholders |
171 | * |
172 | * @return string |
173 | */ |
174 | public function getPath(string ...$arguments) : string |
175 | { |
176 | if ($arguments) { |
177 | return $this->router->fillPlaceholders($this->path, ...$arguments); |
178 | } |
179 | return $this->path; |
180 | } |
181 | |
182 | /** |
183 | * Gets the Route action. |
184 | * |
185 | * @return Closure|string |
186 | */ |
187 | #[Pure] |
188 | public function getAction() : Closure | string |
189 | { |
190 | return $this->action; |
191 | } |
192 | |
193 | /** |
194 | * Sets the Route action. |
195 | * |
196 | * @param Closure|string $action A Closure or a string in the format of the |
197 | * `__METHOD__` constant. Example: `App\Blog::show`. |
198 | * |
199 | * The action can be suffixed with ordered parameters, separated by slashes, |
200 | * to set how the arguments will be passed to the class method. |
201 | * Example: `App\Blog::show/0/2/1`. |
202 | * |
203 | * And, also with the asterisk wildcard, to pass all arguments in the |
204 | * incoming order. Example: `App\Blog::show/*` |
205 | * |
206 | * @see Route::setActionArguments() |
207 | * @see Route::run() |
208 | * |
209 | * @return static |
210 | */ |
211 | public function setAction(Closure | string $action) : static |
212 | { |
213 | $this->action = \is_string($action) ? \trim($action, '\\') : $action; |
214 | return $this; |
215 | } |
216 | |
217 | /** |
218 | * Gets the Route action arguments. |
219 | * |
220 | * @return array<int,string> |
221 | */ |
222 | #[Pure] |
223 | public function getActionArguments() : array |
224 | { |
225 | return $this->actionArguments; |
226 | } |
227 | |
228 | /** |
229 | * Sets the Route action arguments. |
230 | * |
231 | * @param array<int,string> $arguments The arguments. Note that the indexes set |
232 | * the order of how the arguments are passed to the Action |
233 | * |
234 | * @see Route::setAction() |
235 | * |
236 | * @return static |
237 | */ |
238 | public function setActionArguments(array $arguments) : static |
239 | { |
240 | \ksort($arguments); |
241 | $this->actionArguments = $arguments; |
242 | return $this; |
243 | } |
244 | |
245 | /** |
246 | * Runs the Route action. |
247 | * |
248 | * @param mixed ...$construct Class constructor arguments |
249 | * |
250 | * @throws JsonException if the action result is an array, or an instance of |
251 | * JsonSerializable, and the Response cannot be set as JSON |
252 | * @throws RoutingException if class is not an instance of {@see RouteActions}, |
253 | * action method not exists or if the result of the action method has not |
254 | * a valid type |
255 | * |
256 | * @return Response The Response with the action result appended on the body |
257 | */ |
258 | public function run(mixed ...$construct) : Response |
259 | { |
260 | $debug = $this->router->getDebugCollector(); |
261 | if ($debug) { |
262 | $start = \microtime(true); |
263 | $addToDebug = static fn () => $debug->addData([ |
264 | 'type' => 'run', |
265 | 'start' => $start, |
266 | 'end' => \microtime(true), |
267 | ]); |
268 | } |
269 | $action = $this->getAction(); |
270 | if ($action instanceof Closure) { |
271 | $result = $action($this->getActionArguments(), ...$construct); |
272 | $response = $this->makeResponse($result); |
273 | if ($debug) { |
274 | $addToDebug(); |
275 | } |
276 | return $response; |
277 | } |
278 | if ( ! \str_contains($action, '::')) { |
279 | $action .= '::' . $this->router->getDefaultRouteActionMethod(); |
280 | } |
281 | [$classname, $action] = \explode('::', $action, 2); |
282 | [$method, $arguments] = $this->extractMethodAndArguments($action); |
283 | if ( ! \class_exists($classname)) { |
284 | throw new RoutingException("Class not exists: {$classname}"); |
285 | } |
286 | /** |
287 | * @var RouteActions $class |
288 | */ |
289 | $class = new $classname(...$construct); |
290 | if ( ! $class instanceof RouteActions) { |
291 | throw new RoutingException( |
292 | 'Class ' . $class::class . ' is not an instance of ' . RouteActions::class |
293 | ); |
294 | } |
295 | if ( ! \method_exists($class, $method)) { |
296 | throw new RoutingException( |
297 | "Class action method not exists: {$classname}::{$method}" |
298 | ); |
299 | } |
300 | $result = $class->beforeAction($method, $arguments); // @phpstan-ignore-line |
301 | $ran = false; |
302 | if ($result === null) { |
303 | $result = $class->{$method}(...$arguments); |
304 | $ran = true; |
305 | } |
306 | $result = $class->afterAction($method, $arguments, $ran, $result); // @phpstan-ignore-line |
307 | $response = $this->makeResponse($result); |
308 | if ($debug) { |
309 | $addToDebug(); |
310 | } |
311 | return $response; |
312 | } |
313 | |
314 | /** |
315 | * Make the final Response used in the 'run' method. |
316 | * |
317 | * @throws JsonException if the $result is an array, or an instance of |
318 | * JsonSerializable, and the Response cannot be set as JSON |
319 | * @throws RoutingException if the $result type is invalid |
320 | */ |
321 | protected function makeResponse(mixed $result) : Response |
322 | { |
323 | $result = $this->makeResponseBodyPart($result); |
324 | return $this->router->getResponse()->appendBody($result); |
325 | } |
326 | |
327 | /** |
328 | * Make a string to be appended in the Response body based in the route |
329 | * action result. |
330 | * |
331 | * @param mixed $result The return value of the matched route action |
332 | * |
333 | * @throws JsonException if the $result is an array, or an instance of |
334 | * JsonSerializable, and the Response cannot be set as JSON |
335 | * @throws RoutingException if the $result type is invalid |
336 | * |
337 | * @return string |
338 | */ |
339 | protected function makeResponseBodyPart(mixed $result) : string |
340 | { |
341 | if ($result === null || $result instanceof Response) { |
342 | return ''; |
343 | } |
344 | if (\is_scalar($result)) { |
345 | return (string) $result; |
346 | } |
347 | if (\is_object($result) && \method_exists($result, '__toString')) { |
348 | return (string) $result; |
349 | } |
350 | if ( |
351 | \is_array($result) |
352 | || $result instanceof \stdClass |
353 | || $result instanceof \JsonSerializable |
354 | ) { |
355 | $this->router->getResponse()->setJson($result); |
356 | return ''; |
357 | } |
358 | $type = \get_debug_type($result); |
359 | throw new RoutingException( |
360 | "Invalid action return type '{$type}'" . $this->onNamedRoutePart() |
361 | ); |
362 | } |
363 | |
364 | /** |
365 | * @param string $part An action part like: index/0/2/1 |
366 | * |
367 | * @throws InvalidArgumentException for undefined action argument |
368 | * |
369 | * @return array<int,mixed> The action method in the first index, the action |
370 | * arguments in the second |
371 | */ |
372 | #[ArrayShape([0 => 'string', 1 => 'array'])] |
373 | protected function extractMethodAndArguments( |
374 | string $part |
375 | ) : array { |
376 | if ( ! \str_contains($part, '/')) { |
377 | return [$part, []]; |
378 | } |
379 | $arguments = \explode('/', $part); |
380 | $method = $arguments[0]; |
381 | unset($arguments[0]); |
382 | $actionArguments = $this->getActionArguments(); |
383 | $arguments = \array_values($arguments); |
384 | foreach ($arguments as $index => $arg) { |
385 | if (\is_numeric($arg)) { |
386 | $arg = (int) $arg; |
387 | if (\array_key_exists($arg, $actionArguments)) { |
388 | $arguments[$index] = $actionArguments[$arg]; |
389 | continue; |
390 | } |
391 | throw new InvalidArgumentException( |
392 | "Undefined action argument: {$arg}" . $this->onNamedRoutePart() |
393 | ); |
394 | } |
395 | if ($arg !== '*') { |
396 | throw new InvalidArgumentException( |
397 | 'Action argument is not numeric, or has not an allowed wildcard, on index ' . $index |
398 | . $this->onNamedRoutePart() |
399 | ); |
400 | } |
401 | if ($index !== 0 || \count($arguments) > 1) { |
402 | throw new InvalidArgumentException( |
403 | 'Action arguments can only contain an asterisk wildcard and must be passed alone' |
404 | . $this->onNamedRoutePart() |
405 | ); |
406 | } |
407 | $arguments = $actionArguments; |
408 | } |
409 | return [ |
410 | $method, |
411 | $arguments, |
412 | ]; |
413 | } |
414 | |
415 | #[Pure] |
416 | protected function onNamedRoutePart() : string |
417 | { |
418 | $routeName = $this->getName(); |
419 | $part = $routeName ? "named route '{$routeName}'" : 'unnamed route'; |
420 | return ', on ' . $part; |
421 | } |
422 | |
423 | public function jsonSerialize() : string |
424 | { |
425 | return $this->getUrl(); |
426 | } |
427 | |
428 | /** |
429 | * @param array<mixed> $array |
430 | * |
431 | * @return array<int,string> |
432 | */ |
433 | protected static function toArrayOfStrings(array $array) : array |
434 | { |
435 | if ($array === []) { |
436 | return []; |
437 | } |
438 | return \array_map(static function (mixed $value) : string { |
439 | return (string) $value; |
440 | }, $array); |
441 | } |
442 | } |