Completed
Pull Request — master (#61)
by Todd
04:42
created

Schema::parseShortParam()   F

Complexity

Conditions 28
Paths 462

Size

Total Lines 86
Code Lines 57

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 49
CRAP Score 28.1506

Importance

Changes 0
Metric Value
cc 28
eloc 57
nc 462
nop 2
dl 0
loc 86
ccs 49
cts 52
cp 0.9423
crap 28.1506
rs 0.7472
c 0
b 0
f 0

How to fix   Long Method    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
     * @var array All the known types.
26
     *
27
     * If this is ever given some sort of public access then remove the static.
28
     */
29
    private static $types = [
30
        'array' => ['a'],
31
        'object' => ['o'],
32
        'integer' => ['i', 'int'],
33
        'string' => ['s', 'str'],
34
        'number' => ['f', 'float'],
35
        'boolean' => ['b', 'bool'],
36
37
        // Psuedo-types
38
        'timestamp' => ['ts'], // type: integer, format: timestamp
39
        'datetime' => ['dt'], // type: string, format: date-time
40
        'null' => ['n'], // Adds nullable: true
41
    ];
42
43
    /**
44
     * @var string The regular expression to strictly determine if a string is a date.
45
     */
46
    private static $DATE_REGEX = '`^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?`i';
47
48
    private $schema = [];
49
50
    /**
51
     * @var int A bitwise combination of the various **Schema::FLAG_*** constants.
52
     */
53
    private $flags = 0;
54
55
    /**
56
     * @var array An array of callbacks that will filter data in the schema.
57
     */
58
    private $filters = [];
59
60
    /**
61
     * @var array An array of callbacks that will custom validate the schema.
62
     */
63
    private $validators = [];
64
65
    /**
66
     * @var string|Validation The name of the class or an instance that will be cloned.
67
     * @deprecated
68
     */
69
    private $validationClass = Validation::class;
70
71
    /**
72
     * @var callable A callback is used to create validation objects.
73
     */
74
    private $validationFactory = [Validation::class, 'createValidation'];
75
76
    /**
77
     * @var callable
78
     */
79
    private $refLookup;
80
81
    /// Methods ///
82
83
    /**
84
     * Initialize an instance of a new {@link Schema} class.
85
     *
86
     * @param array $schema The array schema to validate against.
87
     * @param callable $refLookup The function used to lookup references.
88
     */
89 277
    public function __construct(array $schema = [], callable $refLookup = null) {
90 277
        $this->schema = $schema;
91
92
        $this->refLookup = $refLookup ?? function (/** @scrutinizer ignore-unused */string $_) {
93 1
            return null;
94 271
        };
95 277
    }
96
97
    /**
98
     * Grab the schema's current description.
99
     *
100
     * @return string
101
     */
102 1
    public function getDescription(): string {
103 1
        return $this->schema['description'] ?? '';
104
    }
105
106
    /**
107
     * Set the description for the schema.
108
     *
109
     * @param string $description The new description.
110
     * @return $this
111
     */
112 1
    public function setDescription(string $description) {
113 1
        $this->schema['description'] = $description;
114 1
        return $this;
115
    }
116
117
    /**
118
     * Get the schema's title.
119
     *
120
     * @return string Returns the title.
121
     */
122 1
    public function getTitle(): string {
123 1
        return $this->schema['title'] ?? '';
124
    }
125
126
    /**
127
     * Set the schema's title.
128
     *
129
     * @param string $title The new title.
130
     */
131 1
    public function setTitle(string $title) {
132 1
        $this->schema['title'] = $title;
133 1
    }
134
135
    /**
136
     * Get a schema field.
137
     *
138
     * @param string|array $path The JSON schema path of the field with parts separated by dots.
139
     * @param mixed $default The value to return if the field isn't found.
140
     * @return mixed Returns the field value or `$default`.
141
     */
142 10
    public function getField($path, $default = null) {
143 10
        if (is_string($path)) {
144 10
            if (strpos($path, '.') !== false && strpos($path, '/') === false) {
145 1
                trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED);
146 1
                $path = explode('.', $path);
147
            } else {
148 9
                $path = explode('/', $path);
149
            }
150
        }
151
152 10
        $value = $this->schema;
153 10
        foreach ($path as $i => $subKey) {
154 10
            if (is_array($value) && isset($value[$subKey])) {
155 10
                $value = $value[$subKey];
156 1
            } elseif ($value instanceof Schema) {
157 1
                return $value->getField(array_slice($path, $i), $default);
158
            } else {
159 10
                return $default;
160
            }
161
        }
162 10
        return $value;
163
    }
164
165
    /**
166
     * Set a schema field.
167
     *
168
     * @param string|array $path The JSON schema path of the field with parts separated by slashes.
169
     * @param mixed $value The new value.
170
     * @return $this
171
     */
172 4
    public function setField($path, $value) {
173 4
        if (is_string($path)) {
174 4
            if (strpos($path, '.') !== false && strpos($path, '/') === false) {
175 1
                trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED);
176 1
                $path = explode('.', $path);
177
            } else {
178 3
                $path = explode('/', $path);
179
            }
180
        }
181
182 4
        $selection = &$this->schema;
183 4
        foreach ($path as $i => $subSelector) {
184 4
            if (is_array($selection)) {
185 4
                if (!isset($selection[$subSelector])) {
186 4
                    $selection[$subSelector] = [];
187
                }
188 1
            } elseif ($selection instanceof Schema) {
189 1
                $selection->setField(array_slice($path, $i), $value);
190 1
                return $this;
191
            } else {
192
                $selection = [$subSelector => []];
193
            }
194 4
            $selection = &$selection[$subSelector];
195
        }
196
197 4
        $selection = $value;
198 4
        return $this;
199
    }
200
201
    /**
202
     * Get the ID for the schema.
203
     *
204
     * @return string
205
     */
206 3
    public function getID(): string {
207 3
        return $this->schema['id'] ?? '';
208
    }
209
210
    /**
211
     * Set the ID for the schema.
212
     *
213
     * @param string $id The new ID.
214
     * @return $this
215
     */
216 1
    public function setID(string $id) {
217 1
        $this->schema['id'] = $id;
218
219 1
        return $this;
220
    }
221
222
    /**
223
     * Return the validation flags.
224
     *
225
     * @return int Returns a bitwise combination of flags.
226
     */
227 1
    public function getFlags(): int {
228 1
        return $this->flags;
229
    }
230
231
    /**
232
     * Set the validation flags.
233
     *
234
     * @param int $flags One or more of the **Schema::FLAG_*** constants.
235
     * @return Schema Returns the current instance for fluent calls.
236
     */
237 7
    public function setFlags(int $flags) {
238 7
        $this->flags = $flags;
239
240 7
        return $this;
241
    }
242
243
    /**
244
     * Whether or not the schema has a flag (or combination of flags).
245
     *
246
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
247
     * @return bool Returns **true** if all of the flags are set or **false** otherwise.
248
     */
249 12
    public function hasFlag(int $flag): bool {
250 12
        return ($this->flags & $flag) === $flag;
251
    }
252
253
    /**
254
     * Set a flag.
255
     *
256
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
257
     * @param bool $value Either true or false.
258
     * @return $this
259
     */
260 1
    public function setFlag(int $flag, bool $value) {
261 1
        if ($value) {
262 1
            $this->flags = $this->flags | $flag;
263
        } else {
264 1
            $this->flags = $this->flags & ~$flag;
265
        }
266 1
        return $this;
267
    }
268
269
    /**
270
     * Merge a schema with this one.
271
     *
272
     * @param Schema $schema A scheme instance. Its parameters will be merged into the current instance.
273
     * @return $this
274
     */
275 4
    public function merge(Schema $schema) {
276 4
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), true, true);
277 4
        return $this;
278
    }
279
280
    /**
281
     * Add another schema to this one.
282
     *
283
     * Adding schemas together is analogous to array addition. When you add a schema it will only add missing information.
284
     *
285
     * @param Schema $schema The schema to add.
286
     * @param bool $addProperties Whether to add properties that don't exist in this schema.
287
     * @return $this
288
     */
289 4
    public function add(Schema $schema, $addProperties = false) {
290 4
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), false, $addProperties);
291 4
        return $this;
292
    }
293
294
    /**
295
     * The internal implementation of schema merging.
296
     *
297
     * @param array $target The target of the merge.
298
     * @param array $source The source of the merge.
299
     * @param bool $overwrite Whether or not to replace values.
300
     * @param bool $addProperties Whether or not to add object properties to the target.
301
     * @return array
302
     */
