Passed
Pull Request — master (#65)
by Raffael
08:11
created

Schema::validateAllOf()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 10
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 8
CRAP Score 1

Importance

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Loading history...
981
                    } else {
982 221
                        $result = $this->validateSingleType($value, $type, $field);
983 221
                    }
984
985
                    if (Invalid::isValid($result)) {
986 7
                        $result = $this->validateEnum($result, $field);
987
                    }
988
                }
989
            } else {
990
                $result = Invalid::value();
991 228
            }
992 212
        }
993
994
        // Validate a custom field validator.
995 228
        if (Invalid::isValid($result)) {
996
            $this->callValidators($result, $field);
0 ignored issues
show
Bug introduced by
It seems like $field can also be of type null; however, parameter $field of Garden\Schema\Schema::callValidators() does only seem to accept Garden\Schema\ValidationField, maybe add an additional type check? ( Ignorable by Annotation )

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

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

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

1534
            if ((null !== $minItems = $field->val('minItems')) && count(/** @scrutinizer ignore-type */ $value) < $minItems) {
Loading history...
1535
                $field->addError(
1536
                    'minItems',
1537
                    [
1538
                        'messageCode' => 'This must contain at least {minItems} {minItems,plural,item,items}.',
1539
                        'minItems' => $minItems,
1540
                    ]
1541
                );
1542 125
            }
1543 125
            if ((null !== $maxItems = $field->val('maxItems')) && count($value) > $maxItems) {
1544 6
                $field->addError(
1545 6
                    'maxItems',
1546 125
                    [
1547
                        'messageCode' => 'This must contain no more than {maxItems} {maxItems,plural,item,items}.',
1548 118
                        'maxItems' => $maxItems,
1549 7
                    ]
1550 3
                );
1551
            }
1552
1553 123
            if ($field->val('uniqueItems') && count($value) > count(array_unique($value))) {
0 ignored issues
show
Bug introduced by
It seems like $value can also be of type Traversable; however, parameter $array of array_unique() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

1553
            if ($field->val('uniqueItems') && count($value) > count(array_unique(/** @scrutinizer ignore-type */ $value))) {
Loading history...
1554 1
                $field->addError(
1555 1
                    'uniqueItems',
1556
                    [
1557 1
                        'messageCode' => 'The array must contain unique items.',
1558 1
                    ]
1559
                );
1560
            }
1561
1562
            if ($field->val('items') !== null) {
1563 123
                list ($items, $schemaPath) = $this->lookupSchema($field->val('items'), $field->getSchemaPath().'/items');
1564 1
1565 1
                // Validate each of the types.
1566
                $itemValidation = new ValidationField(
1567 1
                    $field->getValidation(),
1568 1
                    $items,
1569
                    '',
1570
                    $schemaPath,
1571
                    $field->getOptions()
1572
                );
1573 123
1574
                $result = [];
1575
                $count = 0;
1576
                foreach ($value as $i => $item) {
1577
                    $itemValidation->setName($field->getName()."/$i");
1578
                    $validItem = $this->validateField($item, $itemValidation);
1579
                    if (Invalid::isValid($validItem)) {
1580
                        $result[] = $validItem;
1581
                    }
1582 133
                    $count++;
1583 133
                }
1584
1585
                return empty($result) && $count > 0 ? Invalid::value() : $result;
1586
            } else {
1587
                // Cast the items into a proper numeric array.
1588
                $result = is_array($value) ? array_values($value) : iterator_to_array($value);
1589
                return $result;
1590
            }
1591
        }
1592
    }
1593
1594
    /**
1595 118
     * Validate an object.
1596 118
     *
1597 118
     * @param mixed $value The value to validate.
1598 118
     * @param ValidationField $field The validation results to add.
1599 118
     * @return object|Invalid Returns a clean object or **null** if validation fails.
1600 118
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
1601
     */
