Passed
Push — master ( 10e8f8...ab3c7a )
by Todd
36s
created

Schema   F

Complexity

Total Complexity 410

Size/Duplication

Total Lines 2101
Duplicated Lines 0 %

Test Coverage

Coverage 96.6%

Importance

Changes 0
Metric Value
eloc 910
dl 0
loc 2101
ccs 881
cts 912
cp 0.966
rs 1.69
c 0
b 0
f 0
wmc 410

69 Methods

Rating   Name   Duplication   Size   Complexity  
A validateTimestamp() 0 10 5
A setDescription() 0 3 1
A parseProperties() 0 25 5
A getFlags() 0 2 1
A setFlags() 0 4 1
A getDescription() 0 2 1
A addFilter() 0 4 1
B parseFieldSelector() 0 28 8
A add() 0 3 1
B getField() 0 21 8
A setFlag() 0 7 2
A setTitle() 0 2 1
D parseNode() 0 59 21
A unescapeRef() 0 2 1
A getTitle() 0 2 1
A merge() 0 3 1
A explodeRef() 0 2 1
A parseInternal() 0 31 5
B setField() 0 27 8
F mergeInternal() 0 59 28
F parseShortParam() 0 86 28
A getSchemaArray() 0 2 1
A getType() 0 10 4
A __construct() 0 6 1
A parse() 0 4 1
A addValidator() 0 4 1
B requireOneOf() 0 53 10
A addFormatFilter() 0 9 2
A isValid() 0 6 2
A validate() 0 23 5
A createValidation() 0 2 1
A getValidationFactory() 0 2 1
A validateBoolean() 0 8 3
A setValidationClass() 0 17 3
A validateNumber() 0 10 2
A validateEnum() 0 17 3
B callFilters() 0 26 7
A setID() 0 4 1
A isArray() 0 2 3
B withSparseInternal() 0 27 7
D validateMultipleTypes() 0 64 25
C filterField() 0 28 12
B jsonSerialize() 0 40 10
B validateSingleType() 0 39 11
A getRefLookup() 0 2 1
C validateNumberProperties() 0 36 14
A getID() 0 2 1
B validateObject() 0 32 10
A setRefLookup() 0 3 1
A escapeRef() 0 2 1
B validateDatetime() 0 28 11
A validateInteger() 0 15 3
A offsetGet() 0 2 2
C validateField() 0 44 14
A setValidationFactory() 0 4 1
A withSparse() 0 3 1
F validateProperties() 0 106 25
A lookupSchema() 0 27 6
A hasFlag() 0 2 1
A offsetExists() 0 2 1
A offsetUnset() 0 2 1
C validateArray() 0 61 17
A toObjectArray() 0 12 4
A offsetSet() 0 2 1
F validateString() 0 89 20
B callValidators() 0 18 7
A getValidationClass() 0 3 1
A validateNull() 0 6 2
D resolveDiscriminator() 0 95 19

How to fix   Complexity   

Complex Class

Complex classes like Schema often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use Schema, and based on these observations, apply Extract Interface, too.

1
<?php
2
/**
3
 * @author Todd Burry <[email protected]>
4
 * @copyright 2009-2018 Vanilla Forums Inc.
5
 * @license MIT
6
 */
7
8
namespace Garden\Schema;
9
10
/**
11
 * A class for defining and validating data schemas.
12
 */
13
class Schema implements \JsonSerializable, \ArrayAccess {
14
    /**
15
     * Trigger a notice when extraneous properties are encountered during validation.
16
     */
17
    const VALIDATE_EXTRA_PROPERTY_NOTICE = 0x1;
18
19
    /**
20
     * Throw a ValidationException when extraneous properties are encountered during validation.
21
     */
22
    const VALIDATE_EXTRA_PROPERTY_EXCEPTION = 0x2;
23
24
    /**
25
     * @var array All the known types.
26
     *
27
     * If this is ever given some sort of public access then remove the static.
28
     */
29
    private static $types = [
30
        'array' => ['a'],
31
        'object' => ['o'],
32
        'integer' => ['i', 'int'],
33
        'string' => ['s', 'str'],
34
        'number' => ['f', 'float'],
35
        'boolean' => ['b', 'bool'],
36
37
        // Psuedo-types
38
        'timestamp' => ['ts'], // type: integer, format: timestamp
39
        'datetime' => ['dt'], // type: string, format: date-time
40
        'null' => ['n'], // Adds nullable: true
41
    ];
42
43
    /**
44
     * @var string The regular expression to strictly determine if a string is a date.
45
     */
46
    private static $DATE_REGEX = '`^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?`i';
47
48
    private $schema = [];
49
50
    /**
51
     * @var int A bitwise combination of the various **Schema::FLAG_*** constants.
52
     */
53
    private $flags = 0;
54
55
    /**
56
     * @var array An array of callbacks that will filter data in the schema.
57
     */
58
    private $filters = [];
59
60
    /**
61
     * @var array An array of callbacks that will custom validate the schema.
62
     */
63
    private $validators = [];
64
65
    /**
66
     * @var string|Validation The name of the class or an instance that will be cloned.
67
     * @deprecated
68
     */
69
    private $validationClass = Validation::class;
70
71
    /**
72
     * @var callable A callback is used to create validation objects.
73
     */
74
    private $validationFactory = [Validation::class, 'createValidation'];
75
76
    /**
77
     * @var callable
78
     */
79
    private $refLookup;
80
81
    /// Methods ///
82
83
    /**
84
     * Initialize an instance of a new {@link Schema} class.
85
     *
86
     * @param array $schema The array schema to validate against.
87
     * @param callable $refLookup The function used to lookup references.
88
     */
89 291
    public function __construct(array $schema = [], callable $refLookup = null) {
90 291
        $this->schema = $schema;
91
92 273
        $this->refLookup = $refLookup ?? function (/** @scrutinizer ignore-unused */
93
                string $_) {
94 1
                return null;
95 273
            };
96 291
    }
97
98
    /**
99
     * Parse a short schema and return the associated schema.
100
     *
101
     * @param array $arr The schema array.
102
     * @param mixed[] $args Constructor arguments for the schema instance.
103
     * @return static Returns a new schema.
104
     */
105 179
    public static function parse(array $arr, ...$args) {
106 179
        $schema = new static([], ...$args);
0 ignored issues
show
Bug introduced by
$args is expanded, but the parameter $refLookup of Garden\Schema\Schema::__construct() does not expect variable arguments. ( Ignorable by Annotation )

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

106
        $schema = new static([], /** @scrutinizer ignore-type */ ...$args);
Loading history...
107 179
        $schema->schema = $schema->parseInternal($arr);
108 177
        return $schema;
109
    }
110
111
    /**
112
     * Parse a schema in short form into a full schema array.
113
     *
114
     * @param array $arr The array to parse into a schema.
115
     * @return array The full schema array.
116
     * @throws ParseException Throws an exception when an item in the schema is invalid.
117
     */
118 179
    protected function parseInternal(array $arr): array {
119 179
        if (empty($arr)) {
120
            // An empty schema validates to anything.
121 6
            return [];
122 174
        } elseif (isset($arr['type'])) {
123
            // This is a long form schema and can be parsed as the root.
124 2
            return $this->parseNode($arr);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->parseNode($arr) could return the type ArrayAccess which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
125
        } else {
126
            // Check for a root schema.
127 173
            $value = reset($arr);
128 173
            $key = key($arr);
129 173
            if (is_int($key)) {
130 108
                $key = $value;
131 108
                $value = null;
132
            }
133 173
            list ($name, $param) = $this->parseShortParam($key, $value);
134 171
            if (empty($name)) {
135 63
                return $this->parseNode($param, $value);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->parseNode($param, $value) could return the type ArrayAccess which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
136
            }
137
        }
138
139
        // If we are here then this is n object schema.
140 111
        list($properties, $required) = $this->parseProperties($arr);
141
142
        $result = [
143 111
            'type' => 'object',
144 111
            'properties' => $properties,
145 111
            'required' => $required
146
        ];
147
148 111
        return array_filter($result);
149
    }
150
151
    /**
152
     * Parse a schema node.
153
     *
154
     * @param array|Schema $node The node to parse.
155
     * @param mixed $value Additional information from the node.
156
     * @return array|\ArrayAccess Returns a JSON schema compatible node.
157
     * @throws ParseException Throws an exception if there was a problem parsing the schema node.
158
     */
159 172
    private function parseNode($node, $value = null) {
160 172
        if (is_array($value)) {
161 66
            if (is_array($node['type'])) {
162
                trigger_error('Schemas with multiple types are deprecated.', E_USER_DEPRECATED);
163
            }
164
165
            // The value describes a bit more about the schema.
166 66
            switch ($node['type']) {
167 66
                case 'array':
168 11
                    if (isset($value['items'])) {
169
                        // The value includes array schema information.
170 4
                        $node = array_replace($node, $value);
0 ignored issues
show
Bug introduced by
It seems like $node can also be of type Garden\Schema\Schema; however, parameter $array of array_replace() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

170
                        $node = array_replace(/** @scrutinizer ignore-type */ $node, $value);
Loading history...
171
                    } else {
172 7
                        $node['items'] = $this->parseInternal($value);
173
                    }
174 11
                    break;
175 56
                case 'object':
176
                    // The value is a schema of the object.
177 12
                    if (isset($value['properties'])) {
178
                        list($node['properties']) = $this->parseProperties($value['properties']);
179
                    } else {
180 12
                        list($node['properties'], $required) = $this->parseProperties($value);
181 12
                        if (!empty($required)) {
182 12
                            $node['required'] = $required;
183
                        }
184
                    }
185 12
                    break;
186
                default:
187 44
                    $node = array_replace($node, $value);
188 66
                    break;
189
            }
190 132
        } elseif (is_string($value)) {
191 102
            if ($node['type'] === 'array' && $arrType = $this->getType($value)) {
192 6
                $node['items'] = ['type' => $arrType];
193 98
            } elseif (!empty($value)) {
194 102
                $node['description'] = $value;
195
            }
196 35
        } elseif ($value === null) {
197
            // Parse child elements.
198 31
            if ($node['type'] === 'array' && isset($node['items'])) {
199
                // The value includes array schema information.
200
                $node['items'] = $this->parseInternal($node['items']);
0 ignored issues
show
Bug introduced by
It seems like $node['items'] can also be of type null; however, parameter $arr of Garden\Schema\Schema::parseInternal() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

200
                $node['items'] = $this->parseInternal(/** @scrutinizer ignore-type */ $node['items']);
Loading history...
201 31
            } elseif ($node['type'] === 'object' && isset($node['properties'])) {
202 1
                list($node['properties']) = $this->parseProperties($node['properties']);
203
            }
204
        }
205
206 172
        if (is_array($node)) {
207 171
            if (!empty($node['allowNull'])) {
208 1
                $node['nullable'] = true;
209
            }
210 171
            unset($node['allowNull']);
211
212 171
            if ($node['type'] === null || $node['type'] === []) {
213 4
                unset($node['type']);
214
            }
215
        }
216
217 172
        return $node;
218
    }
219
220
    /**
221
     * Parse the schema for an object's properties.
222
     *
223
     * @param array $arr An object property schema.
224
     * @return array Returns a schema array suitable to be placed in the **properties** key of a schema.
225
     * @throws ParseException Throws an exception if a property name cannot be determined for an array item.
226
     */
227 112
    private function parseProperties(array $arr): array {
228 112
        $properties = [];
229 112
        $requiredProperties = [];
230 112
        foreach ($arr as $key => $value) {
231
            // Fix a schema specified as just a value.
232 112
            if (is_int($key)) {
233 82
                if (is_string($value)) {
234 82
                    $key = $value;
235 82
                    $value = '';
236
                } else {
237
                    throw new ParseException("Schema at position $key is not a valid parameter.", 500);
238
                }
239
            }
240
241
            // The parameter is defined in the key.
242 112
            list($name, $param, $required) = $this->parseShortParam($key, $value);
0 ignored issues
show
Bug introduced by
$value of type string is incompatible with the type array expected by parameter $value of Garden\Schema\Schema::parseShortParam(). ( Ignorable by Annotation )

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

242
            list($name, $param, $required) = $this->parseShortParam($key, /** @scrutinizer ignore-type */ $value);
Loading history...
243
244 112
            $node = $this->parseNode($param, $value);
245
246 112
            $properties[$name] = $node;
247 112
            if ($required) {
248 112
                $requiredProperties[] = $name;
249
            }
250
        }
251 112
        return [$properties, $requiredProperties];
252
    }
253
254
    /**
255
     * Parse a short parameter string into a full array parameter.
256
     *
257
     * @param string $key The short parameter string to parse.
258
     * @param array $value An array of other information that might help resolve ambiguity.
259
     * @return array Returns an array in the form `[string name, array param, bool required]`.
260
     * @throws ParseException Throws an exception if the short param is not in the correct format.
261
     */
262 174
    public function parseShortParam(string $key, $value = []): array {
263
        // Is the parameter optional?
264 174
        if (substr($key, -1) === '?') {
265 70
            $required = false;
266 70
            $key = substr($key, 0, -1);
267
        } else {
268 126
            $required = true;
269
        }
270
271
        // Check for a type.
272 174
        if (false !== ($pos = strrpos($key, ':'))) {
273 168
            $name = substr($key, 0, $pos);
274 168
            $typeStr = substr($key, $pos + 1);
275
276
            // Kludge for names with colons that are not specifying an array of a type.
277 168
            if (isset($value['type']) && 'array' !== $this->getType($typeStr)) {
278 2
                $name = $key;
279 168
                $typeStr = '';
280
            }
281
        } else {
282 16
            $name = $key;
283 16
            $typeStr = '';
284
        }
285 174
        $types = [];
286 174
        $param = [];
287
288 174
        if (!empty($typeStr)) {
289 166
            $shortTypes = explode('|', $typeStr);
290 166
            foreach ($shortTypes as $alias) {
291 166
                $found = $this->getType($alias);
292 166
                if ($found === null) {
293 1
                    throw new ParseException("Unknown type '$alias'.", 500);
294 165
                } elseif ($found === 'datetime') {
295 9
                    $param['format'] = 'date-time';
296 9
                    $types[] = 'string';
297 157
                } elseif ($found === 'timestamp') {
298 12
                    $param['format'] = 'timestamp';
299 12
                    $types[] = 'integer';
300 151
                } elseif ($found === 'null') {
301 11
                    $nullable = true;
302
                } else {
303 165
                    $types[] = $found;
304
                }
305
            }
306
        }
307
308 173
        if ($value instanceof Schema) {
309 6
            if (count($types) === 1 && $types[0] === 'array') {
310 1
                $param += ['type' => $types[0], 'items' => $value];
311
            } else {
312 6
                $param = $value;
313
            }
314 171
        } elseif (isset($value['type'])) {
315 10
            $param = $value + $param;
316
317 10
            if (!empty($types) && $types !== (array)$param['type']) {
318
                $typesStr = implode('|', $types);
319
                $paramTypesStr = implode('|', (array)$param['type']);
320
321 10
                throw new ParseException("Type mismatch between $typesStr and {$paramTypesStr} for field $name.", 500);
322
            }
323
        } else {
324 166
            if (empty($types) && !empty($parts[1])) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $parts seems to never exist and therefore empty should always be true.
Loading history...
325
                throw new ParseException("Invalid type {$parts[1]} for field $name.", 500);
326
            }
327 166
            if (empty($types)) {
328 4
                $param += ['type' => null];
329
            } else {
330 165
                $param += ['type' => count($types) === 1 ? $types[0] : $types];
331
            }
332
333
            // Parsed required strings have a minimum length of 1.
334 166
            if (in_array('string', $types) && !empty($name) && $required && (!isset($value['default']) || $value['default'] !== '')) {
335 41
                $param['minLength'] = 1;
336
            }
337
        }
338
339 173
        if (!empty($nullable)) {
340 11
            $param['nullable'] = true;
341
        }
342
343 173
        if (is_array($param['type'])) {
344 1
            trigger_error('Schemas with multiple types is deprecated.', E_USER_DEPRECATED);
345
        }
346
347 172
        return [$name, $param, $required];
348
    }
