Passed
Push — master ( bcb6db...244194 )
by Todd
02:25 queued 16s
created

Schema::setValidationClass()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 17
Code Lines 11

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 11
CRAP Score 3.0052

Importance

Changes 0
Metric Value
cc 3
eloc 11
nc 2
nop 1
dl 0
loc 17
ccs 11
cts 12
cp 0.9167
crap 3.0052
rs 9.9
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 298
    public function __construct(array $schema = [], callable $refLookup = null) {
90 298
        $this->schema = $schema;
91
92 276
        $this->refLookup = $refLookup ?? function (/** @scrutinizer ignore-unused */
93
                string $_) {
94 1
                return null;
95 276
            };
96 298
    }
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 36
    public static function unescapeRef(string $str): string {
375 36
        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 36
    public static function explodeRef(string $ref): array {
385 36
        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 8
    public function setFlags(int $flags) {
508 8
        $this->flags = $flags;
509
510 8
        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 240
    public function validate($data, $options = []) {
818 240
        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 240
        $options += ['sparse' => false];
823
824
825 240
        list($schema, $schemaPath) = $this->lookupSchema($this->schema, '');
826 236
        $field = new ValidationField($this->createValidation(), $schema, '', $schemaPath, $options);
827
828 236
        $clean = $this->validateField($data, $field);
829
830 232
        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 232
        if (!$field->getValidation()->isValid()) {
836 82
            throw new ValidationException($field->getValidation());
837
        }
838
839 166
        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 240
    private function lookupSchema($schema, string $schemaPath) {
855 240
        if ($schema instanceof Schema) {
856 6
            return [$schema, $schemaPath];
857
        } else {
858 240
            $lookup = $this->getRefLookup();
859 240
            $visited = [];
860
861
            // Resolve any references first.
862 240
            while (!empty($schema['$ref'])) {
863 33
                $schemaPath = $schema['$ref'];
864
865 33
                if (isset($visited[$schemaPath])) {
866 1
                    throw new RefNotFoundException("Cyclical reference cannot be resolved. ($schemaPath)", 508);
867
                }
868 33
                $visited[$schemaPath] = true;
869
870
                try {
871 33
                    $schema = call_user_func($lookup, $schemaPath);
872 1
                } catch (\Exception $ex) {
873 1
                    throw new RefNotFoundException($ex->getMessage(), $ex->getCode(), $ex);
874
                }
875 32
                if ($schema === null) {
876 3
                    throw new RefNotFoundException("Schema reference could not be found. ($schemaPath)");
877
                }
878
            }
879
880 236
            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 240
    public function getRefLookup(): callable {
890 240
        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 236
    protected function createValidation(): Validation {
919 236
        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 236
    public function getValidationFactory(): callable {
928 236
        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 236
    protected function validateField($value, ValidationField $field) {
953 236
        $validated = false;
954 236
        $result = $value = $this->filterField($value, $field, $validated);
955
956 236
        if ($validated) {
957 3
            return $result;
958 233
        } 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 233
        } 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 233
            if (!empty($field->val('discriminator'))) {
970 12
                $field = $this->resolveDiscriminator($value, $field);
971
            }
972
973 232
            if ($field !== null) {
974 225
                if($field->hasAllOf()) {
975 4
                    $result = $this->validateAllOf($value, $field);
976
                } else {
977
                    // Validate the field's type.
978 224
                    $type = $field->getType();
979 224
                    if (is_array($type)) {
980 29
                        $result = $this->validateMultipleTypes($value, $type, $field);
0 ignored issues
show
Deprecated Code introduced by
The function Garden\Schema\Schema::validateMultipleTypes() has been deprecated: Multiple types are being removed next version. ( Ignorable by Annotation )

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

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

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

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

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

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

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

1667
            $keys = array_keys(iterator_to_array(/** @scrutinizer ignore-type */ $data));
Loading history...
1668 4
            $class = get_class($data);
1669 4
            $clean = new $class;
1670
1671 4
            if ($clean instanceof \ArrayObject && $data instanceof \ArrayObject) {
1672 3
                $clean->setFlags($data->getFlags());
1673 3
                $clean->setIteratorClass($data->getIteratorClass());
1674
            }
1675
        }
1676 121
        $keys = array_combine(array_map('strtolower', $keys), $keys);
1677
1678 121
        $propertyField = new ValidationField($field->getValidation(), [], '', '', $field->getOptions());
1679
1680
        // Loop through the schema fields and validate each one.
1681 121
        foreach ($properties as $propertyName => $property) {
1682 119
            list($property, $schemaPath) = $this->lookupSchema($property, $field->getSchemaPath().'/properties/'.self::escapeRef($propertyName));
1683
1684
            $propertyField
1685 119
                ->setField($property)
1686 119
                ->setName(ltrim($field->getName().'/'.self::escapeRef($propertyName), '/'))
1687 119
                ->setSchemaPath($schemaPath);
1688
1689 119
            $lName = strtolower($propertyName);
1690 119
            $isRequired = isset($required[$propertyName]);
1691
1692
            // Check to strip this field if it is readOnly or writeOnly.
1693 119
            if (($isRequest && $propertyField->val('readOnly')) || ($isResponse && $propertyField->val('writeOnly'))) {
1694 6
                unset($keys[$lName]);
1695 6
                continue;
1696
            }
1697
1698
            // Check for required fields.
1699 119
            if (!array_key_exists($lName, $keys)) {
1700 37
                if ($field->isSparse()) {
1701
                    // Sparse validation can leave required fields out.
1702 36
                } elseif ($propertyField->hasVal('default')) {
1703 6
                    $clean[$propertyName] = $propertyField->val('default');
1704 30
                } elseif ($isRequired) {
1705 6
                    $propertyField->addError(
1706 6
                        'required',
1707 37
                        ['messageCode' => '{property} is required.', 'property' => $propertyName]
1708
                    );
1709
                }
1710
            } else {
1711 107
                $value = $data[$keys[$lName]];
1712
1713 107
                if (in_array($value, [null, ''], true) && !$isRequired && !($propertyField->val('nullable') || $propertyField->hasType('null'))) {
1714 5
                    if ($propertyField->getType() !== 'string' || $value === null) {
1715 2
                        continue;
1716
                    }
1717
                }
1718
1719 105
                $clean[$propertyName] = $this->validateField($value, $propertyField);
1720
            }
1721
1722 117
            unset($keys[$lName]);
1723
        }
1724
1725
        // Look for extraneous properties.
1726 121
        if (!empty($keys)) {
1727 24
            if ($additionalProperties) {
1728 10
                list($additionalProperties, $schemaPath) = $this->lookupSchema(
1729 10
                    $additionalProperties,
1730 10
                    $field->getSchemaPath().'/additionalProperties'
1731
                );
1732
1733 10
                $propertyField = new ValidationField(
1734 10
                    $field->getValidation(),
1735 10
                    $additionalProperties,
1736 10
                    '',
1737 10
                    $schemaPath,
1738 10
                    $field->getOptions()
1739
                );
1740
1741 10
                foreach ($keys as $key) {
1742
                    $propertyField
1743 10
                        ->setName(ltrim($field->getName()."/$key", '/'));
1744
1745 10
                    $valid = $this->validateField($data[$key], $propertyField);
1746 10
                    if (Invalid::isValid($valid)) {
1747 10
                        $clean[$key] = $valid;
1748
                    }
1749
                }
1750 14
            } 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...
1751 2
                $msg = sprintf("Unexpected properties: %s.", implode(', ', $keys));
1752 2
                trigger_error($msg, E_USER_NOTICE);
1753 12
            } 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...
1754 3
                $field->addError('unexpectedProperties', [
1755 3
                    'messageCode' => 'Unexpected {extra,plural,property,properties}: {extra}.',
1756 3
                    'extra' => array_values($keys),
1757
                ]);
1758
            }
1759
        }
1760
1761 119
        return $clean;
1762
    }
1763
1764
    /**
1765
     * Escape a JSON reference field.
1766
     *
1767
     * @param string $field The reference field to escape.
1768
     * @return string Returns an escaped reference.
1769
     */
1770 127
    public static function escapeRef(string $field): string {
1771 127
        return str_replace(['~', '/'], ['~0', '~1'], $field);
1772
    }
1773
1774
    /**
1775
     * Whether or not the schema has a flag (or combination of flags).
1776
     *
1777
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
1778
     * @return bool Returns **true** if all of the flags are set or **false** otherwise.
1779
     */
1780 15
    public function hasFlag(int $flag): bool {
1781 15
        return ($this->flags & $flag) === $flag;
1782
    }
1783
1784
    /**
1785
     * Cast a value to an array.
1786
     *
1787
     * @param \Traversable $value The value to convert.
1788
     * @return array Returns an array.
1789
     */
1790 3
    private function toObjectArray(\Traversable $value) {
1791 3
        $class = get_class($value);
1792 3
        if ($value instanceof \ArrayObject) {
1793 2
            return new $class($value->getArrayCopy(), $value->getFlags(), $value->getIteratorClass());
0 ignored issues
show
Bug Best Practice introduced by
The expression return new $class($value...ue->getIteratorClass()) returns the type object which is incompatible with the documented return type array.
Loading history...
1794 1
        } elseif ($value instanceof \ArrayAccess) {
1795 1
            $r = new $class;
1796 1
            foreach ($value as $k => $v) {
1797 1
                $r[$k] = $v;
1798
            }
1799 1
            return $r;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $r returns the type object which is incompatible with the documented return type array.
Loading history...
1800
        }
1801
        return iterator_to_array($value);
1802
    }
1803
1804
    /**
1805
     * Validate a null value.
1806
     *
1807
     * @param mixed $value The value to validate.
1808
     * @param ValidationField $field The error collector for the field.
1809
     * @return null|Invalid Returns **null** or invalid.
1810
     */
1811 1
    protected function validateNull($value, ValidationField $field) {
1812 1
        if ($value === null) {
1813
            return null;
1814
        }
1815 1
        $field->addError('type', ['messageCode' => 'The value should be null.', 'type' => 'null']);
1816 1
        return Invalid::value();
1817
    }
1818
1819
    /**
1820
     * Validate a value against an enum.
1821
     *
1822
     * @param mixed $value The value to test.
1823
     * @param ValidationField $field The validation object for adding errors.
1824
     * @return mixed|Invalid Returns the value if it is one of the enumerated values or invalid otherwise.
1825
     */
1826 214
    protected function validateEnum($value, ValidationField $field) {
1827 214
        $enum = $field->val('enum');
1828 214
        if (empty($enum)) {
1829 213
            return $value;
1830
        }
1831
1832 4
        if (!in_array($value, $enum, true)) {
1833 1
            $field->addError(
1834 1
                'enum',
1835
                [
1836 1
                    'messageCode' => 'The value must be one of: {enum}.',
1837 1
                    'enum' => $enum,
1838
                ]
1839
            );
1840 1
            return Invalid::value();
1841
        }
1842 4
        return $value;
1843
    }
1844
1845
    /**
1846
     * Call all of the validators attached to a field.
1847
     *
1848
     * @param mixed $value The field value being validated.
1849
     * @param ValidationField $field The validation object to add errors.
1850
     */
1851 215
    private function callValidators($value, ValidationField $field) {
1852 215
        $valid = true;
1853
1854
        // Strip array references in the name except for the last one.
1855 215
        $key = $field->getSchemaPath();
1856 215
        if (!empty($this->validators[$key])) {
1857 5
            foreach ($this->validators[$key] as $validator) {
1858 5
                $r = call_user_func($validator, $value, $field);
1859
1860 5
                if ($r === false || Invalid::isInvalid($r)) {
1861 5
                    $valid = false;
1862
                }
1863
            }
1864
        }
1865
1866
        // Add an error on the field if the validator hasn't done so.
1867 215
        if (!$valid && $field->isValid()) {
1868 1
            $field->addError('invalid', ['messageCode' => 'The value is invalid.']);
1869
        }
1870 215
    }
1871
1872
    /**
1873
     * Specify data which should be serialized to JSON.
1874
     *
1875
     * This method specifically returns data compatible with the JSON schema format.
1876
     *
1877
     * @return mixed Returns data which can be serialized by **json_encode()**, which is a value of any type other than a resource.
1878
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
1879
     * @link http://json-schema.org/
1880
     */
1881 19
    public function jsonSerialize() {
1882 19
        $seen = [$this];
1883 19
        return $this->jsonSerializeInternal($seen);
1884
    }
1885
1886
    /**
1887
     * Return the JSON data for serialization with massaging for Open API.
1888
     *
1889
     * - Swap data/time & timestamp types for Open API types.
1890
     * - Turn recursive schema pointers into references.
1891
     *
1892
     * @param Schema[] $seen Schemas that have been seen during traversal.
1893
     * @return array Returns an array of data that `json_encode()` will recognize.
1894
     */
1895
    private function jsonSerializeInternal(array $seen): array {
1896 19
        $fix = function ($schema) use (&$fix, $seen) {
1897 19
            if ($schema instanceof Schema) {
1898 4
                if (in_array($schema, $seen, true)) {
1899 3
                    return ['$ref' => '#/components/schemas/'.($schema->getID() ?: '$no-id')];
1900
                } else {
1901 2
                    $seen[] = $schema;
1902 2
                    return $schema->jsonSerializeInternal($seen);
1903
                }
1904
            }
1905
1906 19
            if (!empty($schema['type'])) {
1907 18
                $types = (array)$schema['type'];
1908
1909 18
                foreach ($types as $i => &$type) {
1910
                    // Swap datetime and timestamp to other types with formats.
1911 18
                    if ($type === 'datetime') {
1912 4
                        $type = 'string';
1913 4
                        $schema['format'] = 'date-time';
1914 17
                    } elseif ($schema['type'] === 'timestamp') {
1915 2
                        $type = 'integer';
1916 18
                        $schema['format'] = 'timestamp';
1917
                    }
1918
                }
1919 18
                $types = array_unique($types);
1920 18
                $schema['type'] = count($types) === 1 ? reset($types) : $types;
1921
            }
1922
1923 19
            if (!empty($schema['items'])) {
1924 5
                $schema['items'] = $fix($schema['items']);
1925
            }
1926 19
            if (!empty($schema['properties'])) {
1927 14
                $properties = [];
1928 14
                foreach ($schema['properties'] as $key => $property) {
1929 14
                    $properties[$key] = $fix($property);
1930
                }
1931 14
                $schema['properties'] = $properties;
1932
            }
1933
1934 19
            return $schema;
1935 19
        };
1936
1937 19
        $result = $fix($this->schema);
1938
1939 19
        return $result;
1940
    }
1941
1942
    /**
1943
     * Get the class that's used to contain validation information.
1944
     *
1945
     * @return Validation|string Returns the validation class.
1946
     * @deprecated
1947
     */
1948 1
    public function getValidationClass() {
1949 1
        trigger_error('Schema::getValidationClass() is deprecated. Use Schema::getValidationFactory() instead.', E_USER_DEPRECATED);
1950 1
        return $this->validationClass;
0 ignored issues
show
Deprecated Code introduced by
The property Garden\Schema\Schema::$validationClass has been deprecated. ( Ignorable by Annotation )

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

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

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

1975
        /** @scrutinizer ignore-deprecated */ $this->validationClass = $class;
Loading history...
1976 1
        return $this;
1977
    }
1978
1979
    /**
1980
     * Return a sparse version of this schema.
1981
     *
1982
     * A sparse schema has no required properties.
1983
     *
1984
     * @return Schema Returns a new sparse schema.
1985
     */
1986 2
    public function withSparse() {
1987 2
        $sparseSchema = $this->withSparseInternal($this, new \SplObjectStorage());
1988 2
        return $sparseSchema;
1989
    }
1990
1991
    /**
1992
     * The internal implementation of `Schema::withSparse()`.
1993
     *
1994
     * @param array|Schema $schema The schema to make sparse.
1995
     * @param \SplObjectStorage $schemas Collected sparse schemas that have already been made.
1996
     * @return mixed
1997
     */
1998 2
    private function withSparseInternal($schema, \SplObjectStorage $schemas) {
1999 2
        if ($schema instanceof Schema) {
2000 2
            if ($schemas->contains($schema)) {
2001 1
                return $schemas[$schema];
2002
            } else {
2003 2
                $schemas[$schema] = $sparseSchema = new Schema();
2004 2
                $sparseSchema->schema = $schema->withSparseInternal($schema->schema, $schemas);
2005 2
                if ($id = $sparseSchema->getID()) {
2006
                    $sparseSchema->setID($id.'Sparse');
2007
                }
2008
2009 2
                return $sparseSchema;
2010
            }
2011
        }
2012
2013 2
        unset($schema['required']);
2014
2015 2
        if (isset($schema['items'])) {
2016 1
            $schema['items'] = $this->withSparseInternal($schema['items'], $schemas);
2017
        }
2018 2
        if (isset($schema['properties'])) {
2019 2
            foreach ($schema['properties'] as $name => &$property) {
2020 2
                $property = $this->withSparseInternal($property, $schemas);
2021
            }
2022
        }
2023
2024 2
        return $schema;
2025
    }
2026
2027
    /**
2028
     * Get the ID for the schema.
2029
     *
2030
     * @return string
2031
     */
2032 6
    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...
2033 6
        return $this->schema['id'] ?? '';
2034
    }
2035
2036
    /**
2037
     * Set the ID for the schema.
2038
     *
2039
     * @param string $id The new ID.
2040
     * @return $this
2041
     */
2042 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...
2043 1
        $this->schema['id'] = $id;
2044
2045 1
        return $this;
2046
    }
2047
2048
    /**
2049
     * Whether a offset exists.
2050
     *
2051
     * @param mixed $offset An offset to check for.
2052
     * @return boolean true on success or false on failure.
2053
     * @link http://php.net/manual/en/arrayaccess.offsetexists.php
2054
     */
2055 7
    public function offsetExists($offset) {
2056 7
        return isset($this->schema[$offset]);
2057
    }
2058
2059
    /**
2060
     * Offset to retrieve.
2061
     *
2062
     * @param mixed $offset The offset to retrieve.
2063
     * @return mixed Can return all value types.
2064
     * @link http://php.net/manual/en/arrayaccess.offsetget.php
2065
     */
2066 7
    public function offsetGet($offset) {
2067 7
        return isset($this->schema[$offset]) ? $this->schema[$offset] : null;
2068
    }
2069
2070
    /**
2071
     * Offset to set.
2072
     *
2073
     * @param mixed $offset The offset to assign the value to.
2074
     * @param mixed $value The value to set.
2075
     * @link http://php.net/manual/en/arrayaccess.offsetset.php
2076
     */
2077 1
    public function offsetSet($offset, $value) {
2078 1
        $this->schema[$offset] = $value;
2079 1
    }
2080
2081
    /**
2082
     * Offset to unset.
2083
     *
2084
     * @param mixed $offset The offset to unset.
2085
     * @link http://php.net/manual/en/arrayaccess.offsetunset.php
2086
     */
2087 1
    public function offsetUnset($offset) {
2088 1
        unset($this->schema[$offset]);
2089 1
    }
2090
2091
    /**
2092
     * Resolve the schema attached to a discriminator.
2093
     *
2094
     * @param mixed $value The value to search for the discriminator.
2095
     * @param ValidationField $field The current node's schema information.
2096
     * @return ValidationField|null Returns the resolved schema or **null** if it can't be resolved.
2097
     * @throws ParseException Throws an exception if the discriminator isn't a string.
2098
     */
2099 12
    private function resolveDiscriminator($value, ValidationField $field, array $visited = []) {
2100 12
        $propertyName = $field->val('discriminator')['propertyName'] ?? '';
2101 12
        if (empty($propertyName) || !is_string($propertyName)) {
2102 1
            throw new ParseException("Invalid propertyName for discriminator at {$field->getSchemaPath()}", 500);
2103
        }
2104
2105 12
        $propertyFieldName = ltrim($field->getName().'/'.self::escapeRef($propertyName), '/');
2106
2107
        // Do some basic validation checking to see if we can even look at the property.
2108 12
        if (!$this->isArray($value)) {
2109 1
            $field->addTypeError($value, 'object');
2110 1
            return null;
2111 11
        } elseif (empty($value[$propertyName])) {
2112 1
            $field->getValidation()->addError(
2113 1
                $propertyFieldName,
2114 1
                'required',
2115 1
                ['messageCode' => '{property} is required.', 'property' => $propertyName]
2116
            );
2117 1
            return null;
2118
        }
2119
2120 10
        $propertyValue = $value[$propertyName];
2121 10
        if (!is_string($propertyValue)) {
2122 1
            $field->getValidation()->addError(
2123 1
                $propertyFieldName,
2124 1
                'type',
2125
                [
2126 1
                    'type' => 'string',
2127 1
                    'value' => is_scalar($value) ? $value : null,
2128 1
                    'messageCode' => is_scalar($value) ? "{value} is not a valid string." : "The value is not a valid string."
2129
                ]
2130
            );
2131 1
            return null;
2132
        }
2133
2134 9
        $mapping = $field->val('discriminator')['mapping'] ?? '';
2135 9
        if (isset($mapping[$propertyValue])) {
2136 2
            $ref = $mapping[$propertyValue];
2137
2138 2
            if (strpos($ref, '#') === false) {
2139 2
                $ref = '#/components/schemas/'.self::escapeRef($ref);
2140
            }
2141
        } else {
2142
            // Don't let a property value provide its own ref as that may pose a security concern..
2143 7
            $ref = '#/components/schemas/'.self::escapeRef($propertyValue);
2144
        }
2145
2146
        // Validate the reference against the oneOf constraint.
2147 9
        $oneOf = $field->val('oneOf', []);
2148 9
        if (!empty($oneOf) && !in_array(['$ref' => $ref], $oneOf)) {
2149 1
            $field->getValidation()->addError(
2150 1
                $propertyFieldName,
2151 1
                'oneOf',
2152
                [
2153 1
                    'type' => 'string',
2154 1
                    'value' => is_scalar($propertyValue) ? $propertyValue : null,
0 ignored issues
show
introduced by
The condition is_scalar($propertyValue) is always true.
Loading history...
2155 1
                    'messageCode' => is_scalar($propertyValue) ? "{value} is not a valid option." : "The value is not a valid option."
0 ignored issues
show
introduced by
The condition is_scalar($propertyValue) is always true.
Loading history...
2156
                ]
2157
            );
2158 1
            return null;
2159
        }
2160
2161
        try {
2162
            // Lookup the schema.
2163 9
            $visited[$field->getSchemaPath()] = true;
2164
2165 9
            list($schema, $schemaPath) = $this->lookupSchema(['$ref' => $ref], $field->getSchemaPath());
2166 8
            if (isset($visited[$schemaPath])) {
2167 2
                throw new RefNotFoundException('Cyclical ref.', 508);
2168
            }
2169
2170 7
            $result = new ValidationField(
2171 7
                $field->getValidation(),
2172 7
                $schema,
2173 7
                $field->getName(),
2174 7
                $schemaPath,
2175 7
                $field->getOptions()
2176
            );
2177 7
            if (!empty($schema['discriminator'])) {
2178 4
                return $this->resolveDiscriminator($value, $result, $visited);
2179
            } else {
2180 4
                return $result;
2181
            }
2182 4
        } catch (RefNotFoundException $ex) {
2183
            // Since this is a ref provided by the value it is technically a validation error.
2184 3
            $field->getValidation()->addError(
2185 3
                $propertyFieldName,
2186 3
                'propertyName',
2187
                [
2188 3
                    'type' => 'string',
2189 3
                    'value' => is_scalar($propertyValue) ? $propertyValue : null,
0 ignored issues
show
introduced by
The condition is_scalar($propertyValue) is always true.
Loading history...
2190 3
                    'messageCode' => is_scalar($propertyValue) ? "{value} is not a valid option." : "The value is not a valid option."
0 ignored issues
show
introduced by
The condition is_scalar($propertyValue) is always true.
Loading history...
2191
                ]
2192
            );
2193 3
            return null;
2194
        }
2195
    }
2196
}
2197