1602 118
    protected function validateObject($value, ValidationField $field) {
1603 114
        if (!$this->isArray($value) || isset($value[0])) {
1604 114
            $field->addTypeError($value, 'object');
1605
            return Invalid::value();
1606 4
        } elseif (is_array($field->val('properties')) || null !== $field->val('additionalProperties')) {
1607 4
            // Validate the data against the internal schema.
1608 4
            $value = $this->validateProperties($value, $field);
1609
        } elseif (!is_array($value)) {
1610 4
            $value = $this->toObjectArray($value);
1611 3
        }
1612 3
1613
        if (($maxProperties = $field->val('maxProperties')) && count($value) > $maxProperties) {
1614
            $field->addError(
1615 118
                'maxProperties',
1616
                [
1617 118
                    'messageCode' => 'This must contain no more than {maxProperties} {maxProperties,plural,item,items}.',
1618
                    'maxItems' => $maxProperties,
1619
                ]
1620 118
            );
1621 116
        }
1622
1623
        if (($minProperties = $field->val('minProperties')) && count($value) < $minProperties) {
1624 116
            $field->addError(
1625 116
                'minProperties',
1626 116
                [
1627
                    'messageCode' => 'This must contain at least {minProperties} {minProperties,plural,item,items}.',
1628 116
                    'minItems' => $minProperties,
1629 116
                ]
1630
            );
1631
        }
1632 116
1633 6
        return $value;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $value also could return the type array which is incompatible with the documented return type Garden\Schema\Invalid|object.
Loading history...
1634 6
    }
1635
1636
    /**
1637
     * Check whether or not a value is an array or accessible like an array.
1638 116
     *
1639 36
     * @param mixed $value The value to check.
1640
     * @return bool Returns **true** if the value can be used like an array or **false** otherwise.
1641 35
     */
1642 5
    private function isArray($value) {
1643 30
        return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable);
1644 6
    }
1645 6
1646 36
    /**
1647
     * Validate data against the schema and return the result.
1648
     *
1649
     * @param array|\Traversable|\ArrayAccess $data The data to validate.
1650 104
     * @param ValidationField $field This argument will be filled with the validation result.
1651
     * @return array|\ArrayObject|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types.
1652 104
     * or invalid if there are no valid properties.
1653 5
     * @throws RefNotFoundException Throws an exception of a property or additional property has a `$ref` that cannot be found.
1654 2
     */
