Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
127 / 127
100.00% covered (success)
100.00%
21 / 21
CRAP
100.00% covered (success)
100.00%
1 / 1
Route
100.00% covered (success)
100.00%
127 / 127
100.00% covered (success)
100.00%
21 / 21
50
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 getOrigin
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 setOrigin
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getUrl
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 getOptions
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setOptions
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getName
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setName
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 setPath
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getPath
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 getAction
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setAction
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 getActionArguments
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 setActionArguments
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 run
100.00% covered (success)
100.00%
40 / 40
100.00% covered (success)
100.00%
1 / 1
10
 makeResponse
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 makeResponseBodyPart
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
9
 extractMethodAndArguments
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
8
 onNamedRoutePart
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 jsonSerialize
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 toArrayOfStrings
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
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 */
10namespace Framework\Routing;
11
12use Closure;
13use Framework\HTTP\Response;
14use InvalidArgumentException;
15use JetBrains\PhpStorm\ArrayShape;
16use JetBrains\PhpStorm\Pure;
17use JsonException;
18
19/**
20 * Class Route.
21 *
22 * @package routing
23 */
24class 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}