303 7
    private function mergeInternal(array &$target, array $source, $overwrite = true, $addProperties = true) {
304
        // We need to do a fix for required properties here.
305 7
        if (isset($target['properties']) && !empty($source['required'])) {
306 5
            $required = isset($target['required']) ? $target['required'] : [];
307
308 5
            if (isset($source['required']) && $addProperties) {
309 4
                $newProperties = array_diff(array_keys($source['properties']), array_keys($target['properties']));
310 4
                $newRequired = array_intersect($source['required'], $newProperties);
311
312 4
                $required = array_merge($required, $newRequired);
313
            }
314
        }
315
316
317 7
        foreach ($source as $key => $val) {
318 7
            if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) {
319 7
                if ($key === 'properties' && !$addProperties) {
320
                    // We just want to merge the properties that exist in the destination.
321 2
                    foreach ($val as $name => $prop) {
322 2
                        if (isset($target[$key][$name])) {
323 2
                            $targetProp = &$target[$key][$name];
324
325 2
                            if (is_array($targetProp) && is_array($prop)) {
326 2
                                $this->mergeInternal($targetProp, $prop, $overwrite, $addProperties);
327 1
                            } elseif (is_array($targetProp) && $prop instanceof Schema) {
328
                                $this->mergeInternal($targetProp, $prop->getSchemaArray(), $overwrite, $addProperties);
329 1
                            } elseif ($overwrite) {
330 2
                                $targetProp = $prop;
331
                            }
332
                        }
333
                    }
334 7
                } elseif (isset($val[0]) || isset($target[$key][0])) {
335 5
                    if ($overwrite) {
336
                        // This is a numeric array, so just do a merge.
337 3
                        $merged = array_merge($target[$key], $val);
338 3
                        if (is_string($merged[0])) {
339 3
                            $merged = array_keys(array_flip($merged));
340
                        }
341 5
                        $target[$key] = $merged;
342
                    }
343
                } else {
344 7
                    $target[$key] = $this->mergeInternal($target[$key], $val, $overwrite, $addProperties);
345
                }
346 7
            } elseif (!$overwrite && array_key_exists($key, $target) && !is_array($val)) {
347
                // Do nothing, we aren't replacing.
348
            } else {
349 7
                $target[$key] = $val;
350
            }
351
        }
352
353 7
        if (isset($required)) {
354 5
            if (empty($required)) {
355 1
                unset($target['required']);
356
            } else {
357 5
                $target['required'] = $required;
358
            }
359
        }
360
361 7
        return $target;
362
    }
363
364
    /**
365
     * Returns the internal schema array.
366
     *
367
     * @return array
368
     * @see Schema::jsonSerialize()
369
     */
370 17
    public function getSchemaArray(): array {
371 17
        return $this->schema;
372
    }
373
374
    /**
375
     * Parse a short schema and return the associated schema.
376
     *
377
     * @param array $arr The schema array.
378
     * @param mixed[] $args Constructor arguments for the schema instance.
379
     * @return static Returns a new schema.
380
     */
381 179
    public static function parse(array $arr, ...$args) {
382 179
        $schema = new static([], ...$args);
0 ignored issues
show
Bug introduced by
$args is expanded, but the parameter $refLookup of Garden\Schema\Schema::__construct() does not expect variable arguments. ( Ignorable by Annotation )

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

382
        $schema = new static([], /** @scrutinizer ignore-type */ ...$args);
Loading history...
383 179
        $schema->schema = $schema->parseInternal($arr);
384 177
        return $schema;
385
    }
386
387
    /**
388
     * Parse a schema in short form into a full schema array.
389
     *
390
     * @param array $arr The array to parse into a schema.
391
     * @return array The full schema array.
392
     * @throws ParseException Throws an exception when an item in the schema is invalid.
393
     */
394 179
    protected function parseInternal(array $arr): array {
395 179
        if (empty($arr)) {
396
            // An empty schema validates to anything.
397 6
            return [];
398 174
        } elseif (isset($arr['type'])) {
399
            // This is a long form schema and can be parsed as the root.
400 2
            return $this->parseNode($arr);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->parseNode($arr) could return the type ArrayAccess which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
401
        } else {
402
            // Check for a root schema.
403 173
            $value = reset($arr);
404 173
            $key = key($arr);
405 173
            if (is_int($key)) {
406 108
                $key = $value;
407 108
                $value = null;
408
            }
409 173
            list ($name, $param) = $this->parseShortParam($key, $value);
410 171
            if (empty($name)) {
411 63
                return $this->parseNode($param, $value);
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->parseNode($param, $value) could return the type ArrayAccess which is incompatible with the type-hinted return array. Consider adding an additional type-check to rule them out.
Loading history...
412
            }
413
        }
414
415
        // If we are here then this is n object schema.
416 111
        list($properties, $required) = $this->parseProperties($arr);
417
418
        $result = [
419 111
            'type' => 'object',
420 111
            'properties' => $properties,
421 111
            'required' => $required
422
        ];
423
424 111
        return array_filter($result);
425
    }
426
427
    /**
428
     * Parse a schema node.
429
     *
430
     * @param array|Schema $node The node to parse.
431
     * @param mixed $value Additional information from the node.
432
     * @return array|\ArrayAccess Returns a JSON schema compatible node.
433
     * @throws ParseException Throws an exception if there was a problem parsing the schema node.
434
     */
435 172
    private function parseNode($node, $value = null) {
436 172
        if (is_array($value)) {
437 66
            if (is_array($node['type'])) {
438
                trigger_error('Schemas with multiple types are deprecated.', E_USER_DEPRECATED);
439
            }
440
441
            // The value describes a bit more about the schema.
442 66
            switch ($node['type']) {
443 66
                case 'array':
444 11
                    if (isset($value['items'])) {
445
                        // The value includes array schema information.
446 4
                        $node = array_replace($node, $value);
0 ignored issues
show
Bug introduced by
It seems like $node can also be of type Garden\Schema\Schema; however, parameter $array of array_replace() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

446
                        $node = array_replace(/** @scrutinizer ignore-type */ $node, $value);
Loading history...
447
                    } else {
448 7
                        $node['items'] = $this->parseInternal($value);
449
                    }
450 11
                    break;
451 56
                case 'object':
452
                    // The value is a schema of the object.
453 12
                    if (isset($value['properties'])) {
454
                        list($node['properties']) = $this->parseProperties($value['properties']);
455
                    } else {
456 12
                        list($node['properties'], $required) = $this->parseProperties($value);
457 12
                        if (!empty($required)) {
458 12
                            $node['required'] = $required;
459
                        }
460
                    }
461 12
                    break;
462
                default:
463 44
                    $node = array_replace($node, $value);
464 66
                    break;
465
            }
466 132
        } elseif (is_string($value)) {
467 102
            if ($node['type'] === 'array' && $arrType = $this->getType($value)) {
468 6
                $node['items'] = ['type' => $arrType];
469 98
            } elseif (!empty($value)) {
470 102
                $node['description'] = $value;
471
            }
472 35
        } elseif ($value === null) {
473
            // Parse child elements.
474 31
            if ($node['type'] === 'array' && isset($node['items'])) {
475
                // The value includes array schema information.
476
                $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

476
                $node['items'] = $this->parseInternal(/** @scrutinizer ignore-type */ $node['items']);
Loading history...
477 31
            } elseif ($node['type'] === 'object' && isset($node['properties'])) {
478 1
                list($node['properties']) = $this->parseProperties($node['properties']);
479
            }
480
        }
481
482 172
        if (is_array($node)) {
483 171
            if (!empty($node['allowNull'])) {
484 1
                $node['nullable'] = true;
485
            }
486 171
            unset($node['allowNull']);
487
488 171
            if ($node['type'] === null || $node['type'] === []) {
489 4
                unset($node['type']);
490
            }
491
        }
492
493 172
        return $node;
494
    }
495
496
    /**
497
     * Parse the schema for an object's properties.
498
     *
499
     * @param array $arr An object property schema.
500
     * @return array Returns a schema array suitable to be placed in the **properties** key of a schema.
501
     * @throws ParseException Throws an exception if a property name cannot be determined for an array item.
502
     */
503 112
    private function parseProperties(array $arr): array {
504 112
        $properties = [];
505 112
        $requiredProperties = [];
506 112
        foreach ($arr as $key => $value) {
507
            // Fix a schema specified as just a value.
508 112
            if (is_int($key)) {
509 82
                if (is_string($value)) {
510 82
                    $key = $value;
511 82
                    $value = '';
512
                } else {
513
                    throw new ParseException("Schema at position $key is not a valid parameter.", 500);
514
                }
515
            }
516
517
            // The parameter is defined in the key.
518 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

518
            list($name, $param, $required) = $this->parseShortParam($key, /** @scrutinizer ignore-type */ $value);
Loading history...
519
520 112
            $node = $this->parseNode($param, $value);
521
522 112
            $properties[$name] = $node;
523 112
            if ($required) {
524 112
                $requiredProperties[] = $name;
525
            }
526
        }
527 112
        return [$properties, $requiredProperties];
528
    }
529
530
    /**
531
     * Parse a short parameter string into a full array parameter.
532
     *
533
     * @param string $key The short parameter string to parse.
534
     * @param array $value An array of other information that might help resolve ambiguity.
535
     * @return array Returns an array in the form `[string name, array param, bool required]`.
536
     * @throws ParseException Throws an exception if the short param is not in the correct format.
537
     */