1655
    protected function validateProperties($data, ValidationField $field) {
1656
        $properties = $field->val('properties', []);
1657
        $additionalProperties = $field->val('additionalProperties');
1658 102
        $required = array_flip($field->val('required', []));
1659
        $isRequest = $field->isRequest();
1660
        $isResponse = $field->isResponse();
1661 114
1662
        if (is_array($data)) {
1663
            $keys = array_keys($data);
1664
            $clean = [];
1665 118
        } else {
1666 23
            $keys = array_keys(iterator_to_array($data));
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type ArrayAccess; however, parameter $iterator of iterator_to_array() does only seem to accept Traversable, maybe add an additional type check? ( Ignorable by Annotation )

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

1666
            $keys = array_keys(iterator_to_array(/** @scrutinizer ignore-type */ $data));
Loading history...
1667 10
            $class = get_class($data);
1668 10
            $clean = new $class;
1669 10
1670
            if ($clean instanceof \ArrayObject && $data instanceof \ArrayObject) {
1671
                $clean->setFlags($data->getFlags());
1672 10
                $clean->setIteratorClass($data->getIteratorClass());
1673 10
            }
1674 10
        }
1675 10
        $keys = array_combine(array_map('strtolower', $keys), $keys);
1676 10
1677 10
        $propertyField = new ValidationField($field->getValidation(), [], '', '', $field->getOptions());
1678
1679
        // Loop through the schema fields and validate each one.
1680 10
        foreach ($properties as $propertyName => $property) {
1681
            list($property, $schemaPath) = $this->lookupSchema($property, $field->getSchemaPath().'/properties/'.self::escapeRef($propertyName));
1682 10
1683
            $propertyField
1684 10
                ->setField($property)
1685 10
                ->setName(ltrim($field->getName().'/'.self::escapeRef($propertyName), '/'))
1686 10
                ->setSchemaPath($schemaPath);
1687
1688
            $lName = strtolower($propertyName);
1689 13
            $isRequired = isset($required[$propertyName]);
1690 2
1691 2
            // Check to strip this field if it is readOnly or writeOnly.
1692 11
            if (($isRequest && $propertyField->val('readOnly')) || ($isResponse && $propertyField->val('writeOnly'))) {
1693 2
                unset($keys[$lName]);
1694 2
                continue;
1695 2
            }
1696
1697
            // Check for required fields.
1698
            if (!array_key_exists($lName, $keys)) {
1699
                if ($field->isSparse()) {
1700 116
                    // Sparse validation can leave required fields out.
1701
                } elseif ($propertyField->hasVal('default')) {
1702
                    $clean[$propertyName] = $propertyField->val('default');
1703
                } elseif ($isRequired) {
1704
                    $propertyField->addError(
1705
                        'required',
1706
                        ['messageCode' => '{property} is required.', 'property' => $propertyName]
1707
                    );
1708
                }
1709 124
            } else {
1710 124
                $value = $data[$keys[$lName]];
1711
1712
                if (in_array($value, [null, ''], true) && !$isRequired && !($propertyField->val('nullable') || $propertyField->hasType('null'))) {
1713
                    if ($propertyField->getType() !== 'string' || $value === null) {
1714
                        continue;
1715
                    }
1716
                }
1717
1718
                $clean[$propertyName] = $this->validateField($value, $propertyField);
1719 14
            }
1720 14
1721
            unset($keys[$lName]);
1722
        }
1723
1724
        // Look for extraneous properties.
1725
        if (!empty($keys)) {
1726
            if ($additionalProperties) {
1727
                list($additionalProperties, $schemaPath) = $this->lookupSchema(
1728
                    $additionalProperties,
1729 3
                    $field->getSchemaPath().'/additionalProperties'
1730 3
                );
1731 3
1732 2
                $propertyField = new ValidationField(
1733 1
                    $field->getValidation(),
1734 1
                    $additionalProperties,
1735 1
                    '',
1736 1
                    $schemaPath,
1737
                    $field->getOptions()
1738 1
                );
1739
1740
                foreach ($keys as $key) {
1741
                    $propertyField
1742
                        ->setName(ltrim($field->getName()."/$key", '/'));
1743
1744
                    $valid = $this->validateField($data[$key], $propertyField);
1745
                    if (Invalid::isValid($valid)) {
1746
                        $clean[$key] = $valid;
1747
                    }
1748
                }
1749
            } elseif ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE)) {
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
1750 1
                $msg = sprintf("Unexpected properties: %s.", implode(', ', $keys));
1751 1
                trigger_error($msg, E_USER_NOTICE);
1752
            } elseif ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION)) {
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

This check looks for accesses to local static members using the fully qualified name instead of self::.

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

    public function __construct()
    {
        $this->key = Certificate::TRIPLEDES_CBC;
    }
}

While this is perfectly valid, the fully qualified name of Certificate::TRIPLEDES_CBC could just as well be replaced by self::TRIPLEDES_CBC. Referencing local members with self:: assured the access will still work when the class is renamed, makes it perfectly clear that the member is in fact local and will usually be shorter.

Loading history...
1753
                $field->addError('unexpectedProperties', [
1754 1
                    'messageCode' => 'Unexpected {extra,plural,property,properties}: {extra}.',
1755 1
                    'extra' => array_values($keys),
1756
                ]);
1757
            }
1758
        }
1759
1760
        return $clean;
1761
    }
1762
1763
    /**
1764
     * Escape a JSON reference field.
1765 211
     *
1766 211
     * @param string $field The reference field to escape.
1767 211
     * @return string Returns an escaped reference.
1768 210
     */
1769
    public static function escapeRef(string $field): string {
1770
        return str_replace(['~', '/'], ['~0', '~1'], $field);
1771 3
    }
1772 1
1773 1
    /**
1774
     * Whether or not the schema has a flag (or combination of flags).
1775 1
     *
1776 1
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
1777
     * @return bool Returns **true** if all of the flags are set or **false** otherwise.
1778
     */
1779 1
    public function hasFlag(int $flag): bool {
1780
        return ($this->flags & $flag) === $flag;
1781 3
    }
1782
1783
    /**
1784
     * Cast a value to an array.
1785
     *
1786
     * @param \Traversable $value The value to convert.
1787
     * @return array Returns an array.
1788
     */
