Issues (31)

src/Schema.php (1 issue)

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
     * Validate string lengths as unicode characters instead of bytes.
26
     */
27
    const VALIDATE_STRING_LENGTH_AS_UNICODE = 0x4;
28
29
    /**
30
     * @var array All the known types.
31
     *
32
     * If this is ever given some sort of public access then remove the static.
33
     */
34
    private static $types = [
35
        'array' => ['a'],
36
        'object' => ['o'],
37
        'integer' => ['i', 'int'],
38
        'string' => ['s', 'str'],
39
        'number' => ['f', 'float'],
40
        'boolean' => ['b', 'bool'],
41
42
        // Psuedo-types
43
        'timestamp' => ['ts'], // type: integer, format: timestamp
44
        'datetime' => ['dt'], // type: string, format: date-time
45
        'null' => ['n'], // Adds nullable: true
46
    ];
47
48
    /**
49
     * @var string The regular expression to strictly determine if a string is a date.
50
     */
51
    private static $DATE_REGEX = '`^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?`i';
52
53
    private $schema = [];
54
55
    /**
56
     * @var int A bitwise combination of the various **Schema::FLAG_*** constants.
57
     */
58
    private $flags = 0;
59
60
    /**
61
     * @var array An array of callbacks that will filter data in the schema.
62
     */
63
    private $filters = [];
64
65
    /**
66
     * @var array An array of callbacks that will custom validate the schema.
67
     */
68
    private $validators = [];
69
70
    /**
71
     * @var string|Validation The name of the class or an instance that will be cloned.
72
     * @deprecated
73
     */
74
    private $validationClass = Validation::class;
75
76
    /**
77
     * @var callable A callback is used to create validation objects.
78
     */
79
    private $validationFactory = [Validation::class, 'createValidation'];
80
81
    /**
82
     * @var callable
83
     */
84
    private $refLookup;
85
86
    /// Methods ///
87
88
    /**
89 298
     * Initialize an instance of a new {@link Schema} class.
90 298
     *
91
     * @param array $schema The array schema to validate against.
92 276
     * @param callable $refLookup The function used to lookup references.
93
     */
94 1
    public function __construct(array $schema = [], callable $refLookup = null) {
95 276
        $this->schema = $schema;
96 298
97
        $this->refLookup = $refLookup ?? function (/** @scrutinizer ignore-unused */
98
                string $_) {
99
                return null;
100
            };
101
    }
102
103
    /**
104
     * Parse a short schema and return the associated schema.
105 179
     *
106 179
     * @param array $arr The schema array.
107 179
     * @param mixed[] $args Constructor arguments for the schema instance.
108 177
     * @return static Returns a new schema.
109
     */
110
    public static function parse(array $arr, ...$args) {
111
        $schema = new static([], ...$args);
0 ignored issues
show
$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

111
        $schema = new static([], /** @scrutinizer ignore-type */ ...$args);
Loading history...
112
        $schema->schema = $schema->parseInternal($arr);
113
        return $schema;
114
    }
115
116
    /**
117
     * Parse a schema in short form into a full schema array.
118 179
     *
119 179
     * @param array $arr The array to parse into a schema.
120
     * @return array The full schema array.
121 6
     * @throws ParseException Throws an exception when an item in the schema is invalid.
122 174
     */
123
    protected function parseInternal(array $arr): array {
124 2
        if (empty($arr)) {
125
            // An empty schema validates to anything.
126
            return [];
127 173
        } elseif (isset($arr['type'])) {
128 173
            // This is a long form schema and can be parsed as the root.
129 173
            return $this->parseNode($arr);
130 108
        } else {
131 108
            // Check for a root schema.
132
            $value = reset($arr);
133 173
            $key = key($arr);
134 171
            if (is_int($key)) {
135 63
                $key = $value;
136
                $value = null;
137
            }
138
            list ($name, $param) = $this->parseShortParam($key, $value);
139
            if (empty($name)) {
140 111
                return $this->parseNode($param, $value);
141
            }
142
        }
143 111
144 111
        // If we are here then this is n object schema.
145 111
        list($properties, $required) = $this->parseProperties($arr);
146
147
        $result = [
148 111
            'type' => 'object',
149
            'properties' => $properties,
150
            'required' => $required
151
        ];
152
153
        return array_filter($result);
154
    }
155
156
    /**
157
     * Parse a schema node.
158
     *
159 172
     * @param array|Schema $node The node to parse.
160 172
     * @param mixed $value Additional information from the node.
161 66
     * @return array|\ArrayAccess Returns a JSON schema compatible node.
162
     * @throws ParseException Throws an exception if there was a problem parsing the schema node.
163
     */
164
    private function parseNode($node, $value = null) {
165
        if (is_array($value)) {
166 66
            if (is_array($node['type'])) {
167 66
                trigger_error('Schemas with multiple types are deprecated.', E_USER_DEPRECATED);
168 11
            }
169
170 4
            // The value describes a bit more about the schema.
171
            switch ($node['type']) {
172 7
                case 'array':
173
                    if (isset($value['items'])) {
174 11
                        // The value includes array schema information.
175 56
                        $node = array_replace($node, $value);
176
                    } else {
177 12
                        $node['items'] = $this->parseInternal($value);
178
                    }
179
                    break;
180 12
                case 'object':
181 12
                    // The value is a schema of the object.
182 12
                    if (isset($value['properties'])) {
183
                        list($node['properties']) = $this->parseProperties($value['properties']);
184
                    } else {
185 12
                        list($node['properties'], $required) = $this->parseProperties($value);
186
                        if (!empty($required)) {
187 44
                            $node['required'] = $required;
188 66
                        }
189
                    }
190 132
                    break;
191 102
                default:
192 6
                    $node = array_replace($node, $value);
193 98
                    break;
194 102
            }
195
        } elseif (is_string($value)) {
196 35
            if ($node['type'] === 'array' && $arrType = $this->getType($value)) {
197
                $node['items'] = ['type' => $arrType];
198 31
            } elseif (!empty($value)) {
199
                $node['description'] = $value;
200
            }
201 31
        } elseif ($value === null) {
202 1
            // Parse child elements.
203
            if ($node['type'] === 'array' && isset($node['items'])) {
204
                // The value includes array schema information.
205
                $node['items'] = $this->parseInternal($node['items']);
206 172
            } elseif ($node['type'] === 'object' && isset($node['properties'])) {
207 171
                list($node['properties']) = $this->parseProperties($node['properties']);
208 1
            }
209
        }
210 171
211
        if (is_array($node)) {
212 171
            if (!empty($node['allowNull'])) {
213 4
                $node['nullable'] = true;
214
            }
215
            unset($node['allowNull']);
216
217 172
            if ($node['type'] === null || $node['type'] === []) {
218
                unset($node['type']);
219
            }
220
        }
221
222
        return $node;
223
    }
224
225
    /**
226
     * Parse the schema for an object's properties.
227 112
     *
228 112
     * @param array $arr An object property schema.
229 112
     * @return array Returns a schema array suitable to be placed in the **properties** key of a schema.
230 112
     * @throws ParseException Throws an exception if a property name cannot be determined for an array item.
231
     */
232 112
    private function parseProperties(array $arr): array {
233 82
        $properties = [];
234 82
        $requiredProperties = [];
235 82
        foreach ($arr as $key => $value) {
236
            // Fix a schema specified as just a value.
237
            if (is_int($key)) {
238
                if (is_string($value)) {
239
                    $key = $value;
240
                    $value = '';
241
                } else {
242 112
                    throw new ParseException("Schema at position $key is not a valid parameter.", 500);
243
                }
244 112
            }
245
246 112
            // The parameter is defined in the key.
247 112
            list($name, $param, $required) = $this->parseShortParam($key, $value);
248 112
249
            $node = $this->parseNode($param, $value);
250
251 112
            $properties[$name] = $node;
252
            if ($required) {
253
                $requiredProperties[] = $name;
254
            }
255
        }
256
        return [$properties, $requiredProperties];
257
    }
258
259
    /**
260
     * Parse a short parameter string into a full array parameter.
261
     *
262 174
     * @param string $key The short parameter string to parse.
263
     * @param array $value An array of other information that might help resolve ambiguity.
264 174
     * @return array Returns an array in the form `[string name, array param, bool required]`.
265 70
     * @throws ParseException Throws an exception if the short param is not in the correct format.
266 70
     */
267
    public function parseShortParam(string $key, $value = []): array {
268 126
        // Is the parameter optional?
269
        if (substr($key, -1) === '?') {
270
            $required = false;
271
            $key = substr($key, 0, -1);
272 174
        } else {
273 168
            $required = true;
274 168
        }
275
276
        // Check for a type.
277 168
        if (false !== ($pos = strrpos($key, ':'))) {
278 2
            $name = substr($key, 0, $pos);
279 168
            $typeStr = substr($key, $pos + 1);
280
281
            // Kludge for names with colons that are not specifying an array of a type.
282 16
            if (isset($value['type']) && 'array' !== $this->getType($typeStr)) {
283 16
                $name = $key;
284
                $typeStr = '';
285 174
            }
286 174
        } else {
287
            $name = $key;
288 174
            $typeStr = '';
289 166
        }
290 166
        $types = [];
291 166
        $param = [];
292 166
293 1
        if (!empty($typeStr)) {
294 165
            $shortTypes = explode('|', $typeStr);
295 9
            foreach ($shortTypes as $alias) {
296 9
                $found = $this->getType($alias);
297 157
                if ($found === null) {
298 12
                    throw new ParseException("Unknown type '$alias'.", 500);
299 12
                } elseif ($found === 'datetime') {
300 151
                    $param['format'] = 'date-time';
301 11
                    $types[] = 'string';
302
                } elseif ($found === 'timestamp') {
303 165
                    $param['format'] = 'timestamp';
304
                    $types[] = 'integer';
305
                } elseif ($found === 'null') {
306
                    $nullable = true;
307
                } else {
308 173
                    $types[] = $found;
309 6
                }
310 1
            }
311
        }
312 6
313
        if ($value instanceof Schema) {
314 171
            if (count($types) === 1 && $types[0] === 'array') {
315 10
                $param += ['type' => $types[0], 'items' => $value];
316
            } else {
317 10
                $param = $value;
318
            }
319
        } elseif (isset($value['type'])) {
320
            $param = $value + $param;
321 10
322
            if (!empty($types) && $types !== (array)$param['type']) {
323
                $typesStr = implode('|', $types);
324 166
                $paramTypesStr = implode('|', (array)$param['type']);
325
326
                throw new ParseException("Type mismatch between $typesStr and {$paramTypesStr} for field $name.", 500);
327 166
            }
328 4
        } else {
329
            if (empty($types) && !empty($parts[1])) {
330 165
                throw new ParseException("Invalid type {$parts[1]} for field $name.", 500);
331
            }
332
            if (empty($types)) {
333
                $param += ['type' => null];
334 166
            } else {
335 41
                $param += ['type' => count($types) === 1 ? $types[0] : $types];
336
            }
337
338
            // Parsed required strings have a minimum length of 1.
339 173
            if (in_array('string', $types) && !empty($name) && $required && (!isset($value['default']) || $value['default'] !== '')) {
340 11
                $param['minLength'] = 1;
341
            }
342
        }
343 173
344 1
        if (!empty($nullable)) {
345
            $param['nullable'] = true;
346
        }
347 172
348
        if (is_array($param['type'])) {
349
            trigger_error('Schemas with multiple types is deprecated.', E_USER_DEPRECATED);
350
        }
351
352
        return [$name, $param, $required];
353
    }