538 174
    public function parseShortParam(string $key, $value = []): array {
539
        // Is the parameter optional?
540 174
        if (substr($key, -1) === '?') {
541 70
            $required = false;
542 70
            $key = substr($key, 0, -1);
543
        } else {
544 126
            $required = true;
545
        }
546
547
        // Check for a type.
548 174
        if (false !== ($pos = strrpos($key, ':'))) {
549 168
            $name = substr($key, 0, $pos);
550 168
            $typeStr = substr($key, $pos + 1);
551
552
            // Kludge for names with colons that are not specifying an array of a type.
553 168
            if (isset($value['type']) && 'array' !== $this->getType($typeStr)) {
554 2
                $name = $key;
555 168
                $typeStr = '';
556
            }
557
        } else {
558 16
            $name = $key;
559 16
            $typeStr = '';
560
        }
561 174
        $types = [];
562 174
        $param = [];
563
564 174
        if (!empty($typeStr)) {
565 166
            $shortTypes = explode('|', $typeStr);
566 166
            foreach ($shortTypes as $alias) {
567 166
                $found = $this->getType($alias);
568 166
                if ($found === null) {
569 1
                    throw new ParseException("Unknown type '$alias'.", 500);
570 165
                } elseif ($found === 'datetime') {
571 9
                    $param['format'] = 'date-time';
572 9
                    $types[] = 'string';
573 157
                } elseif ($found === 'timestamp') {
574 12
                    $param['format'] = 'timestamp';
575 12
                    $types[] = 'integer';
576 151
                } elseif ($found === 'null') {
577 11
                    $nullable = true;
578
                } else {
579 165
                    $types[] = $found;
580
                }
581
            }
582
        }
583
584 173
        if ($value instanceof Schema) {
585 6
            if (count($types) === 1 && $types[0] === 'array') {
586 1
                $param += ['type' => $types[0], 'items' => $value];
587
            } else {
588 6
                $param = $value;
589
            }
590 171
        } elseif (isset($value['type'])) {
591 10
            $param = $value + $param;
592
593 10
            if (!empty($types) && $types !== (array)$param['type']) {
594
                $typesStr = implode('|', $types);
595
                $paramTypesStr = implode('|', (array)$param['type']);
596
597 10
                throw new ParseException("Type mismatch between $typesStr and {$paramTypesStr} for field $name.", 500);
598
            }
599
        } else {
600 166
            if (empty($types) && !empty($parts[1])) {
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $parts seems to never exist and therefore empty should always be true.
Loading history...
601
                throw new ParseException("Invalid type {$parts[1]} for field $name.", 500);
602
            }
603 166
            if (empty($types)) {
604 4
                $param += ['type' => null];
605
            } else {
606 165
                $param += ['type' => count($types) === 1 ? $types[0] : $types];
607
            }
608
609
            // Parsed required strings have a minimum length of 1.
610 166
            if (in_array('string', $types) && !empty($name) && $required && (!isset($value['default']) || $value['default'] !== '')) {
611 41
                $param['minLength'] = 1;
612
            }
613
        }
614
615 173
        if (!empty($nullable)) {
616 11
            $param['nullable'] = true;
617
        }
618
619 173
        if (is_array($param['type'])) {
620 1
            trigger_error('Schemas with multiple types is deprecated.', E_USER_DEPRECATED);
621
        }
622
623 172
        return [$name, $param, $required];
624
    }
625
626
    /**
627
     * Add a custom filter to change data before validation.
628
     *
629
     * @param string $fieldname The name of the field to filter, if any.
630
     *
631
     * If you are adding a filter to a deeply nested field then separate the path with dots.
632
     * @param callable $callback The callback to filter the field.
633
     * @param bool $validate Whether or not the filter should also validate. If true default validation is skipped.
634
     * @return $this
635
     */
636 4
    public function addFilter(string $fieldname, callable $callback, bool $validate = false) {
637 4
        $fieldname = $this->parseFieldSelector($fieldname);
638 4
        $this->filters[$fieldname][] = [$callback, $validate];
639 4
        return $this;
640
    }
641
642
    /**
643
     * Add a custom validator to to validate the schema.
644
     *
645
     * @param string $fieldname The name of the field to validate, if any.
646
     *
647
     * If you are adding a validator to a deeply nested field then separate the path with dots.
648
     * @param callable $callback The callback to validate with.
649
     * @return Schema Returns `$this` for fluent calls.
650
     */
651 5
    public function addValidator(string $fieldname, callable $callback) {
652 5
        $fieldname = $this->parseFieldSelector($fieldname);
653 5
        $this->validators[$fieldname][] = $callback;
654 5
        return $this;
655
    }
656
657
    /**
658
     * Require one of a given set of fields in the schema.
659
     *
660
     * @param array $required The field names to require.
661
     * @param string $fieldname The name of the field to attach to.
662
     * @param int $count The count of required items.
663
     * @return Schema Returns `$this` for fluent calls.
664
     */
665 3
    public function requireOneOf(array $required, string $fieldname = '', int $count = 1) {
666 3
        $result = $this->addValidator(
667 3
            $fieldname,
668
            function ($data, ValidationField $field) use ($required, $count) {
669
                // This validator does not apply to sparse validation.
670 3
                if ($field->isSparse()) {
671 1
                    return true;
672
                }
673
674 2
                $hasCount = 0;
675 2
                $flattened = [];
676
677 2
                foreach ($required as $name) {
678 2
                    $flattened = array_merge($flattened, (array)$name);
679
680 2
                    if (is_array($name)) {
681
                        // This is an array of required names. They all must match.
682 1
                        $hasCountInner = 0;
683 1
                        foreach ($name as $nameInner) {
684 1
                            if (array_key_exists($nameInner, $data)) {
685 1
                                $hasCountInner++;
686
                            } else {
687 1
                                break;
688
                            }
689
                        }
690 1
                        if ($hasCountInner >= count($name)) {
691 1
                            $hasCount++;
692
                        }
693 2
                    } elseif (array_key_exists($name, $data)) {
694 1
                        $hasCount++;
695
                    }
696
697 2
                    if ($hasCount >= $count) {
698 2
                        return true;
699
                    }
700
                }
701
702 2
                if ($count === 1) {
703 1
                    $message = 'One of {properties} are required.';
704
                } else {
705 1
                    $message = '{count} of {properties} are required.';
706
                }
707
708 2
                $field->addError('oneOfRequired', [
709 2
                    'messageCode' => $message,
710 2
                    'properties' => $required,
711 2
                    'count' => $count
712
                ]);
713 2
                return false;
714 3
            }
715
        );
716
717 3
        return $result;
718
    }
719
720
    /**
721
     * Validate data against the schema.
722
     *
723
     * @param mixed $data The data to validate.
724
     * @param array $options Validation options.
725
     *
726
     * - **sparse**: Whether or not this is a sparse validation.
727
     * @return mixed Returns a cleaned version of the data.
728
     * @throws ValidationException Throws an exception when the data does not validate against the schema.
729
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
730
     */
731 222
    public function validate($data, $options = []) {
732 222
        if (is_bool($options)) {
0 ignored issues
show
introduced by
The condition is_bool($options) is always false.
Loading history...
733 1
            trigger_error('The $sparse parameter is deprecated. Use [\'sparse\' => true] instead.', E_USER_DEPRECATED);
734 1
            $options = ['sparse' => true];
735
        }
736 222
        $options += ['sparse' => false];
737
738
739 222
        list($schema, $schemaPath) = $this->lookupSchema($this->schema, '');
740 218
        $field = new ValidationField($this->createValidation(), $schema, '', $schemaPath, $options);
741
742 218
        $clean = $this->validateField($data, $field);
743
744 216
        if (Invalid::isInvalid($clean) && $field->isValid()) {
745
            // This really shouldn't happen, but we want to protect against seeing the invalid object.
746 1
            $field->addError('invalid', ['messageCode' => 'The value is invalid.']);
747
        }
748
749 216
        if (!$field->getValidation()->isValid()) {
750 74
            throw new ValidationException($field->getValidation());
751
        }
752
753 158
        return $clean;
754
    }
755
756
    /**
757
     * Validate data against the schema and return the result.
758
     *
759
     * @param mixed $data The data to validate.
760
     * @param array $options Validation options. See `Schema::validate()`.
761
     * @return bool Returns true if the data is valid. False otherwise.
762
     * @throws RefNotFoundException Throws an exception when there is an unknown `$ref` in the schema.
763
     */
764 45
    public function isValid($data, $options = []) {
765
        try {
766 45
            $this->validate($data, $options);
767 31
            return true;
768 24
        } catch (ValidationException $ex) {
769 24
            return false;
770
        }
771
    }
772
773
    /**
774
     * Validate a field.
775
     *
776
     * @param mixed $value The value to validate.
777
     * @param ValidationField $field A validation object to add errors to.
778
     * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value
779
     * is completely invalid.
780
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
781
     */
782 218
    protected function validateField($value, ValidationField $field) {
783 218
        $validated = false;
784 218
        $result = $value = $this->filterField($value, $field, $validated);
785
786 218
        if ($validated) {
787 2
            return $result;
788 216
        } elseif ($field->getField() instanceof Schema) {
789
            try {
790 5
                $result = $field->getField()->validate($value, $field->getOptions());
791 2
            } catch (ValidationException $ex) {
792
                // The validation failed, so merge the validations together.
793 5
                $field->getValidation()->merge($ex->getValidation(), $field->getName());
794
            }
795 216
        } 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...
796 14
            $result = null;
797
        } else {
798
            // Validate the field's type.
799 216
            $type = $field->getType();
800 216
            if (is_array($type)) {
801 29
                $result = $this->validateMultipleTypes($value, $type, $field);
0 ignored issues
show
Deprecated Code introduced by
The function Garden\Schema\Schema::validateMultipleTypes() has been deprecated: Multiple types are being removed next version. ( Ignorable by Annotation )

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

801
                $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...
802
            } else {
803 195
                $result = $this->validateSingleType($value, $type, $field);
804
            }
805 216
            if (Invalid::isValid($result)) {
806 206
                $result = $this->validateEnum($result, $field);
807
            }
808
        }
809
810
        // Validate a custom field validator.