1789
    private function toObjectArray(\Traversable $value) {
1790 212
        $class = get_class($value);
1791 212
        if ($value instanceof \ArrayObject) {
1792
            return new $class($value->getArrayCopy(), $value->getFlags(), $value->getIteratorClass());
0 ignored issues
show
Bug Best Practice introduced by
The expression return new $class($value...ue->getIteratorClass()) returns the type object which is incompatible with the documented return type array.
Loading history...
1793
        } elseif ($value instanceof \ArrayAccess) {
1794 212
            $r = new $class;
1795 212
            foreach ($value as $k => $v) {
1796 5
                $r[$k] = $v;
1797 5
            }
1798
            return $r;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $r returns the type object which is incompatible with the documented return type array.
Loading history...
1799 5
        }
1800 5
        return iterator_to_array($value);
1801
    }
1802
1803
    /**
1804
     * Validate a null value.
1805
     *
1806 212
     * @param mixed $value The value to validate.
1807 1
     * @param ValidationField $field The error collector for the field.
1808
     * @return null|Invalid Returns **null** or invalid.
1809 212
     */
1810
    protected function validateNull($value, ValidationField $field) {
1811
        if ($value === null) {
1812
            return null;
1813
        }
1814
        $field->addError('type', ['messageCode' => 'The value should be null.', 'type' => 'null']);
1815
        return Invalid::value();
1816
    }
1817
1818
    /**
1819
     * Validate a value against an enum.
1820 19
     *
1821 19
     * @param mixed $value The value to test.
1822 19
     * @param ValidationField $field The validation object for adding errors.
1823
     * @return mixed|Invalid Returns the value if it is one of the enumerated values or invalid otherwise.
1824
     */
1825
    protected function validateEnum($value, ValidationField $field) {
1826
        $enum = $field->val('enum');
1827
        if (empty($enum)) {
1828
            return $value;
1829
        }
1830
1831
        if (!in_array($value, $enum, true)) {
1832
            $field->addError(
1833
                'enum',
1834
                [
1835 19
                    'messageCode' => 'The value must be one of: {enum}.',
1836 19
                    'enum' => $enum,
1837 4
                ]
1838 3
            );
1839
            return Invalid::value();
1840 2
        }
1841 2
        return $value;
1842
    }
1843
1844
    /**
1845 19
     * Call all of the validators attached to a field.
1846 18
     *
1847
     * @param mixed $value The field value being validated.
1848 18
     * @param ValidationField $field The validation object to add errors.
1849
     */
1850 18
    private function callValidators($value, ValidationField $field) {
1851 4
        $valid = true;
1852 4
1853 17
        // Strip array references in the name except for the last one.
1854 2
        $key = $field->getSchemaPath();
1855 18
        if (!empty($this->validators[$key])) {
1856
            foreach ($this->validators[$key] as $validator) {
1857
                $r = call_user_func($validator, $value, $field);
1858 18
1859 18
                if ($r === false || Invalid::isInvalid($r)) {
1860
                    $valid = false;
1861
                }
1862 19
            }
1863 5
        }
1864
1865 19
        // Add an error on the field if the validator hasn't done so.
1866 14
        if (!$valid && $field->isValid()) {
1867 14
            $field->addError('invalid', ['messageCode' => 'The value is invalid.']);
1868 14
        }
1869
    }
1870 14
1871
    /**
1872
     * Specify data which should be serialized to JSON.
1873 19
     *
1874 19
     * This method specifically returns data compatible with the JSON schema format.
1875
     *
1876 19
     * @return mixed Returns data which can be serialized by **json_encode()**, which is a value of any type other than a resource.
1877
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
1878 19
     * @link http://json-schema.org/
1879
     */
1880
    public function jsonSerialize() {
1881
        $seen = [$this];
1882
        return $this->jsonSerializeInternal($seen);
1883
    }
1884
1885
    /**
1886
     * Return the JSON data for serialization with massaging for Open API.
1887 1
     *
1888 1
     * - Swap data/time & timestamp types for Open API types.
1889 1
     * - Turn recursive schema pointers into references.
1890
     *
1891
     * @param Schema[] $seen Schemas that have been seen during traversal.
1892
     * @return array Returns an array of data that `json_encode()` will recognize.
1893
     */