354
355
    /**
356 168
     * Look up a type based on its alias.
357 168
     *
358
     * @param string $alias The type alias or type name to lookup.
359
     * @return mixed
360 168
     */
361 168
    private function getType($alias) {
362 168
        if (isset(self::$types[$alias])) {
363
            return $alias;
364
        }
365 12
        foreach (self::$types as $type => $aliases) {
366
            if (in_array($alias, $aliases, true)) {
367
                return $type;
368
            }
369
        }
370
        return null;
371
    }
372
373
    /**
374 36
     * Unescape a JSON reference segment.
375 36
     *
376
     * @param string $str The segment to unescapeRef.
377
     * @return string Returns the unescaped string.
378
     */
379
    public static function unescapeRef(string $str): string {
380
        return str_replace(['~1', '~0'], ['/', '~'], $str);
381
    }
382
383
    /**
384 36
     * Explode a references into its individual parts.
385 36
     *
386
     * @param string $ref A JSON reference.
387
     * @return string[] The individual parts of the reference.
388
     */
389
    public static function explodeRef(string $ref): array {
390
        return array_map([self::class, 'unescapeRef'], explode('/', $ref));
391
    }
392
393 1
    /**
394 1
     * Grab the schema's current description.
395
     *
396
     * @return string
397
     */
398
    public function getDescription(): string {
399
        return $this->schema['description'] ?? '';
400
    }
401
402
    /**
403 1
     * Set the description for the schema.
404 1
     *
405 1
     * @param string $description The new description.
406
     * @return $this
407
     */
408
    public function setDescription(string $description) {
409
        $this->schema['description'] = $description;
410
        return $this;
411
    }
412
413 1
    /**
414 1
     * Get the schema's title.
415
     *
416
     * @return string Returns the title.
417
     */
418
    public function getTitle(): string {
419
        return $this->schema['title'] ?? '';
420
    }
421
422 1
    /**
423 1
     * Set the schema's title.
424 1
     *
425
     * @param string $title The new title.
426
     */
427
    public function setTitle(string $title) {
428
        $this->schema['title'] = $title;
429
    }
430
431
    /**
432
     * Get a schema field.
433 10
     *
434 10
     * @param string|array $path The JSON schema path of the field with parts separated by dots.
435 10
     * @param mixed $default The value to return if the field isn't found.
436 1
     * @return mixed Returns the field value or `$default`.
437 1
     */
438
    public function getField($path, $default = null) {
439 9
        if (is_string($path)) {
440
            if (strpos($path, '.') !== false && strpos($path, '/') === false) {
441
                trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED);
442
                $path = explode('.', $path);
443 10
            } else {
444 10
                $path = explode('/', $path);
445 10
            }
446 10
        }
447 1
448 1
        $value = $this->schema;
449
        foreach ($path as $i => $subKey) {
450 10
            if (is_array($value) && isset($value[$subKey])) {
451
                $value = $value[$subKey];
452
            } elseif ($value instanceof Schema) {
453 10
                return $value->getField(array_slice($path, $i), $default);
454
            } else {
455
                return $default;
456
            }
457
        }
458
        return $value;
459
    }
460
461
    /**
462
     * Set a schema field.
463 7
     *
464 7
     * @param string|array $path The JSON schema path of the field with parts separated by slashes.
465 7
     * @param mixed $value The new value.
466 1
     * @return $this
467 1
     */
468
    public function setField($path, $value) {
469 6
        if (is_string($path)) {
470
            if (strpos($path, '.') !== false && strpos($path, '/') === false) {
471
                trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED);
472
                $path = explode('.', $path);
473 7
            } else {
474 7
                $path = explode('/', $path);
475 7
            }
476 7
        }
477 7
478
        $selection = &$this->schema;
479 1
        foreach ($path as $i => $subSelector) {
480 1
            if (is_array($selection)) {
481 1
                if (!isset($selection[$subSelector])) {
482
                    $selection[$subSelector] = [];
483
                }
484
            } elseif ($selection instanceof Schema) {
485 7
                $selection->setField(array_slice($path, $i), $value);
486
                return $this;
487
            } else {
488 7
                $selection = [$subSelector => []];
489 7
            }
490
            $selection = &$selection[$subSelector];
491
        }
492
493
        $selection = $value;
494
        return $this;
495
    }
496
497 1
    /**
498 1
     * Return the validation flags.
499
     *
500
     * @return int Returns a bitwise combination of flags.
501
     */
502
    public function getFlags(): int {
503
        return $this->flags;
504
    }
505
506
    /**
507 8
     * Set the validation flags.
508 8
     *
509
     * @param int $flags One or more of the **Schema::FLAG_*** constants.
510 8
     * @return Schema Returns the current instance for fluent calls.
511
     */
512
    public function setFlags(int $flags) {
513
        $this->flags = $flags;
514
515
        return $this;
516
    }
517
518
    /**
519
     * Set a flag.
520 1
     *
521 1
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
522 1
     * @param bool $value Either true or false.
523
     * @return $this
524 1
     */
525
    public function setFlag(int $flag, bool $value) {
526 1
        if ($value) {
527
            $this->flags = $this->flags | $flag;
528
        } else {
529
            $this->flags = $this->flags & ~$flag;
530
        }
531
        return $this;
532
    }
533
534
    /**
535 4
     * Merge a schema with this one.
536 4
     *
537 4
     * @param Schema $schema A scheme instance. Its parameters will be merged into the current instance.
538
     * @return $this
539
     */
540
    public function merge(Schema $schema) {
541
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), true, true);
542
        return $this;
543
    }
544
545
    /**
546
     * The internal implementation of schema merging.
547
     *
548
     * @param array $target The target of the merge.
549 7
     * @param array $source The source of the merge.
550
     * @param bool $overwrite Whether or not to replace values.
551 7
     * @param bool $addProperties Whether or not to add object properties to the target.
552 5
     * @return array
553
     */
554 5
    private function mergeInternal(array &$target, array $source, $overwrite = true, $addProperties = true) {
555 4
        // We need to do a fix for required properties here.
556 4
        if (isset($target['properties']) && !empty($source['required'])) {
557
            $required = isset($target['required']) ? $target['required'] : [];
558 4
559
            if (isset($source['required']) && $addProperties) {
560
                $newProperties = array_diff(array_keys($source['properties']), array_keys($target['properties']));
561
                $newRequired = array_intersect($source['required'], $newProperties);
562
563 7
                $required = array_merge($required, $newRequired);
564 7
            }
565 7
        }
566
567 2
568 2
        foreach ($source as $key => $val) {
569 2
            if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) {
570
                if ($key === 'properties' && !$addProperties) {
571 2
                    // We just want to merge the properties that exist in the destination.
572 2
                    foreach ($val as $name => $prop) {
573 1
                        if (isset($target[$key][$name])) {
574
                            $targetProp = &$target[$key][$name];
575 1
576 2
                            if (is_array($targetProp) && is_array($prop)) {
577
                                $this->mergeInternal($targetProp, $prop, $overwrite, $addProperties);
578
                            } elseif (is_array($targetProp) && $prop instanceof Schema) {
579
                                $this->mergeInternal($targetProp, $prop->getSchemaArray(), $overwrite, $addProperties);
580 7
                            } elseif ($overwrite) {
581 5
                                $targetProp = $prop;
582
                            }
583 3
                        }
584 3
                    }
585 3
                } elseif (isset($val[0]) || isset($target[$key][0])) {
586
                    if ($overwrite) {
587 5
                        // This is a numeric array, so just do a merge.
588
                        $merged = array_merge($target[$key], $val);
589
                        if (is_string($merged[0])) {
590 7
                            $merged = array_keys(array_flip($merged));
591
                        }
592 7
                        $target[$key] = $merged;
593
                    }
594
                } else {
595 7
                    $target[$key] = $this->mergeInternal($target[$key], $val, $overwrite, $addProperties);
596
                }
597
            } elseif (!$overwrite && array_key_exists($key, $target) && !is_array($val)) {
598
                // Do nothing, we aren't replacing.
599 7
            } else {
600 5
                $target[$key] = $val;
601 1
            }
602
        }
603 5
604
        if (isset($required)) {
605
            if (empty($required)) {
606
                unset($target['required']);
607 7
            } else {
608
                $target['required'] = $required;
609
            }
610
        }
611
612
        return $target;
613
    }
614
615
    /**
616 17
     * Returns the internal schema array.
617 17
     *
618
     * @return array
619
     * @see Schema::jsonSerialize()
620
     */
621
    public function getSchemaArray(): array {
622
        return $this->schema;
623
    }