811 216
        if (Invalid::isValid($result)) {
812 207
            $this->callValidators($result, $field);
813
        }
814
815 216
        return $result;
816
    }
817
818
    /**
819
     * Validate an array.
820
     *
821
     * @param mixed $value The value to validate.
822
     * @param ValidationField $field The validation results to add.
823
     * @return array|Invalid Returns an array or invalid if validation fails.
824
     * @throws RefNotFoundException Throws an exception if the array has an items `$ref` that cannot be found.
825
     */
826 38
    protected function validateArray($value, ValidationField $field) {
827 38
        if ((!is_array($value) || (count($value) > 0 && !array_key_exists(0, $value))) && !$value instanceof \Traversable) {
0 ignored issues
show
introduced by
Consider adding parentheses for clarity. Current Interpretation: (! is_array($value) || c... instanceof Traversable, Probably Intended Meaning: ! is_array($value) || (c...instanceof Traversable)
Loading history...
828 6
            $field->addTypeError($value, 'array');
829 6
            return Invalid::value();
830
        } else {
831 33
            if ((null !== $minItems = $field->val('minItems')) && count($value) < $minItems) {
0 ignored issues
show
Bug introduced by
It seems like $value can also be of type Traversable; however, parameter $var of count() does only seem to accept Countable|array, maybe add an additional type check? ( Ignorable by Annotation )

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

831
            if ((null !== $minItems = $field->val('minItems')) && count(/** @scrutinizer ignore-type */ $value) < $minItems) {
Loading history...
832 1
                $field->addError(
833 1
                    'minItems',
834
                    [
835 1
                        'messageCode' => 'This must contain at least {minItems} {minItems,plural,item,items}.',
836 1
                        'minItems' => $minItems,
837
                    ]
838
                );
839
            }
840 33
            if ((null !== $maxItems = $field->val('maxItems')) && count($value) > $maxItems) {
841 1
                $field->addError(
842 1
                    'maxItems',
843
                    [
844 1
                        'messageCode' => 'This must contain no more than {maxItems} {maxItems,plural,item,items}.',
845 1
                        'maxItems' => $maxItems,
846
                    ]
847
                );
848
            }
849
850 33
            if ($field->val('uniqueItems') && count($value) > count(array_unique($value))) {
0 ignored issues
show
Bug introduced by
It seems like $value can also be of type Traversable; however, parameter $array of array_unique() does only seem to accept array, maybe add an additional type check? ( Ignorable by Annotation )

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

850
            if ($field->val('uniqueItems') && count($value) > count(array_unique(/** @scrutinizer ignore-type */ $value))) {
Loading history...
851 1
                $field->addError(
852 1
                    'uniqueItems',
853
                    [
854 1
                        'messageCode' => 'The array must contain unique items.',
855
                    ]
856
                );
857
            }
858
859 33
            if ($field->val('items') !== null) {
860 25
                list ($items, $schemaPath) = $this->lookupSchema($field->val('items'), $field->getSchemaPath().'/items');
861
862
                // Validate each of the types.
863 25
                $itemValidation = new ValidationField(
864 25
                    $field->getValidation(),
865 25
                    $items,
866 25
                    '',
867 25
                    $schemaPath,
868 25
                    $field->getOptions()
869
                );
870
871 25
                $result = [];
872 25
                $count = 0;
873 25
                foreach ($value as $i => $item) {
874 25
                    $itemValidation->setName($field->getName()."/$i");
875 25
                    $validItem = $this->validateField($item, $itemValidation);
876 25
                    if (Invalid::isValid($validItem)) {
877 25
                        $result[] = $validItem;
878
                    }
879 25
                    $count++;
880
                }
881
882 25
                return empty($result) && $count > 0 ? Invalid::value() : $result;
883
            } else {
884
                // Cast the items into a proper numeric array.
885 8
                $result = is_array($value) ? array_values($value) : iterator_to_array($value);
886 8
                return $result;
887
            }
888
        }
889
    }
890
891
    /**
892
     * Validate a boolean value.
893
     *
894
     * @param mixed $value The value to validate.
895
     * @param ValidationField $field The validation results to add.
896
     * @return bool|Invalid Returns the cleaned value or invalid if validation fails.
897
     */
898 32
    protected function validateBoolean($value, ValidationField $field) {
899 32
        $value = $value === null ? $value : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
900 32
        if ($value === null) {
901 4
            $field->addTypeError($value, 'boolean');
902 4
            return Invalid::value();
903
        }
904
905 29
        return $value;
906
    }
907
908
    /**
909
     * Validate a date time.
910
     *
911
     * @param mixed $value The value to validate.
912
     * @param ValidationField $field The validation results to add.
913
     * @return \DateTimeInterface|Invalid Returns the cleaned value or **null** if it isn't valid.
914
     */
915 14
    protected function validateDatetime($value, ValidationField $field) {
916 14
        if ($value instanceof \DateTimeInterface) {
917
            // do nothing, we're good
918 11
        } elseif (is_string($value) && $value !== '' && !is_numeric($value)) {
919
            try {
920 7
                $dt = new \DateTimeImmutable($value);
921 6
                if ($dt) {
0 ignored issues
show
introduced by
$dt is of type DateTimeImmutable, thus it always evaluated to true.
Loading history...
922 6
                    $value = $dt;
923
                } else {
924 6
                    $value = null;
925
                }
926 1
            } catch (\Throwable $ex) {
927 7
                $value = Invalid::value();
928
            }
929 4
        } elseif (is_int($value) && $value > 0) {
930
            try {
931 1
                $value = new \DateTimeImmutable('@'.(string)round($value));
932
            } catch (\Throwable $ex) {
933 1
                $value = Invalid::value();
934
            }
935
        } else {
936 3
            $value = Invalid::value();
937
        }
938
939 14
        if (Invalid::isInvalid($value)) {
940 4
            $field->addTypeError($value, 'date/time');
941
        }
942 14
        return $value;
943
    }
944
945
    /**
946
     * Validate a float.
947
     *
948
     * @param mixed $value The value to validate.
949
     * @param ValidationField $field The validation results to add.
950
     * @return float|Invalid Returns a number or **null** if validation fails.
951
     */
952 17
    protected function validateNumber($value, ValidationField $field) {
953 17
        $result = filter_var($value, FILTER_VALIDATE_FLOAT);
954 17
        if ($result === false) {
955 4
            $field->addTypeError($value, 'number');
956 4
            return Invalid::value();
957
        }
958
959 13
        $result = $this->validateNumberProperties($result, $field);
960
961 13
        return $result;
962
    }
963
    /**
964
     * Validate and integer.
965
     *
966
     * @param mixed $value The value to validate.
967
     * @param ValidationField $field The validation results to add.
968
     * @return int|Invalid Returns the cleaned value or **null** if validation fails.
969
     */
970 65
    protected function validateInteger($value, ValidationField $field) {
971 65
        if ($field->val('format') === 'timestamp') {
972 7
            return $this->validateTimestamp($value, $field);
973
        }
974
975 60
        $result = filter_var($value, FILTER_VALIDATE_INT);
976
977 60
        if ($result === false) {
978 11
            $field->addTypeError($value, 'integer');
979 11
            return Invalid::value();
980
        }
981
982 53
        $result = $this->validateNumberProperties($result, $field);
983
984 53
        return $result;
985
    }
986
987
    /**
988
     * Validate an object.
989
     *
990
     * @param mixed $value The value to validate.
991
     * @param ValidationField $field The validation results to add.
992
     * @return object|Invalid Returns a clean object or **null** if validation fails.
993
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
994
     */
995 121
    protected function validateObject($value, ValidationField $field) {
996 121
        if (!$this->isArray($value) || isset($value[0])) {
997 6
            $field->addTypeError($value, 'object');
998 6
            return Invalid::value();
999 121
        } elseif (is_array($field->val('properties')) || null !== $field->val('additionalProperties')) {
1000
            // Validate the data against the internal schema.
1001 114
            $value = $this->validateProperties($value, $field);
1002 7
        } elseif (!is_array($value)) {
1003 3
            $value = $this->toObjectArray($value);
1004
        }
1005
1006 119
        if (($maxProperties = $field->val('maxProperties')) && count($value) > $maxProperties) {
1007 1
            $field->addError(
1008 1
                'maxProperties',
1009
                [
1010 1
                    'messageCode' => 'This must contain no more than {maxProperties} {maxProperties,plural,item,items}.',
1011 1
                    'maxItems' => $maxProperties,
1012
                ]
1013
            );
1014
        }
1015
1016 119
        if (($minProperties = $field->val('minProperties')) && count($value) < $minProperties) {
1017 1
            $field->addError(
1018 1
                'minProperties',
1019
                [
1020 1
                    'messageCode' => 'This must contain at least {minProperties} {minProperties,plural,item,items}.',
1021 1
                    'minItems' => $minProperties,
1022
                ]
1023
            );
1024
        }
1025
1026 119
        return $value;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $value also could return the type array which is incompatible with the documented return type object|Garden\Schema\Invalid.
Loading history...
1027
    }
1028
1029
    /**
1030
     * Validate data against the schema and return the result.
1031
     *
1032
     * @param array|\Traversable|\ArrayAccess $data The data to validate.
1033
     * @param ValidationField $field This argument will be filled with the validation result.
1034
     * @return array|\ArrayObject|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types.
1035
     * or invalid if there are no valid properties.
1036
     * @throws RefNotFoundException Throws an exception of a property or additional property has a `$ref` that cannot be found.
1037
     */
1038 114
    protected function validateProperties($data, ValidationField $field) {
1039 114
        $properties = $field->val('properties', []);
1040 114
        $additionalProperties = $field->val('additionalProperties');
1041 114
        $required = array_flip($field->val('required', []));
1042 114
        $isRequest = $field->isRequest();
1043 114
        $isResponse = $field->isResponse();
1044
1045 114
        if (is_array($data)) {
1046 110
            $keys = array_keys($data);
1047 110
            $clean = [];
1048
        } else {
1049 4
            $keys = array_keys(iterator_to_array($data));
0 ignored issues
show
Bug introduced by
It seems like $data can also be of type ArrayAccess; however, parameter $iterator of iterator_to_array() does only seem to accept Traversable, maybe add an additional type check? ( Ignorable by Annotation )

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

1049
            $keys = array_keys(iterator_to_array(/** @scrutinizer ignore-type */ $data));
Loading history...
1050 4
            $class = get_class($data);
1051 4
            $clean = new $class;
1052
1053 4
            if ($clean instanceof \ArrayObject && $data instanceof \ArrayObject) {
1054 3
                $clean->setFlags($data->getFlags());
1055 3
                $clean->setIteratorClass($data->getIteratorClass());
1056
            }
1057
        }
1058 114
        $keys = array_combine(array_map('strtolower', $keys), $keys);
1059
1060 114
        $propertyField = new ValidationField($field->getValidation(), [], '', '', $field->getOptions());
1061
1062
        // Loop through the schema fields and validate each one.
1063 114
        foreach ($properties as $propertyName => $property) {
1064 112
            list($property, $schemaPath) = $this->lookupSchema($property, $field->getSchemaPath().'/properties/'.self::escapeRef($propertyName));
1065
1066
            $propertyField
1067 112
                ->setField($property)
1068 112
                ->setName(ltrim($field->getName().'/'.self::escapeRef($propertyName), '/'))
1069 112
                ->setSchemaPath($schemaPath)
1070
            ;
1071
1072 112
            $lName = strtolower($propertyName);
1073 112
            $isRequired = isset($required[$propertyName]);
1074
1075
            // Check to strip this field if it is readOnly or writeOnly.
1076 112
            if (($isRequest && $propertyField->val('readOnly')) || ($isResponse && $propertyField->val('writeOnly'))) {
1077 6
                unset($keys[$lName]);
1078 6
                continue;
1079
            }
1080
1081
            // Check for required fields.
1082 112
            if (!array_key_exists($lName, $keys)) {
1083 34
                if ($field->isSparse()) {
1084
                    // Sparse validation can leave required fields out.
1085 33
                } elseif ($propertyField->hasVal('default')) {
1086 3
                    $clean[$propertyName] = $propertyField->val('default');
1087 30
                } elseif ($isRequired) {
1088 6
                    $propertyField->addError(
1089 6
                        'required',
1090 34
                        ['messageCode' => '{property} is required.', 'property' => $propertyName]
1091
                    );
1092
                }
1093
            } else {
1094 100
                $value = $data[$keys[$lName]];
1095
1096 100
                if (in_array($value, [null, ''], true) && !$isRequired && !($propertyField->val('nullable') || $propertyField->hasType('null'))) {
1097 5
                    if ($propertyField->getType() !== 'string' || $value === null) {
1098 2
                        continue;
1099
                    }
1100
                }
1101
1102 98
                $clean[$propertyName] = $this->validateField($value, $propertyField);
1103
            }
1104
1105 110
            unset($keys[$lName]);
1106
        }
1107
1108
        // Look for extraneous properties.
1109 114
        if (!empty($keys)) {
1110 21
            if ($additionalProperties) {
1111 10
                list($additionalProperties, $schemaPath) = $this->lookupSchema(
1112 10
                    $additionalProperties,
1113 10
                    $field->getSchemaPath().'/additionalProperties'
1114
                );
1115
1116 10
                $propertyField = new ValidationField(
1117 10
                    $field->getValidation(),
1118 10
                    $additionalProperties,
1119 10
                    '',
1120 10
                    $schemaPath,
1121 10
                    $field->getOptions()
1122
                );
1123
1124 10
                foreach ($keys as $key) {
1125
                    $propertyField
1126 10
                        ->setName(ltrim($field->getName()."/$key", '/'));
1127
1128 10
                    $valid = $this->validateField($data[$key], $propertyField);
1129 10
                    if (Invalid::isValid($valid)) {
1130 10
                        $clean[$key] = $valid;
1131
                    }
1132
                }
1133 11
            } elseif ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE)) {
1134 2
                $msg = sprintf("Unexpected properties: %s.", implode(', ', $keys));
1135 2
                trigger_error($msg, E_USER_NOTICE);
1136 9
            } elseif ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION)) {
1137 2
                $field->addError('unexpectedProperties', [
1138 2
                    'messageCode' => 'Unexpected {extra,plural,property,properties}: {extra}.',
1139 2
                    'extra' => array_values($keys),
1140
                ]);
1141
            }
1142
        }
