Passed
Pull Request — master (#57)
by Dmitriy
02:44
created

DefinitionValidator::validate()   B

Complexity

Conditions 8
Paths 5

Size

Total Lines 27
Code Lines 13

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 9.8645

Importance

Changes 0
Metric Value
cc 8
eloc 13
nc 5
nop 2
dl 0
loc 27
ccs 9
cts 13
cp 0.6923
crap 9.8645
rs 8.4444
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 2
            self::validateString($definition);
42
            return;
43
        }
44
45
        // Callable definition
46 29
        if ($definition !== '' && is_callable($definition, true)) {
47
            return;
48
        }
49
50
        // Array definition
51 29
        if (is_array($definition)) {
52 28
            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 31
    public static function validateArrayDefinition(array $definition, ?string $id = null): void
71
    {
72
        /** @var class-string $className */
73 31
        $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. Only string keys are allowed, 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 29
    private static function validateClassName(mixed $class): void
149
    {
150 29
        self::validateString($class);
151
        /** @var string $class */
152 27
        if (!class_exists($class)) {
153
            throw new InvalidConfigException(
154
                sprintf(
155
                    'Invalid definition: class "%s" does not exist.',
156
                    $class,
157
                ),
158
            );
159
        }
160
    }
161
162 11
    private static function generatePossibleMessage(
163
        string $key,
164
        array $classPublicMethods,
165
        array $classPublicProperties,
166
        ReflectionClass $classReflection,
167
        string $className
168
    ): string {
169 11
        $parsedKey = trim(
170 11
            strtr($key, [
171
                '()' => '',
172
                '$' => '',
173
            ])
174
        );
175 11
        if (in_array($parsedKey, $classPublicMethods, true)) {
176 3
            return sprintf(
177
                'Did you mean "%s"?',
178
                $parsedKey . '()',
179
            );
180
        }
181 8
        if (in_array($parsedKey, $classPublicProperties, true)) {
182 1
            return sprintf(
183
                'Did you mean "%s"?',
184
                '$' . $parsedKey,
185
            );
186
        }
187 7
        if ($classReflection->hasMethod($parsedKey)) {
188 1
            return sprintf(
189
                'Method "%s" must be public to be able to be called.',
190
                $className . '::' . $parsedKey . '()',
191
            );
192
        }
193 6
        if ($classReflection->hasProperty($parsedKey)) {
194 1
            return sprintf(
195
                'Property "%s" must be public to be able to be called.',
196
                $className . '::$' . $parsedKey,
197
            );
198
        }
199
200 5
        return 'The key may be a call of a method or a setting of a property.';
201
    }
202
203
    /**
204
     * @param string[] $classPublicMethods
205
     *
206
     * @throws InvalidConfigException
207
     */
208 17
    private static function validateMethod(
209
        string $key,
210
        ReflectionClass $classReflection,
211
        array $classPublicMethods,
212
        string $className,
213
        mixed $value
214
    ): void {
215 17
        $parsedKey = substr($key, 0, -2);
216 17
        if (!$classReflection->hasMethod($parsedKey)) {
217 4
            if ($classReflection->hasMethod('__call') || $classReflection->hasMethod('__callStatic')) {
218
                /**
219
                 * Magic method may intercept the call, but reflection does not know about it.
220
                 */
221
                return;
222
            }
223 4
            $possiblePropertiesMessage = $classPublicMethods === []
224 1
                ? 'No public methods available to call.'
225 3
                : sprintf(
226
                    'Possible methods to call: %s',
227 3
                    '"' . implode('", "', $classPublicMethods) . '"',
228
                );
229 4
            throw new InvalidConfigException(
230 4
                sprintf(
231
                    'Invalid definition: class "%s" does not have the public method with name "%s". ' . $possiblePropertiesMessage,
232
                    $className,
233
                    $parsedKey,
234
                )
235
            );
236
        }
237 13
        if (!in_array($parsedKey, $classPublicMethods, true)) {
238 1
            throw new InvalidConfigException(
239 1
                sprintf(
240
                    'Invalid definition: method "%s" must be public.',
241
                    $className . '::' . $key,
242
                )
243
            );
244
        }
245 12
        if (!is_array($value)) {
246 1
            throw new InvalidConfigException(
247 1
                sprintf(
248
                    'Invalid definition: incorrect method "%s" arguments. Expected array, got "%s". ' .
249
                    'Probably you should wrap them into square brackets.',
250
                    $key,
251 1
                    get_debug_type($value),
252
                )
253
            );
254
        }
255
    }
256
257
    /**
258
     * @param string[] $classPublicProperties
259
     *
260
     * @throws InvalidConfigException
261
     */
262 15
    private static function validateProperty(
263
        string $key,
264
        ReflectionClass $classReflection,
265
        array $classPublicProperties,
266
        string $className
267
    ): void {
268 15
        $parsedKey = substr($key, 1);
269 15
        if (!$classReflection->hasProperty($parsedKey)) {
270 2
            if ($classReflection->hasMethod('__set')) {
271
                /**
272
                 * Magic method may intercept the call, but reflection does not know about it.
273
                 */
274
                return;
275
            }
276 2
            if ($classPublicProperties === []) {
277 1
                $message = sprintf(
278
                    'Invalid definition: class "%s" does not have any public properties.',
279
                    $className,
280
                );
281
            } else {
282 1
                $message = sprintf(
283
                    'Invalid definition: class "%s" does not have the public property with name "%s". Possible properties to set: %s.',
284
                    $className,
285
                    $parsedKey,
286 1
                    '"' . implode('", "', $classPublicProperties) . '"',
287
                );
288
            }
289 2
            throw new InvalidConfigException($message);
290 13
        } elseif (!in_array($parsedKey, $classPublicProperties, true)) {
291 2
            throw new InvalidConfigException(
292 2
                sprintf(
293
                    'Invalid definition: property "%s" must be public.',
294
                    $className . '::' . $key,
295
                )
296
            );
297
        }
298
    }
299
300
    /**
301
     * @throws InvalidConfigException
302
     */
303 13
    private static function validateConstructor(mixed $value): void
304
    {
305 13
        if (!is_array($value)) {
306 1
            throw new InvalidConfigException(
307 1
                sprintf(
308
                    'Invalid definition: incorrect constructor arguments. Expected array, got %s.',
309 1
                    get_debug_type($value)
310
                )
311
            );
312
        }
313
        /** @var mixed $argument */
314 12
        foreach ($value as $argument) {
315 12
            if (is_object($argument) && !self::isValidObject($argument)) {
316 1
                throw new InvalidConfigException(
317
                    'Only references are allowed in constructor arguments, a definition object was provided: ' .
318 1
                    var_export($argument, true)
319
                );
320
            }
321
        }
322
    }
323
324 23
    private static function isMagicMethod(string $getName): bool
325
    {
326 23
        return in_array($getName, [
327
            '__construct',
328
            '__destruct',
329
            '__call',
330
            '__callStatic',
331
            '__get',
332
            '__set',
333
            '__isset',
334
            '__unset',
335
            '__sleep',
336
            '__wakeup',
337
            '__serialize',
338
            '__unserialize',
339
            '__toString',
340
            '__invoke',
341
            '__set_state',
342
            '__clone',
343
            '__debugInfo',
344
        ], true);
345
    }
346
347
    /**
348
     * @throws InvalidConfigException
349
     */
350 31
    private static function validateString(mixed $class): void
351
    {
352 31
        if (!is_string($class)) {
353 1
            throw new InvalidConfigException(
354 1
                sprintf(
355
                    'Invalid definition: class name must be a non-empty string, got %s.',
356 1
                    get_debug_type($class),
357
                )
358
            );
359
        }
360 30
        if (trim($class) === '') {
361 3
            throw new InvalidConfigException('Invalid definition: class name must be a non-empty string.');
362
        }
363
    }
364
}
365