624
625
    /**
626
     * Add another schema to this one.
627
     *
628
     * Adding schemas together is analogous to array addition. When you add a schema it will only add missing information.
629 4
     *
630 4
     * @param Schema $schema The schema to add.
631 4
     * @param bool $addProperties Whether to add properties that don't exist in this schema.
632
     * @return $this
633
     */
634
    public function add(Schema $schema, $addProperties = false) {
635
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), false, $addProperties);
636
        return $this;
637
    }
638
639
    /**
640
     * Add a custom filter to change data before validation.
641
     *
642
     * @param string $fieldname The name of the field to filter, if any.
643
     *
644 4
     * If you are adding a filter to a deeply nested field then separate the path with dots.
645 4
     * @param callable $callback The callback to filter the field.
646 4
     * @param bool $validate Whether or not the filter should also validate. If true default validation is skipped.
647 4
     * @return $this
648
     */
649
    public function addFilter(string $fieldname, callable $callback, bool $validate = false) {
650
        $fieldname = $this->parseFieldSelector($fieldname);
651
        $this->filters[$fieldname][] = [$callback, $validate];
652
        return $this;
653
    }
654
655
    /**
656
     * Parse a nested field name selector.
657
     *
658
     * Field selectors should be separated by "/" characters, but may currently be separated by "." characters which
659 17
     * triggers a deprecated error.
660 17
     *
661 6
     * @param string $field The field selector.
662
     * @return string Returns the field selector in the correct format.
663
     */
664 11
    private function parseFieldSelector(string $field): string {
665 1
        if (strlen($field) === 0) {
666 1
            return $field;
667
        }
668 1
669 1
        if (strpos($field, '.') !== false) {
670
            if (strpos($field, '/') === false) {
671 1
                trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED);
672
673 11
                $parts = explode('.', $field);
674 1
                $parts = @array_map([$this, 'parseFieldSelector'], $parts); // silence because error triggered already.
675 1
676 10
                $field = implode('/', $parts);
677 3
            }
678 3
        } elseif ($field === '[]') {
679
            trigger_error('Field selectors with item selector "[]" must be converted to "items".', E_USER_DEPRECATED);
680
            $field = 'items';
681 11
        } elseif (strpos($field, '/') === false && !in_array($field, ['items', 'additionalProperties'], true)) {
682 1
            trigger_error("Field selectors must specify full schema paths. ($field)", E_USER_DEPRECATED);
683 1
            $field = "/properties/$field";
684
        }
685
686 11
        if (strpos($field, '[]') !== false) {
687
            trigger_error('Field selectors with item selector "[]" must be converted to "/items".', E_USER_DEPRECATED);
688
            $field = str_replace('[]', '/items', $field);
689
        }
690
691
        return ltrim($field, '/');
692
    }
693
694
    /**
695
     * Add a custom filter for a schema format.
696
     *
697
     * Schemas can use the `format` property to specify a specific format on a field. Adding a filter for a format
698
     * allows you to customize the behavior of that format.
699
     *
700 2
     * @param string $format The format to filter.
701 2
     * @param callable $callback The callback used to filter values.
702
     * @param bool $validate Whether or not the filter should also validate. If true default validation is skipped.
703
     * @return $this
704
     */
705 2
    public function addFormatFilter(string $format, callable $callback, bool $validate = false) {
706 2
        if (empty($format)) {
707
            throw new \InvalidArgumentException('The filter format cannot be empty.', 500);
708 2
        }
709
710
        $filter = "/format/$format";
711
        $this->filters[$filter][] = [$callback, $validate];
712
713
        return $this;
714
    }
715
716
    /**
717
     * Require one of a given set of fields in the schema.
718
     *
719 3
     * @param array $required The field names to require.
720 3
     * @param string $fieldname The name of the field to attach to.
721 3
     * @param int $count The count of required items.
722 3
     * @return Schema Returns `$this` for fluent calls.
723
     */
724 3
    public function requireOneOf(array $required, string $fieldname = '', int $count = 1) {
725 1
        $result = $this->addValidator(
726
            $fieldname,
727
            function ($data, ValidationField $field) use ($required, $count) {
728 2
                // This validator does not apply to sparse validation.
729 2
                if ($field->isSparse()) {
730
                    return true;
731 2
                }
732 2
733
                $hasCount = 0;
734 2
                $flattened = [];
735
736 1
                foreach ($required as $name) {
737 1
                    $flattened = array_merge($flattened, (array)$name);
738 1
739 1
                    if (is_array($name)) {
740
                        // This is an array of required names. They all must match.
741 1
                        $hasCountInner = 0;
742
                        foreach ($name as $nameInner) {
743
                            if (array_key_exists($nameInner, $data)) {
744 1
                                $hasCountInner++;
745 1
                            } else {
746
                                break;
747 2
                            }
748 1
                        }
749
                        if ($hasCountInner >= count($name)) {
750
                            $hasCount++;
751 2
                        }
752 2
                    } elseif (array_key_exists($name, $data)) {
753
                        $hasCount++;
754
                    }
755
756 2
                    if ($hasCount >= $count) {
757 1
                        return true;
758
                    }
759 1
                }
760
761
                if ($count === 1) {
762 2
                    $message = 'One of {properties} are required.';
763 2
                } else {
764 2
                    $message = '{count} of {properties} are required.';
765 2
                }
766
767 2
                $field->addError('oneOfRequired', [
768 3
                    'messageCode' => $message,
769
                    'properties' => $required,
770
                    'count' => $count
771 3
                ]);
772
                return false;
773
            }
774
        );
775
776
        return $result;
777
    }
778
779
    /**
780
     * Add a custom validator to to validate the schema.
781
     *
782
     * @param string $fieldname The name of the field to validate, if any.
783 5
     *
784 5
     * If you are adding a validator to a deeply nested field then separate the path with dots.
785 5
     * @param callable $callback The callback to validate with.
786 5
     * @return Schema Returns `$this` for fluent calls.
787
     */
788
    public function addValidator(string $fieldname, callable $callback) {
789
        $fieldname = $this->parseFieldSelector($fieldname);
790
        $this->validators[$fieldname][] = $callback;
791
        return $this;
792
    }
793
794
    /**
795
     * Validate data against the schema and return the result.
796
     *
797 45
     * @param mixed $data The data to validate.
798
     * @param array $options Validation options. See `Schema::validate()`.
799 45
     * @return bool Returns true if the data is valid. False otherwise.
800 31
     * @throws RefNotFoundException Throws an exception when there is an unknown `$ref` in the schema.
801 24
     */
802 24
    public function isValid($data, $options = []) {
803
        try {
804
            $this->validate($data, $options);
805
            return true;
806
        } catch (ValidationException $ex) {
807
            return false;
808
        }
809
    }
810
811
    /**
812
     * Validate data against the schema.
813
     *
814
     * @param mixed $data The data to validate.
815
     * @param array $options Validation options.
816
     *
817 240
     * - **sparse**: Whether or not this is a sparse validation.
818 240
     * @return mixed Returns a cleaned version of the data.
819 1
     * @throws ValidationException Throws an exception when the data does not validate against the schema.
820 1
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
821
     */
822 240
    public function validate($data, $options = []) {
823
        if (is_bool($options)) {
824
            trigger_error('The $sparse parameter is deprecated. Use [\'sparse\' => true] instead.', E_USER_DEPRECATED);
825 240
            $options = ['sparse' => true];
826 236
        }
827
        $options += ['sparse' => false];
828 236
829
830 232
        list($schema, $schemaPath) = $this->lookupSchema($this->schema, '');
831
        $field = new ValidationField($this->createValidation(), $schema, '', $schemaPath, $options);
832 7
833
        $clean = $this->validateField($data, $field);
834
835 232
        if (Invalid::isInvalid($clean) && $field->isValid()) {
836 82
            // This really shouldn't happen, but we want to protect against seeing the invalid object.
837
            $field->addError('invalid', ['messageCode' => 'The value is invalid.']);
838
        }
839 166
840
        if (!$field->getValidation()->isValid()) {
841
            throw new ValidationException($field->getValidation());
842
        }
843
844
        return $clean;
845
    }
846
847
    /**
848
     * Lookup a schema based on a schema node.
849
     *
850
     * The node could be a schema array, `Schema` object, or a schema reference.
851
     *
852
     * @param mixed $schema The schema node to lookup with.
853
     * @param string $schemaPath The current path of the schema.
854 240
     * @return array Returns an array with two elements:
855 240
     * - Schema|array|\ArrayAccess The schema that was found.
856 6
     * - string The path of the schema. This is either the reference or the `$path` parameter for inline schemas.
857
     * @throws RefNotFoundException Throws an exception when a reference could not be found.
858 240
     */
859 240
    private function lookupSchema($schema, string $schemaPath) {
860
        if ($schema instanceof Schema) {
861
            return [$schema, $schemaPath];
862 240
        } else {
863 33
            $lookup = $this->getRefLookup();
864
            $visited = [];
865 33
866 1
            // Resolve any references first.
867
            while (!empty($schema['$ref'])) {
868 33
                $schemaPath = $schema['$ref'];
869
870
                if (isset($visited[$schemaPath])) {
871 33
                    throw new RefNotFoundException("Cyclical reference cannot be resolved. ($schemaPath)", 508);
872 1
                }
873 1
                $visited[$schemaPath] = true;
874
875 32
                try {
876 3
                    $schema = call_user_func($lookup, $schemaPath);
877
                } catch (\Exception $ex) {
878
                    throw new RefNotFoundException($ex->getMessage(), $ex->getCode(), $ex);
879
                }
880 236
                if ($schema === null) {
881
                    throw new RefNotFoundException("Schema reference could not be found. ($schemaPath)");
882
                }
883
            }
884
885
            return [$schema, $schemaPath];
886
        }
887
    }
888
889 240
    /**
890 240
     * Get the function used to resolve `$ref` lookups.
891
     *
892
     * @return callable Returns the current `$ref` lookup.
893
     */
894
    public function getRefLookup(): callable {
895
        return $this->refLookup;
896
    }