1143
1144 112
        return $clean;
1145
    }
1146
1147
    /**
1148
     * Validate a string.
1149
     *
1150
     * @param mixed $value The value to validate.
1151
     * @param ValidationField $field The validation results to add.
1152
     * @return string|Invalid Returns the valid string or **null** if validation fails.
1153
     */
1154 89
    protected function validateString($value, ValidationField $field) {
1155 89
        if ($field->val('format') === 'date-time') {
1156 12
            $result = $this->validateDatetime($value, $field);
1157 12
            return $result;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $result also could return the type DateTimeInterface which is incompatible with the documented return type Garden\Schema\Invalid|string.
Loading history...
1158
        }
1159
1160 78
        if (is_string($value) || is_numeric($value)) {
1161 76
            $value = $result = (string)$value;
1162
        } else {
1163 5
            $field->addTypeError($value, 'string');
1164 5
            return Invalid::value();
1165
        }
1166
1167 76
        if (($minLength = $field->val('minLength', 0)) > 0 && mb_strlen($value) < $minLength) {
1168 4
            $field->addError(
1169 4
                'minLength',
1170
                [
1171 4
                    'messageCode' => 'The value should be at least {minLength} {minLength,plural,character,characters} long.',
1172 4
                    'minLength' => $minLength,
1173
                ]
1174
            );
1175
        }
1176 76
        if (($maxLength = $field->val('maxLength', 0)) > 0 && mb_strlen($value) > $maxLength) {
1177 1
            $field->addError(
1178 1
                'maxLength',
1179
                [
1180 1
                    'messageCode' => 'The value is {overflow} {overflow,plural,character,characters} too long.',
1181 1
                    'maxLength' => $maxLength,
1182 1
                    'overflow' => mb_strlen($value) - $maxLength,
1183
                ]
1184
            );
1185
        }
1186 76
        if ($pattern = $field->val('pattern')) {
1187 4
            $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`';
1188
1189 4
            if (!preg_match($regex, $value)) {
1190 2
                $field->addError(
1191 2
                    'pattern',
1192
                    [
1193 2
                        'messageCode' => $field->val('x-patternMessageCode'. 'The value doesn\'t match the required pattern.'),
1194
                    ]
1195
                );
1196
            }
1197
        }
1198 76
        if ($format = $field->val('format')) {
1199 11
            $type = $format;
1200 11
            switch ($format) {
1201 11
                case 'date':
1202
                    $result = $this->validateDatetime($result, $field);
1203
                    if ($result instanceof \DateTimeInterface) {
1204
                        $result = $result->format("Y-m-d\T00:00:00P");
1205
                    }
1206
                    break;
1207 11
                case 'email':
1208 1
                    $result = filter_var($result, FILTER_VALIDATE_EMAIL);
1209 1
                    break;
1210 10
                case 'ipv4':
1211 1
                    $type = 'IPv4 address';
1212 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
1213 1
                    break;
1214 9
                case 'ipv6':
1215 1
                    $type = 'IPv6 address';
1216 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
1217 1
                    break;
1218 8
                case 'ip':
1219 1
                    $type = 'IP address';
1220 1
                    $result = filter_var($result, FILTER_VALIDATE_IP);
1221 1
                    break;
1222 7
                case 'uri':
1223 7
                    $type = 'URL';
1224 7
                    $result = filter_var($result, FILTER_VALIDATE_URL);
1225 7
                    break;
1226
                default:
1227
                    trigger_error("Unrecognized format '$format'.", E_USER_NOTICE);
1228
            }
1229 11
            if ($result === false) {
1230 5
                $field->addError('format', [
1231 5
                    'format' => $format,
1232 5
                    'formatCode' => $type,
1233 5
                    'value' => $value,
1234 5
                    'messageCode' => '{value} is not a valid {formatCode}.'
1235
                ]);
1236
            }
1237
        }
1238
1239 76
        if ($field->isValid()) {
1240 68
            return $result;
1241
        } else {
1242 12
            return Invalid::value();
1243
        }
1244
    }
1245
1246
    /**
1247
     * Validate a unix timestamp.
1248
     *
1249
     * @param mixed $value The value to validate.
1250
     * @param ValidationField $field The field being validated.
1251
     * @return int|Invalid Returns a valid timestamp or invalid if the value doesn't validate.
1252
     */
1253 8
    protected function validateTimestamp($value, ValidationField $field) {
1254 8
        if (is_numeric($value) && $value > 0) {
1255 3
            $result = (int)$value;
1256 5
        } elseif (is_string($value) && $ts = strtotime($value)) {
1257 1
            $result = $ts;
1258
        } else {
1259 4
            $field->addTypeError($value, 'timestamp');
1260 4
            $result = Invalid::value();
1261
        }
1262 8
        return $result;
1263
    }
1264
1265
    /**
1266
     * Validate a null value.
1267
     *
1268
     * @param mixed $value The value to validate.
1269
     * @param ValidationField $field The error collector for the field.
1270
     * @return null|Invalid Returns **null** or invalid.
1271
     */
1272 1
    protected function validateNull($value, ValidationField $field) {
1273 1
        if ($value === null) {
1274
            return null;
1275
        }
1276 1
        $field->addError('type', ['messageCode' => 'The value should be null.', 'type' => 'null']);
1277 1
        return Invalid::value();
1278
    }
1279
1280
    /**
1281
     * Validate a value against an enum.
1282
     *
1283
     * @param mixed $value The value to test.
1284
     * @param ValidationField $field The validation object for adding errors.
1285
     * @return mixed|Invalid Returns the value if it is one of the enumerated values or invalid otherwise.
1286
     */
1287 206
    protected function validateEnum($value, ValidationField $field) {
1288 206
        $enum = $field->val('enum');
1289 206
        if (empty($enum)) {
1290 205
            return $value;
1291
        }
1292
1293 1
        if (!in_array($value, $enum, true)) {
1294 1
            $field->addError(
1295 1
                'enum',
1296
                [
1297 1
                    'messageCode' => 'The value must be one of: {enum}.',
1298 1
                    'enum' => $enum,
1299
                ]
1300
            );
1301 1
            return Invalid::value();
1302
        }
1303 1
        return $value;
1304
    }
1305
1306
    /**
1307
     * Call all of the filters attached to a field.
1308
     *
1309
     * @param mixed $value The field value being filtered.
1310
     * @param ValidationField $field The validation object.
1311
     * @param bool $validated Whether or not a filter validated the field.
1312
     * @return mixed Returns the filtered value. If there are no filters for the field then the original value is returned.
1313
     */
1314 218
    private function callFilters($value, ValidationField $field, bool &$validated = false) {
1315
        // Strip array references in the name except for the last one.
1316 218
        $key = $field->getSchemaPath();
1317 218
        if (!empty($this->filters[$key])) {
1318 4
            foreach ($this->filters[$key] as list($filter, $validate)) {
1319 4
                $value = call_user_func($filter, $value, $field);
1320 4
                $validated |= $validate;
1321
1322 4
                if (Invalid::isInvalid($value)) {
1323 4
                    return $value;
1324
                }
1325
            }
1326
        }
1327 217
        return $value;
1328
    }
1329
1330
    /**
1331
     * Call all of the validators attached to a field.
1332
     *
1333
     * @param mixed $value The field value being validated.
1334
     * @param ValidationField $field The validation object to add errors.
1335
     */
1336 207
    private function callValidators($value, ValidationField $field) {
1337 207
        $valid = true;
1338
1339
        // Strip array references in the name except for the last one.
1340 207
        $key = $field->getSchemaPath();
1341 207
        if (!empty($this->validators[$key])) {
1342 5
            foreach ($this->validators[$key] as $validator) {
1343 5
                $r = call_user_func($validator, $value, $field);
1344
1345 5
                if ($r === false || Invalid::isInvalid($r)) {
1346 5
                    $valid = false;
1347
                }
1348
            }
1349
        }
1350
1351
        // Add an error on the field if the validator hasn't done so.
1352 207
        if (!$valid && $field->isValid()) {
1353 1
            $field->addError('invalid', ['messageCode' => 'The value is invalid.']);
1354
        }
1355 207
    }
1356
1357
    /**
1358
     * Specify data which should be serialized to JSON.
1359
     *
1360
     * This method specifically returns data compatible with the JSON schema format.
1361
     *
1362
     * @return mixed Returns data which can be serialized by **json_encode()**, which is a value of any type other than a resource.
1363
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
1364
     * @link http://json-schema.org/
1365
     */
1366 16
    public function jsonSerialize() {
1367
        $fix = function ($schema) use (&$fix) {
1368 16
            if ($schema instanceof Schema) {
1369 1
                return $schema->jsonSerialize();
1370
            }
1371
1372 16
            if (!empty($schema['type'])) {
1373 15
                $types = (array)$schema['type'];
1374
1375 15
                foreach ($types as $i => &$type) {
1376
                    // Swap datetime and timestamp to other types with formats.
1377 15
                    if ($type === 'datetime') {
1378 4
                        $type = 'string';
1379 4
                        $schema['format'] = 'date-time';
1380 14
                    } elseif ($schema['type'] === 'timestamp') {
1381 2
                        $type = 'integer';
1382 15
                        $schema['format'] = 'timestamp';
1383
                    }
1384
                }
1385 15
                $types = array_unique($types);
1386 15
                $schema['type'] = count($types) === 1 ? reset($types) : $types;
1387
            }
1388
1389 16
            if (!empty($schema['items'])) {
1390 4
                $schema['items'] = $fix($schema['items']);
1391
            }
1392 16
            if (!empty($schema['properties'])) {
1393 11
                $properties = [];
1394 11
                foreach ($schema['properties'] as $key => $property) {
1395 11
                    $properties[$key] = $fix($property);
1396
                }
1397 11
                $schema['properties'] = $properties;
1398
            }
1399
1400 16
            return $schema;
1401 16
        };
1402
1403 16
        $result = $fix($this->schema);
1404
1405 16
        return $result;
1406
    }
1407
1408
    /**
1409
     * Look up a type based on its alias.
1410
     *
1411
     * @param string $alias The type alias or type name to lookup.
1412
     * @return mixed
1413
     */
1414 168
    private function getType($alias) {
1415 168
        if (isset(self::$types[$alias])) {
1416
            return $alias;
1417
        }
1418 168
        foreach (self::$types as $type => $aliases) {
1419 168
            if (in_array($alias, $aliases, true)) {
1420 168
                return $type;
1421
            }
1422
        }
1423 12
        return null;
1424
    }
1425
1426
    /**
1427
     * Get the class that's used to contain validation information.
1428
     *
1429
     * @return Validation|string Returns the validation class.
1430
     * @deprecated
1431
     */
1432 1
    public function getValidationClass() {
1433 1
        trigger_error('Schema::getValidationClass() is deprecated. Use Schema::getValidationFactory() instead.', E_USER_DEPRECATED);
1434 1
        return $this->validationClass;
0 ignored issues
show
Deprecated Code introduced by
The property Garden\Schema\Schema::$validationClass has been deprecated. ( Ignorable by Annotation )

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

1434
        return /** @scrutinizer ignore-deprecated */ $this->validationClass;
Loading history...
1435
    }
1436
1437
    /**
1438
     * Set the class that's used to contain validation information.
1439
     *
1440
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
1441
     * @return $this
1442
     * @deprecated
1443
     */
1444 1
    public function setValidationClass($class) {
1445 1
        trigger_error('Schema::setValidationClass() is deprecated. Use Schema::setValidationFactory() instead.', E_USER_DEPRECATED);
1446
1447 1
        if (!is_a($class, Validation::class, true)) {
1448
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
1449
        }
1450
1451
        $this->setValidationFactory(function () use ($class) {
1452 1
            if ($class instanceof Validation) {
1453 1
                $result = clone $class;
1454
            } else {
1455 1
                $result = new $class;
1456
            }
1457 1
            return $result;
1458 1
        });
1459 1
        $this->validationClass = $class;
0 ignored issues
show
Deprecated Code introduced by
The property Garden\Schema\Schema::$validationClass has been deprecated. ( Ignorable by Annotation )

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

1459
        /** @scrutinizer ignore-deprecated */ $this->validationClass = $class;
Loading history...
1460 1
        return $this;
1461
    }
1462
1463
    /**
1464
     * Create a new validation instance.
1465
     *
1466
     * @return Validation Returns a validation object.
1467
     */
1468 218
    protected function createValidation(): Validation {
1469 218
        return call_user_func($this->getValidationFactory());
1470
    }
1471
1472
    /**
1473
     * Check whether or not a value is an array or accessible like an array.
1474
     *
1475
     * @param mixed $value The value to check.
1476
     * @return bool Returns **true** if the value can be used like an array or **false** otherwise.
1477
     */
1478 121
    private function isArray($value) {
1479 121
        return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable);
1480
    }
1481
1482
    /**
1483
     * Cast a value to an array.
1484
     *
1485
     * @param \Traversable $value The value to convert.
1486
     * @return array Returns an array.
1487
     */
1488 3
    private function toObjectArray(\Traversable $value) {
1489 3
        $class = get_class($value);
1490 3
        if ($value instanceof \ArrayObject) {
1491 2
            return new $class($value->getArrayCopy(), $value->getFlags(), $value->getIteratorClass());
0 ignored issues
show
Bug Best Practice introduced by
The expression return new $class($value...ue->getIteratorClass()) returns the type object which is incompatible with the documented return type array.
Loading history...
1492 1
        } elseif ($value instanceof \ArrayAccess) {
1493 1
            $r = new $class;
1494 1
            foreach ($value as $k => $v) {
1495 1
                $r[$k] = $v;
1496
            }
1497 1
            return $r;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $r returns the type object which is incompatible with the documented return type array.
Loading history...
1498
        }
1499
        return iterator_to_array($value);
1500
    }
1501
1502
    /**
1503
     * Return a sparse version of this schema.
1504
     *
1505
     * A sparse schema has no required properties.
1506
     *
1507
     * @return Schema Returns a new sparse schema.
1508
     */
1509 2
    public function withSparse() {
1510 2
        $sparseSchema = $this->withSparseInternal($this, new \SplObjectStorage());
1511 2
        return $sparseSchema;
1512
    }
1513
1514
    /**
1515
     * The internal implementation of `Schema::withSparse()`.
1516
     *
1517
     * @param array|Schema $schema The schema to make sparse.
1518
     * @param \SplObjectStorage $schemas Collected sparse schemas that have already been made.
1519
     * @return mixed
1520
     */
1521 2
    private function withSparseInternal($schema, \SplObjectStorage $schemas) {
1522 2
        if ($schema instanceof Schema) {
1523 2
            if ($schemas->contains($schema)) {
1524 1
                return $schemas[$schema];
1525
            } else {
1526 2
                $schemas[$schema] = $sparseSchema = new Schema();
1527 2
                $sparseSchema->schema = $schema->withSparseInternal($schema->schema, $schemas);
1528 2
                if ($id = $sparseSchema->getID()) {
1529
                    $sparseSchema->setID($id.'Sparse');
1530
                }
1531
1532 2
                return $sparseSchema;
1533
            }
1534
        }
1535
1536 2
        unset($schema['required']);
1537
1538 2
        if (isset($schema['items'])) {
1539 1
            $schema['items'] = $this->withSparseInternal($schema['items'], $schemas);
1540
        }
1541 2
        if (isset($schema['properties'])) {
1542 2
            foreach ($schema['properties'] as $name => &$property) {
1543 2
                $property = $this->withSparseInternal($property, $schemas);
1544
            }
1545
        }
1546
1547 2
        return $schema;
1548
    }
1549
1550
    /**
1551
     * Filter a field's value using built in and custom filters.
1552
     *
1553
     * @param mixed $value The original value of the field.
1554
     * @param ValidationField $field The field information for the field.
1555
     * @param bool $validated Whether or not a filter validated the value.
1556
     * @return mixed Returns the filtered field or the original field value if there are no filters.
1557
     */
1558 218
    private function filterField($value, ValidationField $field, bool &$validated = false) {
1559
        // Check for limited support for Open API style.
1560 218
        if (!empty($field->val('style')) && is_string($value)) {
1561 8
            $doFilter = true;
1562 8
            if ($field->hasType('boolean') && in_array($value, ['true', 'false', '0', '1'], true)) {
1563 4
                $doFilter = false;
1564 4
            } elseif ($field->hasType('integer') || $field->hasType('number') && is_numeric($value)) {
1565
                $doFilter = false;
1566
            }
1567
1568 8
            if ($doFilter) {
1569 4
                switch ($field->val('style')) {
1570 4
                    case 'form':
1571 2
                        $value = explode(',', $value);
1572 2
                        break;
1573 2
                    case 'spaceDelimited':
1574 1
                        $value = explode(' ', $value);
1575 1
                        break;
1576 1
                    case 'pipeDelimited':
1577 1
                        $value = explode('|', $value);
1578 1
                        break;
1579
                }
1580
            }
1581
        }
1582
1583 218
        $value = $this->callFilters($value, $field, $validated);
1584
1585 218
        return $value;
1586
    }
1587
1588
    /**
1589
     * Whether a offset exists.
1590
     *
1591
     * @param mixed $offset An offset to check for.
1592
     * @return boolean true on success or false on failure.
1593
     * @link http://php.net/manual/en/arrayaccess.offsetexists.php
1594
     */
1595 7
    public function offsetExists($offset) {
1596 7
        return isset($this->schema[$offset]);
1597
    }
1598
1599
    /**
1600
     * Offset to retrieve.
1601
     *
1602
     * @param mixed $offset The offset to retrieve.
1603
     * @return mixed Can return all value types.
1604
     * @link http://php.net/manual/en/arrayaccess.offsetget.php
1605
     */
1606 7
    public function offsetGet($offset) {
1607 7
        return isset($this->schema[$offset]) ? $this->schema[$offset] : null;
1608
    }
1609
1610
    /**
1611
     * Offset to set.
1612
     *
1613
     * @param mixed $offset The offset to assign the value to.
1614
     * @param mixed $value The value to set.
1615
     * @link http://php.net/manual/en/arrayaccess.offsetset.php
1616
     */
1617 1
    public function offsetSet($offset, $value) {
1618 1
        $this->schema[$offset] = $value;
1619 1
    }
1620
1621
    /**
1622
     * Offset to unset.
1623
     *
1624
     * @param mixed $offset The offset to unset.
1625
     * @link http://php.net/manual/en/arrayaccess.offsetunset.php
1626
     */
1627 1
    public function offsetUnset($offset) {
1628 1
        unset($this->schema[$offset]);
1629 1
    }
1630
1631
    /**
1632
     * Validate a field against a single type.
1633
     *
1634
     * @param mixed $value The value to validate.
1635
     * @param string $type The type to validate against.
1636
     * @param ValidationField $field Contains field and validation information.
1637
     * @return mixed Returns the valid value or `Invalid`.
1638
     * @throws \InvalidArgumentException Throws an exception when `$type` is not recognized.
1639
     * @throws RefNotFoundException Throws an exception when internal validation has a reference that isn't found.
1640
     */
1641 216
    protected function validateSingleType($value, string $type, ValidationField $field) {
1642 216
        switch ($type) {
1643 216
            case 'boolean':
1644 32
                $result = $this->validateBoolean($value, $field);
1645 32
                break;
1646 196
            case 'integer':
1647 65
                $result = $this->validateInteger($value, $field);
1648 65
                break;
1649 177
            case 'number':
1650 17
                $result = $this->validateNumber($value, $field);
1651 17
                break;
1652 168
            case 'string':
1653 89
                $result = $this->validateString($value, $field);
1654 89
                break;
1655 144
            case 'timestamp':
1656 1
                trigger_error('The timestamp type is deprecated. Use an integer with a format of timestamp instead.', E_USER_DEPRECATED);
1657 1
                $result = $this->validateTimestamp($value, $field);
1658 1
                break;
1659 144
            case 'datetime':
1660 2
                trigger_error('The datetime type is deprecated. Use a string with a format of date-time instead.', E_USER_DEPRECATED);
1661 2
                $result = $this->validateDatetime($value, $field);
1662 2
                break;
1663 143
            case 'array':
1664 38
                $result = $this->validateArray($value, $field);
1665 38
                break;
1666 123
            case 'object':
1667 121
                $result = $this->validateObject($value, $field);
1668 119
                break;
1669 6
            case 'null':
1670 1
                $result = $this->validateNull($value, $field);
1671 1
                break;
1672 5
            case '':
1673
                // No type was specified so we are valid.
1674 5
                $result = $value;
1675 5
                break;
1676
            default:
1677
                throw new \InvalidArgumentException("Unrecognized type $type.", 500);
1678
        }
1679 216
        return $result;
1680
    }
1681
1682
    /**
1683
     * Validate a field against multiple basic types.
1684
     *
1685
     * The first validation that passes will be returned. If no type can be validated against then validation will fail.
1686
     *
1687
     * @param mixed $value The value to validate.
1688
     * @param string[] $types The types to validate against.
1689
     * @param ValidationField $field Contains field and validation information.
1690
     * @return mixed Returns the valid value or `Invalid`.
1691
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
1692
     * @deprecated Multiple types are being removed next version.
1693
     */
1694 29
    private function validateMultipleTypes($value, array $types, ValidationField $field) {
1695 29
        trigger_error('Multiple schema types are deprecated.', E_USER_DEPRECATED);
1696
1697
        // First check for an exact type match.
1698 29
        switch (gettype($value)) {
1699 29
            case 'boolean':
1700 4
                if (in_array('boolean', $types)) {
1701 4
                    $singleType = 'boolean';
1702
                }
1703 4
                break;
1704 26
            case 'integer':
1705 7
                if (in_array('integer', $types)) {
1706 5
                    $singleType = 'integer';
1707 2
                } elseif (in_array('number', $types)) {
1708 1
                    $singleType = 'number';
1709
                }
1710 7
                break;
1711 21
            case 'double':
1712 4
                if (in_array('number', $types)) {
1713 4
                    $singleType = 'number';
1714
                } elseif (in_array('integer', $types)) {
1715
                    $singleType = 'integer';
1716
                }
1717 4
                break;
1718 18
            case 'string':
1719 9
                if (in_array('datetime', $types) && preg_match(self::$DATE_REGEX, $value)) {
1720 1
                    $singleType = 'datetime';
1721 8
                } elseif (in_array('string', $types)) {
1722 4
                    $singleType = 'string';
1723
                }
1724 9
                break;
1725 10
            case 'array':
1726 10
                if (in_array('array', $types) && in_array('object', $types)) {
1727 1
                    $singleType = isset($value[0]) || empty($value) ? 'array' : 'object';
1728 9
                } elseif (in_array('object', $types)) {
1729
                    $singleType = 'object';
1730 9
                } elseif (in_array('array', $types)) {
1731 9
                    $singleType = 'array';
1732
                }
1733 10
                break;
1734 1
            case 'NULL':
1735
                if (in_array('null', $types)) {
1736
                    $singleType = $this->validateSingleType($value, 'null', $field);
1737
                }
1738
                break;
1739
        }
1740 29
        if (!empty($singleType)) {
1741 25
            return $this->validateSingleType($value, $singleType, $field);
1742
        }
1743
1744
        // Clone the validation field to collect errors.
1745 6
        $typeValidation = new ValidationField(new Validation(), $field->getField(), '', '', $field->getOptions());
1746
1747
        // Try and validate against each type.
1748 6
        foreach ($types as $type) {
1749 6
            $result = $this->validateSingleType($value, $type, $typeValidation);
1750 6
            if (Invalid::isValid($result)) {
1751 6
                return $result;
1752
            }
1753
        }
1754
1755
        // Since we got here the value is invalid.
1756
        $field->merge($typeValidation->getValidation());
1757
        return Invalid::value();
1758
    }
1759
1760
    /**
1761
     * Validate specific numeric validation properties.
1762
     *
1763
     * @param int|float $value The value to test.
1764
     * @param ValidationField $field Field information.
1765
     * @return int|float|Invalid Returns the number of invalid.
1766
     */
1767 63
    private function validateNumberProperties($value, ValidationField $field) {
1768 63
        $count = $field->getErrorCount();
1769
1770 63
        if ($multipleOf = $field->val('multipleOf')) {
1771 4
            $divided = $value / $multipleOf;
1772
1773 4
            if ($divided != round($divided)) {
1774 2
                $field->addError('multipleOf', ['messageCode' => 'The value must be a multiple of {multipleOf}.', 'multipleOf' => $multipleOf]);
1775
            }
1776
        }
1777
1778 63
        if ($maximum = $field->val('maximum')) {
1779 4
            $exclusive = $field->val('exclusiveMaximum');
1780
1781 4
            if ($value > $maximum || ($exclusive && $value == $maximum)) {
1782 2
                if ($exclusive) {
1783 1
                    $field->addError('maximum', ['messageCode' => 'The value must be less than {maximum}.', 'maximum' => $maximum]);
1784
                } else {
1785 1
                    $field->addError('maximum', ['messageCode' => 'The value must be less than or equal to {maximum}.', 'maximum' => $maximum]);
1786
                }
1787
            }
1788
        }
1789
1790 63
        if ($minimum = $field->val('minimum')) {
1791 4
            $exclusive = $field->val('exclusiveMinimum');
1792
1793 4
            if ($value < $minimum || ($exclusive && $value == $minimum)) {
1794 2
                if ($exclusive) {
1795 1
                    $field->addError('minimum', ['messageCode' => 'The value must be greater than {minimum}.', 'minimum' => $minimum]);
1796
                } else {
1797 1
                    $field->addError('minimum', ['messageCode' => 'The value must be greater than or equal to {minimum}.', 'minimum' => $minimum]);
1798
                }
1799
            }
1800
        }
1801
1802 63
        return $field->getErrorCount() === $count ? $value : Invalid::value();
1803
    }
1804
1805
    /**
1806
     * Parse a nested field name selector.
1807
     *
1808
     * Field selectors should be separated by "/" characters, but may currently be separated by "." characters which
1809
     * triggers a deprecated error.
1810
     *
1811
     * @param string $field The field selector.
1812
     * @return string Returns the field selector in the correct format.
1813
     */
1814 17
    private function parseFieldSelector(string $field): string {
1815 17
        if (strlen($field) === 0) {
1816 6
            return $field;
1817
        }
1818
1819 11
        if (strpos($field, '.') !== false) {
1820 1
            if (strpos($field, '/') === false) {
1821 1
                trigger_error('Field selectors must be separated by "/" instead of "."', E_USER_DEPRECATED);
1822
1823 1
                $parts = explode('.', $field);
1824 1
                $parts = @array_map([$this, 'parseFieldSelector'], $parts); // silence because error triggered already.
1825
1826 1
                $field = implode('/', $parts);
1827
            }
1828 11
        } elseif ($field === '[]') {
1829 1
            trigger_error('Field selectors with item selector "[]" must be converted to "items".', E_USER_DEPRECATED);
1830 1
            $field = 'items';
1831 10
        } elseif (strpos($field, '/') === false && !in_array($field, ['items', 'additionalProperties'], true)) {
1832 3
            trigger_error("Field selectors must specify full schema paths. ($field)", E_USER_DEPRECATED);
1833 3
            $field = "/properties/$field";
1834
        }
1835
1836 11
        if (strpos($field, '[]') !== false) {
1837 1
            trigger_error('Field selectors with item selector "[]" must be converted to "/items".', E_USER_DEPRECATED);
1838 1
            $field = str_replace('[]', '/items', $field);
1839
        }
1840
1841 11
        return ltrim($field, '/');
1842
    }
1843
1844
    /**
1845
     * Lookup a schema based on a schema node.
1846
     *
1847
     * The node could be a schema array, `Schema` object, or a schema reference.
1848
     *
1849
     * @param mixed $schema The schema node to lookup with.
1850
     * @param string $schemaPath The current path of the schema.
1851
     * @return array Returns an array with two elements:
1852
     * - Schema|array|\ArrayAccess The schema that was found.
1853
     * - string The path of the schema. This is either the reference or the `$path` parameter for inline schemas.
1854
     * @throws RefNotFoundException Throws an exception when a reference could not be found.
1855
     */
1856 222
    private function lookupSchema($schema, string $schemaPath) {
1857 222
        if ($schema instanceof Schema) {
1858 6
            return [$schema, $schemaPath];
1859
        } else {
1860 222
            $lookup = $this->getRefLookup();
1861 222
            $visited = [];
1862
1863 222
            while (!empty($schema['$ref'])) {
1864 17
                $schemaPath = $schema['$ref'];
1865
1866 17
                if (isset($visited[$schemaPath])) {
1867 1
                    throw new RefNotFoundException("Cyclical reference cannot be resolved. ($schemaPath)", 508);
1868
                }
1869 17
                $visited[$schemaPath] = true;
1870
1871
                try {
1872 17
                    $schema = call_user_func($lookup, $schemaPath);
1873 1
                } catch (\Exception $ex) {
1874 1
                    throw new RefNotFoundException($ex->getMessage(), $ex->getCode(), $ex);
1875
                }
1876 16
                if ($schema === null) {
1877 2
                    throw new RefNotFoundException("Schema reference could not be found. ($schemaPath)");
1878
                }
1879
            }
1880 218
            return [$schema, $schemaPath];
1881
        }
1882
    }
1883
1884
    /**
1885
     * Get the function used to resolve `$ref` lookups.
1886
     *
1887
     * @return callable Returns the current `$ref` lookup.
1888
     */
1889 222
    public function getRefLookup(): callable {
1890 222
        return $this->refLookup;
1891
    }
1892
1893
    /**
1894
     * Set the function used to resolve `$ref` lookups.
1895
     *
1896
     * The function should have the following signature:
1897
     *
1898
     * ```php
1899
     * function(string $ref): array|Schema|null {
1900
     *     ...
1901
     * }
1902
     * ```
1903
     * The function should take a string reference and return a schema array, `Schema` or **null**.
1904
     *
1905
     * @param callable $refLookup The new lookup function.
1906
     * @return $this
1907
     */
1908 10
    public function setRefLookup(callable $refLookup) {
1909 10
        $this->refLookup = $refLookup;
1910 10
        return $this;
1911
    }
1912
1913
    /**
1914
     * Get factory used to create validation objects.
1915
     *
1916
     * @return callable Returns the current factory.
1917
     */
1918 218
    public function getValidationFactory(): callable {
1919 218
        return $this->validationFactory;
1920
    }
1921
1922
    /**
1923
     * Set the factory used to create validation objects.
1924
     *
1925
     * @param callable $validationFactory The new factory.
1926
     * @return $this
1927
     */
1928 2
    public function setValidationFactory(callable $validationFactory) {
1929 2
        $this->validationFactory = $validationFactory;
1930 2
        $this->validationClass = null;
0 ignored issues
show
Deprecated Code introduced by
The property Garden\Schema\Schema::$validationClass has been deprecated. ( Ignorable by Annotation )

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

1930
        /** @scrutinizer ignore-deprecated */ $this->validationClass = null;
Loading history...
1931 2
        return $this;
1932
    }
1933
1934
    /**
1935
     * Escape a JSON reference field.
1936
     *
1937
     * @param string $field The reference field to escape.
1938
     * @return string Returns an escaped reference.
1939
     */
1940 112
    public static function escapeRef(string $field): string {
1941 112
        return str_replace(['~', '/'], ['~0', '~1'], $field);
1942
    }
1943
1944
    /**
1945
     * Unescape a JSON reference segment.
1946
     *
1947
     * @param string $str The segment to unescapeRef.
1948
     * @return string Returns the unescaped string.
1949
     */
1950 20
    public static function unescapeRef(string $str): string {
1951 20
        return str_replace(['~1', '~0'], ['/', '~'], $str);
1952
    }
1953
1954
    /**
1955
     * Explode a references into its individual parts.
1956
     *
1957
     * @param string $ref A JSON reference.
1958
     * @return string[] The individual parts of the reference.
1959
     */
1960 20
    public static function explodeRef(string $ref): array {
1961 20
        return array_map([self::class, 'unescapeRef'], explode('/', $ref));
1962
    }
1963
}
1964