1894
    private function jsonSerializeInternal(array $seen): array {
1895
        $fix = function ($schema) use (&$fix, $seen) {
1896
            if ($schema instanceof Schema) {
1897
                if (in_array($schema, $seen, true)) {
1898
                    return ['$ref' => '#/components/schemas/'.($schema->getID() ?: '$no-id')];
1899 1
                } else {
1900 1
                    $seen[] = $schema;
1901
                    return $schema->jsonSerializeInternal($seen);
1902 1
                }
1903
            }
1904
1905
            if (!empty($schema['type'])) {
1906 1
                $types = (array)$schema['type'];
1907 1
1908 1
                foreach ($types as $i => &$type) {
1909
                    // Swap datetime and timestamp to other types with formats.
1910 1
                    if ($type === 'datetime') {
1911
                        $type = 'string';
1912 1
                        $schema['format'] = 'date-time';
1913 1
                    } elseif ($schema['type'] === 'timestamp') {
1914 1
                        $type = 'integer';
1915 1
                        $schema['format'] = 'timestamp';
1916
                    }
1917
                }
1918
                $types = array_unique($types);
1919
                $schema['type'] = count($types) === 1 ? reset($types) : $types;
1920
            }
1921
1922
            if (!empty($schema['items'])) {
1923
                $schema['items'] = $fix($schema['items']);
1924
            }
1925 2
            if (!empty($schema['properties'])) {
1926 2
                $properties = [];
1927 2
                foreach ($schema['properties'] as $key => $property) {
1928
                    $properties[$key] = $fix($property);
1929
                }
1930
                $schema['properties'] = $properties;
1931
            }
1932
1933
            return $schema;
1934
        };
1935
1936
        $result = $fix($this->schema);
1937 2
1938 2
        return $result;
1939 2
    }
1940 1
1941
    /**
1942 2
     * Get the class that's used to contain validation information.
1943 2
     *
1944 2
     * @return Validation|string Returns the validation class.
1945
     * @deprecated
1946
     */
1947
    public function getValidationClass() {
1948 2
        trigger_error('Schema::getValidationClass() is deprecated. Use Schema::getValidationFactory() instead.', E_USER_DEPRECATED);
1949
        return $this->validationClass;
0 ignored issues
show
Deprecated Code introduced by
The property Garden\Schema\Schema::$validationClass has been deprecated. ( Ignorable by Annotation )

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

1949
        return /** @scrutinizer ignore-deprecated */ $this->validationClass;
Loading history...
1950
    }
1951
1952 2
    /**
1953
     * Set the class that's used to contain validation information.
1954 2
     *
1955 1
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
1956
     * @return $this
1957 2
     * @deprecated
1958 2
     */
1959 2
    public function setValidationClass($class) {
1960
        trigger_error('Schema::setValidationClass() is deprecated. Use Schema::setValidationFactory() instead.', E_USER_DEPRECATED);
1961
1962
        if (!is_a($class, Validation::class, true)) {
1963 2
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
1964
        }
1965
1966
        $this->setValidationFactory(function () use ($class) {
1967
            if ($class instanceof Validation) {
1968
                $result = clone $class;
1969
            } else {
1970
                $result = new $class;
1971 6
            }
1972 6
            return $result;
1973
        });
1974
        $this->validationClass = $class;
0 ignored issues
show
Deprecated Code introduced by
The property Garden\Schema\Schema::$validationClass has been deprecated. ( Ignorable by Annotation )

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

1974
        /** @scrutinizer ignore-deprecated */ $this->validationClass = $class;
Loading history...
1975
        return $this;
1976
    }
1977
1978
    /**
1979
     * Return a sparse version of this schema.
1980
     *
1981 1
     * A sparse schema has no required properties.
1982 1
     *
1983
     * @return Schema Returns a new sparse schema.
1984 1
     */
1985
    public function withSparse() {
1986
        $sparseSchema = $this->withSparseInternal($this, new \SplObjectStorage());
1987
        return $sparseSchema;
1988
    }
1989
1990
    /**
1991
     * The internal implementation of `Schema::withSparse()`.
1992
     *
1993
     * @param array|Schema $schema The schema to make sparse.
1994 7
     * @param \SplObjectStorage $schemas Collected sparse schemas that have already been made.
1995 7
     * @return mixed
1996
     */
