Code Coverage
 
Lines
Branches
Paths
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
132 / 132
93.91% covered (success)
93.91%
108 / 115
22.75% covered (danger)
22.75%
38 / 167
45.45% covered (danger)
45.45%
5 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
View
100.00% covered (success)
100.00%
132 / 132
93.91% covered (success)
93.91%
108 / 115
22.75% covered (danger)
22.75%
38 / 167
100.00% covered (success)
100.00%
11 / 11
1249.84
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 execute
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
11 / 11
22.22% covered (danger)
22.22%
6 / 27
100.00% covered (success)
100.00%
1 / 1
16.76
 attributes
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
3
 prepareView
100.00% covered (success)
100.00%
12 / 12
90.00% covered (success)
90.00%
9 / 10
66.67% covered (warning)
66.67%
4 / 6
100.00% covered (success)
100.00%
1 / 1
4.59
 prepareControllerAction
100.00% covered (success)
100.00%
10 / 10
93.75% covered (success)
93.75%
15 / 16
7.14% covered (danger)
7.14%
4 / 56
100.00% covered (success)
100.00%
1 / 1
46.23
 getClosure
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
8 / 8
80.00% covered (warning)
80.00%
4 / 5
100.00% covered (success)
100.00%
1 / 1
4.13
 getArgs
100.00% covered (success)
100.00%
19 / 19
91.67% covered (success)
91.67%
22 / 24
23.08% covered (danger)
23.08%
3 / 13
100.00% covered (success)
100.00%
1 / 1
45.87
 resolveUnknown
100.00% covered (success)
100.00%
6 / 6
90.00% covered (success)
90.00%
9 / 10
57.14% covered (warning)
57.14%
4 / 7
100.00% covered (success)
100.00%
1 / 1
5.26
 resolveParam
100.00% covered (success)
100.00%
21 / 21
95.00% covered (success)
95.00%
19 / 20
15.00% covered (danger)
15.00%
6 / 40
100.00% covered (success)
100.00%
1 / 1
58.74
 paramInfo