897
898
    /**
899
     * Set the function used to resolve `$ref` lookups.
900
     *
901
     * The function should have the following signature:
902
     *
903
     * ```php
904
     * function(string $ref): array|Schema|null {
905
     *     ...
906
     * }
907
     * ```
908 10
     * The function should take a string reference and return a schema array, `Schema` or **null**.
909 10
     *
910 10
     * @param callable $refLookup The new lookup function.
911
     * @return $this
912
     */
913
    public function setRefLookup(callable $refLookup) {
914
        $this->refLookup = $refLookup;
915
        return $this;
916
    }
917
918 236
    /**
919 236
     * Create a new validation instance.
920
     *
921
     * @return Validation Returns a validation object.
922
     */
923
    protected function createValidation(): Validation {
924
        return call_user_func($this->getValidationFactory());
925
    }
926
927 236
    /**
928 236
     * Get factory used to create validation objects.
929
     *
930
     * @return callable Returns the current factory.
931
     */
932
    public function getValidationFactory(): callable {
933
        return $this->validationFactory;
934
    }
935
936
    /**
937 2
     * Set the factory used to create validation objects.
938 2
     *
939 2
     * @param callable $validationFactory The new factory.
940 2
     * @return $this
941
     */
942
    public function setValidationFactory(callable $validationFactory) {
943
        $this->validationFactory = $validationFactory;
944
        $this->validationClass = null;
945
        return $this;
946
    }
947
948
    /**
949
     * Validate a field.
950
     *
951
     * @param mixed $value The value to validate.
952 236
     * @param ValidationField $field A validation object to add errors to.
953 236
     * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value
954 236
     * is completely invalid.
955
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
956 236
     */
957 3
    protected function validateField($value, ValidationField $field) {
958 233
        $validated = false;
959
        $result = $value = $this->filterField($value, $field, $validated);
960 5
961 2
        if ($validated) {
962
            return $result;
963 5
        } elseif ($field->getField() instanceof Schema) {
964
            try {
965 233
                $result = $field->getField()->validate($value, $field->getOptions());
966 14
            } catch (ValidationException $ex) {
967
                // The validation failed, so merge the validations together.
968
                $field->getValidation()->merge($ex->getValidation(), $field->getName());
969 233
            }
970 12
        } elseif (($value === null || ($value === '' && !$field->hasType('string'))) && ($field->val('nullable') || $field->hasType('null'))) {
971
            $result = null;
972
        } else {
973 232
            // Look for a discriminator.
974 225
            if (!empty($field->val('discriminator'))) {
975 4
                $field = $this->resolveDiscriminator($value, $field);
976
            }
977
978 224
            if ($field !== null) {
979 224
                if($field->hasAllOf()) {
980 29
                    $result = $this->validateAllOf($value, $field);
981
                } else {
982 203
                    // Validate the field's type.
983
                    $type = $field->getType();
984
                    if (is_array($type)) {
985 224
                        $result = $this->validateMultipleTypes($value, $type, $field);
986 224
                    } else {
987
                        $result = $this->validateSingleType($value, $type, $field);
988
                    }
989
990 7
                    if (Invalid::isValid($result)) {
991
                        $result = $this->validateEnum($result, $field);
992
                    }
993
                }
994
            } else {
995 231
                $result = Invalid::value();
996 215
            }
997
        }
998
999 231
        // Validate a custom field validator.
1000
        if (Invalid::isValid($result)) {
1001
            $this->callValidators($result, $field);
1002
        }
1003
1004
        return $result;
1005
    }
1006
1007
    /**
1008
     * Filter a field's value using built in and custom filters.
1009
     *
1010 236
     * @param mixed $value The original value of the field.
1011
     * @param ValidationField $field The field information for the field.
1012 236
     * @param bool $validated Whether or not a filter validated the value.
1013 8
     * @return mixed Returns the filtered field or the original field value if there are no filters.
1014 8
     */
1015 4
    private function filterField($value, ValidationField $field, bool &$validated = false) {
1016 4
        // Check for limited support for Open API style.
1017
        if (!empty($field->val('style')) && is_string($value)) {
1018
            $doFilter = true;
1019
            if ($field->hasType('boolean') && in_array($value, ['true', 'false', '0', '1'], true)) {
1020 8
                $doFilter = false;
1021 4
            } elseif (($field->hasType('integer') || $field->hasType('number')) && is_numeric($value)) {
1022 4
                $doFilter = false;
1023 2
            }
1024 2
1025 2
            if ($doFilter) {
1026 1
                switch ($field->val('style')) {
1027 1
                    case 'form':
1028 1
                        $value = explode(',', $value);
1029 1
                        break;
1030 1
                    case 'spaceDelimited':
1031
                        $value = explode(' ', $value);
1032
                        break;
1033
                    case 'pipeDelimited':
1034
                        $value = explode('|', $value);
1035 236
                        break;
1036
                }
1037 236
            }
1038
        }
1039
1040
        $value = $this->callFilters($value, $field, $validated);
1041
1042
        return $value;
1043
    }
1044
1045
    /**
1046
     * Call all of the filters attached to a field.
1047
     *
1048 236
     * @param mixed $value The field value being filtered.
1049
     * @param ValidationField $field The validation object.
1050 236
     * @param bool $validated Whether or not a filter validated the field.
1051 236
     * @return mixed Returns the filtered value. If there are no filters for the field then the original value is returned.
1052 4
     */
1053 4
    private function callFilters($value, ValidationField $field, bool &$validated = false) {
1054 4
        // Strip array references in the name except for the last one.
1055
        $key = $field->getSchemaPath();
1056 4
        if (!empty($this->filters[$key])) {
1057 4
            foreach ($this->filters[$key] as list($filter, $validate)) {
1058
                $value = call_user_func($filter, $value, $field);
1059
                $validated |= $validate;
1060
1061 235
                if (Invalid::isInvalid($value)) {
1062 235
                    return $value;
1063 2
                }
1064 2
            }
1065 2
        }
1066
        $key = '/format/'.$field->val('format');
1067 2
        if (!empty($this->filters[$key])) {
1068 2
            foreach ($this->filters[$key] as list($filter, $validate)) {
1069
                $value = call_user_func($filter, $value, $field);
1070
                $validated |= $validate;
1071
1072
                if (Invalid::isInvalid($value)) {
1073 235
                    return $value;
1074
                }
1075
            }
1076
        }
1077
1078
        return $value;
1079
    }
1080
1081
    /**
1082
     * Validate a field against multiple basic types.
1083
     *
1084
     * The first validation that passes will be returned. If no type can be validated against then validation will fail.
1085
     *
1086
     * @param mixed $value The value to validate.
1087
     * @param string[] $types The types to validate against.
1088 29
     * @param ValidationField $field Contains field and validation information.
1089 29
     * @return mixed Returns the valid value or `Invalid`.
1090
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
1091
     * @deprecated Multiple types are being removed next version.
1092 29
     */
1093 29
    private function validateMultipleTypes($value, array $types, ValidationField $field) {
1094 4
        trigger_error('Multiple schema types are deprecated.', E_USER_DEPRECATED);
1095 4
1096
        // First check for an exact type match.
1097 4
        switch (gettype($value)) {
1098 26
            case 'boolean':
1099 7
                if (in_array('boolean', $types)) {
1100 5
                    $singleType = 'boolean';
1101 2
                }
1102 1
                break;
1103
            case 'integer':
1104 7
                if (in_array('integer', $types)) {
1105 21
                    $singleType = 'integer';
1106 4
                } elseif (in_array('number', $types)) {
1107 4
                    $singleType = 'number';
1108
                }
1109
                break;
1110
            case 'double':
1111 4
                if (in_array('number', $types)) {
1112 18
                    $singleType = 'number';
1113 9
                } elseif (in_array('integer', $types)) {
1114 1
                    $singleType = 'integer';
1115 8
                }
1116 4
                break;
1117
            case 'string':
1118 9
                if (in_array('datetime', $types) && preg_match(self::$DATE_REGEX, $value)) {
1119 10
                    $singleType = 'datetime';
1120 10
                } elseif (in_array('string', $types)) {
1121 1
                    $singleType = 'string';
1122 9
                }
1123
                break;
1124 9
            case 'array':
1125 9
                if (in_array('array', $types) && in_array('object', $types)) {
1126
                    $singleType = isset($value[0]) || empty($value) ? 'array' : 'object';
1127 10
                } elseif (in_array('object', $types)) {
1128 1
                    $singleType = 'object';
1129
                } elseif (in_array('array', $types)) {
1130
                    $singleType = 'array';
1131
                }
1132
                break;
1133
            case 'NULL':
1134 29
                if (in_array('null', $types)) {
1135 25
                    $singleType = $this->validateSingleType($value, 'null', $field);
1136
                }
1137
                break;
1138
        }
1139 6
        if (!empty($singleType)) {
1140
            return $this->validateSingleType($value, $singleType, $field);
1141
        }
1142 6
1143 6
        // Clone the validation field to collect errors.
1144 6
        $typeValidation = new ValidationField(new Validation(), $field->getField(), '', '', $field->getOptions());
1145 6
1146
        // Try and validate against each type.
1147
        foreach ($types as $type) {
1148
            $result = $this->validateSingleType($value, $type, $typeValidation);
1149
            if (Invalid::isValid($result)) {
1150
                return $result;
1151
            }
1152
        }
1153
1154
        // Since we got here the value is invalid.
1155
        $field->merge($typeValidation->getValidation());
1156
        return Invalid::value();
1157
    }
1158
1159
    /**
1160
     * Validate a field against a single type.
1161
     *
1162
     * @param mixed $value The value to validate.
1163
     * @param string $type The type to validate against.
1164 224
     * @param ValidationField $field Contains field and validation information.
1165
     * @return mixed Returns the valid value or `Invalid`.
1166 224
     * @throws \InvalidArgumentException Throws an exception when `$type` is not recognized.
1167 34
     * @throws RefNotFoundException Throws an exception when internal validation has a reference that isn't found.
1168 34
     */
