Passed
Push — main ( ee0fb9...269aa4 )
by Thomas
02:42
created

View::getClosure()   A

Complexity

Conditions 6
Paths 15

Size

Total Lines 29
Code Lines 18

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 6

Importance

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