Schema::filterField()   C
last analyzed

Complexity

Conditions 12
Paths 16

Size

Total Lines 28
Code Lines 19

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 15
CRAP Score 12.0351

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 12
eloc 19
c 1
b 0
f 0
nc 16
nop 3
dl 0
loc 28
ccs 15
cts 16
cp 0.9375
crap 12.0351
rs 6.9666

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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

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

175
                        $node = array_replace(/** @scrutinizer ignore-type */ $node, $value);
Loading history...
176
                    } else {
177 12
                        $node['items'] = $this->parseInternal($value);
178
                    }
179
                    break;
180 12
                case 'object':
181 12
                    // The value is a schema of the object.
182 12
                    if (isset($value['properties'])) {
183
                        list($node['properties']) = $this->parseProperties($value['properties']);
184
                    } else {
185 12
                        list($node['properties'], $required) = $this->parseProperties($value);
186
                        if (!empty($required)) {
187 44
                            $node['required'] = $required;
188 66
                        }
189
                    }
190 132
                    break;
191 102
                default:
192 6
                    $node = array_replace($node, $value);
193 98
                    break;
194 102
            }
195
        } elseif (is_string($value)) {
196 35
            if ($node['type'] === 'array' && $arrType = $this->getType($value)) {
197
                $node['items'] = ['type' => $arrType];
198 31
            } elseif (!empty($value)) {
199
                $node['description'] = $value;
200
            }
201 31
        } elseif ($value === null) {
202 1
            // Parse child elements.
203
            if ($node['type'] === 'array' && isset($node['items'])) {
204
                // The value includes array schema information.
205
                $node['items'] = $this->parseInternal($node['items']);
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

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

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

944
        /** @scrutinizer ignore-deprecated */ $this->validationClass = null;
Loading history...
945
        return $this;
946
    }
947
948
    /**
949
     * Validate a field.
950
     *
951
     * @param mixed $value The value to validate.
952 236
     * @param ValidationField $field A validation object to add errors to.
953 236
     * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value
954 236
     * is completely invalid.
955
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
956 236
     */
957 3
    protected function validateField($value, ValidationField $field) {
958 233
        $validated = false;
959
        $result = $value = $this->filterField($value, $field, $validated);
960 5
961 2
        if ($validated) {
962
            return $result;
963 5
        } elseif ($field->getField() instanceof Schema) {
964
            try {
965 233
                $result = $field->getField()->validate($value, $field->getOptions());
966 14
            } catch (ValidationException $ex) {
967
                // The validation failed, so merge the validations together.
968
                $field->getValidation()->merge($ex->getValidation(), $field->getName());
969 233
            }
970 12
        } elseif (($value === null || ($value === '' && !$field->hasType('string'))) && ($field->val('nullable') || $field->hasType('null'))) {
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...
971
            $result = null;
972
        } else {
973 232
            // Look for a discriminator.
974 225
            if (!empty($field->val('discriminator'))) {
975 4
                $field = $this->resolveDiscriminator($value, $field);
976
            }
977
978 224
            if ($field !== null) {
979 224
                if($field->hasAllOf()) {
980 29
                    $result = $this->validateAllOf($value, $field);
981
                } else {
982 203
                    // Validate the field's type.
983
                    $type = $field->getType();
984
                    if (is_array($type)) {
985 224
                        $result = $this->validateMultipleTypes($value, $type, $field);
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

985
                        $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...
986 224
                    } else {
987
                        $result = $this->validateSingleType($value, $type, $field);
988
                    }
989
990 7
                    if (Invalid::isValid($result)) {
991
                        $result = $this->validateEnum($result, $field);
992
                    }
993
                }
994
            } else {
995 231
                $result = Invalid::value();
996 215
            }
997
        }
998
999 231
        // Validate a custom field validator.
1000
        if (Invalid::isValid($result)) {
1001
            $this->callValidators($result, $field);
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

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

1543
            if ((null !== $minItems = $field->val('minItems')) && count(/** @scrutinizer ignore-type */ $value) < $minItems) {
Loading history...
1544 33
                $field->addError(
1545 1
                    'minItems',
1546 1
                    [
1547
                        'messageCode' => 'This must contain at least {minItems} {minItems,plural,item,items}.',
1548 1
                        'minItems' => $minItems,
1549 1
                    ]
1550
                );
1551
            }
1552
            if ((null !== $maxItems = $field->val('maxItems')) && count($value) > $maxItems) {
1553
                $field->addError(
1554 33
                    'maxItems',
1555 1
                    [
1556 1
                        'messageCode' => 'This must contain no more than {maxItems} {maxItems,plural,item,items}.',
1557
                        'maxItems' => $maxItems,
1558 1
                    ]
1559
                );
1560
            }
1561
1562
            if ($field->val('uniqueItems') && count($value) > count(array_unique($value))) {
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

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

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

1958
        return /** @scrutinizer ignore-deprecated */ $this->validationClass;
Loading history...
1959
    }
1960 1
1961 1
    /**
1962
     * Set the class that's used to contain validation information.
1963 1
     *
1964
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
1965
     * @return $this
1966
     * @deprecated
1967 1
     */
1968 1
    public function setValidationClass($class) {
1969 1
        trigger_error('Schema::setValidationClass() is deprecated. Use Schema::setValidationFactory() instead.', E_USER_DEPRECATED);
1970
1971 1
        if (!is_a($class, Validation::class, true)) {
1972
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
1973 1
        }
1974 1
1975 1
        $this->setValidationFactory(function () use ($class) {
1976 1
            if ($class instanceof Validation) {
1977
                $result = clone $class;
1978
            } else {
1979
                $result = new $class;
1980
            }
1981
            return $result;
1982
        });
1983
        $this->validationClass = $class;
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

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