1997
    private function withSparseInternal($schema, \SplObjectStorage $schemas) {
1998
        if ($schema instanceof Schema) {
1999
            if ($schemas->contains($schema)) {
2000
                return $schemas[$schema];
2001
            } else {
2002
                $schemas[$schema] = $sparseSchema = new Schema();
2003
                $sparseSchema->schema = $schema->withSparseInternal($schema->schema, $schemas);
2004
                if ($id = $sparseSchema->getID()) {
2005 7
                    $sparseSchema->setID($id.'Sparse');
2006 7
                }
2007
2008
                return $sparseSchema;
2009
            }
2010
        }
2011
2012
        unset($schema['required']);
2013
2014
        if (isset($schema['items'])) {
2015
            $schema['items'] = $this->withSparseInternal($schema['items'], $schemas);
2016 1
        }
2017 1
        if (isset($schema['properties'])) {
2018 1
            foreach ($schema['properties'] as $name => &$property) {
2019
                $property = $this->withSparseInternal($property, $schemas);
2020
            }
2021
        }
2022
2023
        return $schema;
2024
    }
2025
2026 1
    /**
2027 1
     * Get the ID for the schema.
2028 1
     *
2029
     * @return string
2030
     */
2031
    public function getID(): string {
0 ignored issues
show
Coding Style introduced by
This method is not in camel caps format.

This check looks for method names that are not written in camelCase.

In camelCase names are written without any punctuation, the start of each new word being marked by a capital letter. Thus the name database connection seeker becomes databaseConnectionSeeker.

Loading history...
2032
        return $this->schema['id'] ?? '';
2033
    }
2034
2035
    /**
2036
     * Set the ID for the schema.
2037
     *
2038 12
     * @param string $id The new ID.
2039 12
     * @return $this
2040 12
     */
2041 1
    public function setID(string $id) {
0 ignored issues
show
Coding Style introduced by
This method is not in camel caps format.

This check looks for method names that are not written in camelCase.

In camelCase names are written without any punctuation, the start of each new word being marked by a capital letter. Thus the name database connection seeker becomes databaseConnectionSeeker.

Loading history...
2042
        $this->schema['id'] = $id;
2043
2044 12
        return $this;
2045
    }
2046
2047 12
    /**
2048 1
     * Whether a offset exists.
2049 1
     *
2050 11
     * @param mixed $offset An offset to check for.
2051 1
     * @return boolean true on success or false on failure.
2052 1
     * @link http://php.net/manual/en/arrayaccess.offsetexists.php
2053 1
     */
2054 1
    public function offsetExists($offset) {
2055
        return isset($this->schema[$offset]);
2056 1
    }
2057
2058
    /**
2059 10
     * Offset to retrieve.
2060 10
     *
2061 1
     * @param mixed $offset The offset to retrieve.
2062 1
     * @return mixed Can return all value types.
2063 1
     * @link http://php.net/manual/en/arrayaccess.offsetget.php
2064
     */
2065 1
    public function offsetGet($offset) {
2066 1
        return isset($this->schema[$offset]) ? $this->schema[$offset] : null;
2067 1
    }
2068
2069
    /**
2070 1
     * Offset to set.
2071
     *
2072
     * @param mixed $offset The offset to assign the value to.
2073 9
     * @param mixed $value The value to set.
2074 9
     * @link http://php.net/manual/en/arrayaccess.offsetset.php
2075 2
     */
2076
    public function offsetSet($offset, $value) {
2077 2
        $this->schema[$offset] = $value;
2078 2
    }
2079
2080
    /**
2081
     * Offset to unset.
2082 7
     *
2083
     * @param mixed $offset The offset to unset.
2084
     * @link http://php.net/manual/en/arrayaccess.offsetunset.php
2085
     */
2086 9
    public function offsetUnset($offset) {
2087 9
        unset($this->schema[$offset]);
2088 1
    }
2089 1
2090 1
    /**
2091
     * Resolve the schema attached to a discriminator.
2092 1
     *
2093 1
     * @param mixed $value The value to search for the discriminator.
2094 1
     * @param ValidationField $field The current node's schema information.
2095
     * @return ValidationField|null Returns the resolved schema or **null** if it can't be resolved.
2096
     * @throws ParseException Throws an exception if the discriminator isn't a string.
2097 1
     */
