Passed
Push — main ( a1a461...2097df )
by Thomas
12:50
created

View::respond()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 37
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 5

Importance

Changes 0
Metric Value
eloc 15
dl 0
loc 37
ccs 16
cts 16
cp 1
rs 9.4555
c 0
b 0
f 0
cc 5
nc 5
nop 2
crap 5
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Conia\Chuck\Http;
6
7
use Closure;
8
use Conia\Chuck\Di\Resolver;
9
use Conia\Chuck\Exception\ContainerException;
10
use Conia\Chuck\Exception\RuntimeException;
11
use Conia\Chuck\Psr\Factory;
12
use Conia\Chuck\Registry;
13
use Conia\Chuck\Renderer\Config as RendererConfig;
14
use Conia\Chuck\Renderer\Render;
15
use Conia\Chuck\Renderer\Renderer;
16
use Conia\Chuck\Response;
17
use Conia\Chuck\Route;
18
use Psr\Http\Message\ResponseInterface as PsrResponse;
19
use ReflectionAttribute;
20
use ReflectionClass;
21
use ReflectionFunction;
22
use ReflectionFunctionAbstract;
23
use ReflectionMethod;
24
use ReflectionObject;
25
use Throwable;
26
27
class View
28
{
29
    protected ?array $attributes = null;
30
    protected Closure $closure;
31
32 42
    public function __construct(
33
        callable|string|array $view,
34
        protected readonly array $routeArgs,
35
        protected readonly Registry $registry
36
    ) {
37 42
        if (is_callable($view)) {
38
            /** @var callable $view -- Psalm complains even though we use is_callable() */
39 18
            $this->closure = Closure::fromCallable($view);
40
        } else {
41 24
            $this->closure = $this->getClosure($view);
42
        }
43
    }
44
45 35
    public function execute(): mixed
46
    {
47 35
        return ($this->closure)(...$this->getArgs(
48 35
            self::getReflectionFunction($this->closure)
49 35
        ));
50
    }
51
52 29
    public function respond(
53
        Route $route,
54
        Registry $registry,
55
    ): Response {
56
        /**
57
         * @psalm-suppress MixedAssignment
58
         *
59
         * Later in the function we check the type of $result.
60
         */
61 29
        $result = $this->execute();
62
63 25
        if ($result instanceof Response) {
64 6
            return $result;
65
        }
66
67 19
        if ($result instanceof PsrResponse) {
68 1
            $factory = $registry->get(Factory::class);
69 1
            assert($factory instanceof Factory);
70
71 1
            return new Response($result, $factory);
72
        }
73
74 18
        $renderAttributes = $this->attributes(Render::class);
75
76 18
        if (count($renderAttributes) > 0) {
77 8
            assert($renderAttributes[0] instanceof Render);
78
79 8
            return $renderAttributes[0]->response($registry, $result);
80
        }
81
82 10
        $rendererConfig = $route->getRenderer();
83
84 10
        if ($rendererConfig) {
85 7
            return $this->respondFromRenderer($registry, $rendererConfig, $result);
86
        }
87
88 3
        throw new RuntimeException('Unable to determine a response handler for the returned value of the view');
89
    }
90
91 36
    public static function getReflectionFunction(
92
        callable $callable
93
    ): ReflectionFunction|ReflectionMethod {
94 36
        if ($callable instanceof Closure) {
95 36
            return new ReflectionFunction($callable);
96
        }
97 1
        if (is_object($callable)) {
98 1
            return (new ReflectionObject($callable))->getMethod('__invoke');
99
        }
100
        /** @var Closure|non-falsy-string $callable */
101 1
        return new ReflectionFunction($callable);
102
    }
103
104
    /** @psalm-param $filter ?class-string */
105 37
    public function attributes(string $filter = null): array
106
    {
107 37
        $reflector = new ReflectionFunction($this->closure);
108
109 37
        if (!isset($this->attributes)) {
110 37
            $this->attributes = array_map(function ($attribute) {
111 19
                return $this->newAttributeInstance($attribute);
112 37
            }, $reflector->getAttributes());
113
        }
114
115 37
        if ($filter) {
116 31
            return array_values(
117 31
                array_filter($this->attributes, function ($attribute) use ($filter) {
118 13
                    return $attribute instanceof $filter;
119 31
                })
120 31
            );
121
        }
122
123 8
        return $this->attributes;
124
    }
125
126 19
    protected function newAttributeInstance(ReflectionAttribute $attribute): object
127
    {
128 19
        return (new Resolver($this->registry))
129 19
            ->resolveCallAttributes($attribute->newInstance());
130
    }
131
132 7
    protected function respondFromRenderer(
133
        Registry $registry,
134
        RendererConfig $rendererConfig,
135
        mixed $result,
136
    ): Response {
137 7
        $renderer = $registry->tag(Renderer::class)->get($rendererConfig->type);
138 7
        assert($renderer instanceof Renderer);
139
140 7
        return $renderer->response($result, ...$rendererConfig->args);
141
    }
142
143 24
    protected function getClosure(array|string $view): Closure
144
    {
145 24
        if (is_array($view)) {
146 3
            [$controllerName, $method] = $view;
147 3
            assert(is_string($controllerName));
148 3
            assert(is_string($method));
149
        } else {
150 21
            if (!str_contains($view, '::')) {
151 1
                $view .= '::__invoke';
152
            }
153
154 21
            [$controllerName, $method] = explode('::', $view);
155
        }
156
157 24
        if (class_exists($controllerName)) {
158 23
            $rc = new ReflectionClass($controllerName);
159 23
            $constructor = $rc->getConstructor();
160 23
            $args = $constructor ? $this->getArgs($constructor) : [];
161 23
            $controller = $rc->newInstance(...$args);
162
163 23
            if (method_exists($controller, $method)) {
164 22
                return Closure::fromCallable([$controller, $method]);
165
            }
166 1
            $view = $controllerName . '::' . $method;
167
168 1
            throw new RuntimeException("View method not found {$view}");
169
        }
170
171 1
        throw new RuntimeException("Controller not found {$controllerName}");
172
    }
173
174
    /**
175
     * Determines the arguments passed to the view and/or controller constructor.
176
     *
177
     * - If a view parameter implements Request, the request will be passed.
178
     * - If names of the view parameters match names of the route arguments
179
     *   it will try to convert the argument to the parameter type and add it to
180
     *   the returned args list.
181
     * - If the parameter is typed, try to resolve it via registry or
182
     *   autowiring.
183
     * - Otherwise fail.
184
     *
185
     * @psalm-suppress MixedAssignment -- $args values are mixed
186
     */
187 35
    protected function getArgs(ReflectionFunctionAbstract $rf): array
188
    {
189
        /** @var array<string, mixed> */
190 35
        $args = [];
191 35
        $params = $rf->getParameters();
192 35
        $errMsg = 'View parameters cannot be resolved. Details: ';
193
194 35
        foreach ($params as $param) {
195 17
            $name = $param->getName();
196
197
            try {
198 17
                $args[$name] = match ((string)$param->getType()) {
199 17
                    'int' => is_numeric($this->routeArgs[$name]) ?
200 3
                        (int)$this->routeArgs[$name] :
201 4
                        throw new RuntimeException($errMsg . "Cannot cast '{$name}' to int"),
202 17
                    'float' => is_numeric($this->routeArgs[$name]) ?
203 3
                        (float)$this->routeArgs[$name] :
204 4
                        throw new RuntimeException($errMsg . "Cannot cast '{$name}' to float"),
205 17
                    'string' => $this->routeArgs[$name],
206 17
                    default => (new Resolver($this->registry))->resolveParam($param),
207 17
                };
208 6
            } catch (ContainerException $e) {
209 2
                throw $e;
210 4
            } catch (Throwable $e) {
211
                // Check if the view parameter has a default value
212 4
                if (!array_key_exists($name, $this->routeArgs) && $param->isDefaultValueAvailable()) {
213 1
                    $args[$name] = $param->getDefaultValue();
214
215 1
                    continue;
216
                }
217
218 3
                throw new RuntimeException($errMsg . $e->getMessage());
219
            }
220
        }
221
222 33
        assert(count($params) === count($args));
223
224 33
        return $args;
225
    }
226
}
227