100.00% covered (success)
100.00%
14 / 14
88.89% covered (warning)
88.89%
8 / 9
25.00% covered (danger)
25.00%
2 / 8
100.00% covered (success)
100.00%
1 / 1
10.75
 middleware
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2
3declare(strict_types=1);
4
5namespace Celemas\Router;
6
7use Celemas\Router\Exception\RuntimeException;
8use Celemas\Wire\Creator;
9use Celemas\Wire\Exception\WireException;
10use Closure;
11use Psr\Container\ContainerInterface as Container;
12use Psr\Http\Message\ResponseInterface as Response;
13use Psr\Http\Message\ServerRequestInterface as Request;
14use Psr\Http\Server\MiddlewareInterface as Middleware;
15use ReflectionClass;
16use ReflectionFunctionAbstract;
17use ReflectionMethod;
18use ReflectionNamedType;
19use ReflectionParameter;
20
21/** @internal */
22final class View
23{
24    use AddsBeforeAfter;
25
26    private Creator $creator;
27    private ?AttributesResolver $attributes = null;
28
29    /** @var Closure|array{class-string, string} */
30    private Closure|array $view;
31
32    /**
33     * @param list<Before> $beforeHandlers
34     * @param list<After> $afterHandlers
35     */
36    public function __construct(
37        protected readonly RouteMatch $match,
38        protected readonly ?Container $container,
39        array $beforeHandlers = [],
40        array $afterHandlers = [],
41    ) {
42        $route = $match->route();
43        $this->creator = new Creator($container);
44        $this->view = $this->prepareView($route->view());
45        $this->setBeforeHandlers($this->doMergeBeforeHandlers($beforeHandlers, $route->beforeHandlers()));
46        $this->setAfterHandlers($this->doMergeAfterHandlers($afterHandlers, $route->afterHandlers()));
47    }
48
49    public function execute(Request $request): Response
50    {
51        $closure = $this->getClosure($request);
52
53        /** @var list<Before> $beforeAttributes */
54        $beforeAttributes = $this->attributes(Before::class);
55        foreach ($this->mergeBeforeHandlers($beforeAttributes) as $handler) {
56            $request = $handler->handle($request);
57        }
58
59        /** @psalm-suppress MixedAssignment -- views may return arbitrary data for after handlers */
60        $result = $closure(...$this->getArgs(getReflectionFunction($closure), $request));
61
62        /** @var list<After> $afterAttributes */
63        $afterAttributes = $this->attributes(After::class);
64        foreach ($this->mergeAfterHandlers($afterAttributes) as $handler) {
65            /** @psalm-suppress MixedAssignment -- after handlers may transform arbitrary data */
66            $result = $handler->handle($result);
67        }
68
69        if ($result instanceof Response) {
70            return $result;
71        }
72
73        if ($result instanceof ResponseWrapper) {
74            return $result->unwrap();
75        }
76
77        throw new RuntimeException(
78            'Unable to determine a response handler for the returned value of the view',
79        );
80    }
81
82    /** @param ?class-string $filter */
83    public function attributes(?string $filter = null): array
84    {
85        if (!isset($this->attributes)) {
86            if ($this->view instanceof Closure) {
87                $this->attributes = new AttributesResolver(
88                    [getReflectionFunction($this->view)],
89                    $this->container,
90                );
91            } else {
92                [$controller, $method] = $this->view;
93                $reflectionClass = new ReflectionClass($controller);
94                $this->attributes = new AttributesResolver([
95                    $reflectionClass,
96                    $reflectionClass->getMethod($method),
97                ], $this->container);
98            }
99        }
100
101        return $this->attributes->get($filter);
102    }
103
104    /** @return Closure|array{class-string, string} */
105    private function prepareView(callable|string|array $view): Closure|array
106    {
107        if (is_callable($view)) {
108            return Closure::fromCallable($view);
109        }
110
111        if (is_array($view)) {
112            return $this->prepareControllerAction($view[0] ?? null, $view[1] ?? null);
113        }
114
115        if (class_exists($view)) {
116            return $this->prepareControllerAction($view, '__invoke');
117        }
118
119        throw new RuntimeException(
120            'Route action string is not callable: '
121            . $view
122            . ". Use a callable, [Controller::class, 'method'], an invokable controller class, "
123            . 'or a controller group.',
124        );
125    }
126
127    /** @return array{class-string, string} */
128    private function prepareControllerAction(mixed $controllerName, mixed $method): array
129    {
130        if (
131            !is_string($controllerName)
132            || $controllerName === ''
133            || !is_string($method)
134            || $method === ''
135        ) {
136            throw new RuntimeException("Controller actions must use [Controller::class, 'method'].");
137        }
138
139        if (!class_exists($controllerName)) {
140            throw new RuntimeException('Route controller not found: ' . $controllerName);
141        }
142
143        if (!method_exists($controllerName, $method)) {
144            throw new RuntimeException('Route action method not found: ' . $controllerName . '::' . $method);
145        }
146
147        return [$controllerName, $method];
148    }
149
150    private function getClosure(Request $request): Closure
151    {
152        if ($this->view instanceof Closure) {
153            return $this->view;
154        }
155
156        [$controllerName, $method] = $this->view;
157        $rc = new ReflectionClass($controllerName);
158        $constructor = $rc->getConstructor();
159        $args = $constructor ? $this->getArgs($constructor, $request) : [];
160        $controller = $rc->newInstance(...$args);
161
162        if (is_callable([$controller, $method])) {
163            return Closure::fromCallable([$controller, $method]);
164        }
165
166        throw new RuntimeException(
167            'Route action method is not callable: ' . $controllerName . '::' . $method,
168        );
169    }
170
171    /**
172     * Determines the arguments passed to the view and/or controller constructor.
173     *
174     * - If a view parameter implements Request, the request will be passed.
175     * - If a view parameter is a subclass of Route, the route will be passed.
176     * - If names of the view parameters match names of the route arguments
177     *   it will try to convert the argument to the parameter type and add it to
178     *   the returned args list.
179     * - If the parameter is typed, try to resolve it via container or
180     *   autowiring.
181     * - If parameter resolution fails and a default value exists, the default
182     *   will be used.
183     * - Exceptions thrown while constructing dependencies bubble unchanged.
184     * - Otherwise fail.
185     *
186     * @psalm-suppress MixedAssignment -- $args values are mixed
187     */
188    private function getArgs(ReflectionFunctionAbstract $rf, Request $request): array
189    {
190        /** @var array<string, mixed> $args */
191        $args = [];
192        $params = $rf->getParameters();
193        $errMsg = 'View parameters cannot be resolved. Details: ';
194
195        foreach ($params as $param) {
196            $name = $param->getName();
197            $routeArgs = $this->match->params();
198
199            if (array_key_exists($name, $routeArgs)) {
200                $args[$name] = match ((string) $param->getType()) {
201                    'int' => is_numeric($routeArgs[$name])
202                        ? (int) $routeArgs[$name]
203                        : throw new RuntimeException($errMsg . "Cannot cast '{$name}' to int"),
204                    'float' => is_numeric($routeArgs[$name])
205                        ? (float) $routeArgs[$name]
206                        : throw new RuntimeException($errMsg . "Cannot cast '{$name}' to float"),
207                    'string' => $routeArgs[$name],
208                    default => $this->resolveUnknown($param, $request, $errMsg),
209                };
210            } else {
211                $args[$name] = $this->resolveUnknown($param, $request, $errMsg);
212            }
213        }
214
215        assert(count($params) === count($args), 'Resolved argument count must match parameter count.');
216
217        return $args;
218    }
219
220    private function resolveUnknown(
221        ReflectionParameter $param,
222        Request $request,
223        string $errMsg,
224    ): mixed {
225        try {
226            return $this->resolveParam($param, $request);
227        } catch (RuntimeException|WireException $e) {
228            if ($param->isDefaultValueAvailable()) {
229                return $param->getDefaultValue();
230            }
231
232            $code = $e->getCode();
233
234            throw new RuntimeException($errMsg . $e->getMessage(), is_int($code) ? $code : 0, $e);
235        }
236    }
237
238    private function resolveParam(ReflectionParameter $param, Request $request): mixed
239    {
240        $type = $param->getType();
241
242        if ($type instanceof ReflectionNamedType) {
243            $typeName = ltrim($type->getName(), '?');
244
245            if ($typeName === Request::class || is_subclass_of($typeName, Request::class)) {
246                return $request;
247            }
248
249            if ($typeName === Route::class || is_subclass_of($typeName, Route::class)) {
250                return $this->match->route();
251            }
252
253            if (!class_exists($typeName) && !interface_exists($typeName)) {
254                throw new RuntimeException(
255                    "Type '{$typeName}' is not a class or interface. Source: \n" . $this->paramInfo($param),
256                );
257            }
258
259            return $this->creator->create($typeName, predefinedTypes: [Request::class => $request]);
260        }
261        if ($type) {
262            throw new RuntimeException(
263                "Autowiring does not support union or intersection types. Source: \n"
264                    . $this->paramInfo($param),
265            );
266        }
267
268        throw new RuntimeException(
269            "Autowired entities need to have typed constructor parameters. Source: \n"
270                . $this->paramInfo($param),
271        );
272    }
273
274    public function paramInfo(ReflectionParameter $param): string
275    {
276        $type = $param->getType();
277        $rf = $param->getDeclaringFunction();
278        $rc = null;
279
280        if ($rf instanceof ReflectionMethod) {
281            $rc = $rf->getDeclaringClass();
282        }
283
284        return (
285            ($rc ? $rc->getName() . '::' : '')
286            . $rf->getName()
287            . '(..., '
288            . ($type ? (string) $type . ' ' : '')
289            . '$'
290            . $param->getName()
291            . ', ...)'
292        );
293    }
294
295    /** @return list<Middleware> */
296    public function middleware(): array
297    {
298        /** @var list<Middleware> $middlewareAttributes */
299        $middlewareAttributes = $this->attributes(Middleware::class);
300
301        return array_merge(
302            $this->match->route()->getMiddleware(),
303            $middlewareAttributes,
304        );
305    }
306}