Completed
Pull Request — master (#58)
by Todd
02:59
created

Schema::escapeRef()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 2
Code Lines 1

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 2
CRAP Score 1

Importance

Changes 0
Metric Value
cc 1
eloc 1
nc 1
nop 1
dl 0
loc 2
ccs 2
cts 2
cp 1
crap 1
rs 10
c 0
b 0
f 0
1
<?php
2
/**
3
 * @author Todd Burry <[email protected]>
4
 * @copyright 2009-2018 Vanilla Forums Inc.
5
 * @license MIT
6
 */
7
8
namespace Garden\Schema;
9
10
/**
11
 * A class for defining and validating data schemas.
12
 */
13
class Schema implements \JsonSerializable, \ArrayAccess {
14
    /**
15
     * Trigger a notice when extraneous properties are encountered during validation.
16
     */
17
    const VALIDATE_EXTRA_PROPERTY_NOTICE = 0x1;
18
19
    /**
20
     * Throw a ValidationException when extraneous properties are encountered during validation.
21
     */
22
    const VALIDATE_EXTRA_PROPERTY_EXCEPTION = 0x2;
23
24
    /**
25
     * @var array All the known types.
26
     *
27
     * If this is ever given some sort of public access then remove the static.
28
     */
29
    private static $types = [
30
        'array' => ['a'],
31
        'object' => ['o'],
32
        'integer' => ['i', 'int'],
33
        'string' => ['s', 'str'],
34
        'number' => ['f', 'float'],
35
        'boolean' => ['b', 'bool'],
36
37
        // Psuedo-types
38
        'timestamp' => ['ts'], // type: integer, format: timestamp
39
        'datetime' => ['dt'], // type: string, format: date-time
40
        'null' => ['n'], // Adds nullable: true
41
    ];
42
43
    /**
44
     * @var string The regular expression to strictly determine if a string is a date.
45
     */
46
    private static $DATE_REGEX = '`^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?`i';
47
48
    private $schema = [];
49
50
    /**
51
     * @var int A bitwise combination of the various **Schema::FLAG_*** constants.
52
     */
53
    private $flags = 0;
54
55
    /**
56
     * @var array An array of callbacks that will filter data in the schema.
57
     */
58
    private $filters = [];
59
60
    /**
61
     * @var array An array of callbacks that will custom validate the schema.
62
     */
63
    private $validators = [];
64
65
    /**
66
     * @var string|Validation The name of the class or an instance that will be cloned.
67
     * @deprecated
68
     */
69
    private $validationClass = Validation::class;
70
71
    /**
72
     * @var callable A callback is used to create validation objects.
73
     */
74
    private $validationFactory = [Validation::class, 'createValidation'];
75
76
    /**
77
     * @var callable
78
     */
79
    private $refLookup;
80
81
    /// Methods ///
82
83
    /**
84
     * Initialize an instance of a new {@link Schema} class.
85
     *
86
     * @param array $schema The array schema to validate against.
87
     * @param callable $refLookup The function used to lookup references.
88
     */
89 275
    public function __construct(array $schema = [], callable $refLookup = null) {
90 275
        $this->schema = $schema;
91
92
        $this->refLookup = $refLookup ?? function (/** @scrutinizer ignore-unused */string $_) {
93 1
            return null;
94 269
        };
95 275
    }
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
     * @return $this
634
     */
635 2
    public function addFilter(string $fieldname, callable $callback) {
636 2
        $fieldname = $this->parseFieldSelector($fieldname);
637 2
        $this->filters[$fieldname][] = $callback;
638 2
        return $this;
639
    }
640
641
    /**
642
     * Add a custom validator to to validate the schema.
643
     *
644
     * @param string $fieldname The name of the field to validate, if any.
645
     *
646
     * If you are adding a validator to a deeply nested field then separate the path with dots.
647
     * @param callable $callback The callback to validate with.
648
     * @return Schema Returns `$this` for fluent calls.
649
     */
650 5
    public function addValidator(string $fieldname, callable $callback) {
651 5
        $fieldname = $this->parseFieldSelector($fieldname);
652 5
        $this->validators[$fieldname][] = $callback;
653 5
        return $this;
654
    }
655
656
    /**
657
     * Require one of a given set of fields in the schema.
658
     *
659
     * @param array $required The field names to require.
660
     * @param string $fieldname The name of the field to attach to.
661
     * @param int $count The count of required items.
662
     * @return Schema Returns `$this` for fluent calls.
663
     */
664 3
    public function requireOneOf(array $required, string $fieldname = '', int $count = 1) {
665 3
        $result = $this->addValidator(
666 3
            $fieldname,
667
            function ($data, ValidationField $field) use ($required, $count) {
668
                // This validator does not apply to sparse validation.
669 3
                if ($field->isSparse()) {
670 1
                    return true;
671
                }
672
673 2
                $hasCount = 0;
674 2
                $flattened = [];
675
676 2
                foreach ($required as $name) {
677 2
                    $flattened = array_merge($flattened, (array)$name);
678
679 2
                    if (is_array($name)) {
680
                        // This is an array of required names. They all must match.
681 1
                        $hasCountInner = 0;
682 1
                        foreach ($name as $nameInner) {
683 1
                            if (array_key_exists($nameInner, $data)) {
684 1
                                $hasCountInner++;
685
                            } else {
686 1
                                break;
687
                            }
688
                        }
689 1
                        if ($hasCountInner >= count($name)) {
690 1
                            $hasCount++;
691
                        }
692 2
                    } elseif (array_key_exists($name, $data)) {
693 1
                        $hasCount++;
694
                    }
695
696 2
                    if ($hasCount >= $count) {
697 2
                        return true;
698
                    }
699
                }
700
701 2
                if ($count === 1) {
702 1
                    $message = 'One of {properties} are required.';
703
                } else {
704 1
                    $message = '{count} of {properties} are required.';
705
                }
706
707 2
                $field->addError('oneOfRequired', [
708 2
                    'messageCode' => $message,
709 2
                    'properties' => $required,
710 2
                    'count' => $count
711
                ]);
712 2
                return false;
713 3
            }
714
        );
715
716 3
        return $result;
717
    }
718
719
    /**
720
     * Validate data against the schema.
721
     *
722
     * @param mixed $data The data to validate.
723
     * @param array $options Validation options.
724
     *
725
     * - **sparse**: Whether or not this is a sparse validation.
726
     * @return mixed Returns a cleaned version of the data.
727
     * @throws ValidationException Throws an exception when the data does not validate against the schema.
728
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
729
     */
730 220
    public function validate($data, $options = []) {
731 220
        if (is_bool($options)) {
0 ignored issues
show
introduced by
The condition is_bool($options) is always false.
Loading history...
732 1
            trigger_error('The $sparse parameter is deprecated. Use [\'sparse\' => true] instead.', E_USER_DEPRECATED);
733 1
            $options = ['sparse' => true];
734
        }
735 220
        $options += ['sparse' => false];
736
737
738 220
        list($schema, $schemaPath) = $this->lookupSchema($this->schema, '');
739 216
        $field = new ValidationField($this->createValidation(), $schema, '', $schemaPath, $options);
740
741 216
        $clean = $this->validateField($data, $field);
742
743 214
        if (Invalid::isInvalid($clean) && $field->isValid()) {
744
            // This really shouldn't happen, but we want to protect against seeing the invalid object.
745
            $field->addError('invalid', ['messageCode' => 'The value is invalid.']);
746
        }
747
748 214
        if (!$field->getValidation()->isValid()) {
749 73
            throw new ValidationException($field->getValidation());
750
        }
751
752 157
        return $clean;
753
    }
754
755
    /**
756
     * Validate data against the schema and return the result.
757
     *
758
     * @param mixed $data The data to validate.
759
     * @param array $options Validation options. See `Schema::validate()`.
760
     * @return bool Returns true if the data is valid. False otherwise.
761
     * @throws RefNotFoundException Throws an exception when there is an unknown `$ref` in the schema.
762
     */
763 44
    public function isValid($data, $options = []) {
764
        try {
765 44
            $this->validate($data, $options);
766 31
            return true;
767 23
        } catch (ValidationException $ex) {
768 23
            return false;
769
        }
770
    }
771
772
    /**
773
     * Validate a field.
774
     *
775
     * @param mixed $value The value to validate.
776
     * @param ValidationField $field A validation object to add errors to.
777
     * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value
778
     * is completely invalid.
779
     * @throws RefNotFoundException Throws an exception when a schema `$ref` is not found.
780
     */
781 216
    protected function validateField($value, ValidationField $field) {
782 216
        $result = $value = $this->filterField($value, $field);
0 ignored issues
show
Unused Code introduced by
The assignment to $result is dead and can be removed.
Loading history...
783
784 216
        if ($field->getField() instanceof Schema) {
785
            try {
786 5
                $result = $field->getField()->validate($value, $field->getOptions());
787 2
            } catch (ValidationException $ex) {
788
                // The validation failed, so merge the validations together.
789 5
                $field->getValidation()->merge($ex->getValidation(), $field->getName());
790
            }
791 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...
792 14
            $result = null;
793
        } else {
794
            // Validate the field's type.
795 216
            $type = $field->getType();
796 216
            if (is_array($type)) {
797 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

797
                $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...
798
            } else {
799 195
                $result = $this->validateSingleType($value, $type, $field);
800
            }
801 216
            if (Invalid::isValid($result)) {
802 206
                $result = $this->validateEnum($result, $field);
803
            }
804
        }
805
806
        // Validate a custom field validator.
807 216
        if (Invalid::isValid($result)) {
808 207
            $this->callValidators($result, $field);
809
        }
810
811 216
        return $result;
812
    }
813
814
    /**
815
     * Validate an array.
816
     *
817
     * @param mixed $value The value to validate.
818
     * @param ValidationField $field The validation results to add.
819
     * @return array|Invalid Returns an array or invalid if validation fails.
820
     * @throws RefNotFoundException Throws an exception if the array has an items `$ref` that cannot be found.
821
     */
822 38
    protected function validateArray($value, ValidationField $field) {
823 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...
824 6
            $field->addTypeError($value, 'array');
825 6
            return Invalid::value();
826
        } else {
827 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

827
            if ((null !== $minItems = $field->val('minItems')) && count(/** @scrutinizer ignore-type */ $value) < $minItems) {
Loading history...
828 1
                $field->addError(
829 1
                    'minItems',
830
                    [
831 1
                        'messageCode' => 'This must contain at least {minItems} {minItems,plural,item,items}.',
832 1
                        'minItems' => $minItems,
833
                    ]
834
                );
835
            }
836 33
            if ((null !== $maxItems = $field->val('maxItems')) && count($value) > $maxItems) {
837 1
                $field->addError(
838 1
                    'maxItems',
839
                    [
840 1
                        'messageCode' => 'This must contain no more than {maxItems} {maxItems,plural,item,items}.',
841 1
                        'maxItems' => $maxItems,
842
                    ]
843
                );
844
            }
845
846 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

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

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

1424
        return /** @scrutinizer ignore-deprecated */ $this->validationClass;
Loading history...
1425
    }
1426
1427
    /**
1428
     * Set the class that's used to contain validation information.
1429
     *
1430
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
1431
     * @return $this
1432
     * @deprecated
1433
     */
1434 1
    public function setValidationClass($class) {
1435 1
        trigger_error('Schema::setValidationClass() is deprecated. Use Schema::setValidationFactory() instead.', E_USER_DEPRECATED);
1436
1437 1
        if (!is_a($class, Validation::class, true)) {
1438
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
1439
        }
1440
1441
        $this->setValidationFactory(function () use ($class) {
1442 1
            if ($class instanceof Validation) {
1443 1
                $result = clone $class;
1444
            } else {
1445 1
                $result = new $class;
1446
            }
1447 1
            return $result;
1448 1
        });
1449 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

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

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