349
350
    /**
351
     * Look up a type based on its alias.
352
     *
353
     * @param string $alias The type alias or type name to lookup.
354
     * @return mixed
355
     */
356 168
    private function getType($alias) {
357 168
        if (isset(self::$types[$alias])) {
358
            return $alias;
359
        }
360 168
        foreach (self::$types as $type => $aliases) {
361 168
            if (in_array($alias, $aliases, true)) {
362 168
                return $type;
363
            }
364
        }
365 12
        return null;
366
    }
367
368
    /**
369
     * Unescape a JSON reference segment.
370
     *
371
     * @param string $str The segment to unescapeRef.
372
     * @return string Returns the unescaped string.
373
     */
374 32
    public static function unescapeRef(string $str): string {
375 32
        return str_replace(['~1', '~0'], ['/', '~'], $str);
376
    }
377
378
    /**
379
     * Explode a references into its individual parts.
380
     *
381
     * @param string $ref A JSON reference.
382
     * @return string[] The individual parts of the reference.
383
     */
384 32
    public static function explodeRef(string $ref): array {
385 32
        return array_map([self::class, 'unescapeRef'], explode('/', $ref));
386
    }
387
388
    /**
389
     * Grab the schema's current description.
390
     *
391
     * @return string
392
     */
393 1
    public function getDescription(): string {
394 1
        return $this->schema['description'] ?? '';
395
    }
396
397
    /**
398
     * Set the description for the schema.
399
     *
400
     * @param string $description The new description.
401
     * @return $this
402
     */
403 1
    public function setDescription(string $description) {
404 1
        $this->schema['description'] = $description;
405 1
        return $this;
406
    }
407
408
    /**
409
     * Get the schema's title.
410
     *
411
     * @return string Returns the title.
412
     */
413 1
    public function getTitle(): string {
414 1
        return $this->schema['title'] ?? '';
415
    }
416
417
    /**
418
     * Set the schema's title.
419
     *
420
     * @param string $title The new title.
421
     */
422 1
    public function setTitle(string $title) {
423 1
        $this->schema['title'] = $title;
424 1
    }
425
426
    /**
427
     * Get a schema field.
428
     *
429
     * @param string|array $path The JSON schema path of the field with parts separated by dots.
430
     * @param mixed $default The value to return if the field isn't found.
431
     * @return mixed Returns the field value or `$default`.
432
     */
433 10
    public function getField($path, $default = null) {
434 10
        if (is_string($path)) {
435 10
            if (strpos($path, '.') !== false && strpos($path, '/') === false) {
436 1
                trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED);
437 1
                $path = explode('.', $path);
438
            } else {
439 9
                $path = explode('/', $path);
440
            }
441
        }
442
443 10
        $value = $this->schema;
444 10
        foreach ($path as $i => $subKey) {
445 10
            if (is_array($value) && isset($value[$subKey])) {
446 10
                $value = $value[$subKey];
447 1
            } elseif ($value instanceof Schema) {
448 1
                return $value->getField(array_slice($path, $i), $default);
449
            } else {
450 10
                return $default;
451
            }
452
        }
453 10
        return $value;
454
    }
455
456
    /**
457
     * Set a schema field.
458
     *
459
     * @param string|array $path The JSON schema path of the field with parts separated by slashes.
460
     * @param mixed $value The new value.
461
     * @return $this
462
     */
463 4
    public function setField($path, $value) {
464 4
        if (is_string($path)) {
465 4
            if (strpos($path, '.') !== false && strpos($path, '/') === false) {
466 1
                trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED);
467 1
                $path = explode('.', $path);
468
            } else {
469 3
                $path = explode('/', $path);
470
            }
471
        }
472
473 4
        $selection = &$this->schema;
474 4
        foreach ($path as $i => $subSelector) {
475 4
            if (is_array($selection)) {
476 4
                if (!isset($selection[$subSelector])) {
477 4
                    $selection[$subSelector] = [];
478
                }
479 1
            } elseif ($selection instanceof Schema) {
480 1
                $selection->setField(array_slice($path, $i), $value);
481 1
                return $this;
482
            } else {
483
                $selection = [$subSelector => []];
484
            }
485 4
            $selection = &$selection[$subSelector];
486
        }
487
488 4
        $selection = $value;
489 4
        return $this;
490
    }
491
492
    /**
493
     * Return the validation flags.
494
     *
495
     * @return int Returns a bitwise combination of flags.
496
     */
497 1
    public function getFlags(): int {
498 1
        return $this->flags;
499
    }
500
501
    /**
502
     * Set the validation flags.
503
     *
504
     * @param int $flags One or more of the **Schema::FLAG_*** constants.
505
     * @return Schema Returns the current instance for fluent calls.
506
     */
507 7
    public function setFlags(int $flags) {
508 7
        $this->flags = $flags;
509
510 7
        return $this;
511
    }
512
513
    /**
514
     * Set a flag.
515
     *
516
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
517
     * @param bool $value Either true or false.
518
     * @return $this
519
     */
520 1
    public function setFlag(int $flag, bool $value) {
521 1
        if ($value) {
522 1
            $this->flags = $this->flags | $flag;
523
        } else {
524 1
            $this->flags = $this->flags & ~$flag;
525
        }
526 1
        return $this;
527
    }
