Passed
Push — main ( b92cd4...d72029 )
by Thomas
03:18
created

View::respondFromRenderer()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 20
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 10
CRAP Score 2

Importance

Changes 0
Metric Value
eloc 9
c 0
b 0
f 0
dl 0
loc 20
ccs 10
cts 10
cp 1
rs 9.9666
cc 2
nc 2
nop 4
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Conia\Chuck;
6
7
use Closure;
8
use Conia\Chuck\Attribute\Render;
9
use Conia\Chuck\Exception\ContainerException;
10
use Conia\Chuck\Exception\HttpServerError;
11
use Conia\Chuck\Exception\RuntimeException;
12
use Conia\Chuck\Registry;
13
use Conia\Chuck\Renderer\Config as RendererConfig;
14
use Conia\Chuck\Renderer\Renderer;
15
use Conia\Chuck\Request;
16
use Conia\Chuck\Response;
17
use Conia\Chuck\ResponseFactory;
18
use Conia\Chuck\Routing\Route;
19
use JsonException;
20
use Psr\Http\Message\ResponseInterface;
21
use Psr\Http\Message\StreamFactoryInterface;
22
use ReflectionClass;
23
use ReflectionFunction;
24
use ReflectionFunctionAbstract;
25
use ReflectionMethod;
26
use ReflectionObject;
27
use Stringable;
28
use Throwable;
29
30
class View
31
{
32
    protected ?array $attributes = null;
33
    protected Closure $closure;
34
35 39
    public function __construct(
36
        callable|string|array $view,
37
        protected readonly array $routeArgs,
38
        protected readonly Registry $registry
39
    ) {
40 39
        if (is_callable($view)) {
41
            /** @var callable $view -- Psalm complains even though we use is_callable() */
42 17
            $this->closure = Closure::fromCallable($view);
43
        } else {
44 22
            $this->closure = $this->getClosure($view);
45
        }
46
    }
47
48 34
    public function execute(): mixed
49
    {
50 34
        return ($this->closure)(...$this->getArgs(
51 34
            self::getReflectionFunction($this->closure)
52 34
        ));
53
    }
54
55 28
    public function respond(
56
        Request $request,
57
        Route $route,
58
        Registry $registry,
59
    ): Response {
60
        /**
61
         * @psalm-suppress MixedAssignment
62
         *
63
         * Later in the function we check the type of $result.
64
         */
65 28
        $result = $this->execute();
66
67 24
        if ($result instanceof Response) {
68 6
            return $result;
69
        }
70
71 18
        if ($result instanceof ResponseInterface) {
72 1
            $sf = $registry->get(StreamFactoryInterface::class);
73 1
            assert($sf instanceof StreamFactoryInterface);
74
75 1
            return new Response($result, $sf);
76
        }
77
78 17
        $rendererConfig = $route->getRenderer();
79
80 17
        if ($rendererConfig) {
81 7
            return $this->respondFromRenderer($request, $registry, $rendererConfig, $result);
82
        }
83
84 10
        $renderAttributes = $this->attributes(Render::class);
85
86 10
        if (count($renderAttributes) > 0) {
87 1
            assert($renderAttributes[0] instanceof Render);
88
89 1
            return $renderAttributes[0]->response($request, $registry, $result);
90
        }
91
92 9
        $responseFactory = new ResponseFactory($registry);
93
94 9
        if (is_string($result)) {
95 5
            return $responseFactory->html($result);
96
        }
97 4
        if ($result instanceof Stringable) {
98 2
            return $responseFactory->html($result->__toString());
99
        }
100
101
        try {
102 2
            return $responseFactory->json($result);
103 1
        } catch (JsonException) {
104 1
            throw new RuntimeException('Cannot determine a response handler for the return type of the view');
105
        }
106
    }
107
108 35
    public static function getReflectionFunction(
109
        callable $callable
110
    ): ReflectionFunction|ReflectionMethod {
111 35
        if ($callable instanceof Closure) {
112 35
            return new ReflectionFunction($callable);
113
        }
114 1
        if (is_object($callable)) {
115 1
            return (new ReflectionObject($callable))->getMethod('__invoke');
116
        }
117
        /** @var Closure|non-falsy-string $callable */
118 1
        return new ReflectionFunction($callable);
119
    }
120
121
    /** @psalm-param $filter ?class-string */
122 31
    public function attributes(string $filter = null): array
123
    {
124 31
        $reflector = new ReflectionFunction($this->closure);
125
126 31
        if (!isset($this->attributes)) {
127 31
            $this->attributes = array_map(function ($attribute) {
128 13
                return $attribute->newInstance();
129 31
            }, $reflector->getAttributes());
130
        }
131
132 31
        if ($filter) {
133 26
            return array_filter($this->attributes, function ($attribute) use ($filter) {
1 ignored issue
show
Bug introduced by
It seems like $this->attributes can also be of type null; however, parameter $array of array_filter() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

133
            return array_filter(/** @scrutinizer ignore-type */ $this->attributes, function ($attribute) use ($filter) {
Loading history...
134 8
                return $attribute instanceof $filter;
135 26
            });
136
        }
137
138 7
        return $this->attributes;
1 ignored issue
show
Bug Best Practice introduced by
The expression return $this->attributes could return the type null which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
139
    }
140
141 7
    protected function respondFromRenderer(
142
        Request $request,
143
        Registry $registry,
144
        RendererConfig $rendererConfig,
145
        mixed $result,
146
    ): Response {
147 7
        $entry = $registry->tag(Renderer::class)->entry($rendererConfig->type);
148 7
        $class = $entry->definition();
149 7
        $options = $entry->getArgs();
150
151 7
        if ($options instanceof Closure) {
152
            /** @var mixed */
153 1
            $options = $options();
154
        }
155
156 7
        assert(is_string($class));
157 7
        assert(is_subclass_of($class, Renderer::class));
158 7
        $renderer = new $class($request, $registry, $rendererConfig->args, $options);
159
160 7
        return $renderer->response($result);
161
    }
162
163 22
    protected function getClosure(array|string $view): Closure
164
    {
165 22
        if (is_array($view)) {
1 ignored issue
show
introduced by
The condition is_array($view) is always true.
Loading history...
166 3
            [$controllerName, $method] = $view;
167 3
            assert(is_string($controllerName));
168 3
            assert(is_string($method));
169
        } else {
170 19
            if (!str_contains($view, '::')) {
171 1
                $view .= '::__invoke';
172
            }
173
174 19
            [$controllerName, $method] = explode('::', $view);
175
        }
176
177 22
        if (class_exists($controllerName)) {
178 21
            $rc = new ReflectionClass($controllerName);
179 21
            $constructor = $rc->getConstructor();
180 21
            $args = $constructor ? $this->getArgs($constructor) : [];
181 21
            $controller = $rc->newInstance(...$args);
182
183 21
            if (method_exists($controller, $method)) {
184 20
                return Closure::fromCallable([$controller, $method]);
185
            }
186 1
            $view = $controllerName . '::' . $method;
187
188 1
            throw HttpServerError::withSubTitle("Controller method not found {$view}");
189
        }
190
191 1
        throw HttpServerError::withSubTitle("Controller not found {$controllerName}");
192
    }
193
194
    /**
195
     * Determines the arguments passed to the view and/or controller constructor.
196
     *
197
     * - If a view parameter implements Request, the request will be passed.
198
     * - If names of the view parameters match names of the route arguments
199
     *   it will try to convert the argument to the parameter type and add it to
200
     *   the returned args list.
201
     * - If the parameter is typed, try to resolve it via registry or
202
     *   autowiring.
203
     * - Otherwise fail.
204
     *
205
     * @psalm-suppress MixedAssignment -- $args values are mixed
206
     */
207 34
    protected function getArgs(ReflectionFunctionAbstract $rf): array
208
    {
209
        /** @var array<string, mixed> */
210 34
        $args = [];
211 34
        $params = $rf->getParameters();
212 34
        $errMsg = 'View parameters cannot be resolved. Details: ';
213
214 34
        foreach ($params as $param) {
215 17
            $name = $param->getName();
216
217
            try {
218 17
                $args[$name] = match ((string)$param->getType()) {
219 17
                    'int' => is_numeric($this->routeArgs[$name]) ?
220 3
                        (int)$this->routeArgs[$name] :
221 4
                        throw new RuntimeException($errMsg . "Cannot cast '{$name}' to int"),
222 17
                    'float' => is_numeric($this->routeArgs[$name]) ?
223 3
                        (float)$this->routeArgs[$name] :
224 4
                        throw new RuntimeException($errMsg . "Cannot cast '{$name}' to float"),
225 17
                    'string' => $this->routeArgs[$name],
226 17
                    default => $this->registry->resolveParam($param),
227 17
                };
228 6
            } catch (ContainerException $e) {
229 2
                throw $e;
230 4
            } catch (Throwable $e) {
231
                // Check if the view parameter has a default value
232 4
                if (!array_key_exists($name, $this->routeArgs) && $param->isDefaultValueAvailable()) {
233 1
                    $args[$name] = $param->getDefaultValue();
234
235 1
                    continue;
236
                }
237
238 3
                throw new RuntimeException($errMsg . $e->getMessage());
239
            }
240
        }
241
242 32
        assert(count($params) === count($args));
243
244 32
        return $args;
245
    }
246
}
247