Passed
Pull Request — master (#57)
by Sergei
26:05 queued 12:34
created

DefinitionValidator::validateMethod()   B

Complexity

Conditions 7
Paths 6

Size

Total Lines 44
Code Lines 26

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 17
CRAP Score 7.0084

Importance

Changes 0
Metric Value
cc 7
eloc 26
nc 6
nop 5
dl 0
loc 44
ccs 17
cts 18
cp 0.9444
crap 7.0084
rs 8.5706
c 0
b 0
f 0
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Yiisoft\Definitions\Helpers;
6
7
use ReflectionClass;
8
use ReflectionException;
9
use Yiisoft\Definitions\ArrayDefinition;
10
use Yiisoft\Definitions\Contract\DefinitionInterface;
11
use Yiisoft\Definitions\Contract\ReferenceInterface;
12
use Yiisoft\Definitions\Exception\InvalidConfigException;
13
14
use function is_array;
15
use function is_callable;
16
use function is_object;
17
use function is_string;
18
19
/**
20
 * Definition validator checks if definition is valid.
21
 */
22
final class DefinitionValidator
23
{
24
    /**
25
     * Validates that definition is valid. Throws exception otherwise.
26
     *
27
     * @param mixed $definition Definition to validate.
28
     *
29
     * @throws InvalidConfigException If definition is not valid.
30
     * @throws ReflectionException
31
     */
32 31
    public static function validate(mixed $definition, ?string $id = null): void
33
    {
34
        // Reference or ready object
35 31
        if (is_object($definition) && self::isValidObject($definition)) {
36
            return;
37
        }
38
39
        // Class
40 31
        if (is_string($definition)) {
41 3
            self::validateClassName($definition);
42
            return;
43
        }
44
45
        // Callable definition
46 28
        if ($definition !== '' && is_callable($definition, true)) {
47
            return;
48
        }
49
50
        // Array definition
51 28
        if (is_array($definition)) {
52 27
            self::validateArrayDefinition($definition, $id);
53
            return;
54
        }
55
56 1
        throw new InvalidConfigException(
57
            'Invalid definition: '
58 1
            . ($definition === '' ? 'empty string.' : var_export($definition, true))
59
        );
60
    }
61
62
    /**
63
     * Validates that array definition is valid. Throws exception otherwise.
64
     *
65
     * @param array $definition Array definition to validate.
66
     *
67
     * @throws InvalidConfigException If definition is not valid.
68
     * @throws ReflectionException
69
     */
70 30
    public static function validateArrayDefinition(array $definition, ?string $id = null): void
71
    {
72
        /** @var class-string $className */
73 30
        $className = $definition[ArrayDefinition::CLASS_NAME] ?? $id ?? throw new InvalidConfigException(
74
            'Invalid definition: no class name specified.'
75
        );
76 29
        self::validateClassName($className);
77 27
        $classReflection = new ReflectionClass($className);
78 27
        $classPublicMethods = [];
79 27
        foreach ($classReflection->getMethods() as $reflectionMethod) {
80 23
            if ($reflectionMethod->isPublic() && !self::isMagicMethod($reflectionMethod->getName())) {
81 22
                $classPublicMethods[] = $reflectionMethod->getName();
82
            }
83
        }
84 27
        $classPublicProperties = [];
85 27
        foreach ($classReflection->getProperties() as $reflectionProperty) {
86 22
            if ($reflectionProperty->isPublic()) {
87 20
                $classPublicProperties[] = $reflectionProperty->getName();
88
            }
89
        }
90
91
        /** @var mixed $value */
92 27
        foreach ($definition as $key => $value) {
93 26
            if (!is_string($key)) {
94 1
                throw new InvalidConfigException(
95 1
                    sprintf(
96
                        'Invalid definition: invalid key in array definition. Allow only string keys, got %d.',
97
                        $key,
98
                    ),
99
                );
100
            }
101
102
            // Class
103 26
            if ($key === ArrayDefinition::CLASS_NAME) {
104 26
                continue;
105
            }
106
107
            // Constructor arguments
108 23
            if ($key === ArrayDefinition::CONSTRUCTOR) {
109 13
                self::validateConstructor($value);
110 11
                continue;
111
            }
112
113
            // Methods and properties
114 21
            if (str_ends_with($key, '()')) {
115 17
                self::validateMethod($key, $classReflection, $classPublicMethods, $className, $value);
116 11
                continue;
117
            }
118 15
            if (str_starts_with($key, '$')) {
119 15
                self::validateProperty($key, $classReflection, $classPublicProperties, $className);
120 11
                continue;
121
            }
122
123 11
            $possibleOptionsMessage = self::generatePossibleMessage(
124
                $key,
125
                $classPublicMethods,
126
                $classPublicProperties,
127
                $classReflection,
128
                $className
129
            );
130
131 11
            throw new InvalidConfigException(
132 11
                "Invalid definition: key \"$key\" is not allowed. $possibleOptionsMessage",
133
            );
134
        }
135
    }
136
137
    /**
138
     * Deny `DefinitionInterface`, exclude `ReferenceInterface`
139
     */
140 1
    private static function isValidObject(object $value): bool
141
    {
142 1
        return !($value instanceof DefinitionInterface) || $value instanceof ReferenceInterface;
143
    }
144
145
    /**
146
     * @throws InvalidConfigException
147
     */
148 32
    private static function validateClassName(mixed $class): void
149
    {
150 32
        if (!is_string($class)) {
151 1
            throw new InvalidConfigException(
152 1
                sprintf(
153
                    'Invalid definition: class name must be a non-empty string, got %s.',
154 1
                    get_debug_type($class),
155
                )
156
            );
157
        }
158 31
        if (trim($class) === '') {
159 3
            throw new InvalidConfigException('Invalid definition: class name must be a non-empty string.');
160
        }
161 28
        if (!class_exists($class)) {
162 1
            throw new InvalidConfigException(
163 1
                sprintf(
164
                    'Invalid definition: class "%s" does not exist.',
165
                    $class,
166
                ),
167
            );
168
        }
169
    }
170
171 11
    private static function generatePossibleMessage(
172
        string $key,
173
        array $classPublicMethods,
174
        array $classPublicProperties,
175
        ReflectionClass $classReflection,
176
        string $className
177
    ): string {
178 11
        $parsedKey = trim(
179 11
            strtr($key, [
180
                '()' => '',
181
                '$' => '',
182
            ])
183
        );
184 11
        if (in_array($parsedKey, $classPublicMethods, true)) {
185 3
            return sprintf(
186
                'Did you mean "%s"?',
187
                $parsedKey . '()',
188
            );
189
        }
190 8
        if (in_array($parsedKey, $classPublicProperties, true)) {
191 1
            return sprintf(
192
                'Did you mean "%s"?',
193
                '$' . $parsedKey,
194
            );
195
        }
196 7
        if ($classReflection->hasMethod($parsedKey)) {
197 1
            return sprintf(
198
                'Method "%s" must be public to be able to be called.',
199
                $className . '::' . $parsedKey . '()',
200
            );
201
        }
202 6
        if ($classReflection->hasProperty($parsedKey)) {
203 1
            return sprintf(
204
                'Property "%s" must be public to be able to be called.',
205
                $className . '::$' . $parsedKey,
206
            );
207
        }
208
209 5
        return 'The key may be a call of a method or a setting of a property.';
210
    }
211
212
    /**
213
     * @param string[] $classPublicMethods
214
     *
215
     * @throws InvalidConfigException
216
     */
217 17
    private static function validateMethod(
218
        string $key,
219
        ReflectionClass $classReflection,
220
        array $classPublicMethods,
221
        string $className,
222
        mixed $value
223
    ): void {
224 17
        $parsedKey = substr($key, 0, -2);
225 17
        if (!$classReflection->hasMethod($parsedKey)) {
226 4
            if ($classReflection->hasMethod('__call') || $classReflection->hasMethod('__callStatic')) {
227
                /**
228
                 * Magic method may intercept the call, but reflection does not know about it.
229
                 */
230
                return;
231
            }
232 4
            $possiblePropertiesMessage = $classPublicMethods === []
233 1
                ? 'No public methods available to call.'
234 3
                : sprintf(
235
                    'Possible methods to call: %s',
236 3
                    '"' . implode('", "', $classPublicMethods) . '"',
237
                );
238 4
            throw new InvalidConfigException(
239 4
                sprintf(
240
                    'Invalid definition: class "%s" does not have the public method with name "%s". ' . $possiblePropertiesMessage,
241
                    $className,
242
                    $parsedKey,
243
                )
244
            );
245
        }
246 13
        if (!in_array($parsedKey, $classPublicMethods, true)) {
247 1
            throw new InvalidConfigException(
248 1
                sprintf(
249
                    'Invalid definition: method "%s" must be public.',
250
                    $className . '::' . $key,
251
                )
252
            );
253
        }
254 12
        if (!is_array($value)) {
255 1
            throw new InvalidConfigException(
256 1
                sprintf(
257
                    'Invalid definition: incorrect method "%s" arguments. Expected array, got "%s". ' .
258
                    'Probably you should wrap them into square brackets.',
259
                    $key,
260 1
                    get_debug_type($value),
261
                )
262
            );
263
        }
264
    }
265
266
    /**
267
     * @param string[] $classPublicProperties
268
     *
269
     * @throws InvalidConfigException
270
     */
271 15
    private static function validateProperty(
272
        string $key,
273
        ReflectionClass $classReflection,
274
        array $classPublicProperties,
275
        string $className
276
    ): void {
277 15
        $parsedKey = substr($key, 1);
278 15
        if (!$classReflection->hasProperty($parsedKey)) {
279 2
            if ($classReflection->hasMethod('__set')) {
280
                /**
281
                 * Magic method may intercept the call, but reflection does not know about it.
282
                 */
283
                return;
284
            }
285 2
            if ($classPublicProperties === []) {
286 1
                $message = sprintf(
287
                    'Invalid definition: class "%s" does not have any public properties.',
288
                    $className,
289
                );
290
            } else {
291 1
                $message = sprintf(
292
                    'Invalid definition: class "%s" does not have the public property with name "%s". Possible properties to set: %s.',
293
                    $className,
294
                    $parsedKey,
295 1
                    '"' . implode('", "', $classPublicProperties) . '"',
296
                );
297
            }
298 2
            throw new InvalidConfigException($message);
299 13
        } elseif (!in_array($parsedKey, $classPublicProperties, true)) {
300 2
            throw new InvalidConfigException(
301 2
                sprintf(
302
                    'Invalid definition: property "%s" must be public.',
303
                    $className . '::' . $key,
304
                )
305
            );
306
        }
307
    }
308
309
    /**
310
     * @throws InvalidConfigException
311
     */
312 13
    private static function validateConstructor(mixed $value): void
313
    {
314 13
        if (!is_array($value)) {
315 1
            throw new InvalidConfigException(
316 1
                sprintf(
317
                    'Invalid definition: incorrect constructor arguments. Expected array, got %s.',
318 1
                    get_debug_type($value)
319
                )
320
            );
321
        }
322
        /** @var mixed $argument */
323 12
        foreach ($value as $argument) {
324 12
            if (is_object($argument) && !self::isValidObject($argument)) {
325 1
                throw new InvalidConfigException(
326
                    'Only references are allowed in constructor arguments, a definition object was provided: ' .
327 1
                    var_export($argument, true)
328
                );
329
            }
330
        }
331
    }
332
333 23
    private static function isMagicMethod(string $getName): bool
334
    {
335 23
        return in_array($getName, [
336
            '__construct',
337
            '__destruct',
338
            '__call',
339
            '__callStatic',
340
            '__get',
341
            '__set',
342
            '__isset',
343
            '__unset',
344
            '__sleep',
345
            '__wakeup',
346
            '__serialize',
347
            '__unserialize',
348
            '__toString',
349
            '__invoke',
350
            '__set_state',
351
            '__clone',
352
            '__debugInfo',
353
        ], true);
354
    }
355
}
356