528
529
    /**
530
     * Merge a schema with this one.
531
     *
532
     * @param Schema $schema A scheme instance. Its parameters will be merged into the current instance.
533
     * @return $this
534
     */
535 4
    public function merge(Schema $schema) {
536 4
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), true, true);
537 4
        return $this;
538
    }
539
540
    /**
541
     * The internal implementation of schema merging.
542
     *
543
     * @param array $target The target of the merge.
544
     * @param array $source The source of the merge.
545
     * @param bool $overwrite Whether or not to replace values.
546
     * @param bool $addProperties Whether or not to add object properties to the target.
547
     * @return array
548
     */
549 7
    private function mergeInternal(array &$target, array $source, $overwrite = true, $addProperties = true) {
550
        // We need to do a fix for required properties here.
551 7
        if (isset($target['properties']) && !empty($source['required'])) {
552 5
            $required = isset($target['required']) ? $target['required'] : [];
553
554 5
            if (isset($source['required']) && $addProperties) {
555 4
                $newProperties = array_diff(array_keys($source['properties']), array_keys($target['properties']));
556 4
                $newRequired = array_intersect($source['required'], $newProperties);
557
558 4
                $required = array_merge($required, $newRequired);
559
            }
560
        }
561
562
563 7
        foreach ($source as $key => $val) {
564 7
            if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) {
565 7
                if ($key === 'properties' && !$addProperties) {
566
                    // We just want to merge the properties that exist in the destination.
567 2
                    foreach ($val as $name => $prop) {
568 2
                        if (isset($target[$key][$name])) {
569 2
                            $targetProp = &$target[$key][$name];
570
571 2
                            if (is_array($targetProp) && is_array($prop)) {
572 2
                                $this->mergeInternal($targetProp, $prop, $overwrite, $addProperties);
573 1
                            } elseif (is_array($targetProp) && $prop instanceof Schema) {
574
                                $this->mergeInternal($targetProp, $prop->getSchemaArray(), $overwrite, $addProperties);
575 1
                            } elseif ($overwrite) {
576 2
                                $targetProp = $prop;
577
                            }
578
                        }
579
                    }
580 7
                } elseif (isset($val[0]) || isset($target[$key][0])) {
581 5
                    if ($overwrite) {
582
                        // This is a numeric array, so just do a merge.
583 3
                        $merged = array_merge($target[$key], $val);
584 3
                        if (is_string($merged[0])) {
585 3
                            $merged = array_keys(array_flip($merged));
586
                        }
587 5
                        $target[$key] = $merged;
588
                    }
589
                } else {
590 7
                    $target[$key] = $this->mergeInternal($target[$key], $val, $overwrite, $addProperties);
591
                }
592 7
            } elseif (!$overwrite && array_key_exists($key, $target) && !is_array($val)) {
593
                // Do nothing, we aren't replacing.
594
            } else {
595 7
                $target[$key] = $val;
596
            }
597
        }
598
599 7
        if (isset($required)) {
600 5
            if (empty($required)) {
601 1
                unset($target['required']);
602
            } else {
603 5
                $target['required'] = $required;
604
            }
605
        }
606
607 7
        return $target;
608
    }
609
610
    /**
611
     * Returns the internal schema array.
612
     *
613
     * @return array
614
     * @see Schema::jsonSerialize()
615
     */
616 17
    public function getSchemaArray(): array {
617 17
        return $this->schema;
618
    }
619
620
    /**
621
     * Add another schema to this one.
622
     *
623
     * Adding schemas together is analogous to array addition. When you add a schema it will only add missing information.
624
     *
625
     * @param Schema $schema The schema to add.
626
     * @param bool $addProperties Whether to add properties that don't exist in this schema.
627
     * @return $this
628
     */
629 4
    public function add(Schema $schema, $addProperties = false) {
630 4
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), false, $addProperties);
631 4
        return $this;
632
    }
633
634
    /**
635
     * Add a custom filter to change data before validation.
636
     *
637
     * @param string $fieldname The name of the field to filter, if any.
638
     *
639
     * If you are adding a filter to a deeply nested field then separate the path with dots.
640
     * @param callable $callback The callback to filter the field.
641
     * @param bool $validate Whether or not the filter should also validate. If true default validation is skipped.
642
     * @return $this
643
     */
644 4
    public function addFilter(string $fieldname, callable $callback, bool $validate = false) {
645 4
        $fieldname = $this->parseFieldSelector($fieldname);
646 4
        $this->filters[$fieldname][] = [$callback, $validate];
647 4
        return $this;
648
    }
649
650
    /**
651
     * Parse a nested field name selector.
652
     *
653
     * Field selectors should be separated by "/" characters, but may currently be separated by "." characters which
654
     * triggers a deprecated error.
655
     *
656
     * @param string $field The field selector.
657
     * @return string Returns the field selector in the correct format.
658
     */
659 17
    private function parseFieldSelector(string $field): string {
660 17
        if (strlen($field) === 0) {
661 6
            return $field;
662
        }
663
664 11
        if (strpos($field, '.') !== false) {
665 1
            if (strpos($field, '/') === false) {
666 1
                trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED);
667
668 1
                $parts = explode('.', $field);
669 1
                $parts = @array_map([$this, 'parseFieldSelector'], $parts); // silence because error triggered already.
670
671 1
                $field = implode('/', $parts);
672
            }
673 11
        } elseif ($field === '[]') {
674 1
            trigger_error('Field selectors with item selector "[]" must be converted to "items".', E_USER_DEPRECATED);
675 1
            $field = 'items';
676 10
        } elseif (strpos($field, '/') === false && !in_array($field, ['items', 'additionalProperties'], true)) {
677 3
            trigger_error("Field selectors must specify full schema paths. ($field)", E_USER_DEPRECATED);
678 3
            $field = "/properties/$field";
679
        }
680
681 11
        if (strpos($field, '[]') !== false) {
682 1
            trigger_error('Field selectors with item selector "[]" must be converted to "/items".', E_USER_DEPRECATED);
683 1
            $field = str_replace('[]', '/items', $field);
684
        }
685
686 11
        return ltrim($field, '/');
687
    }
688
689
    /**
690
     * Add a custom filter for a schema format.
691
     *
692
     * Schemas can use the `format` property to specify a specific format on a field. Adding a filter for a format
693
     * allows you to customize the behavior of that format.
694
     *
695
     * @param string $format The format to filter.
696
     * @param callable $callback The callback used to filter values.
697
     * @param bool $validate Whether or not the filter should also validate. If true default validation is skipped.
698
     * @return $this
699
     */
700 2
    public function addFormatFilter(string $format, callable $callback, bool $validate = false) {
701 2
        if (empty($format)) {
702
            throw new \InvalidArgumentException('The filter format cannot be empty.', 500);
703
        }
704
705 2
        $filter = "/format/$format";
706 2
        $this->filters[$filter][] = [$callback, $validate];
707
708 2
        return $this;
709
    }
710
711
    /**
712
     * Require one of a given set of fields in the schema.
713
     *
714
     * @param array $required The field names to require.
715
     * @param string $fieldname The name of the field to attach to.
716
     * @param int $count The count of required items.
717
     * @return Schema Returns `$this` for fluent calls.
718
     */
719 3
    public function requireOneOf(array $required, string $fieldname = '', int $count = 1) {
720 3
        $result = $this->addValidator(
721 3
            $fieldname,
722 3
            function ($data, ValidationField $field) use ($required, $count) {
723
                // This validator does not apply to sparse validation.
724 3
                if ($field->isSparse()) {
725 1
                    return true;
726
                }
727
728 2
                $hasCount = 0;
729 2
                $flattened = [];
730
731 2
                foreach ($required as $name) {
732 2
                    $flattened = array_merge($flattened, (array)$name);
733
734 2
                    if (is_array($name)) {
735
                        // This is an array of required names. They all must match.
736 1
                        $hasCountInner = 0;
737 1
                        foreach ($name as $nameInner) {
738 1
                            if (array_key_exists($nameInner, $data)) {
739 1
                                $hasCountInner++;
740
                            } else {
741 1
                                break;
742
                            }
743
                        }
744 1
                        if ($hasCountInner >= count($name)) {
745 1
                            $hasCount++;
746
                        }
747 2
                    } elseif (array_key_exists($name, $data)) {
748 1
                        $hasCount++;
749
                    }
750
751 2
                    if ($hasCount >= $count) {
752 2
                        return true;
753
                    }
754
                }
755
756 2
                if ($count === 1) {
757 1
                    $message = 'One of {properties} are required.';
758
                } else {
759 1
                    $message = '{count} of {properties} are required.';
760
                }
761
762 2
                $field->addError('oneOfRequired', [
763 2
                    'messageCode' => $message,
764 2
                    'properties' => $required,
765 2
                    'count' => $count
766
                ]);
767 2
                return false;
768 3
            }
769
        );
770
771 3
        return $result;
772
    }
773
774
    /**
775
     * Add a custom validator to to validate the schema.
776
     *
777
     * @param string $fieldname The name of the field to validate, if any.
778
     *
779
     * If you are adding a validator to a deeply nested field then separate the path with dots.
780
     * @param callable $callback The callback to validate with.
781
     * @return Schema Returns `$this` for fluent calls.
782
     */
783 5
    public function addValidator(string $fieldname, callable $callback) {
784 5
        $fieldname = $this->parseFieldSelector($fieldname);
785 5
        $this->validators[$fieldname][] = $callback;
786 5
        return $this;
787
    }
788
789
    /**
790
     * Validate data against the schema and return the result.
791
     *
792
     * @param mixed $data The data to validate.
793
     * @param array $options Validation options. See `Schema::validate()`.
794
     * @return bool Returns true if the data is valid. False otherwise.
795
     * @throws RefNotFoundException Throws an exception when there is an unknown `$ref` in the schema.
796
     */
797 45
    public function isValid($data, $options = []) {
798
        try {
799 45
            $this->validate($data, $options);
800 31
            return true;
801 24
        } catch (ValidationException $ex) {
802 24
            return false;
803
        }
804
    }
805
806
    /**
807
     * Validate data against the schema.
808
     *
809
     * @param mixed $data The data to validate.
810
     * @param array $options Validation options.
811
     *
812
     * - **sparse**: Whether or not this is a sparse validation.
813
     * @return mixed Returns a cleaned version of the data.
814
     * @throws ValidationException Throws an exception when the data does not validate against the schema.
815
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
816
     */
