Passed
Push — main ( f41f09...08852a )
by Thomas
02:36
created

View::newAttributeInstance()   A

Complexity

Conditions 3
Paths 4

Size

Total Lines 16
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 3

Importance

Changes 0
Metric Value
eloc 9
c 0
b 0
f 0
dl 0
loc 16
ccs 9
cts 9
cp 1
rs 9.9666
cc 3
nc 4
nop 1
crap 3
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\Registry;
13
use Conia\Chuck\Registry\Resolver;
14
use Conia\Chuck\Renderer\Config as RendererConfig;
15
use Conia\Chuck\Renderer\Renderer;
16
use Conia\Chuck\Request;
17
use Conia\Chuck\Response;
18
use Conia\Chuck\ResponseFactory;
19
use Conia\Chuck\Routing\Route;
20
use Conia\Chuck\ViewAttributeInterface;
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 43
    public function __construct(
39
        callable|string|array $view,
40
        protected readonly array $routeArgs,
41
        protected readonly Registry $registry
42
    ) {
43 43
        if (is_callable($view)) {
44
            /** @var callable $view -- Psalm complains even though we use is_callable() */
45 21
            $this->closure = Closure::fromCallable($view);
46
        } else {
47 22
            $this->closure = $this->getClosure($view);
48
        }
49
    }
50
51 35
    public function execute(): mixed
52
    {
53 35
        return ($this->closure)(...$this->getArgs(
54 35
            self::getReflectionFunction($this->closure)
55 35
        ));
56
    }
57
58 29
    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 29
        $result = $this->execute();
69
70 25
        if ($result instanceof Response) {
71 6
            return $result;
72
        }
73
74 19
        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 18
        $rendererConfig = $route->getRenderer();
82
83 18
        if ($rendererConfig) {
84 7
            return $this->respondFromRenderer($request, $registry, $rendererConfig, $result);
85
        }
86
87 11
        $renderAttributes = $this->attributes(Render::class);
88
89 11
        if (count($renderAttributes) > 0) {
90 1
            assert($renderAttributes[0] instanceof Render);
91
92 1
            return $renderAttributes[0]->response($request, $registry, $result);
93
        }
94
95 10
        $responseFactory = new ResponseFactory($registry);
96
97 10
        if (is_string($result)) {
98 6
            return $responseFactory->html($result);
99
        }
100 4
        if ($result instanceof Stringable) {
101 2
            return $responseFactory->html($result->__toString());
102
        }
103
104
        try {
105 2
            return $responseFactory->json($result);
106 1
        } catch (JsonException) {
107 1
            throw new RuntimeException('Cannot determine a response handler for the return type of the view');
108
        }
109
    }
110
111 36
    public static function getReflectionFunction(
112
        callable $callable
113
    ): ReflectionFunction|ReflectionMethod {
114 36
        if ($callable instanceof Closure) {
115 36
            return new ReflectionFunction($callable);
116
        }
117 1
        if (is_object($callable)) {
118 1
            return (new ReflectionObject($callable))->getMethod('__invoke');
119
        }
120
        /** @var Closure|non-falsy-string $callable */
121 1
        return new ReflectionFunction($callable);
122
    }
123
124
    /** @psalm-param $filter ?class-string */
125 35
    public function attributes(string $filter = null): array
126
    {
127 35
        $reflector = new ReflectionFunction($this->closure);
128
129 35
        if (!isset($this->attributes)) {
130 35
            $this->attributes = array_map(function ($attribute) {
131 15
                return $this->newAttributeInstance($attribute);
132 35
            }, $reflector->getAttributes());
133
        }
134
135 35
        if ($filter) {
136 28
            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

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