2098
    private function resolveDiscriminator($value, ValidationField $field, array $visited = []) {
2099
        $propertyName = $field->val('discriminator')['propertyName'] ?? '';
2100
        if (empty($propertyName) || !is_string($propertyName)) {
2101
            throw new ParseException("Invalid propertyName for discriminator at {$field->getSchemaPath()}", 500);
2102 9
        }
2103
2104 9
        $propertyFieldName = ltrim($field->getName().'/'.self::escapeRef($propertyName), '/');
2105 8
2106 2
        // Do some basic validation checking to see if we can even look at the property.
2107
        if (!$this->isArray($value)) {
2108
            $field->addTypeError($value, 'object');
2109 7
            return null;
2110 7
        } elseif (empty($value[$propertyName])) {
2111 7
            $field->getValidation()->addError(
2112 7
                $propertyFieldName,
2113 7
                'required',
2114 7
                ['messageCode' => '{property} is required.', 'property' => $propertyName]
2115
            );
2116 7
            return null;
2117 4
        }
2118
2119 4
        $propertyValue = $value[$propertyName];
2120
        if (!is_string($propertyValue)) {
2121 4
            $field->getValidation()->addError(
2122
                $propertyFieldName,
2123 3
                'type',
2124 3
                [
2125 3
                    'type' => 'string',
2126
                    'value' => is_scalar($value) ? $value : null,
2127 3
                    'messageCode' => is_scalar($value) ? "{value} is not a valid string." : "The value is not a valid string."
2128 3
                ]
2129 3
            );
2130
            return null;
2131
        }
2132 3
2133
        $mapping = $field->val('discriminator')['mapping'] ?? '';
2134
        if (isset($mapping[$propertyValue])) {
2135
            $ref = $mapping[$propertyValue];
2136
2137
            if (strpos($ref, '#') === false) {
2138
                $ref = '#/components/schemas/'.self::escapeRef($ref);
2139
            }
2140
        } else {
2141
            // Don't let a property value provide its own ref as that may pose a security concern..
2142
            $ref = '#/components/schemas/'.self::escapeRef($propertyValue);
2143
        }
2144
2145
        // Validate the reference against the oneOf constraint.
2146
        $oneOf = $field->val('oneOf', []);
2147
        if (!empty($oneOf) && !in_array(['$ref' => $ref], $oneOf)) {
2148
            $field->getValidation()->addError(
2149
                $propertyFieldName,
2150
                'oneOf',
2151
                [
2152
                    'type' => 'string',
2153
                    'value' => is_scalar($propertyValue) ? $propertyValue : null,
0 ignored issues
show
introduced by
The condition is_scalar($propertyValue) is always true.
Loading history...
2154
                    'messageCode' => is_scalar($propertyValue) ? "{value} is not a valid option." : "The value is not a valid option."
0 ignored issues
show
introduced by
The condition is_scalar($propertyValue) is always true.
Loading history...
2155
                ]
2156
            );
2157
            return null;
2158
        }
2159
2160
        try {
2161
            // Lookup the schema.
2162
            $visited[$field->getSchemaPath()] = true;
2163
2164
            list($schema, $schemaPath) = $this->lookupSchema(['$ref' => $ref], $field->getSchemaPath());
2165
            if (isset($visited[$schemaPath])) {
2166
                throw new RefNotFoundException('Cyclical ref.', 508);
2167
            }
2168
2169
            $result = new ValidationField(
2170
                $field->getValidation(),
2171
                $schema,
2172
                $field->getName(),
2173
                $schemaPath,
2174
                $field->getOptions()
2175
            );
2176
            if (!empty($schema['discriminator'])) {
2177
                return $this->resolveDiscriminator($value, $result, $visited);
2178
            } else {
2179
                return $result;
2180
            }
2181
        } catch (RefNotFoundException $ex) {
2182
            // Since this is a ref provided by the value it is technically a validation error.
2183
            $field->getValidation()->addError(
2184
                $propertyFieldName,
2185
                'propertyName',
2186
                [
2187
                    'type' => 'string',
2188
                    'value' => is_scalar($propertyValue) ? $propertyValue : null,
0 ignored issues
show
introduced by
The condition is_scalar($propertyValue) is always true.
Loading history...
2189
                    'messageCode' => is_scalar($propertyValue) ? "{value} is not a valid option." : "The value is not a valid option."
0 ignored issues
show
introduced by
The condition is_scalar($propertyValue) is always true.
Loading history...
2190
                ]
2191
            );
2192
            return null;
2193
        }
2194
    }
2195
}
2196