Passed
Push — main ( af6a91...92bf86 )
by Thomas
03:24
created

View::respond()   A

Complexity

Conditions 5
Paths 5

Size

Total Lines 38
Code Lines 15

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 16
CRAP Score 5

Importance

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