1169 204
    protected function validateSingleType($value, string $type, ValidationField $field) {
1170 66
        switch ($type) {
1171 66
            case 'boolean':
1172 184
                $result = $this->validateBoolean($value, $field);
1173 17
                break;
1174 17
            case 'integer':
1175 175
                $result = $this->validateInteger($value, $field);
1176 96
                break;
1177 96
            case 'number':
1178 151
                $result = $this->validateNumber($value, $field);
1179 1
                break;
1180 1
            case 'string':
1181 1
                $result = $this->validateString($value, $field);
1182 151
                break;
1183 2
            case 'timestamp':
1184 2
                trigger_error('The timestamp type is deprecated. Use an integer with a format of timestamp instead.', E_USER_DEPRECATED);
1185 2
                $result = $this->validateTimestamp($value, $field);
1186 150
                break;
1187 38
            case 'datetime':
1188 38
                trigger_error('The datetime type is deprecated. Use a string with a format of date-time instead.', E_USER_DEPRECATED);
1189 130
                $result = $this->validateDatetime($value, $field);
1190 128
                break;
1191 126
            case 'array':
1192 6
                $result = $this->validateArray($value, $field);
1193 1
                break;
1194 1
            case 'object':
1195 5
                $result = $this->validateObject($value, $field);
1196
                break;
1197 5
            case 'null':
1198 5
                $result = $this->validateNull($value, $field);
1199
                break;
1200
            case '':
1201
                // No type was specified so we are valid.
1202 224
                $result = $value;
1203
                break;
1204
            default:
1205
                throw new \InvalidArgumentException("Unrecognized type $type.", 500);
1206
        }
1207
        return $result;
1208
    }
1209
1210
    /**
1211
     * Validate a boolean value.
1212 34
     *
1213 34
     * @param mixed $value The value to validate.
1214 34
     * @param ValidationField $field The validation results to add.
1215 4
     * @return bool|Invalid Returns the cleaned value or invalid if validation fails.
1216 4
     */
1217
    protected function validateBoolean($value, ValidationField $field) {
1218
        $value = $value === null ? $value : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
1219 31
        if ($value === null) {
1220
            $field->addTypeError($value, 'boolean');
1221
            return Invalid::value();
1222
        }
1223
1224
        return $value;
1225
    }
1226
1227
    /**
1228
     * Validate and integer.
1229 66
     *
1230 66
     * @param mixed $value The value to validate.
1231 7
     * @param ValidationField $field The validation results to add.
1232
     * @return int|Invalid Returns the cleaned value or **null** if validation fails.
1233
     */
1234 61
    protected function validateInteger($value, ValidationField $field) {
1235
        if ($field->val('format') === 'timestamp') {
1236 61
            return $this->validateTimestamp($value, $field);
1237 11
        }
1238 11
1239
        $result = filter_var($value, FILTER_VALIDATE_INT);
1240
1241 54
        if ($result === false) {
1242
            $field->addTypeError($value, 'integer');
1243 54
            return Invalid::value();
1244
        }
1245
1246
        $result = $this->validateNumberProperties($result, $field);
1247
1248
        return $result;
1249
    }
1250
1251
    /**
1252
     * Validate a unix timestamp.
1253 8
     *
1254 8
     * @param mixed $value The value to validate.
1255 3
     * @param ValidationField $field The field being validated.
1256 5
     * @return int|Invalid Returns a valid timestamp or invalid if the value doesn't validate.
1257 1
     */
1258
    protected function validateTimestamp($value, ValidationField $field) {
1259 4
        if (is_numeric($value) && $value > 0) {
1260 4
            $result = (int)$value;
1261
        } elseif (is_string($value) && $ts = strtotime($value)) {
1262 8
            $result = $ts;
1263
        } else {
1264
            $field->addTypeError($value, 'timestamp');
1265
            $result = Invalid::value();
1266
        }
1267
        return $result;
1268
    }
1269
1270
    /**
1271
     * Validate specific numeric validation properties.
1272 64
     *
1273 64
     * @param int|float $value The value to test.
1274
     * @param ValidationField $field Field information.
1275 64
     * @return int|float|Invalid Returns the number of invalid.
1276 4
     */
1277
    private function validateNumberProperties($value, ValidationField $field) {
1278 4
        $count = $field->getErrorCount();
1279 2
1280
        if ($multipleOf = $field->val('multipleOf')) {
1281
            $divided = $value / $multipleOf;
1282
1283 64
            if ($divided != round($divided)) {
1284 4
                $field->addError('multipleOf', ['messageCode' => 'The value must be a multiple of {multipleOf}.', 'multipleOf' => $multipleOf]);
1285
            }
1286 4
        }
1287 2
1288 1
        if ($maximum = $field->val('maximum')) {
1289
            $exclusive = $field->val('exclusiveMaximum');
1290 1
1291
            if ($value > $maximum || ($exclusive && $value == $maximum)) {
1292
                if ($exclusive) {
1293
                    $field->addError('maximum', ['messageCode' => 'The value must be less than {maximum}.', 'maximum' => $maximum]);
1294
                } else {
1295 64
                    $field->addError('maximum', ['messageCode' => 'The value must be less than or equal to {maximum}.', 'maximum' => $maximum]);
1296 4
                }
1297
            }
1298 4
        }
1299 2
1300 1
        if ($minimum = $field->val('minimum')) {
1301
            $exclusive = $field->val('exclusiveMinimum');
1302 1
1303
            if ($value < $minimum || ($exclusive && $value == $minimum)) {
1304
                if ($exclusive) {
1305
                    $field->addError('minimum', ['messageCode' => 'The value must be greater than {minimum}.', 'minimum' => $minimum]);
1306
                } else {
1307 64
                    $field->addError('minimum', ['messageCode' => 'The value must be greater than or equal to {minimum}.', 'minimum' => $minimum]);
1308
                }
1309
            }
1310
        }
1311
1312
        return $field->getErrorCount() === $count ? $value : Invalid::value();
1313
    }
1314
1315
    /**
1316
     * Validate a float.
1317 17
     *
1318 17
     * @param mixed $value The value to validate.
1319 17
     * @param ValidationField $field The validation results to add.
1320 4
     * @return float|Invalid Returns a number or **null** if validation fails.
1321 4
     */
1322
    protected function validateNumber($value, ValidationField $field) {
1323
        $result = filter_var($value, FILTER_VALIDATE_FLOAT);
1324 13
        if ($result === false) {
1325
            $field->addTypeError($value, 'number');
1326 13
            return Invalid::value();
1327
        }
1328
1329
        $result = $this->validateNumberProperties($result, $field);
1330
1331
        return $result;
1332
    }
1333
1334
    /**
1335
     * Validate a string.
1336 96
     *
1337 96
     * @param mixed $value The value to validate.
1338 12
     * @param ValidationField $field The validation results to add.
1339 12
     * @return string|Invalid Returns the valid string or **null** if validation fails.
1340
     */
