View   A
last analyzed

Complexity

Total Complexity 30

Size/Duplication

Total Lines 195
Duplicated Lines 0 %

Test Coverage

Coverage 100%

Importance

Changes 0
Metric Value
wmc 30
eloc 84
dl 0
loc 195
ccs 91
cts 91
cp 1
rs 10
c 0
b 0
f 0

9 Methods

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