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