817 236
    public function validate($data, $options = []) {
818 236
        if (is_bool($options)) {
0 ignored issues
show
introduced by
The condition is_bool($options) is always false.
Loading history...
819 1
            trigger_error('The $sparse parameter is deprecated. Use [\'sparse\' => true] instead.', E_USER_DEPRECATED);
820 1
            $options = ['sparse' => true];
821
        }
822 236
        $options += ['sparse' => false];
823
824
825 236
        list($schema, $schemaPath) = $this->lookupSchema($this->schema, '');
826 232
        $field = new ValidationField($this->createValidation(), $schema, '', $schemaPath, $options);
827
828 232
        $clean = $this->validateField($data, $field);
829
830 229
        if (Invalid::isInvalid($clean) && $field->isValid()) {
831
            // This really shouldn't happen, but we want to protect against seeing the invalid object.
832 7
            $field->addError('invalid', ['messageCode' => 'The value is invalid.']);
833
        }
834
835 229
        if (!$field->getValidation()->isValid()) {
836 81
            throw new ValidationException($field->getValidation());
837
        }
838
839 164
        return $clean;
840
    }
841
842
    /**
843
     * Lookup a schema based on a schema node.
844
     *
845
     * The node could be a schema array, `Schema` object, or a schema reference.
846
     *
847
     * @param mixed $schema The schema node to lookup with.
848
     * @param string $schemaPath The current path of the schema.
849
     * @return array Returns an array with two elements:
850
     * - Schema|array|\ArrayAccess The schema that was found.
851
     * - string The path of the schema. This is either the reference or the `$path` parameter for inline schemas.
852
     * @throws RefNotFoundException Throws an exception when a reference could not be found.
853
     */
854 236
    private function lookupSchema($schema, string $schemaPath) {
855 236
        if ($schema instanceof Schema) {
856 6
            return [$schema, $schemaPath];
857
        } else {
858 236
            $lookup = $this->getRefLookup();
859 236
            $visited = [];
860
861
            // Resolve any references first.
862 236
            while (!empty($schema['$ref'])) {
863 29
                $schemaPath = $schema['$ref'];
864
865 29
                if (isset($visited[$schemaPath])) {
866 1
                    throw new RefNotFoundException("Cyclical reference cannot be resolved. ($schemaPath)", 508);
867
                }
868 29
                $visited[$schemaPath] = true;
869
870
                try {
871 29
                    $schema = call_user_func($lookup, $schemaPath);
872 1
                } catch (\Exception $ex) {
873 1
                    throw new RefNotFoundException($ex->getMessage(), $ex->getCode(), $ex);
874
                }
875 28
                if ($schema === null) {
876 3
                    throw new RefNotFoundException("Schema reference could not be found. ($schemaPath)");
877
                }
878
            }
879
880 232
            return [$schema, $schemaPath];
881
        }
882
    }
883
884
    /**
885
     * Get the function used to resolve `$ref` lookups.
886
     *
887
     * @return callable Returns the current `$ref` lookup.
888
     */
889 236
    public function getRefLookup(): callable {
890 236
        return $this->refLookup;
891
    }
892
893
    /**
894
     * Set the function used to resolve `$ref` lookups.
895
     *
896
     * The function should have the following signature:
897
     *
898
     * ```php
899
     * function(string $ref): array|Schema|null {
900
     *     ...
901
     * }
902
     * ```
903
     * The function should take a string reference and return a schema array, `Schema` or **null**.
904
     *
905
     * @param callable $refLookup The new lookup function.
906
     * @return $this
907
     */
908 10
    public function setRefLookup(callable $refLookup) {
909 10
        $this->refLookup = $refLookup;
910 10
        return $this;
911
    }
912
913
    /**
914
     * Create a new validation instance.
915
     *
916
     * @return Validation Returns a validation object.
917
     */
918 232
    protected function createValidation(): Validation {
919 232
        return call_user_func($this->getValidationFactory());
920
    }
921
922
    /**
923
     * Get factory used to create validation objects.
924
     *
925
     * @return callable Returns the current factory.
926
     */
927 232
    public function getValidationFactory(): callable {
928 232
        return $this->validationFactory;
929
    }
930
931
    /**
932
     * Set the factory used to create validation objects.
933
     *
934
     * @param callable $validationFactory The new factory.
935
     * @return $this
936
     */
937 2
    public function setValidationFactory(callable $validationFactory) {
938 2
        $this->validationFactory = $validationFactory;
939 2
        $this->validationClass = null;
0 ignored issues
show
Deprecated Code introduced by
The property Garden\Schema\Schema::$validationClass has been deprecated. ( Ignorable by Annotation )

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

939
        /** @scrutinizer ignore-deprecated */ $this->validationClass = null;
Loading history...
940 2
        return $this;
941
    }
942
943
    /**
944
     * Validate a field.
945
     *
946
     * @param mixed $value The value to validate.
947
     * @param ValidationField $field A validation object to add errors to.
948
     * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value
949
     * is completely invalid.
950
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
951
     */
