Passed
Pull Request — 1.x (#321)
by Akihito
02:21
created

InputParam::createWithoutConstructor()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 23
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 4
eloc 10
nc 4
nop 2
dl 0
loc 23
rs 9.9332
c 1
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace BEAR\Resource;
6
7
use BackedEnum;
8
use BEAR\Resource\Annotation\Input;
9
use BEAR\Resource\Exception\ParameterEnumTypeException;
10
use BEAR\Resource\Exception\ParameterException;
11
use BEAR\Resource\Exception\ParameterInvalidEnumException;
12
use InvalidArgumentException;
13
use Ray\Di\InjectorInterface;
14
use ReflectionClass;
15
use ReflectionEnum;
0 ignored issues
show
Bug introduced by
The type ReflectionEnum was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
16
use ReflectionMethod;
17
use ReflectionNamedType;
18
use ReflectionParameter;
19
use Throwable;
20
use UnitEnum;
21
22
use function assert;
23
use function class_exists;
24
use function count;
25
use function enum_exists;
0 ignored issues
show
introduced by
The function enum_exists was not found. Maybe you did not declare it correctly or list all dependencies?
Loading history...
26
use function is_a;
27
use function is_array;
28
use function is_int;
29
use function is_iterable;
30
use function is_string;
31
use function ltrim;
32
use function preg_replace;
33
use function strtolower;
34
35
final class InputParam implements ParamInterface
36
{
37
    private readonly string $type;
38
    private readonly bool $isDefaultAvailable;
39
    private readonly mixed $defaultValue;
40
41
    public function __construct(
42
        ReflectionNamedType $type,
43
        private readonly ReflectionParameter $parameter,
44
    ) {
45
        $this->type = $type->getName();
0 ignored issues
show
Bug introduced by
The property type is declared read-only in BEAR\Resource\InputParam.
Loading history...
46
        $this->isDefaultAvailable = $parameter->isDefaultValueAvailable();
0 ignored issues
show
Bug introduced by
The property isDefaultAvailable is declared read-only in BEAR\Resource\InputParam.
Loading history...
47
        $this->defaultValue = $this->isDefaultAvailable ? $parameter->getDefaultValue() : null;
0 ignored issues
show
Bug introduced by
The property defaultValue is declared read-only in BEAR\Resource\InputParam.
Loading history...
48
    }
49
50
    /**
51
     * {@inheritDoc}
52
     */
53
54
    /** @param array<string, mixed> $query */
55
    public function __invoke(string $varName, array $query, InjectorInterface $injector): mixed
56
    {
57
        /** @var class-string $type */
58
        $type = $this->type;
59
        /** @psalm-suppress MixedArgument */
60
        assert(class_exists($type) || enum_exists($type));
0 ignored issues
show
Bug introduced by
The function enum_exists was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

60
        assert(class_exists($type) || /** @scrutinizer ignore-call */ enum_exists($type));
Loading history...
61
        $refClass = new ReflectionClass($type);
62
63
        // Handle enums
64
        if ($refClass->isEnum()) {
0 ignored issues
show
Bug introduced by
The method isEnum() does not exist on ReflectionClass. ( Ignorable by Annotation )

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

64
        if ($refClass->/** @scrutinizer ignore-call */ isEnum()) {

This check looks for calls to methods that do not seem to exist on a given type. It looks for the method on the type itself as well as in inherited classes or implemented interfaces.

This is most likely a typographical error or the method has been renamed.

Loading history...
65
            return $this->createEnum($varName, $query);
66
        }
67
68
        $constructor = $refClass->getConstructor();
69
        if ($constructor === null) {
70
            // Handle classes without constructor (like ClassParam does)
71
            return $this->createWithoutConstructor($varName, $query);
72
        }
73
74
        // Check if this parameter has #[Input] attribute
75
        $inputAttr = $this->getInputAttribute($this->parameter);
76
77
        // If no #[Input] attribute, use ClassParam behavior (structured data with parameter name as key)
78
        if ($inputAttr === null) {
79
            return $this->createFromStructuredData($refClass, $constructor, $varName, $query, $injector);
80
        }
81
82
        // If #[Input] has key specified, use structured data approach
83
        if ($inputAttr->key !== null) {
84
            return $this->createFromStructuredData($refClass, $constructor, $inputAttr->key, $query, $injector);
85
        }
86
87
        try {
88
            /** @var list<mixed> $constructorArgs */
89
            $constructorArgs = [];
90
91
            foreach ($constructor->getParameters() as $param) {
92
                $paramName = $param->getName();
93
94
                // Check if parameter has #[Input] attribute for nested input objects
95
                $inputAttr = $this->getInputAttribute($param);
96
                if ($inputAttr !== null) {
97
                    $paramType = $param->getType();
98
                    if ($paramType instanceof ReflectionNamedType) {
99
                        $nestedInputParam = new InputParam($paramType, $param);
100
                        /** @psalm-suppress MixedAssignment */
101
                        $constructorArgs[] = $nestedInputParam($paramName, $query, $injector);
102
                        continue;
103
                    }
104
                }
105
106
                // Use query parameter if available (with snake_case/kebab-case support)
107
                $paramValue = $this->getParamValue($paramName, $query);
108
                if ($paramValue !== null) {
109
                    /** @psalm-suppress MixedAssignment */
110
                    $constructorArgs[] = $paramValue;
111
                    continue;
112
                }
113
114
                // Use default value if available
115
                if ($param->isDefaultValueAvailable()) {
116
                    /** @psalm-suppress MixedAssignment */
117
                    $constructorArgs[] = $param->getDefaultValue();
118
                    continue;
119
                }
120
121
                throw new ParameterException("Required parameter '{$paramName}' not found for {$this->type}");
122
            }
123
124
            return $refClass->newInstanceArgs($constructorArgs);
0 ignored issues
show
Bug introduced by
$constructorArgs of type BEAR\Resource\list is incompatible with the type array expected by parameter $args of ReflectionClass::newInstanceArgs(). ( Ignorable by Annotation )

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

124
            return $refClass->newInstanceArgs(/** @scrutinizer ignore-type */ $constructorArgs);
Loading history...
125
        } catch (Throwable $e) {
126
            // Re-throw validation exceptions directly
127
            if ($e instanceof InvalidArgumentException) {
128
                throw $e;
129
            }
130
131
            throw new ParameterException("Failed to create {$this->type}: " . $e->getMessage(), 0, $e);
132
        }
133
    }
134
135
    private function getInputAttribute(ReflectionParameter $param): Input|null
136
    {
137
        $attributes = $param->getAttributes(Input::class);
138
        if (count($attributes) === 0) {
139
            return null;
140
        }
141
142
        return $attributes[0]->newInstance();
143
    }
144
145
    /**
146
     * Get parameter value from query with camelCase/kebab-case support
147
     *
148
     * @param array<string, mixed> $query
149
     */
150
    private function getParamValue(string $paramName, array $query): mixed
151
    {
152
        // Try exact match first
153
        if (isset($query[$paramName])) {
154
            return $query[$paramName];
155
        }
156
157
        // Try kebab-case version (camelCase -> kebab-case)
158
        $kebabName = ltrim(strtolower((string) preg_replace('/[A-Z]/', '-\0', $paramName)), '-');
159
        if (isset($query[$kebabName])) {
160
            return $query[$kebabName];
161
        }
162
163
        return null;
164
    }
165
166
    /**
167
     * Create object from structured data (ClassParam style)
168
     *
169
     * @param ReflectionClass<object> $refClass
170
     * @param array<string, mixed>    $query
171
     */
172
    private function createFromStructuredData(
173
        ReflectionClass $refClass,
174
        ReflectionMethod $constructor,
175
        string $key,
176
        array $query,
177
        InjectorInterface $injector,
178
    ): mixed {
179
        // Get structured data from the specified key
180
        if (! isset($query[$key])) {
181
            if ($this->isDefaultAvailable) {
182
                return $this->defaultValue;
183
            }
184
185
            throw new ParameterException("Required key '{$key}' not found for {$this->type}");
186
        }
187
188
        $data = $query[$key];
189
        if (! is_array($data)) {
190
            throw new ParameterException("Data under key '{$key}' must be an array for {$this->type}");
191
        }
192
193
        try {
194
            /** @var list<mixed> $constructorArgs */
195
            $constructorArgs = [];
196
197
            foreach ($constructor->getParameters() as $param) {
198
                $paramName = $param->getName();
199
200
                // Check if parameter has #[Input] attribute for nested input objects
201
                $inputAttr = $this->getInputAttribute($param);
202
                if ($inputAttr !== null) {
203
                    $paramType = $param->getType();
204
                    if ($paramType instanceof ReflectionNamedType) {
205
                        $nestedInputParam = new InputParam($paramType, $param);
206
                        /** @var array<string, mixed> $dataForNested */
207
                        $dataForNested = $data;
208
                        /** @psalm-suppress MixedAssignment */
209
                        $constructorArgs[] = $nestedInputParam($paramName, $dataForNested, $injector);
210
                        continue;
211
                    }
212
                }
213
214
                // Use data parameter if available
215
                if (isset($data[$paramName])) {
216
                    /** @psalm-suppress MixedAssignment */
217
                    $constructorArgs[] = $data[$paramName];
218
                    continue;
219
                }
220
221
                // Use default value if available
222
                if ($param->isDefaultValueAvailable()) {
223
                    /** @psalm-suppress MixedAssignment */
224
                    $constructorArgs[] = $param->getDefaultValue();
225
                    continue;
226
                }
227
228
                throw new ParameterException("Required parameter '{$paramName}' not found in key '{$key}' for {$this->type}");
229
            }
230
231
            return $refClass->newInstanceArgs($constructorArgs);
0 ignored issues
show
Bug introduced by
$constructorArgs of type BEAR\Resource\list is incompatible with the type array expected by parameter $args of ReflectionClass::newInstanceArgs(). ( Ignorable by Annotation )

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

231
            return $refClass->newInstanceArgs(/** @scrutinizer ignore-type */ $constructorArgs);
Loading history...
232
        } catch (Throwable $e) {
233
            // Re-throw validation exceptions directly
234
            if ($e instanceof InvalidArgumentException) {
235
                throw $e;
236
            }
237
238
            throw new ParameterException("Failed to create {$this->type} from key '{$key}': " . $e->getMessage(), 0, $e);
239
        }
240
    }
241
242
    /**
243
     * Create enum from query parameter (ClassParam style)
244
     *
245
     * @param array<string, mixed> $query
246
     */
247
    private function createEnum(string $varName, array $query): mixed
248
    {
249
        // Get the value using ClassParam behavior (snake_case conversion)
250
        $props = $this->getPropsForClassParam($varName, $query);
251
252
        /** @var class-string<UnitEnum> $type */
253
        $type = $this->type;
254
        $refEnum = new ReflectionEnum($type);
255
        assert(enum_exists($type));
0 ignored issues
show
Bug introduced by
The function enum_exists was not found. Maybe you did not declare it correctly or list all dependencies? ( Ignorable by Annotation )

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

255
        assert(/** @scrutinizer ignore-call */ enum_exists($type));
Loading history...
256
257
        if (! $refEnum->isBacked()) {
258
            throw new NotBackedEnumException($type);
259
        }
260
261
        assert(is_a($type, BackedEnum::class, true));
262
        if (! (is_int($props) || is_string($props))) {
263
            // If props is not a scalar but we have a default value, return it
264
            if ($this->isDefaultAvailable) {
265
                return $this->defaultValue;
266
            }
267
268
            throw new ParameterEnumTypeException($varName);
269
        }
270
271
        // Get the backing type of the enum
272
        $backingType = $refEnum->getBackingType();
273
        if ($backingType instanceof ReflectionNamedType && $backingType->getName() === 'int' && is_string($props)) {
274
            // Convert string to int for int-backed enums
275
            $props = (int) $props;
276
        }
277
278
        /** @psalm-suppress MixedAssignment */
279
        $value = $type::tryFrom($props);
280
        if ($value === null) {
281
            throw new ParameterInvalidEnumException($varName);
282
        }
283
284
        return $value;
285
    }
286
287
    /**
288
     * Create object without constructor (ClassParam style)
289
     *
290
     * @param array<string, mixed> $query
291
     */
292
    private function createWithoutConstructor(string $varName, array $query): mixed
293
    {
294
        // Get the props using ClassParam behavior
295
        $props = $this->getPropsForClassParam($varName, $query);
296
297
        if (! is_iterable($props)) {
298
            if ($this->isDefaultAvailable) {
299
                return $this->defaultValue;
300
            }
301
302
            throw new ParameterException("Expected array data for {$this->type}");
303
        }
304
305
        /** @var class-string $type */
306
        $type = $this->type;
307
        /** @psalm-suppress MixedMethodCall */
308
        $obj = new $type();
309
        /** @psalm-suppress MixedAssignment */
310
        foreach ($props as $propName => $propValue) {
311
            $obj->{$propName} = $propValue;
312
        }
313
314
        return $obj;
315
    }
316
317
    /**
318
     * Get props using ClassParam behavior (snake_case conversion like QueryProp)
319
     *
320
     * @param array<string, mixed> $query
321
     */
322
    private function getPropsForClassParam(string $varName, array $query): mixed
323
    {
324
        if (isset($query[$varName])) {
325
            return $query[$varName];
326
        }
327
328
        // try snake_case variable name (ClassParam compatible)
329
        $snakeName = ltrim(strtolower((string) preg_replace('/[A-Z]/', '_\0', $varName)), '_');
330
        if (isset($query[$snakeName])) {
331
            return $query[$snakeName];
332
        }
333
334
        if ($this->isDefaultAvailable) {
335
            return $this->defaultValue;
336
        }
337
338
        throw new ParameterException($varName);
339
    }
340
}
341