1341
    protected function validateString($value, ValidationField $field) {
1342 85
        if ($field->val('format') === 'date-time') {
1343 83
            $result = $this->validateDatetime($value, $field);
1344
            return $result;
1345 5
        }
1346 5
1347
        if (is_string($value) || is_numeric($value)) {
1348
            $value = $result = (string)$value;
1349 83
        } else {
1350 4
            $field->addTypeError($value, 'string');
1351 4
            return Invalid::value();
1352
        }
1353 4
1354 4
        $strFn = $this->hasFlag(self::VALIDATE_STRING_LENGTH_AS_UNICODE) ? "mb_strlen" : "strlen";
1355
        $strLen = $strFn($value);
1356
        if (($minLength = $field->val('minLength', 0)) > 0 && $strLen < $minLength) {
1357
            $field->addError(
1358 83
                'minLength',
1359 1
                [
1360 1
                    'messageCode' => 'The value should be at least {minLength} {minLength,plural,character,characters} long.',
1361
                    'minLength' => $minLength,
1362 1
                ]
1363 1
            );
1364 1
        }
1365
1366
        if (($maxLength = $field->val('maxLength', 0)) > 0 && $strLen > $maxLength) {
1367
            $field->addError(
1368 83
                'maxLength',
1369 4
                [
1370
                    'messageCode' => 'The value is {overflow} {overflow,plural,character,characters} too long.',
1371 4
                    'maxLength' => $maxLength,
1372 2
                    'overflow' => $strLen - $maxLength,
1373 2
                ]
1374
            );
1375 2
        }
1376 2
        if ($pattern = $field->val('pattern')) {
1377
            $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`';
1378
1379
            if (!preg_match($regex, $value)) {
1380
                $field->addError(
1381 83
                    'pattern',
1382 11
                    [
1383
                        'messageCode' => $field->val('x-patternMessageCode', 'The value doesn\'t match the required pattern {pattern}.'),
1384 11
                        'pattern' => $regex,
1385
                    ]
1386
                );
1387
            }
1388
        }
1389
        if ($format = $field->val('format')) {
1390 11
            $type = $format;
1391 1
            switch ($format) {
1392 1
                case 'date':
1393 10
                    $result = $this->validateDatetime($result, $field);
1394 1
                    if ($result instanceof \DateTimeInterface) {
1395 1
                        $result = $result->format("Y-m-d\T00:00:00P");
1396 1
                    }
1397 9
                    break;
1398 1
                case 'email':
1399 1
                    $result = filter_var($result, FILTER_VALIDATE_EMAIL);
1400 1
                    break;
1401 8
                case 'ipv4':
1402 1
                    $type = 'IPv4 address';
1403 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
1404 1
                    break;
1405 7
                case 'ipv6':
1406 7
                    $type = 'IPv6 address';
1407 7
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
1408 7
                    break;
1409
                case 'ip':
1410
                    $type = 'IP address';
1411
                    $result = filter_var($result, FILTER_VALIDATE_IP);
1412 11
                    break;
1413 5
                case 'uri':
1414 5
                    $type = 'URL';
1415 5
                    $result = filter_var($result, FILTER_VALIDATE_URL);
1416 5
                    break;
1417 5
                default:
1418
                    trigger_error("Unrecognized format '$format'.", E_USER_NOTICE);
1419
            }
1420
            if ($result === false) {
1421
                $field->addError('format', [
1422 83
                    'format' => $format,
1423 75
                    'formatCode' => $type,
1424
                    'value' => $value,
1425 12
                    'messageCode' => '{value} is not a valid {formatCode}.'
1426
                ]);
1427
            }
1428
        }
1429
1430
        if ($field->isValid()) {
1431
            return $result;
1432
        } else {
1433
            return Invalid::value();
1434
        }
1435
    }
1436 14
1437 14
    /**
1438
     * Validate a date time.
1439 11
     *
1440
     * @param mixed $value The value to validate.
1441 7
     * @param ValidationField $field The validation results to add.
1442 6
     * @return \DateTimeInterface|Invalid Returns the cleaned value or **null** if it isn't valid.
1443 6
     */
1444
    protected function validateDatetime($value, ValidationField $field) {
1445 6
        if ($value instanceof \DateTimeInterface) {
1446
            // do nothing, we're good
1447 1
        } elseif (is_string($value) && $value !== '' && !is_numeric($value)) {
1448 7
            try {
1449
                $dt = new \DateTimeImmutable($value);
1450 4
                if ($dt) {
1451
                    $value = $dt;
1452 1
                } else {
1453
                    $value = null;
1454 1
                }
1455
            } catch (\Throwable $ex) {
1456
                $value = Invalid::value();
1457 3
            }
1458
        } elseif (is_int($value) && $value > 0) {
1459
            try {
1460 14
                $value = new \DateTimeImmutable('@'.(string)round($value));
1461 4
            } catch (\Throwable $ex) {
1462
                $value = Invalid::value();
1463 14
            }
1464
        } else {
1465
            $value = Invalid::value();
1466
        }
1467
1468
        if (Invalid::isInvalid($value)) {
1469
            $field->addTypeError($value, 'date/time');
1470
        }
1471
        return $value;
1472
    }
1473
1474 4
    /**
1475 4
     * Recursively resolve allOf inheritance tree and return a merged resource specification
1476
     *
1477 4
     * @param ValidationField $field The validation results to add.
1478 4
     * @return array Returns an array of merged specs.
1479 1
     * @throws ParseException Throws an exception if an invalid allof member is provided
1480
     * @throws RefNotFoundException Throws an exception if the array has an items `$ref` that cannot be found.
1481
     */
1482 4
    private function resolveAllOfTree(ValidationField $field) {
1483
        $result = [];
1484 4
1485 4
        foreach($field->getAllOf() as $allof) {
1486 4
            if (!is_array($allof) || empty($allof)) {
1487 4
                throw new ParseException("Invalid allof member in {$field->getSchemaPath()}, array expected", 500);
1488 4
            }
1489 4
1490
            list ($items, $schemaPath) = $this->lookupSchema($allof, $field->getSchemaPath());
1491
1492 4
            $allOfValidation = new ValidationField(
1493 3
                $field->getValidation(),
1494
                $items,
1495 4
                '',
1496
                $schemaPath,
1497
                $field->getOptions()
1498
            );
1499 4
1500
            if($allOfValidation->hasAllOf()) {
1501
                $result = array_replace_recursive($result, $this->resolveAllOfTree($allOfValidation));
1502
            } else {
1503
                $result = array_replace_recursive($result, $items);
1504
            }
1505
        }
1506
1507
        return $result;
1508
    }
1509
1510 4
    /**
1511 4
     * Validate allof tree
1512 4
     *
1513 4
     * @param mixed $value The value to validate.
1514 3
     * @param ValidationField $field The validation results to add.
1515 3
     * @return array|Invalid Returns an array or invalid if validation fails.
1516 3
     * @throws RefNotFoundException Throws an exception if the array has an items `$ref` that cannot be found.
1517
     */
1518
    private function validateAllOf($value, ValidationField $field) {
1519 3
        $allOfValidation = new ValidationField(
1520
            $field->getValidation(),
1521
            $this->resolveAllOfTree($field),
1522
            '',
1523
            $field->getSchemaPath(),
1524
            $field->getOptions()
1525
        );
1526
1527
        return $this->validateField($value, $allOfValidation);
1528
    }
1529
1530 38
    /**
1531 38
     * Validate an array.
1532 6
     *
1533 6
     * @param mixed $value The value to validate.
1534
     * @param ValidationField $field The validation results to add.
1535 33
     * @return array|Invalid Returns an array or invalid if validation fails.
1536 1
     * @throws RefNotFoundException Throws an exception if the array has an items `$ref` that cannot be found.
1537 1
     */
1538
    protected function validateArray($value, ValidationField $field) {
1539 1
        if ((!is_array($value) || (count($value) > 0 && !array_key_exists(0, $value))) && !$value instanceof \Traversable) {
1540 1
            $field->addTypeError($value, 'array');
1541
            return Invalid::value();
1542
        } else {
1543
            if ((null !== $minItems = $field->val('minItems')) && count($value) < $minItems) {
1544 33
                $field->addError(
1545 1
                    'minItems',
1546 1
                    [
1547
                        'messageCode' => 'This must contain at least {minItems} {minItems,plural,item,items}.',
1548 1
                        'minItems' => $minItems,
1549 1
                    ]
1550
                );
1551
            }
1552
            if ((null !== $maxItems = $field->val('maxItems')) && count($value) > $maxItems) {
1553
                $field->addError(
1554 33
                    'maxItems',
1555 1
                    [
1556 1
                        'messageCode' => 'This must contain no more than {maxItems} {maxItems,plural,item,items}.',
1557
                        'maxItems' => $maxItems,
1558 1
                    ]
1559
                );
1560
            }
1561
1562
            if ($field->val('uniqueItems') && count($value) > count(array_unique($value))) {
1563 33
                $field->addError(
1564 25
                    'uniqueItems',
1565
                    [
1566
                        'messageCode' => 'The array must contain unique items.',
1567 25
                    ]
1568 25
                );
1569 25
            }
1570 25
1571 25
            if ($field->val('items') !== null) {
1572 25
                list ($items, $schemaPath) = $this->lookupSchema($field->val('items'), $field->getSchemaPath().'/items');
1573
1574
                // Validate each of the types.
1575 25
                $itemValidation = new ValidationField(
1576 25
                    $field->getValidation(),
1577 25
                    $items,
1578 25
                    '',
1579 25
                    $schemaPath,
1580 25
                    $field->getOptions()
1581 25
                );
1582
1583 25
                $result = [];
1584
                $count = 0;
1585
                foreach ($value as $i => $item) {
1586 25
                    $itemValidation->setName($field->getName()."/$i");
1587
                    $validItem = $this->validateField($item, $itemValidation);
1588
                    if (Invalid::isValid($validItem)) {
1589 8
                        $result[] = $validItem;
1590 8
                    }
1591
                    $count++;
1592
                }
1593
1594
                return empty($result) && $count > 0 ? Invalid::value() : $result;
1595
            } else {
1596
                // Cast the items into a proper numeric array.
1597
                $result = is_array($value) ? array_values($value) : iterator_to_array($value);
1598
                return $result;
1599
            }
1600
        }
1601
    }
1602
1603 128
    /**
1604 128
     * Validate an object.
1605 6
     *
1606 6
     * @param mixed $value The value to validate.
1607 128
     * @param ValidationField $field The validation results to add.
1608
     * @return object|Invalid Returns a clean object or **null** if validation fails.
1609 121
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
1610 7
     */
1611 3
    protected function validateObject($value, ValidationField $field) {
1612
        if (!$this->isArray($value) || isset($value[0])) {
1613
            $field->addTypeError($value, 'object');
1614 126
            return Invalid::value();
1615 1
        } elseif (is_array($field->val('properties')) || null !== $field->val('additionalProperties')) {
1616 1
            // Validate the data against the internal schema.
1617
            $value = $this->validateProperties($value, $field);
1618 1
        } elseif (!is_array($value)) {
1619 1
            $value = $this->toObjectArray($value);
1620
        }
1621
1622
        if (($maxProperties = $field->val('maxProperties')) && count($value) > $maxProperties) {
1623
            $field->addError(
1624 126
                'maxProperties',
1625 1
                [
1626 1
                    'messageCode' => 'This must contain no more than {maxProperties} {maxProperties,plural,item,items}.',
1627
                    'maxItems' => $maxProperties,
1628 1
                ]
1629 1
            );
1630
        }
1631
1632
        if (($minProperties = $field->val('minProperties')) && count($value) < $minProperties) {
1633
            $field->addError(
1634 126
                'minProperties',
1635
                [
1636
                    'messageCode' => 'This must contain at least {minProperties} {minProperties,plural,item,items}.',
1637
                    'minItems' => $minProperties,
1638
                ]
1639
            );
1640
        }
1641
1642
        return $value;
1643 136
    }
1644 136
1645
    /**
1646
     * Check whether or not a value is an array or accessible like an array.
1647
     *
1648
     * @param mixed $value The value to check.
1649
     * @return bool Returns **true** if the value can be used like an array or **false** otherwise.
1650
     */
1651
    private function isArray($value) {
1652
        return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable);
1653
    }
1654
1655
    /**
1656 121
     * Validate data against the schema and return the result.
1657 121
     *
1658 121
     * @param array|\Traversable|\ArrayAccess $data The data to validate.
1659 121
     * @param ValidationField $field This argument will be filled with the validation result.
1660 121
     * @return array|\ArrayObject|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types.
1661 121
     * or invalid if there are no valid properties.
1662
     * @throws RefNotFoundException Throws an exception of a property or additional property has a `$ref` that cannot be found.
1663 121
     */
1664 117
    protected function validateProperties($data, ValidationField $field) {
1665 117
        $properties = $field->val('properties', []);
1666
        $additionalProperties = $field->val('additionalProperties');
1667 4
        $required = array_flip($field->val('required', []));
1668 4
        $isRequest = $field->isRequest();
1669 4
        $isResponse = $field->isResponse();
1670
1671 4
        if (is_array($data)) {
1672 3
            $keys = array_keys($data);
1673 3
            $clean = [];
1674
        } else {
1675
            $keys = array_keys(iterator_to_array($data));
1676 121
            $class = get_class($data);
1677
            $clean = new $class;
1678 121
1679
            if ($clean instanceof \ArrayObject && $data instanceof \ArrayObject) {
1680
                $clean->setFlags($data->getFlags());
1681 121
                $clean->setIteratorClass($data->getIteratorClass());
1682 119
            }
1683
        }
1684
        $keys = array_combine(array_map('strtolower', $keys), $keys);
1685 119
1686 119
        $propertyField = new ValidationField($field->getValidation(), [], '', '', $field->getOptions());
1687 119
1688
        // Loop through the schema fields and validate each one.
1689 119
        foreach ($properties as $propertyName => $property) {
1690 119
            list($property, $schemaPath) = $this->lookupSchema($property, $field->getSchemaPath().'/properties/'.self::escapeRef($propertyName));
1691
1692
            $propertyField
1693 119
                ->setField($property)
1694 6
                ->setName(ltrim($field->getName().'/'.self::escapeRef($propertyName), '/'))
1695 6
                ->setSchemaPath($schemaPath);
1696
1697
            $lName = strtolower($propertyName);
1698
            $isRequired = isset($required[$propertyName]);
1699 119
1700 37
            // Check to strip this field if it is readOnly or writeOnly.
1701
            if (($isRequest && $propertyField->val('readOnly')) || ($isResponse && $propertyField->val('writeOnly'))) {
1702 36
                unset($keys[$lName]);
1703 6
                continue;
1704 30
            }
1705 6
1706 6
            // Check for required fields.
1707 37
            if (!array_key_exists($lName, $keys)) {
1708
                if ($field->isSparse()) {
1709
                    // Sparse validation can leave required fields out.
1710
                } elseif ($propertyField->hasVal('default')) {
1711 107
                    $clean[$propertyName] = $propertyField->val('default');
1712
                } elseif ($isRequired) {
1713 107
                    $propertyField->addError(
1714 5
                        'required',
1715 2
                        ['messageCode' => '{property} is required.', 'property' => $propertyName]
1716
                    );
1717
                }
1718
            } else {
1719 105
                $value = $data[$keys[$lName]];
1720
1721
                if (in_array($value, [null, ''], true) && !$isRequired && !($propertyField->val('nullable') || $propertyField->hasType('null'))) {
1722 117
                    if ($propertyField->getType() !== 'string' || $value === null) {
1723
                        continue;
1724
                    }
1725
                }
1726 121
1727 24
                $clean[$propertyName] = $this->validateField($value, $propertyField);
1728 10
            }
1729 10
1730 10
            unset($keys[$lName]);
1731
        }
1732
1733 10
        // Look for extraneous properties.
1734 10
        if (!empty($keys)) {
1735 10
            if ($additionalProperties) {
1736 10
                list($additionalProperties, $schemaPath) = $this->lookupSchema(
1737 10
                    $additionalProperties,
1738 10
                    $field->getSchemaPath().'/additionalProperties'
1739
                );
1740
1741 10
                $propertyField = new ValidationField(
1742
                    $field->getValidation(),
1743 10
                    $additionalProperties,
1744
                    '',
1745 10
                    $schemaPath,
1746 10
                    $field->getOptions()
1747 10
                );
1748
1749
                foreach ($keys as $key) {
1750 14
                    $propertyField
1751 2
                        ->setName(ltrim($field->getName()."/$key", '/'));
1752 2
1753 12
                    $valid = $this->validateField($data[$key], $propertyField);
1754 3
                    if (Invalid::isValid($valid)) {
1755 3
                        $clean[$key] = $valid;
1756 3
                    }
1757
                }
1758
            } elseif ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE)) {
1759
                $msg = sprintf("Unexpected properties: %s.", implode(', ', $keys));
1760
                trigger_error($msg, E_USER_NOTICE);
1761 119
            } elseif ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION)) {
1762
                $field->addError('unexpectedProperties', [
1763
                    'messageCode' => 'Unexpected {extra,plural,property,properties}: {extra}.',
1764
                    'extra' => array_values($keys),
1765
                ]);
1766
            }
1767
        }
1768
1769
        return $clean;
1770 127
    }
1771 127
1772
    /**
1773
     * Escape a JSON reference field.
1774
     *
1775
     * @param string $field The reference field to escape.
1776
     * @return string Returns an escaped reference.
1777
     */
1778
    public static function escapeRef(string $field): string {
1779
        return str_replace(['~', '/'], ['~0', '~1'], $field);
1780 15
    }
1781 15
1782
    /**
1783
     * Whether or not the schema has a flag (or combination of flags).
1784
     *
1785
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
1786
     * @return bool Returns **true** if all of the flags are set or **false** otherwise.
1787
     */
1788
    public function hasFlag(int $flag): bool {
1789
        return ($this->flags & $flag) === $flag;
1790 3
    }
1791 3
1792 3
    /**
1793 2
     * Cast a value to an array.
1794 1
     *
1795 1
     * @param \Traversable $value The value to convert.
1796 1
     * @return array Returns an array.
1797 1
     */
1798
    private function toObjectArray(\Traversable $value) {
1799 1
        $class = get_class($value);
1800
        if ($value instanceof \ArrayObject) {
1801
            return new $class($value->getArrayCopy(), $value->getFlags(), $value->getIteratorClass());
1802
        } elseif ($value instanceof \ArrayAccess) {
1803
            $r = new $class;
1804
            foreach ($value as $k => $v) {
1805
                $r[$k] = $v;
1806
            }
1807
            return $r;
1808
        }
1809
        return iterator_to_array($value);
1810
    }
1811 1
1812 1
    /**
1813
     * Validate a null value.
1814
     *
1815 1
     * @param mixed $value The value to validate.
1816 1
     * @param ValidationField $field The error collector for the field.
1817
     * @return null|Invalid Returns **null** or invalid.
1818
     */
1819
    protected function validateNull($value, ValidationField $field) {
1820
        if ($value === null) {
1821
            return null;
1822
        }
1823
        $field->addError('type', ['messageCode' => 'The value should be null.', 'type' => 'null']);
1824
        return Invalid::value();
1825
    }
1826 214
1827 214
    /**
1828 214
     * Validate a value against an enum.
1829 213
     *
1830
     * @param mixed $value The value to test.
1831
     * @param ValidationField $field The validation object for adding errors.
1832 4
     * @return mixed|Invalid Returns the value if it is one of the enumerated values or invalid otherwise.
1833 1
     */
1834 1
    protected function validateEnum($value, ValidationField $field) {
1835
        $enum = $field->val('enum');
1836 1
        if (empty($enum)) {
1837 1
            return $value;
1838
        }
1839
1840 1
        if (!in_array($value, $enum, true)) {
1841
            $field->addError(
1842 4
                'enum',
1843
                [
1844
                    'messageCode' => 'The value must be one of: {enum}.',
1845
                    'enum' => $enum,
1846
                ]
1847
            );
1848
            return Invalid::value();
1849
        }
1850
        return $value;
1851 215
    }
1852 215
1853
    /**
1854
     * Call all of the validators attached to a field.
1855 215
     *
1856 215
     * @param mixed $value The field value being validated.
1857 5
     * @param ValidationField $field The validation object to add errors.
1858 5
     */
1859
    private function callValidators($value, ValidationField $field) {
1860 5
        $valid = true;
1861 5
1862
        // Strip array references in the name except for the last one.
1863
        $key = $field->getSchemaPath();
1864
        if (!empty($this->validators[$key])) {
1865
            foreach ($this->validators[$key] as $validator) {
1866
                $r = call_user_func($validator, $value, $field);
1867 215
1868 1
                if ($r === false || Invalid::isInvalid($r)) {
1869
                    $valid = false;
1870 215
                }
1871
            }
1872
        }
1873
1874
        // Add an error on the field if the validator hasn't done so.
1875
        if (!$valid && $field->isValid()) {
1876
            $field->addError('invalid', ['messageCode' => 'The value is invalid.']);
1877
        }
1878
    }
1879
1880
    /**
1881 19
     * Specify data which should be serialized to JSON.
1882 19
     *
1883 19
     * This method specifically returns data compatible with the JSON schema format.
1884
     *
1885
     * @return mixed Returns data which can be serialized by **json_encode()**, which is a value of any type other than a resource.
1886
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
1887
     * @link http://json-schema.org/
1888
     */
1889
    public function jsonSerialize() {
1890
        $seen = [$this];
1891
        return $this->jsonSerializeInternal($seen);
1892
    }
1893
1894
    /**
1895
     * Return the JSON data for serialization with massaging for Open API.
1896 19
     *
1897 19
     * - Swap data/time & timestamp types for Open API types.
1898 4
     * - Turn recursive schema pointers into references.
1899 3
     *
1900
     * @param Schema[] $seen Schemas that have been seen during traversal.
1901 2
     * @return array Returns an array of data that `json_encode()` will recognize.
1902 2
     */
1903
    private function jsonSerializeInternal(array $seen): array {
1904
        $fix = function ($schema) use (&$fix, $seen) {
1905
            if ($schema instanceof Schema) {
1906 19
                if (in_array($schema, $seen, true)) {
1907 18
                    return ['$ref' => '#/components/schemas/'.($schema->getID() ?: '$no-id')];
1908
                } else {
1909 18
                    $seen[] = $schema;
1910
                    return $schema->jsonSerializeInternal($seen);
1911 18
                }
1912 4
            }
1913 4
1914 17
            if (!empty($schema['type'])) {
1915 2
                $types = (array)$schema['type'];
1916 18
1917
                foreach ($types as $i => &$type) {
1918
                    // Swap datetime and timestamp to other types with formats.
1919 18
                    if ($type === 'datetime') {
1920 18
                        $type = 'string';
1921
                        $schema['format'] = 'date-time';
1922
                    } elseif ($schema['type'] === 'timestamp') {
1923 19
                        $type = 'integer';
1924 5
                        $schema['format'] = 'timestamp';
1925
                    }
1926 19
                }
1927 14
                $types = array_unique($types);
1928 14
                $schema['type'] = count($types) === 1 ? reset($types) : $types;
1929 14
            }
1930
1931 14
            if (!empty($schema['items'])) {
1932
                $schema['items'] = $fix($schema['items']);
1933
            }
1934 19
            if (!empty($schema['properties'])) {
1935 19
                $properties = [];
1936
                foreach ($schema['properties'] as $key => $property) {
1937 19
                    $properties[$key] = $fix($property);
1938
                }
1939 19
                $schema['properties'] = $properties;
1940
            }
1941
1942
            return $schema;
1943
        };
1944
1945
        $result = $fix($this->schema);
1946
1947
        return $result;
1948 1
    }
1949 1
1950 1
    /**
1951
     * Get the class that's used to contain validation information.
1952
     *
1953
     * @return Validation|string Returns the validation class.
1954
     * @deprecated
1955
     */
1956
    public function getValidationClass() {
1957
        trigger_error('Schema::getValidationClass() is deprecated. Use Schema::getValidationFactory() instead.', E_USER_DEPRECATED);
1958
        return $this->validationClass;
1959
    }
1960 1
1961 1
    /**
1962
     * Set the class that's used to contain validation information.
1963 1
     *
1964
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
1965
     * @return $this
1966
     * @deprecated
1967 1
     */
1968 1
    public function setValidationClass($class) {
1969 1
        trigger_error('Schema::setValidationClass() is deprecated. Use Schema::setValidationFactory() instead.', E_USER_DEPRECATED);
1970
1971 1
        if (!is_a($class, Validation::class, true)) {
1972
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
1973 1
        }
1974 1
1975 1
        $this->setValidationFactory(function () use ($class) {
1976 1
            if ($class instanceof Validation) {
1977
                $result = clone $class;
1978
            } else {
1979
                $result = new $class;
1980
            }
1981
            return $result;
1982
        });
1983
        $this->validationClass = $class;
1984
        return $this;
1985
    }
1986 2
1987 2
    /**
1988 2
     * Return a sparse version of this schema.
1989
     *
1990
     * A sparse schema has no required properties.
1991
     *
1992
     * @return Schema Returns a new sparse schema.
1993
     */
1994
    public function withSparse() {
1995
        $sparseSchema = $this->withSparseInternal($this, new \SplObjectStorage());
1996
        return $sparseSchema;
1997
    }
1998 2
1999 2
    /**
2000 2
     * The internal implementation of `Schema::withSparse()`.
2001 1
     *
2002
     * @param array|Schema $schema The schema to make sparse.
2003 2
     * @param \SplObjectStorage $schemas Collected sparse schemas that have already been made.
2004 2
     * @return mixed
2005 2
     */
2006
    private function withSparseInternal($schema, \SplObjectStorage $schemas) {
2007
        if ($schema instanceof Schema) {
2008
            if ($schemas->contains($schema)) {
2009 2
                return $schemas[$schema];
2010
            } else {
2011
                $schemas[$schema] = $sparseSchema = new Schema();
2012
                $sparseSchema->schema = $schema->withSparseInternal($schema->schema, $schemas);
2013 2
                if ($id = $sparseSchema->getID()) {
2014
                    $sparseSchema->setID($id.'Sparse');
2015 2
                }
2016 1
2017
                return $sparseSchema;
2018 2
            }
2019 2
        }
2020 2
2021
        unset($schema['required']);
2022
2023
        if (isset($schema['items'])) {
2024 2
            $schema['items'] = $this->withSparseInternal($schema['items'], $schemas);
2025
        }
2026
        if (isset($schema['properties'])) {
2027
            foreach ($schema['properties'] as $name => &$property) {
2028
                $property = $this->withSparseInternal($property, $schemas);
2029
            }
2030
        }
2031
2032 6
        return $schema;
2033 6
    }
2034
2035
    /**
2036
     * Get the ID for the schema.
2037
     *
2038
     * @return string
2039
     */
2040
    public function getID(): string {
2041
        return $this->schema['id'] ?? '';
2042 1
    }
2043 1
2044
    /**
2045 1
     * Set the ID for the schema.
2046
     *
2047
     * @param string $id The new ID.
2048
     * @return $this
2049
     */
2050
    public function setID(string $id) {
2051
        $this->schema['id'] = $id;
2052
2053
        return $this;
2054
    }
2055 7
2056 7
    /**
2057
     * Whether a offset exists.
2058
     *
2059
     * @param mixed $offset An offset to check for.
2060
     * @return boolean true on success or false on failure.
2061
     * @link http://php.net/manual/en/arrayaccess.offsetexists.php
2062
     */
2063
    public function offsetExists($offset) {
2064
        return isset($this->schema[$offset]);
2065
    }
2066 7
2067 7
    /**
2068
     * Offset to retrieve.
2069
     *
2070
     * @param mixed $offset The offset to retrieve.
2071
     * @return mixed Can return all value types.
2072
     * @link http://php.net/manual/en/arrayaccess.offsetget.php
2073
     */
2074
    public function offsetGet($offset) {
2075
        return isset($this->schema[$offset]) ? $this->schema[$offset] : null;
2076
    }
2077 1
2078 1
    /**
2079 1
     * Offset to set.
2080
     *
2081
     * @param mixed $offset The offset to assign the value to.
2082
     * @param mixed $value The value to set.
2083
     * @link http://php.net/manual/en/arrayaccess.offsetset.php
2084
     */
2085
    public function offsetSet($offset, $value) {
2086
        $this->schema[$offset] = $value;
2087 1
    }
2088 1
2089 1
    /**
2090
     * Offset to unset.
2091
     *
2092
     * @param mixed $offset The offset to unset.
2093
     * @link http://php.net/manual/en/arrayaccess.offsetunset.php
2094
     */
2095
    public function offsetUnset($offset) {
2096
        unset($this->schema[$offset]);
2097
    }
2098
2099 12
    /**
2100 12
     * Resolve the schema attached to a discriminator.
2101 12
     *
2102 1
     * @param mixed $value The value to search for the discriminator.
2103
     * @param ValidationField $field The current node's schema information.
2104
     * @return ValidationField|null Returns the resolved schema or **null** if it can't be resolved.
2105 12
     * @throws ParseException Throws an exception if the discriminator isn't a string.
2106
     */
2107
    private function resolveDiscriminator($value, ValidationField $field, array $visited = []) {
2108 12
        $propertyName = $field->val('discriminator')['propertyName'] ?? '';
2109 1
        if (empty($propertyName) || !is_string($propertyName)) {
2110 1
            throw new ParseException("Invalid propertyName for discriminator at {$field->getSchemaPath()}", 500);
2111 11
        }
2112 1
2113 1
        $propertyFieldName = ltrim($field->getName().'/'.self::escapeRef($propertyName), '/');
2114 1
2115 1
        // Do some basic validation checking to see if we can even look at the property.
2116
        if (!$this->isArray($value)) {
2117 1
            $field->addTypeError($value, 'object');
2118
            return null;
2119
        } elseif (empty($value[$propertyName])) {
2120 10
            $field->getValidation()->addError(
2121 10
                $propertyFieldName,
2122 1
                'required',
2123 1
                ['messageCode' => '{property} is required.', 'property' => $propertyName]
2124 1
            );
2125
            return null;
2126 1
        }
2127 1
2128 1
        $propertyValue = $value[$propertyName];
2129
        if (!is_string($propertyValue)) {
2130
            $field->getValidation()->addError(
2131 1
                $propertyFieldName,
2132
                'type',
2133
                [
2134 9
                    'type' => 'string',
2135 9
                    'value' => is_scalar($value) ? $value : null,
2136 2
                    'messageCode' => is_scalar($value) ? "{value} is not a valid string." : "The value is not a valid string."
2137
                ]
2138 2
            );
2139 2
            return null;
2140
        }
2141
2142
        $mapping = $field->val('discriminator')['mapping'] ?? '';
2143 7
        if (isset($mapping[$propertyValue])) {
2144
            $ref = $mapping[$propertyValue];
2145
2146
            if (strpos($ref, '#') === false) {
2147 9
                $ref = '#/components/schemas/'.self::escapeRef($ref);
2148 9
            }
2149 1
        } else {
2150 1
            // Don't let a property value provide its own ref as that may pose a security concern..
2151 1
            $ref = '#/components/schemas/'.self::escapeRef($propertyValue);
2152
        }
2153 1
2154 1
        // Validate the reference against the oneOf constraint.
2155 1
        $oneOf = $field->val('oneOf', []);
2156
        if (!empty($oneOf) && !in_array(['$ref' => $ref], $oneOf)) {
2157
            $field->getValidation()->addError(
2158 1
                $propertyFieldName,
2159
                'oneOf',
2160
                [
2161
                    'type' => 'string',
2162
                    'value' => is_scalar($propertyValue) ? $propertyValue : null,
2163 9
                    'messageCode' => is_scalar($propertyValue) ? "{value} is not a valid option." : "The value is not a valid option."
2164
                ]
2165 9
            );
2166 8
            return null;
2167 2
        }
2168
2169
        try {
2170 7
            // Lookup the schema.
2171 7
            $visited[$field->getSchemaPath()] = true;
2172 7
2173 7
            list($schema, $schemaPath) = $this->lookupSchema(['$ref' => $ref], $field->getSchemaPath());
2174 7
            if (isset($visited[$schemaPath])) {
2175 7
                throw new RefNotFoundException('Cyclical ref.', 508);
2176
            }
2177 7
2178 4
            $result = new ValidationField(
2179
                $field->getValidation(),
2180 4
                $schema,
2181
                $field->getName(),
2182 4
                $schemaPath,
2183
                $field->getOptions()
2184 3
            );
2185 3
            if (!empty($schema['discriminator'])) {
2186 3
                return $this->resolveDiscriminator($value, $result, $visited);
2187
            } else {
2188 3
                return $result;
2189 3
            }
2190 3
        } catch (RefNotFoundException $ex) {
2191
            // Since this is a ref provided by the value it is technically a validation error.
2192
            $field->getValidation()->addError(
2193 3
                $propertyFieldName,
2194
                'propertyName',
2195
                [
2196
                    'type' => 'string',
2197
                    'value' => is_scalar($propertyValue) ? $propertyValue : null,
2198
                    'messageCode' => is_scalar($propertyValue) ? "{value} is not a valid option." : "The value is not a valid option."
2199
                ]
2200
            );
2201
            return null;
2202
        }
2203
    }
2204
}
2205