952 232
    protected function validateField($value, ValidationField $field) {
953 232
        $validated = false;
954 232
        $result = $value = $this->filterField($value, $field, $validated);
955
956 232
        if ($validated) {
957 3
            return $result;
958 229
        } elseif ($field->getField() instanceof Schema) {
959
            try {
960 5
                $result = $field->getField()->validate($value, $field->getOptions());
961 2
            } catch (ValidationException $ex) {
962
                // The validation failed, so merge the validations together.
963 5
                $field->getValidation()->merge($ex->getValidation(), $field->getName());
964
            }
965 229
        } elseif (($value === null || ($value === '' && !$field->hasType('string'))) && ($field->val('nullable') || $field->hasType('null'))) {
0 ignored issues
show
introduced by
Consider adding parentheses for clarity. Current Interpretation: ($value === null || $val...$field->hasType('null'), Probably Intended Meaning: $value === null || ($val...field->hasType('null'))
Loading history...
966 14
            $result = null;
967
        } else {
968
            // Look for a discriminator.
969 229
            if (!empty($field->val('discriminator'))) {
970 12
                $field = $this->resolveDiscriminator($value, $field);
971
            }
972
973 228
            if ($field !== null) {
974
                // Validate the field's type.
975 221
                $type = $field->getType();
976 221
                if (is_array($type)) {
977 29
                    $result = $this->validateMultipleTypes($value, $type, $field);
0 ignored issues
show
Deprecated Code introduced by
The function Garden\Schema\Schema::validateMultipleTypes() has been deprecated: Multiple types are being removed next version. ( Ignorable by Annotation )

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

977
                    $result = /** @scrutinizer ignore-deprecated */ $this->validateMultipleTypes($value, $type, $field);

This function has been deprecated. The supplier of the function has supplied an explanatory message.

The explanatory message should give you some clue as to whether and when the function will be removed and what other function to use instead.

Loading history...
978
                } else {
979 200
                    $result = $this->validateSingleType($value, $type, $field);
980
                }
981
982 221
                if (Invalid::isValid($result)) {
983 221
                    $result = $this->validateEnum($result, $field);
984
                }
985
            } else {
986 7
                $result = Invalid::value();
987
            }
988
        }
989
990
        // Validate a custom field validator.
991 228
        if (Invalid::isValid($result)) {
992 212
            $this->callValidators($result, $field);
993
        }
994
995 228
        return $result;
996
    }
997
998
    /**
999
     * Filter a field's value using built in and custom filters.
1000
     *
1001
     * @param mixed $value The original value of the field.
1002
     * @param ValidationField $field The field information for the field.
1003
     * @param bool $validated Whether or not a filter validated the value.
1004
     * @return mixed Returns the filtered field or the original field value if there are no filters.
1005
     */
1006 232
    private function filterField($value, ValidationField $field, bool &$validated = false) {
1007
        // Check for limited support for Open API style.
1008 232
        if (!empty($field->val('style')) && is_string($value)) {
1009 8
            $doFilter = true;
1010 8
            if ($field->hasType('boolean') && in_array($value, ['true', 'false', '0', '1'], true)) {
1011 4
                $doFilter = false;
1012 4
            } elseif (($field->hasType('integer') || $field->hasType('number')) && is_numeric($value)) {
1013
                $doFilter = false;
1014
            }
1015
1016 8
            if ($doFilter) {
1017 4
                switch ($field->val('style')) {
1018 4
                    case 'form':
1019 2
                        $value = explode(',', $value);
1020 2
                        break;
1021 2
                    case 'spaceDelimited':
1022 1
                        $value = explode(' ', $value);
1023 1
                        break;
1024 1
                    case 'pipeDelimited':
1025 1
                        $value = explode('|', $value);
1026 1
                        break;
1027
                }
1028
            }
1029
        }
1030
1031 232
        $value = $this->callFilters($value, $field, $validated);
1032
1033 232
        return $value;
1034
    }
1035
1036
    /**
1037
     * Call all of the filters attached to a field.
1038
     *
1039
     * @param mixed $value The field value being filtered.
1040
     * @param ValidationField $field The validation object.
1041
     * @param bool $validated Whether or not a filter validated the field.
1042
     * @return mixed Returns the filtered value. If there are no filters for the field then the original value is returned.
1043
     */
1044 232
    private function callFilters($value, ValidationField $field, bool &$validated = false) {
1045
        // Strip array references in the name except for the last one.
1046 232
        $key = $field->getSchemaPath();
1047 232
        if (!empty($this->filters[$key])) {
1048 4
            foreach ($this->filters[$key] as list($filter, $validate)) {
1049 4
                $value = call_user_func($filter, $value, $field);
1050 4
                $validated |= $validate;
1051
1052 4
                if (Invalid::isInvalid($value)) {
1053 4
                    return $value;
1054
                }
1055
            }
1056
        }
1057 231
        $key = '/format/'.$field->val('format');
1058 231
        if (!empty($this->filters[$key])) {
1059 2
            foreach ($this->filters[$key] as list($filter, $validate)) {
1060 2
                $value = call_user_func($filter, $value, $field);
1061 2
                $validated |= $validate;
1062
1063 2
                if (Invalid::isInvalid($value)) {
1064 2
                    return $value;
1065
                }
1066
            }
1067
        }
1068
1069 231
        return $value;
1070
    }
1071
1072
    /**
1073
     * Validate a field against multiple basic types.
1074
     *
1075
     * The first validation that passes will be returned. If no type can be validated against then validation will fail.
1076
     *
1077
     * @param mixed $value The value to validate.
1078
     * @param string[] $types The types to validate against.
1079
     * @param ValidationField $field Contains field and validation information.
1080
     * @return mixed Returns the valid value or `Invalid`.
1081
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
1082
     * @deprecated Multiple types are being removed next version.
1083
     */
1084 29
    private function validateMultipleTypes($value, array $types, ValidationField $field) {
1085 29
        trigger_error('Multiple schema types are deprecated.', E_USER_DEPRECATED);
1086
1087
        // First check for an exact type match.
1088 29
        switch (gettype($value)) {
1089 29
            case 'boolean':
1090 4
                if (in_array('boolean', $types)) {
1091 4
                    $singleType = 'boolean';
1092
                }
1093 4
                break;
1094 26
            case 'integer':
1095 7
                if (in_array('integer', $types)) {
1096 5
                    $singleType = 'integer';
1097 2
                } elseif (in_array('number', $types)) {
1098 1
                    $singleType = 'number';
1099
                }
1100 7
                break;
1101 21
            case 'double':
1102 4
                if (in_array('number', $types)) {
1103 4
                    $singleType = 'number';
1104
                } elseif (in_array('integer', $types)) {
1105
                    $singleType = 'integer';
1106
                }
1107 4
                break;
1108 18
            case 'string':
1109 9
                if (in_array('datetime', $types) && preg_match(self::$DATE_REGEX, $value)) {
1110 1
                    $singleType = 'datetime';
1111 8
                } elseif (in_array('string', $types)) {
1112 4
                    $singleType = 'string';
1113
                }
1114 9
                break;
1115 10
            case 'array':
1116 10
                if (in_array('array', $types) && in_array('object', $types)) {
1117 1
                    $singleType = isset($value[0]) || empty($value) ? 'array' : 'object';
1118 9
                } elseif (in_array('object', $types)) {
1119
                    $singleType = 'object';
1120 9
                } elseif (in_array('array', $types)) {
1121 9
                    $singleType = 'array';
1122
                }
1123 10
                break;
1124 1
            case 'NULL':
1125
                if (in_array('null', $types)) {
1126
                    $singleType = $this->validateSingleType($value, 'null', $field);
1127
                }
1128
                break;
1129
        }
1130 29
        if (!empty($singleType)) {
1131 25
            return $this->validateSingleType($value, $singleType, $field);
1132
        }
1133
1134
        // Clone the validation field to collect errors.
1135 6
        $typeValidation = new ValidationField(new Validation(), $field->getField(), '', '', $field->getOptions());
1136
1137
        // Try and validate against each type.
1138 6
        foreach ($types as $type) {
1139 6
            $result = $this->validateSingleType($value, $type, $typeValidation);
1140 6
            if (Invalid::isValid($result)) {
1141 6
                return $result;
1142
            }
1143
        }
1144
1145
        // Since we got here the value is invalid.
1146
        $field->merge($typeValidation->getValidation());
1147
        return Invalid::value();
1148
    }
1149
1150
    /**
1151
     * Validate a field against a single type.
1152
     *
1153
     * @param mixed $value The value to validate.
1154
     * @param string $type The type to validate against.
1155
     * @param ValidationField $field Contains field and validation information.
1156
     * @return mixed Returns the valid value or `Invalid`.
1157
     * @throws \InvalidArgumentException Throws an exception when `$type` is not recognized.
1158
     * @throws RefNotFoundException Throws an exception when internal validation has a reference that isn't found.
1159
     */
1160 221
    protected function validateSingleType($value, string $type, ValidationField $field) {
1161
        switch ($type) {
1162 221
            case 'boolean':
1163 32
                $result = $this->validateBoolean($value, $field);
1164 32
                break;
1165 201
            case 'integer':
1166 66
                $result = $this->validateInteger($value, $field);
1167 66
                break;
1168 181
            case 'number':
1169 17
                $result = $this->validateNumber($value, $field);
1170 17
                break;
1171 172
            case 'string':
1172 93
                $result = $this->validateString($value, $field);
1173 93
                break;
1174 148
            case 'timestamp':
1175 1
                trigger_error('The timestamp type is deprecated. Use an integer with a format of timestamp instead.', E_USER_DEPRECATED);
1176 1
                $result = $this->validateTimestamp($value, $field);
1177 1
                break;
1178 148
            case 'datetime':
1179 2
                trigger_error('The datetime type is deprecated. Use a string with a format of date-time instead.', E_USER_DEPRECATED);
1180 2
                $result = $this->validateDatetime($value, $field);
1181 2
                break;
1182 147
            case 'array':
1183 38
                $result = $this->validateArray($value, $field);
1184 38
                break;
1185 127
            case 'object':
1186 125
                $result = $this->validateObject($value, $field);
1187 123
                break;
1188 6
            case 'null':
1189 1
                $result = $this->validateNull($value, $field);
1190 1
                break;
1191 5
            case '':
1192
                // No type was specified so we are valid.
1193 5
                $result = $value;
1194 5
                break;
1195
            default:
1196
                throw new \InvalidArgumentException("Unrecognized type $type.", 500);
1197
        }
1198 221
        return $result;
1199
    }
1200
1201
    /**
1202
     * Validate a boolean value.
1203
     *
1204
     * @param mixed $value The value to validate.
1205
     * @param ValidationField $field The validation results to add.
1206
     * @return bool|Invalid Returns the cleaned value or invalid if validation fails.
1207
     */
1208 32
    protected function validateBoolean($value, ValidationField $field) {
1209 32
        $value = $value === null ? $value : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
1210 32
        if ($value === null) {
1211 4
            $field->addTypeError($value, 'boolean');
1212 4
            return Invalid::value();
1213
        }
1214
1215 29
        return $value;
1216
    }
1217
1218
    /**
1219
     * Validate and integer.
1220
     *
1221
     * @param mixed $value The value to validate.
1222
     * @param ValidationField $field The validation results to add.
1223
     * @return int|Invalid Returns the cleaned value or **null** if validation fails.
1224
     */
1225 66
    protected function validateInteger($value, ValidationField $field) {
1226 66
        if ($field->val('format') === 'timestamp') {
1227 7
            return $this->validateTimestamp($value, $field);
1228
        }
1229
1230 61
        $result = filter_var($value, FILTER_VALIDATE_INT);
1231
1232 61
        if ($result === false) {
1233 11
            $field->addTypeError($value, 'integer');
1234 11
            return Invalid::value();
1235
        }
1236
1237 54
        $result = $this->validateNumberProperties($result, $field);
1238
1239 54
        return $result;
1240
    }
1241
1242
    /**
1243
     * Validate a unix timestamp.
1244
     *
1245
     * @param mixed $value The value to validate.
1246
     * @param ValidationField $field The field being validated.
1247
     * @return int|Invalid Returns a valid timestamp or invalid if the value doesn't validate.
1248
     */
1249 8
    protected function validateTimestamp($value, ValidationField $field) {
1250 8
        if (is_numeric($value) && $value > 0) {
1251 3
            $result = (int)$value;
1252 5
        } elseif (is_string($value) && $ts = strtotime($value)) {
1253 1
            $result = $ts;
1254
        } else {
1255 4
            $field->addTypeError($value, 'timestamp');
1256 4
            $result = Invalid::value();
1257
        }
1258 8
        return $result;
1259
    }
1260
1261
    /**
1262
     * Validate specific numeric validation properties.
1263
     *
1264
     * @param int|float $value The value to test.
1265
     * @param ValidationField $field Field information.
1266
     * @return int|float|Invalid Returns the number of invalid.
1267
     */
1268 64
    private function validateNumberProperties($value, ValidationField $field) {
1269 64
        $count = $field->getErrorCount();
1270
1271 64
        if ($multipleOf = $field->val('multipleOf')) {
1272 4
            $divided = $value / $multipleOf;
1273
1274 4
            if ($divided != round($divided)) {
1275 2
                $field->addError('multipleOf', ['messageCode' => 'The value must be a multiple of {multipleOf}.', 'multipleOf' => $multipleOf]);
1276
            }
1277
        }
1278
1279 64
        if ($maximum = $field->val('maximum')) {
1280 4
            $exclusive = $field->val('exclusiveMaximum');
1281
1282 4
            if ($value > $maximum || ($exclusive && $value == $maximum)) {
1283 2
                if ($exclusive) {
1284 1
                    $field->addError('maximum', ['messageCode' => 'The value must be less than {maximum}.', 'maximum' => $maximum]);
1285
                } else {
1286 1
                    $field->addError('maximum', ['messageCode' => 'The value must be less than or equal to {maximum}.', 'maximum' => $maximum]);
1287
                }
1288
            }
1289
        }
1290
1291 64
        if ($minimum = $field->val('minimum')) {
1292 4
            $exclusive = $field->val('exclusiveMinimum');
1293
1294 4
            if ($value < $minimum || ($exclusive && $value == $minimum)) {
1295 2
                if ($exclusive) {
1296 1
                    $field->addError('minimum', ['messageCode' => 'The value must be greater than {minimum}.', 'minimum' => $minimum]);
1297
                } else {
1298 1
                    $field->addError('minimum', ['messageCode' => 'The value must be greater than or equal to {minimum}.', 'minimum' => $minimum]);
1299
                }
1300
            }
1301
        }
1302
1303 64
        return $field->getErrorCount() === $count ? $value : Invalid::value();
1304
    }
1305
1306
    /**
1307
     * Validate a float.
1308
     *
1309
     * @param mixed $value The value to validate.
1310
     * @param ValidationField $field The validation results to add.
1311
     * @return float|Invalid Returns a number or **null** if validation fails.
1312
     */
1313 17
    protected function validateNumber($value, ValidationField $field) {
1314 17
        $result = filter_var($value, FILTER_VALIDATE_FLOAT);
1315 17
        if ($result === false) {
1316 4
            $field->addTypeError($value, 'number');
1317 4
            return Invalid::value();
1318
        }
1319
1320 13
        $result = $this->validateNumberProperties($result, $field);
1321
1322 13
        return $result;
1323
    }
1324
1325
    /**
1326
     * Validate a string.
1327
     *
1328
     * @param mixed $value The value to validate.
1329
     * @param ValidationField $field The validation results to add.
1330
     * @return string|Invalid Returns the valid string or **null** if validation fails.
1331
     */
1332 93
    protected function validateString($value, ValidationField $field) {
1333 93
        if ($field->val('format') === 'date-time') {
1334 12
            $result = $this->validateDatetime($value, $field);
1335 12
            return $result;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $result also could return the type DateTimeInterface which is incompatible with the documented return type Garden\Schema\Invalid|string.
Loading history...
1336
        }
1337
1338 82
        if (is_string($value) || is_numeric($value)) {
1339 80
            $value = $result = (string)$value;
1340
        } else {
1341 5
            $field->addTypeError($value, 'string');
1342 5
            return Invalid::value();
1343
        }
1344
1345 80
        if (($minLength = $field->val('minLength', 0)) > 0 && mb_strlen($value) < $minLength) {
1346 4
            $field->addError(
1347 4
                'minLength',
1348
                [
1349 4
                    'messageCode' => 'The value should be at least {minLength} {minLength,plural,character,characters} long.',
1350 4
                    'minLength' => $minLength,
1351
                ]
1352
            );
1353
        }
1354 80
        if (($maxLength = $field->val('maxLength', 0)) > 0 && mb_strlen($value) > $maxLength) {
1355 1
            $field->addError(
1356 1
                'maxLength',
1357
                [
1358 1
                    'messageCode' => 'The value is {overflow} {overflow,plural,character,characters} too long.',
1359 1
                    'maxLength' => $maxLength,
1360 1
                    'overflow' => mb_strlen($value) - $maxLength,
1361
                ]
1362
            );
1363
        }
1364 80
        if ($pattern = $field->val('pattern')) {
1365 4
            $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`';
1366
1367 4
            if (!preg_match($regex, $value)) {
1368 2
                $field->addError(
1369 2
                    'pattern',
1370
                    [
1371 2
                        'messageCode' => $field->val('x-patternMessageCode', 'The value doesn\'t match the required pattern.'),
1372
                    ]
1373
                );
1374
            }
1375
        }
1376 80
        if ($format = $field->val('format')) {
1377 11
            $type = $format;
1378
            switch ($format) {
1379 11
                case 'date':
1380
                    $result = $this->validateDatetime($result, $field);
1381
                    if ($result instanceof \DateTimeInterface) {
1382
                        $result = $result->format("Y-m-d\T00:00:00P");
1383
                    }
1384
                    break;
1385 11
                case 'email':
1386 1
                    $result = filter_var($result, FILTER_VALIDATE_EMAIL);
1387 1
                    break;
1388 10
                case 'ipv4':
1389 1
                    $type = 'IPv4 address';
1390 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
1391 1
                    break;
1392 9
                case 'ipv6':
1393 1
                    $type = 'IPv6 address';
1394 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
1395 1
                    break;
1396 8
                case 'ip':
1397 1
                    $type = 'IP address';
1398 1
                    $result = filter_var($result, FILTER_VALIDATE_IP);
1399 1
                    break;
1400 7
                case 'uri':
1401 7
                    $type = 'URL';
1402 7
                    $result = filter_var($result, FILTER_VALIDATE_URL);
1403 7
                    break;
1404
                default:
1405
                    trigger_error("Unrecognized format '$format'.", E_USER_NOTICE);
1406
            }
1407 11
            if ($result === false) {
1408 5
                $field->addError('format', [
1409 5
                    'format' => $format,
1410 5
                    'formatCode' => $type,
1411 5
                    'value' => $value,
1412 5
                    'messageCode' => '{value} is not a valid {formatCode}.'
1413
                ]);
1414
            }
1415
        }
1416
1417 80
        if ($field->isValid()) {
1418 72
            return $result;
1419
        } else {
1420 12
            return Invalid::value();
1421
        }
1422
    }
1423
1424
    /**
1425
     * Validate a date time.
1426
     *
1427
     * @param mixed $value The value to validate.
1428
     * @param ValidationField $field The validation results to add.
1429
     * @return \DateTimeInterface|Invalid Returns the cleaned value or **null** if it isn't valid.
1430
     */
1431 14
    protected function validateDatetime($value, ValidationField $field) {
1432 14
        if ($value instanceof \DateTimeInterface) {
1433
            // do nothing, we're good
1434 11
        } elseif (is_string($value) && $value !== '' && !is_numeric($value)) {
1435
            try {
1436 7
                $dt = new \DateTimeImmutable($value);
1437 6
                if ($dt) {
0 ignored issues
show
introduced by
$dt is of type DateTimeImmutable, thus it always evaluated to true.
Loading history...
1438 6
                    $value = $dt;
1439
                } else {
1440 6
                    $value = null;
1441
                }
1442 1
            } catch (\Throwable $ex) {
1443 7
                $value = Invalid::value();
1444
            }
1445 4
        } elseif (is_int($value) && $value > 0) {
1446
            try {
1447 1
                $value = new \DateTimeImmutable('@'.(string)round($value));
1448
            } catch (\Throwable $ex) {
1449 1
                $value = Invalid::value();
1450
            }
1451
        } else {
1452 3
            $value = Invalid::value();
1453
        }
1454
1455 14
        if (Invalid::isInvalid($value)) {
1456 4
            $field->addTypeError($value, 'date/time');
1457
        }
1458 14
        return $value;
1459
    }
1460
1461
    /**
1462
     * Validate an array.
1463
     *
1464
     * @param mixed $value The value to validate.
1465
     * @param ValidationField $field The validation results to add.
1466
     * @return array|Invalid Returns an array or invalid if validation fails.
1467
     * @throws RefNotFoundException Throws an exception if the array has an items `$ref` that cannot be found.
1468
     */
1469 38
    protected function validateArray($value, ValidationField $field) {
1470 38
        if ((!is_array($value) || (count($value) > 0 && !array_key_exists(0, $value))) && !$value instanceof \Traversable) {
0 ignored issues
show
introduced by
Consider adding parentheses for clarity. Current Interpretation: (! is_array($value) || c... instanceof Traversable, Probably Intended Meaning: ! is_array($value) || (c...instanceof Traversable)
Loading history...
1471 6
            $field->addTypeError($value, 'array');
1472 6
            return Invalid::value();
1473
        } else {
1474 33
            if ((null !== $minItems = $field->val('minItems')) && count($value) < $minItems) {
0 ignored issues
show
Bug introduced by
It seems like $value can also be of type Traversable; however, parameter $var of count() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

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

1474
            if ((null !== $minItems = $field->val('minItems')) && count(/** @scrutinizer ignore-type */ $value) < $minItems) {
Loading history...
1475 1
                $field->addError(
1476 1
                    'minItems',
1477
                    [
1478 1
                        'messageCode' => 'This must contain at least {minItems} {minItems,plural,item,items}.',
1479 1
                        'minItems' => $minItems,
1480
                    ]
1481
                );
1482
            }
1483 33
            if ((null !== $maxItems = $field->val('maxItems')) && count($value) > $maxItems) {
1484 1
                $field->addError(
1485 1
                    'maxItems',
1486
                    [
1487 1
                        'messageCode' => 'This must contain no more than {maxItems} {maxItems,plural,item,items}.',
1488 1
                        'maxItems' => $maxItems,
1489
                    ]
1490
                );
1491
            }
1492
1493 33
            if ($field->val('uniqueItems') && count($value) > count(array_unique($value))) {
0 ignored issues
show
Bug introduced by
It seems like $value can also be of type Traversable; however, parameter $array of array_unique() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

1493
            if ($field->val('uniqueItems') && count($value) > count(array_unique(/** @scrutinizer ignore-type */ $value))) {
Loading history...
1494 1
                $field->addError(
1495 1
                    'uniqueItems',
1496
                    [
1497 1
                        'messageCode' => 'The array must contain unique items.',
1498
                    ]
1499
                );
1500
            }
1501
1502 33
            if ($field->val('items') !== null) {
1503 25
                list ($items, $schemaPath) = $this->lookupSchema($field->val('items'), $field->getSchemaPath().'/items');
1504
1505
                // Validate each of the types.
1506 25
                $itemValidation = new ValidationField(
1507 25
                    $field->getValidation(),
1508 25
                    $items,
1509 25
                    '',
1510 25
                    $schemaPath,
1511 25
                    $field->getOptions()
1512
                );
1513
1514 25
                $result = [];
1515 25
                $count = 0;
1516 25
                foreach ($value as $i => $item) {
1517 25
                    $itemValidation->setName($field->getName()."/$i");
1518 25
                    $validItem = $this->validateField($item, $itemValidation);
1519 25
                    if (Invalid::isValid($validItem)) {
1520 25
                        $result[] = $validItem;
1521
                    }
1522 25
                    $count++;
1523
                }
1524
1525 25
                return empty($result) && $count > 0 ? Invalid::value() : $result;
1526
            } else {
1527
                // Cast the items into a proper numeric array.
1528 8
                $result = is_array($value) ? array_values($value) : iterator_to_array($value);
1529 8
                return $result;
1530
            }
1531
        }
1532
    }
1533
1534
    /**
1535
     * Validate an object.
1536
     *
1537
     * @param mixed $value The value to validate.
1538
     * @param ValidationField $field The validation results to add.
1539
     * @return object|Invalid Returns a clean object or **null** if validation fails.
1540
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
1541
     */
1542 125
    protected function validateObject($value, ValidationField $field) {
1543 125
        if (!$this->isArray($value) || isset($value[0])) {
1544 6
            $field->addTypeError($value, 'object');
1545 6
            return Invalid::value();
1546 125
        } elseif (is_array($field->val('properties')) || null !== $field->val('additionalProperties')) {
1547
            // Validate the data against the internal schema.
1548 118
            $value = $this->validateProperties($value, $field);
1549 7
        } elseif (!is_array($value)) {
1550 3
            $value = $this->toObjectArray($value);
1551
        }
1552
1553 123
        if (($maxProperties = $field->val('maxProperties')) && count($value) > $maxProperties) {
1554 1
            $field->addError(
1555 1
                'maxProperties',
1556
                [
1557 1
                    'messageCode' => 'This must contain no more than {maxProperties} {maxProperties,plural,item,items}.',
1558 1
                    'maxItems' => $maxProperties,
1559
                ]
1560
            );
1561
        }
1562
1563 123
        if (($minProperties = $field->val('minProperties')) && count($value) < $minProperties) {
1564 1
            $field->addError(
1565 1
                'minProperties',
1566
                [
1567 1
                    'messageCode' => 'This must contain at least {minProperties} {minProperties,plural,item,items}.',
1568 1
                    'minItems' => $minProperties,
1569
                ]
1570
            );
1571
        }
1572
1573 123
        return $value;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $value also could return the type array which is incompatible with the documented return type object|Garden\Schema\Invalid.
Loading history...
1574
    }
1575
1576
    /**
1577
     * Check whether or not a value is an array or accessible like an array.
1578
     *
1579
     * @param mixed $value The value to check.
1580
     * @return bool Returns **true** if the value can be used like an array or **false** otherwise.
1581
     */
1582 133
    private function isArray($value) {
1583 133
        return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable);
1584
    }
1585
1586
    /**
1587
     * Validate data against the schema and return the result.
1588
     *
1589
     * @param array|\Traversable|\ArrayAccess $data The data to validate.
1590
     * @param ValidationField $field This argument will be filled with the validation result.
1591
     * @return array|\ArrayObject|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types.
1592
     * or invalid if there are no valid properties.
1593
     * @throws RefNotFoundException Throws an exception of a property or additional property has a `$ref` that cannot be found.
1594
     */
1595 118
    protected function validateProperties($data, ValidationField $field) {
1596 118
        $properties = $field->val('properties', []);
1597 118
        $additionalProperties = $field->val('additionalProperties');
1598 118
        $required = array_flip($field->val('required', []));
1599 118
        $isRequest = $field->isRequest();
1600 118
        $isResponse = $field->isResponse();
1601
1602 118
        if (is_array($data)) {
1603 114
            $keys = array_keys($data);
1604 114
            $clean = [];
1605
        } else {
1606 4
            $keys = array_keys(iterator_to_array($data));
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type ArrayAccess; however, parameter $iterator of iterator_to_array() does only seem to accept Traversable, maybe add an additional type check? ( Ignorable by Annotation )

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

1606
            $keys = array_keys(iterator_to_array(/** @scrutinizer ignore-type */ $data));
Loading history...
1607 4
            $class = get_class($data);
1608 4
            $clean = new $class;
1609
1610 4
            if ($clean instanceof \ArrayObject && $data instanceof \ArrayObject) {
1611 3
                $clean->setFlags($data->getFlags());
1612 3
                $clean->setIteratorClass($data->getIteratorClass());
1613
            }
1614
        }
1615 118
        $keys = array_combine(array_map('strtolower', $keys), $keys);
1616
1617 118
        $propertyField = new ValidationField($field->getValidation(), [], '', '', $field->getOptions());
1618
1619
        // Loop through the schema fields and validate each one.
1620 118
        foreach ($properties as $propertyName => $property) {
1621 116
            list($property, $schemaPath) = $this->lookupSchema($property, $field->getSchemaPath().'/properties/'.self::escapeRef($propertyName));
1622
1623
            $propertyField
1624 116
                ->setField($property)
1625 116
                ->setName(ltrim($field->getName().'/'.self::escapeRef($propertyName), '/'))
1626 116
                ->setSchemaPath($schemaPath);
1627
1628 116
            $lName = strtolower($propertyName);
1629 116
            $isRequired = isset($required[$propertyName]);
1630
1631
            // Check to strip this field if it is readOnly or writeOnly.
1632 116
            if (($isRequest && $propertyField->val('readOnly')) || ($isResponse && $propertyField->val('writeOnly'))) {
1633 6
                unset($keys[$lName]);
1634 6
                continue;
1635
            }
1636
1637
            // Check for required fields.
1638 116
            if (!array_key_exists($lName, $keys)) {
1639 36
                if ($field->isSparse()) {
1640
                    // Sparse validation can leave required fields out.
1641 35
                } elseif ($propertyField->hasVal('default')) {
1642 5
                    $clean[$propertyName] = $propertyField->val('default');
1643 30
                } elseif ($isRequired) {
1644 6
                    $propertyField->addError(
1645 6
                        'required',
1646 36
                        ['messageCode' => '{property} is required.', 'property' => $propertyName]
1647
                    );
1648
                }
1649
            } else {
1650 104
                $value = $data[$keys[$lName]];
1651
1652 104
                if (in_array($value, [null, ''], true) && !$isRequired && !($propertyField->val('nullable') || $propertyField->hasType('null'))) {
1653 5
                    if ($propertyField->getType() !== 'string' || $value === null) {
1654 2
                        continue;
1655
                    }
1656
                }
1657
1658 102
                $clean[$propertyName] = $this->validateField($value, $propertyField);
1659
            }
1660
1661 114
            unset($keys[$lName]);
1662
        }
1663
1664
        // Look for extraneous properties.
1665 118
        if (!empty($keys)) {
1666 23
            if ($additionalProperties) {
1667 10
                list($additionalProperties, $schemaPath) = $this->lookupSchema(
1668 10
                    $additionalProperties,
1669 10
                    $field->getSchemaPath().'/additionalProperties'
1670
                );
1671
1672 10
                $propertyField = new ValidationField(
1673 10
                    $field->getValidation(),
1674 10
                    $additionalProperties,
1675 10
                    '',
1676 10
                    $schemaPath,
1677 10
                    $field->getOptions()
1678
                );
1679
1680 10
                foreach ($keys as $key) {
1681
                    $propertyField
1682 10
                        ->setName(ltrim($field->getName()."/$key", '/'));
1683
1684 10
                    $valid = $this->validateField($data[$key], $propertyField);
1685 10
                    if (Invalid::isValid($valid)) {
1686 10
                        $clean[$key] = $valid;
1687
                    }
1688
                }
1689 13
            } elseif ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE)) {
1690 2
                $msg = sprintf("Unexpected properties: %s.", implode(', ', $keys));
1691 2
                trigger_error($msg, E_USER_NOTICE);
1692 11
            } elseif ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION)) {
1693 2
                $field->addError('unexpectedProperties', [
1694 2
                    'messageCode' => 'Unexpected {extra,plural,property,properties}: {extra}.',
1695 2
                    'extra' => array_values($keys),
1696
                ]);
1697
            }
1698
        }
1699
1700 116
        return $clean;
1701
    }
1702
1703
    /**
1704
     * Escape a JSON reference field.
1705
     *
1706
     * @param string $field The reference field to escape.
1707
     * @return string Returns an escaped reference.
1708
     */
1709 124
    public static function escapeRef(string $field): string {
1710 124
        return str_replace(['~', '/'], ['~0', '~1'], $field);
1711
    }
1712
1713
    /**
1714
     * Whether or not the schema has a flag (or combination of flags).
1715
     *
1716
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
1717
     * @return bool Returns **true** if all of the flags are set or **false** otherwise.
1718
     */
1719 14
    public function hasFlag(int $flag): bool {
1720 14
        return ($this->flags & $flag) === $flag;
1721
    }
1722
1723
    /**
1724
     * Cast a value to an array.
1725
     *
1726
     * @param \Traversable $value The value to convert.
1727
     * @return array Returns an array.
1728
     */
1729 3
    private function toObjectArray(\Traversable $value) {
1730 3
        $class = get_class($value);
1731 3
        if ($value instanceof \ArrayObject) {
1732 2
            return new $class($value->getArrayCopy(), $value->getFlags(), $value->getIteratorClass());
0 ignored issues
show
Bug Best Practice introduced by
The expression return new $class($value...ue->getIteratorClass()) returns the type object which is incompatible with the documented return type array.
Loading history...
1733 1
        } elseif ($value instanceof \ArrayAccess) {
1734 1
            $r = new $class;
1735 1
            foreach ($value as $k => $v) {
1736 1
                $r[$k] = $v;
1737
            }
1738 1
            return $r;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $r returns the type object which is incompatible with the documented return type array.
Loading history...
1739
        }
1740
        return iterator_to_array($value);
1741
    }
1742
1743
    /**
1744
     * Validate a null value.
1745
     *
1746
     * @param mixed $value The value to validate.
1747
     * @param ValidationField $field The error collector for the field.
1748
     * @return null|Invalid Returns **null** or invalid.
1749
     */
1750 1
    protected function validateNull($value, ValidationField $field) {
1751 1
        if ($value === null) {
1752
            return null;
1753
        }
1754 1
        $field->addError('type', ['messageCode' => 'The value should be null.', 'type' => 'null']);
1755 1
        return Invalid::value();
1756
    }
1757
1758
    /**
1759
     * Validate a value against an enum.
1760
     *
1761
     * @param mixed $value The value to test.
1762
     * @param ValidationField $field The validation object for adding errors.
1763
     * @return mixed|Invalid Returns the value if it is one of the enumerated values or invalid otherwise.
1764
     */
1765 211
    protected function validateEnum($value, ValidationField $field) {
1766 211
        $enum = $field->val('enum');
1767 211
        if (empty($enum)) {
1768 210
            return $value;
1769
        }
1770
1771 3
        if (!in_array($value, $enum, true)) {
1772 1
            $field->addError(
1773 1
                'enum',
1774
                [
1775 1
                    'messageCode' => 'The value must be one of: {enum}.',
1776 1
                    'enum' => $enum,
1777
                ]
1778
            );
1779 1
            return Invalid::value();
1780
        }
1781 3
        return $value;
1782
    }
1783
1784
    /**
1785
     * Call all of the validators attached to a field.
1786
     *
1787
     * @param mixed $value The field value being validated.
1788
     * @param ValidationField $field The validation object to add errors.
1789
     */
1790 212
    private function callValidators($value, ValidationField $field) {
1791 212
        $valid = true;
1792
1793
        // Strip array references in the name except for the last one.
1794 212
        $key = $field->getSchemaPath();
1795 212
        if (!empty($this->validators[$key])) {
1796 5
            foreach ($this->validators[$key] as $validator) {
1797 5
                $r = call_user_func($validator, $value, $field);
1798
1799 5
                if ($r === false || Invalid::isInvalid($r)) {
1800 5
                    $valid = false;
1801
                }
1802
            }
1803
        }
1804
1805
        // Add an error on the field if the validator hasn't done so.
1806 212
        if (!$valid && $field->isValid()) {
1807 1
            $field->addError('invalid', ['messageCode' => 'The value is invalid.']);
1808
        }
1809 212
    }
1810
1811
    /**
1812
     * Specify data which should be serialized to JSON.
1813
     *
1814
     * This method specifically returns data compatible with the JSON schema format.
1815
     *
1816
     * @return mixed Returns data which can be serialized by **json_encode()**, which is a value of any type other than a resource.
1817
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
1818
     * @link http://json-schema.org/
1819
     */
1820
    public function jsonSerialize() {
1821 16
        $fix = function ($schema) use (&$fix) {
1822 16
            if ($schema instanceof Schema) {
1823 1
                return $schema->jsonSerialize();
1824
            }
1825
1826 16
            if (!empty($schema['type'])) {
1827 15
                $types = (array)$schema['type'];
1828
1829 15
                foreach ($types as $i => &$type) {
1830
                    // Swap datetime and timestamp to other types with formats.
1831 15
                    if ($type === 'datetime') {
1832 4
                        $type = 'string';
1833 4
                        $schema['format'] = 'date-time';
1834 14
                    } elseif ($schema['type'] === 'timestamp') {
1835 2
                        $type = 'integer';
1836 15
                        $schema['format'] = 'timestamp';
1837
                    }
1838
                }
1839 15
                $types = array_unique($types);
1840 15
                $schema['type'] = count($types) === 1 ? reset($types) : $types;
1841
            }
1842
1843 16
            if (!empty($schema['items'])) {
1844 4
                $schema['items'] = $fix($schema['items']);
1845
            }
1846 16
            if (!empty($schema['properties'])) {
1847 11
                $properties = [];
1848 11
                foreach ($schema['properties'] as $key => $property) {
1849 11
                    $properties[$key] = $fix($property);
1850
                }
1851 11
                $schema['properties'] = $properties;
1852
            }
1853
1854 16
            return $schema;
1855 16
        };
1856
1857 16
        $result = $fix($this->schema);
1858
1859 16
        return $result;
1860
    }
1861
1862
    /**
1863
     * Get the class that's used to contain validation information.
1864
     *
1865
     * @return Validation|string Returns the validation class.
1866
     * @deprecated
1867
     */
1868 1
    public function getValidationClass() {
1869 1
        trigger_error('Schema::getValidationClass() is deprecated. Use Schema::getValidationFactory() instead.', E_USER_DEPRECATED);
1870 1
        return $this->validationClass;
0 ignored issues
show
Deprecated Code introduced by
The property Garden\Schema\Schema::$validationClass has been deprecated. ( Ignorable by Annotation )

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

1870
        return /** @scrutinizer ignore-deprecated */ $this->validationClass;
Loading history...
1871
    }
1872
1873
    /**
1874
     * Set the class that's used to contain validation information.
1875
     *
1876
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
1877
     * @return $this
1878
     * @deprecated
1879
     */
1880 1
    public function setValidationClass($class) {
1881 1
        trigger_error('Schema::setValidationClass() is deprecated. Use Schema::setValidationFactory() instead.', E_USER_DEPRECATED);
1882
1883 1
        if (!is_a($class, Validation::class, true)) {
1884
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
1885
        }
1886
1887 1
        $this->setValidationFactory(function () use ($class) {
1888 1
            if ($class instanceof Validation) {
1889 1
                $result = clone $class;
1890
            } else {
1891 1
                $result = new $class;
1892
            }
1893 1
            return $result;
1894 1
        });
1895 1
        $this->validationClass = $class;
0 ignored issues
show
Deprecated Code introduced by
The property Garden\Schema\Schema::$validationClass has been deprecated. ( Ignorable by Annotation )

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

1895
        /** @scrutinizer ignore-deprecated */ $this->validationClass = $class;
Loading history...
1896 1
        return $this;
1897
    }
1898
1899
    /**
1900
     * Return a sparse version of this schema.
1901
     *
1902
     * A sparse schema has no required properties.
1903
     *
1904
     * @return Schema Returns a new sparse schema.
1905
     */
1906 2
    public function withSparse() {
1907 2
        $sparseSchema = $this->withSparseInternal($this, new \SplObjectStorage());
1908 2
        return $sparseSchema;
1909
    }
1910
1911
    /**
1912
     * The internal implementation of `Schema::withSparse()`.
1913
     *
1914
     * @param array|Schema $schema The schema to make sparse.
1915
     * @param \SplObjectStorage $schemas Collected sparse schemas that have already been made.
1916
     * @return mixed
1917
     */
1918 2
    private function withSparseInternal($schema, \SplObjectStorage $schemas) {
1919 2
        if ($schema instanceof Schema) {
1920 2
            if ($schemas->contains($schema)) {
1921 1
                return $schemas[$schema];
1922
            } else {
1923 2
                $schemas[$schema] = $sparseSchema = new Schema();
1924 2
                $sparseSchema->schema = $schema->withSparseInternal($schema->schema, $schemas);
1925 2
                if ($id = $sparseSchema->getID()) {
1926
                    $sparseSchema->setID($id.'Sparse');
1927
                }
1928
1929 2
                return $sparseSchema;
1930
            }
1931
        }
1932
1933 2
        unset($schema['required']);
1934
1935 2
        if (isset($schema['items'])) {
1936 1
            $schema['items'] = $this->withSparseInternal($schema['items'], $schemas);
1937
        }
1938 2
        if (isset($schema['properties'])) {
1939 2
            foreach ($schema['properties'] as $name => &$property) {
1940 2
                $property = $this->withSparseInternal($property, $schemas);
1941
            }
1942
        }
1943
1944 2
        return $schema;
1945
    }
1946
1947
    /**
1948
     * Get the ID for the schema.
1949
     *
1950
     * @return string
1951
     */
1952 3
    public function getID(): string {
1953 3
        return $this->schema['id'] ?? '';
1954
    }
1955
1956
    /**
1957
     * Set the ID for the schema.
1958
     *
1959
     * @param string $id The new ID.
1960
     * @return $this
1961
     */
1962 1
    public function setID(string $id) {
1963 1
        $this->schema['id'] = $id;
1964
1965 1
        return $this;
1966
    }
1967
1968
    /**
1969
     * Whether a offset exists.
1970
     *
1971
     * @param mixed $offset An offset to check for.
1972
     * @return boolean true on success or false on failure.
1973
     * @link http://php.net/manual/en/arrayaccess.offsetexists.php
1974
     */
1975 7
    public function offsetExists($offset) {
1976 7
        return isset($this->schema[$offset]);
1977
    }
1978
1979
    /**
1980
     * Offset to retrieve.
1981
     *
1982
     * @param mixed $offset The offset to retrieve.
1983
     * @return mixed Can return all value types.
1984
     * @link http://php.net/manual/en/arrayaccess.offsetget.php
1985
     */
1986 7
    public function offsetGet($offset) {
1987 7
        return isset($this->schema[$offset]) ? $this->schema[$offset] : null;
1988
    }
1989
1990
    /**
1991
     * Offset to set.
1992
     *
1993
     * @param mixed $offset The offset to assign the value to.
1994
     * @param mixed $value The value to set.
1995
     * @link http://php.net/manual/en/arrayaccess.offsetset.php
1996
     */
1997 1
    public function offsetSet($offset, $value) {
1998 1
        $this->schema[$offset] = $value;
1999 1
    }
2000
2001
    /**
2002
     * Offset to unset.
2003
     *
2004
     * @param mixed $offset The offset to unset.
2005
     * @link http://php.net/manual/en/arrayaccess.offsetunset.php
2006
     */
2007 1
    public function offsetUnset($offset) {
2008 1
        unset($this->schema[$offset]);
2009 1
    }
2010
2011
    /**
2012
     * Resolve the schema attached to a discriminator.
2013
     *
2014
     * @param mixed $value The value to search for the discriminator.
2015
     * @param ValidationField $field The current node's schema information.
2016
     * @return ValidationField|null Returns the resolved schema or **null** if it can't be resolved.
2017
     * @throws ParseException Throws an exception if the discriminator isn't a string.
2018
     */
2019 12
    private function resolveDiscriminator($value, ValidationField $field, array $visited = []) {
2020 12
        $propertyName = $field->val('discriminator')['propertyName'] ?? '';
2021 12
        if (empty($propertyName) || !is_string($propertyName)) {
2022 1
            throw new ParseException("Invalid propertyName for discriminator at {$field->getSchemaPath()}", 500);
2023
        }
2024
2025 12
        $propertyFieldName = ltrim($field->getName().'/'.self::escapeRef($propertyName), '/');
2026
2027
        // Do some basic validation checking to see if we can even look at the property.
2028 12
        if (!$this->isArray($value)) {
2029 1
            $field->addTypeError($value, 'object');
2030 1
            return null;
2031 11
        } elseif (empty($value[$propertyName])) {
2032 1
            $field->getValidation()->addError(
2033 1
                $propertyFieldName,
2034 1
                'required',
2035 1
                ['messageCode' => '{property} is required.', 'property' => $propertyName]
2036
            );
2037 1
            return null;
2038
        }
2039
2040 10
        $propertyValue = $value[$propertyName];
2041 10
        if (!is_string($propertyValue)) {
2042 1
            $field->getValidation()->addError(
2043 1
                $propertyFieldName,
2044 1
                'type',
2045
                [
2046 1
                    'type' => 'string',
2047 1
                    'value' => is_scalar($value) ? $value : null,
2048 1
                    'messageCode' => is_scalar($value) ? "{value} is not a valid string." : "The value is not a valid string."
2049
                ]
2050
            );
2051 1
            return null;
2052
        }
2053
2054 9
        $mapping = $field->val('discriminator')['mapping'] ?? '';
2055 9
        if (isset($mapping[$propertyValue])) {
2056 2
            $ref = $mapping[$propertyValue];
2057
2058 2
            if (strpos($ref, '#') === false) {
2059 2
                $ref = '#/components/schemas/'.self::escapeRef($ref);
2060
            }
2061
        } else {
2062
            // Don't let a property value provide its own ref as that may pose a security concern..
2063 7
            $ref = '#/components/schemas/'.self::escapeRef($propertyValue);
2064
        }
2065
2066
        // Validate the reference against the oneOf constraint.
2067 9
        $oneOf = $field->val('oneOf', []);
2068 9
        if (!empty($oneOf) && !in_array(['$ref' => $ref], $oneOf)) {
2069 1
            $field->getValidation()->addError(
2070 1
                $propertyFieldName,
2071 1
                'oneOf',
2072
                [
2073 1
                    'type' => 'string',
2074 1
                    'value' => is_scalar($propertyValue) ? $propertyValue : null,
0 ignored issues
show
introduced by
The condition is_scalar($propertyValue) is always true.
Loading history...
2075 1
                    'messageCode' => is_scalar($propertyValue) ? "{value} is not a valid option." : "The value is not a valid option."
0 ignored issues
show
introduced by
The condition is_scalar($propertyValue) is always true.
Loading history...
2076
                ]
2077
            );
2078 1
            return null;
2079
        }
2080
2081
        try {
2082
            // Lookup the schema.
2083 9
            $visited[$field->getSchemaPath()] = true;
2084
2085 9
            list($schema, $schemaPath) = $this->lookupSchema(['$ref' => $ref], $field->getSchemaPath());
2086 8
            if (isset($visited[$schemaPath])) {
2087 2
                throw new RefNotFoundException('Cyclical ref.', 508);
2088
            }
2089
2090 7
            $result = new ValidationField(
2091 7
                $field->getValidation(),
2092 7
                $schema,
2093 7
                $field->getName(),
2094 7
                $schemaPath,
2095 7
                $field->getOptions()
2096
            );
2097 7
            if (!empty($schema['discriminator'])) {
2098 4
                return $this->resolveDiscriminator($value, $result, $visited);
2099
            } else {
2100 4
                return $result;
2101
            }
2102 4
        } catch (RefNotFoundException $ex) {
2103
            // Since this is a ref provided by the value it is technically a validation error.
2104 3
            $field->getValidation()->addError(
2105 3
                $propertyFieldName,
2106 3
                'propertyName',
2107
                [
2108 3
                    'type' => 'string',
2109 3
                    'value' => is_scalar($propertyValue) ? $propertyValue : null,
0 ignored issues
show
introduced by
The condition is_scalar($propertyValue) is always true.
Loading history...
2110 3
                    'messageCode' => is_scalar($propertyValue) ? "{value} is not a valid option." : "The value is not a valid option."
0 ignored issues
show
introduced by
The condition is_scalar($propertyValue) is always true.
Loading history...
2111
                ]
2112
            );
2113 3
            return null;
2114
        }
2115
    }
2116
}
2117