Completed
Push — master ( 046691...c61cf5 )
by Todd
12s
created

Schema::getType()   A

Complexity

Conditions 4
Paths 4

Size

Total Lines 11
Code Lines 7

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 7
CRAP Score 4.0312

Importance

Changes 0
Metric Value
dl 0
loc 11
ccs 7
cts 8
cp 0.875
rs 9.2
c 0
b 0
f 0
cc 4
eloc 7
nc 4
nop 1
crap 4.0312
1
<?php
2
/**
3
 * @author Todd Burry <[email protected]>
4
 * @copyright 2009-2017 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
        'timestamp' => ['ts'],
37
        'datetime' => ['dt'],
38
        'null' => ['n']
39
    ];
40
41
    /**
42
     * @var string The regular expression to strictly determine if a string is a date.
43
     */
44
    private static $DATE_REGEX = '`^\d{4}-\d{2}-\d{2}([ T]\d{2}:\d{2}(:\d{2})?)?`i';
45
46
    private $schema = [];
47
48
    /**
49
     * @var int A bitwise combination of the various **Schema::FLAG_*** constants.
50
     */
51
    private $flags = 0;
52
53
    /**
54
     * @var array An array of callbacks that will filter data in the schema.
55
     */
56
    private $filters = [];
57
58
    /**
59
     * @var array An array of callbacks that will custom validate the schema.
60
     */
61
    private $validators = [];
62
63
    /**
64
     * @var string|Validation The name of the class or an instance that will be cloned.
65
     */
66
    private $validationClass = Validation::class;
67
68
69
    /// Methods ///
70
71
    /**
72
     * Initialize an instance of a new {@link Schema} class.
73
     *
74
     * @param array $schema The array schema to validate against.
75
     */
76 196
    public function __construct($schema = []) {
77 196
        $this->schema = $schema;
78 196
    }
79
80
    /**
81
     * Grab the schema's current description.
82
     *
83
     * @return string
84
     */
85 1
    public function getDescription() {
86 1
        return isset($this->schema['description']) ? $this->schema['description'] : '';
87
    }
88
89
    /**
90
     * Set the description for the schema.
91
     *
92
     * @param string $description The new description.
93
     * @throws \InvalidArgumentException Throws an exception when the provided description is not a string.
94
     * @return Schema
95
     */
96 2
    public function setDescription($description) {
97 2
        if (is_string($description)) {
98 1
            $this->schema['description'] = $description;
99 1
        } else {
100 1
            throw new \InvalidArgumentException("The description is not a valid string.", 500);
101
        }
102
103 1
        return $this;
104
    }
105
106
    /**
107
     * Get a schema field.
108
     *
109
     * @param string|array $path The JSON schema path of the field with parts separated by dots.
110
     * @param mixed $default The value to return if the field isn't found.
111
     * @return mixed Returns the field value or `$default`.
112
     */
113 4
    public function getField($path, $default = null) {
114 4
        if (is_string($path)) {
115 4
            $path = explode('.', $path);
116 4
        }
117
118 4
        $value = $this->schema;
119 4
        foreach ($path as $i => $subKey) {
120 4
            if (is_array($value) && isset($value[$subKey])) {
121 4
                $value = $value[$subKey];
122 4
            } elseif ($value instanceof Schema) {
123 1
                return $value->getField(array_slice($path, $i), $default);
124
            } else {
125
                return $default;
126
            }
127 4
        }
128 4
        return $value;
129
    }
130
131
    /**
132
     * Set a schema field.
133
     *
134
     * @param string|array $path The JSON schema path of the field with parts separated by dots.
135
     * @param mixed $value The new value.
136
     * @return $this
137
     */
138 3
    public function setField($path, $value) {
139 3
        if (is_string($path)) {
140 3
            $path = explode('.', $path);
141 3
        }
142
143 3
        $selection = &$this->schema;
144 3
        foreach ($path as $i => $subSelector) {
145 3
            if (is_array($selection)) {
146 3
                if (!isset($selection[$subSelector])) {
147 1
                    $selection[$subSelector] = [];
148 1
                }
149 3
            } elseif ($selection instanceof Schema) {
150 1
                $selection->setField(array_slice($path, $i), $value);
151 1
                return $this;
152
            } else {
153
                $selection = [$subSelector => []];
154
            }
155 3
            $selection = &$selection[$subSelector];
156 3
        }
157
158 3
        $selection = $value;
159 3
        return $this;
160
    }
161
162
    /**
163
     * Get the ID for the schema.
164
     *
165
     * @return string
166
     */
167 3
    public function getID() {
168 3
        return isset($this->schema['id']) ? $this->schema['id'] : '';
169
    }
170
171
    /**
172
     * Set the ID for the schema.
173
     *
174
     * @param string $id The new ID.
175
     * @throws \InvalidArgumentException Throws an exception when the provided ID is not a string.
176
     * @return Schema
177
     */
178 1
    public function setID($id) {
179 1
        if (is_string($id)) {
180 1
            $this->schema['id'] = $id;
181 1
        } else {
182
            throw new \InvalidArgumentException("The ID is not a valid string.", 500);
183
        }
184
185 1
        return $this;
186
    }
187
188
    /**
189
     * Return the validation flags.
190
     *
191
     * @return int Returns a bitwise combination of flags.
192
     */
193 1
    public function getFlags() {
194 1
        return $this->flags;
195
    }
196
197
    /**
198
     * Set the validation flags.
199
     *
200
     * @param int $flags One or more of the **Schema::FLAG_*** constants.
201
     * @return Schema Returns the current instance for fluent calls.
202
     */
203 8
    public function setFlags($flags) {
204 8
        if (!is_int($flags)) {
205 1
            throw new \InvalidArgumentException('Invalid flags.', 500);
206
        }
207 7
        $this->flags = $flags;
208
209 7
        return $this;
210
    }
211
212
    /**
213
     * Whether or not the schema has a flag (or combination of flags).
214
     *
215
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
216
     * @return bool Returns **true** if all of the flags are set or **false** otherwise.
217
     */
218 18
    public function hasFlag($flag) {
219 18
        return ($this->flags & $flag) === $flag;
220
    }
221
222
    /**
223
     * Set a flag.
224
     *
225
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
226
     * @param bool $value Either true or false.
227
     * @return $this
228
     */
229 1
    public function setFlag($flag, $value) {
230 1
        if ($value) {
231 1
            $this->flags = $this->flags | $flag;
232 1
        } else {
233 1
            $this->flags = $this->flags & ~$flag;
234
        }
235 1
        return $this;
236
    }
237
238
    /**
239
     * Merge a schema with this one.
240
     *
241
     * @param Schema $schema A scheme instance. Its parameters will be merged into the current instance.
242
     * @return $this
243
     */
244 3
    public function merge(Schema $schema) {
245 3
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), true, true);
246 3
        return $this;
247
    }
248
249
    /**
250
     * Add another schema to this one.
251
     *
252
     * Adding schemas together is analogous to array addition. When you add a schema it will only add missing information.
253
     *
254
     * @param Schema $schema The schema to add.
255
     * @param bool $addProperties Whether to add properties that don't exist in this schema.
256
     * @return $this
257
     */
258 3
    public function add(Schema $schema, $addProperties = false) {
259 3
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), false, $addProperties);
260 3
        return $this;
261
    }
262
263
    /**
264
     * The internal implementation of schema merging.
265
     *
266
     * @param array &$target The target of the merge.
267
     * @param array $source The source of the merge.
268
     * @param bool $overwrite Whether or not to replace values.
269
     * @param bool $addProperties Whether or not to add object properties to the target.
270
     * @return array
271
     */
272 6
    private function mergeInternal(array &$target, array $source, $overwrite = true, $addProperties = true) {
273
        // We need to do a fix for required properties here.
274 6
        if (isset($target['properties']) && !empty($source['required'])) {
275 4
            $required = isset($target['required']) ? $target['required'] : [];
276
277 4
            if (isset($source['required']) && $addProperties) {
278 3
                $newProperties = array_diff(array_keys($source['properties']), array_keys($target['properties']));
279 3
                $newRequired = array_intersect($source['required'], $newProperties);
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
280
281 3
                $required = array_merge($required, $newRequired);
282 3
            }
283 4
        }
284
285
286 6
        foreach ($source as $key => $val) {
287 6
            if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) {
288 6
                if ($key === 'properties' && !$addProperties) {
289
                    // We just want to merge the properties that exist in the destination.
290 1
                    foreach ($val as $name => $prop) {
291 1
                        if (isset($target[$key][$name])) {
292 1
                            $this->mergeInternal($target[$key][$name], $prop, $overwrite, $addProperties);
293 1
                        }
294 1
                    }
295 6
                } elseif (isset($val[0]) || isset($target[$key][0])) {
296 4
                    if ($overwrite) {
297
                        // This is a numeric array, so just do a merge.
298 2
                        $merged = array_merge($target[$key], $val);
299 2
                        if (is_string($merged[0])) {
300 2
                            $merged = array_keys(array_flip($merged));
301 2
                        }
302 2
                        $target[$key] = $merged;
303 2
                    }
304 4
                } else {
305 3
                    $target[$key] = $this->mergeInternal($target[$key], $val, $overwrite, $addProperties);
306
                }
307 6
            } elseif (!$overwrite && array_key_exists($key, $target) && !is_array($val)) {
0 ignored issues
show
Unused Code introduced by
This elseif statement is empty, and could be removed.

This check looks for the bodies of elseif statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These elseif bodies can be removed. If you have an empty elseif but statements in the else branch, consider inverting the condition.

Loading history...
308
                // Do nothing, we aren't replacing.
309 3
            } else {
310 5
                $target[$key] = $val;
311
            }
312 6
        }
313
314 6
        if (isset($required)) {
315 4
            if (empty($required)) {
316 1
                unset($target['required']);
317 1
            } else {
318 4
                $target['required'] = $required;
319
            }
320 4
        }
321
322 6
        return $target;
323
    }
324
325
//    public function overlay(Schema $schema )
0 ignored issues
show
Unused Code Comprehensibility introduced by
42% of this comment could be valid code. Did you maybe forget this after debugging?

Sometimes obsolete code just ends up commented out instead of removed. In this case it is better to remove the code once you have checked you do not need it.

The code might also have been commented out for debugging purposes. In this case it is vital that someone uncomments it again or your project may behave in very unexpected ways in production.

This check looks for comments that seem to be mostly valid code and reports them.

Loading history...
326
327
    /**
328
     * Returns the internal schema array.
329
     *
330
     * @return array
331
     * @see Schema::jsonSerialize()
332
     */
333 15
    public function getSchemaArray() {
334 15
        return $this->schema;
335
    }
336
337
    /**
338
     * Parse a short schema and return the associated schema.
339
     *
340
     * @param array $arr The schema array.
341
     * @param mixed ...$args Constructor arguments for the schema instance.
342
     * @return static Returns a new schema.
343
     */
344 160
    public static function parse(array $arr, ...$args) {
345 160
        $schema = new static([], ...$args);
0 ignored issues
show
Unused Code introduced by
The call to Schema::__construct() has too many arguments starting with $args.

This check compares calls to functions or methods with their respective definitions. If the call has more arguments than are defined, it raises an issue.

If a function is defined several times with a different number of parameters, the check may pick up the wrong definition and report false positives. One codebase where this has been known to happen is Wordpress.

In this case you can add the @ignore PhpDoc annotation to the duplicate definition and it will be ignored.

Loading history...
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 9 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
346 160
        $schema->schema = $schema->parseInternal($arr);
347 160
        return $schema;
348
    }
349
350
    /**
351
     * Parse a schema in short form into a full schema array.
352
     *
353
     * @param array $arr The array to parse into a schema.
354
     * @return array The full schema array.
355
     * @throws \InvalidArgumentException Throws an exception when an item in the schema is invalid.
356
     */
357 160
    protected function parseInternal(array $arr) {
358 160
        if (empty($arr)) {
359
            // An empty schema validates to anything.
360 7
            return [];
361 154
        } elseif (isset($arr['type'])) {
362
            // This is a long form schema and can be parsed as the root.
363
            return $this->parseNode($arr);
364
        } else {
365
            // Check for a root schema.
366 154
            $value = reset($arr);
367 154
            $key = key($arr);
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
368 154
            if (is_int($key)) {
369 95
                $key = $value;
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
370 95
                $value = null;
371 95
            }
372 154
            list ($name, $param) = $this->parseShortParam($key, $value);
0 ignored issues
show
Documentation introduced by
$value is of type null|false, but the function expects a array.

It seems like the type of the argument is not accepted by the function/method which you are calling.

In some cases, in particular if PHP’s automatic type-juggling kicks in this might be fine. In other cases, however this might be a bug.

We suggest to add an explicit type cast like in the following example:

function acceptsInteger($int) { }

$x = '123'; // string "123"

// Instead of
acceptsInteger($x);

// we recommend to use
acceptsInteger((integer) $x);
Loading history...
373 154
            if (empty($name)) {
374 58
                return $this->parseNode($param, $value);
375
            }
376
        }
377
378
        // If we are here then this is n object schema.
379 99
        list($properties, $required) = $this->parseProperties($arr);
380
381
        $result = [
382 99
            'type' => 'object',
383 99
            'properties' => $properties,
384
            'required' => $required
385 99
        ];
386
387 99
        return array_filter($result);
388
    }
389
390
    /**
391
     * Parse a schema node.
392
     *
393
     * @param array $node The node to parse.
394
     * @param mixed $value Additional information from the node.
395
     * @return array Returns a JSON schema compatible node.
396
     */
397 154
    private function parseNode($node, $value = null) {
398 154
        if (is_array($value)) {
399
            // The value describes a bit more about the schema.
400 57
            switch ($node['type']) {
401 57
                case 'array':
402 11
                    if (isset($value['items'])) {
403
                        // The value includes array schema information.
404 4
                        $node = array_replace($node, $value);
405 4
                    } else {
406 7
                        $node['items'] = $this->parseInternal($value);
407
                    }
408 11
                    break;
409 47
                case 'object':
410
                    // The value is a schema of the object.
411 11
                    if (isset($value['properties'])) {
412
                        list($node['properties']) = $this->parseProperties($value['properties']);
413
                    } else {
414 11
                        list($node['properties'], $required) = $this->parseProperties($value);
415 11
                        if (!empty($required)) {
416 11
                            $node['required'] = $required;
417 11
                        }
418
                    }
419 11
                    break;
420 36
                default:
421 36
                    $node = array_replace($node, $value);
422 36
                    break;
423 57
            }
424 154
        } elseif (is_string($value)) {
425 93
            if ($node['type'] === 'array' && $arrType = $this->getType($value)) {
426 5
                $node['items'] = ['type' => $arrType];
427 93
            } elseif (!empty($value)) {
428 23
                $node['description'] = $value;
429 23
            }
430 116
        } elseif ($value === null) {
431
            // Parse child elements.
432 24
            if ($node['type'] === 'array' && isset($node['items'])) {
433
                // The value includes array schema information.
434
                $node['items'] = $this->parseInternal($node['items']);
435 24
            } elseif ($node['type'] === 'object' && isset($node['properties'])) {
436
                list($node['properties']) = $this->parseProperties($node['properties']);
437
438
            }
439 24
        }
440
441 154
        if (is_array($node) && $node['type'] === null) {
442 3
            unset($node['type']);
443 3
        }
444
445 154
        return $node;
446
    }
447
448
    /**
449
     * Parse the schema for an object's properties.
450
     *
451
     * @param array $arr An object property schema.
452
     * @return array Returns a schema array suitable to be placed in the **properties** key of a schema.
453
     */
454 99
    private function parseProperties(array $arr) {
455 99
        $properties = [];
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 9 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
456 99
        $requiredProperties = [];
457 99
        foreach ($arr as $key => $value) {
458
            // Fix a schema specified as just a value.
459 99
            if (is_int($key)) {
460 75
                if (is_string($value)) {
461 75
                    $key = $value;
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
462 75
                    $value = '';
463 75
                } else {
464
                    throw new \InvalidArgumentException("Schema at position $key is not a valid parameter.", 500);
465
                }
466 75
            }
467
468
            // The parameter is defined in the key.
469 99
            list($name, $param, $required) = $this->parseShortParam($key, $value);
470
471 99
            $node = $this->parseNode($param, $value);
472
473 99
            $properties[$name] = $node;
474 99
            if ($required) {
475 53
                $requiredProperties[] = $name;
476 53
            }
477 99
        }
478 99
        return array($properties, $requiredProperties);
479
    }
480
481
    /**
482
     * Parse a short parameter string into a full array parameter.
483
     *
484
     * @param string $key The short parameter string to parse.
485
     * @param array $value An array of other information that might help resolve ambiguity.
486
     * @return array Returns an array in the form `[string name, array param, bool required]`.
487
     * @throws \InvalidArgumentException Throws an exception if the short param is not in the correct format.
488
     */
489 154
    public function parseShortParam($key, $value = []) {
490
        // Is the parameter optional?
491 154
        if (substr($key, -1) === '?') {
492 67
            $required = false;
493 67
            $key = substr($key, 0, -1);
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 6 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
494 67
        } else {
495 108
            $required = true;
496
        }
497
498
        // Check for a type.
499 154
        $parts = explode(':', $key);
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 5 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
500 154
        $name = $parts[0];
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 6 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
501 154
        $allowNull = false;
502 154
        if (!empty($parts[1])) {
503 150
            $types = explode('|', $parts[1]);
504 150
            foreach ($types as $alias) {
505 150
                $found = $this->getType($alias);
506 150
                if ($found === null) {
507
                    throw new \InvalidArgumentException("Unknown type '$alias'", 500);
508 150
                } elseif ($found === 'null') {
509 11
                    $allowNull = true;
510 11
                } else {
511 150
                    $type = $found;
512
                }
513 150
            }
514 150
        } else {
515 8
            $type = null;
516
        }
517
518 154
        if ($value instanceof Schema) {
519 3
            if ($type === 'array') {
520 1
                $param = ['type' => $type, 'items' => $value];
0 ignored issues
show
Bug introduced by
The variable $type does not seem to be defined for all execution paths leading up to this point.

If you define a variable conditionally, it can happen that it is not defined for all execution paths.

Let’s take a look at an example:

function myFunction($a) {
    switch ($a) {
        case 'foo':
            $x = 1;
            break;

        case 'bar':
            $x = 2;
            break;
    }

    // $x is potentially undefined here.
    echo $x;
}

In the above example, the variable $x is defined if you pass “foo” or “bar” as argument for $a. However, since the switch statement has no default case statement, if you pass any other value, the variable $x would be undefined.

Available Fixes

  1. Check for existence of the variable explicitly:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        if (isset($x)) { // Make sure it's always set.
            echo $x;
        }
    }
    
  2. Define a default value for the variable:

    function myFunction($a) {
        $x = ''; // Set a default which gets overridden for certain paths.
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
        }
    
        echo $x;
    }
    
  3. Add a value for the missing path:

    function myFunction($a) {
        switch ($a) {
            case 'foo':
                $x = 1;
                break;
    
            case 'bar':
                $x = 2;
                break;
    
            // We add support for the missing case.
            default:
                $x = '';
                break;
        }
    
        echo $x;
    }
    
Loading history...
521 1
            } else {
522 2
                $param = $value;
523
            }
524 154
        } elseif (isset($value['type'])) {
525 3
            $param = $value;
526
527 3
            if (!empty($type) && $type !== $param['type']) {
528
                throw new \InvalidArgumentException("Type mismatch between $type and {$param['type']} for field $name.", 500);
529
            }
530 3
        } else {
531 151
            if (empty($type) && !empty($parts[1])) {
532
                throw new \InvalidArgumentException("Invalid type {$parts[1]} for field $name.", 500);
533
            }
534 151
            $param = ['type' => $type];
535
536
            // Parsed required strings have a minimum length of 1.
537 151
            if ($type === 'string' && !empty($name) && $required && (!isset($value['default']) || $value['default'] !== '')) {
538 34
                $param['minLength'] = 1;
539 34
            }
540
        }
541 154
        if ($allowNull) {
542 11
            $param['allowNull'] = true;
543 11
        }
544
545 154
        return [$name, $param, $required];
546
    }
547
548
    /**
549
     * Add a custom filter to change data before validation.
550
     *
551
     * @param string $fieldname The name of the field to filter, if any.
552
     *
553
     * If you are adding a filter to a deeply nested field then separate the path with dots.
554
     * @param callable $callback The callback to filter the field.
555
     * @return $this
556
     */
557 1
    public function addFilter($fieldname, callable $callback) {
558 1
        $this->filters[$fieldname][] = $callback;
559 1
        return $this;
560
    }
561
562
    /**
563
     * Add a custom validator to to validate the schema.
564
     *
565
     * @param string $fieldname The name of the field to validate, if any.
566
     *
567
     * If you are adding a validator to a deeply nested field then separate the path with dots.
568
     * @param callable $callback The callback to validate with.
569
     * @return Schema Returns `$this` for fluent calls.
570
     */
571 2
    public function addValidator($fieldname, callable $callback) {
572 2
        $this->validators[$fieldname][] = $callback;
573 2
        return $this;
574
    }
575
576
    /**
577
     * Require one of a given set of fields in the schema.
578
     *
579
     * @param array $required The field names to require.
580
     * @param string $fieldname The name of the field to attach to.
581
     * @param int $count The count of required items.
582
     * @return Schema Returns `$this` for fluent calls.
583
     */
584 1
    public function requireOneOf(array $required, $fieldname = '', $count = 1) {
585 1
        $result = $this->addValidator(
586 1
            $fieldname,
587
            function ($data, ValidationField $field) use ($required, $count) {
588 1
                $hasCount = 0;
589 1
                $flattened = [];
590
591 1
                foreach ($required as $name) {
592 1
                    $flattened = array_merge($flattened, (array)$name);
593
594 1
                    if (is_array($name)) {
595
                        // This is an array of required names. They all must match.
596 1
                        $hasCountInner = 0;
597 1
                        foreach ($name as $nameInner) {
598 1
                            if (isset($data[$nameInner]) && $data[$nameInner]) {
599 1
                                $hasCountInner++;
600 1
                            } else {
601 1
                                break;
602
                            }
603 1
                        }
604 1
                        if ($hasCountInner >= count($name)) {
605 1
                            $hasCount++;
606 1
                        }
607 1
                    } elseif (isset($data[$name]) && $data[$name]) {
608 1
                        $hasCount++;
609 1
                    }
610
611 1
                    if ($hasCount >= $count) {
612 1
                        return true;
613
                    }
614 1
                }
615
616 1
                if ($count === 1) {
617 1
                    $message = 'One of {required} are required.';
618 1
                } else {
619
                    $message = '{count} of {required} are required.';
620
                }
621
622 1
                $field->addError('missingField', [
623 1
                    'messageCode' => $message,
624 1
                    'required' => $required,
625
                    'count' => $count
626 1
                ]);
627 1
                return false;
628
            }
629 1
        );
630
631 1
        return $result;
632
    }
633
634
    /**
635
     * Validate data against the schema.
636
     *
637
     * @param mixed $data The data to validate.
638
     * @param bool $sparse Whether or not this is a sparse validation.
639
     * @return mixed Returns a cleaned version of the data.
640
     * @throws ValidationException Throws an exception when the data does not validate against the schema.
641
     */
642 160
    public function validate($data, $sparse = false) {
643 160
        $field = new ValidationField($this->createValidation(), $this->schema, '', $sparse);
644
645 160
        $clean = $this->validateField($data, $field, $sparse);
646
647 158
        if (Invalid::isInvalid($clean) && $field->isValid()) {
648
            // This really shouldn't happen, but we want to protect against seeing the invalid object.
649
            $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]);
650
        }
651
652 158
        if (!$field->getValidation()->isValid()) {
653 57
            throw new ValidationException($field->getValidation());
654
        }
655
656 114
        return $clean;
657
    }
658
659
    /**
660
     * Validate data against the schema and return the result.
661
     *
662
     * @param mixed $data The data to validate.
663
     * @param bool $sparse Whether or not to do a sparse validation.
664
     * @return bool Returns true if the data is valid. False otherwise.
665
     */
666 35
    public function isValid($data, $sparse = false) {
667
        try {
668 35
            $this->validate($data, $sparse);
669 25
            return true;
670 18
        } catch (ValidationException $ex) {
671 18
            return false;
672
        }
673
    }
674
675
    /**
676
     * Validate a field.
677
     *
678
     * @param mixed $value The value to validate.
679
     * @param ValidationField $field A validation object to add errors to.
680
     * @param bool $sparse Whether or not this is a sparse validation.
681
     * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value
682
     * is completely invalid.
683
     */
684 160
    protected function validateField($value, ValidationField $field, $sparse = false) {
685 160
        $result = $value = $this->filterField($value, $field);
686
687 160
        if ($field->getField() instanceof Schema) {
688
            try {
689 3
                $result = $field->getField()->validate($value, $sparse);
690 3
            } catch (ValidationException $ex) {
691
                // The validation failed, so merge the validations together.
692 1
                $field->getValidation()->merge($ex->getValidation(), $field->getName());
693
            }
694 160
        } elseif (($value === null || ($value === '' && $field->getType() !== 'string')) && $field->val('allowNull', false)) {
695 11
            $result = null;
696 11
        } else {
697
            // Validate the field's type.
698 160
            $type = $field->getType();
699 160
            if (is_array($type)) {
700 28
                $result = $this->validateMultipleTypes($value, $type, $field, $sparse);
701 28
            } else {
702 140
                $result = $this->validateSingleType($value, $type, $field, $sparse);
703
            }
704 160
            if (Invalid::isValid($result)) {
705 158
                $result = $this->validateEnum($result, $field);
706 158
            }
707
        }
708
709
        // Validate a custom field validator.
710 160
        if (Invalid::isValid($result)) {
711 158
            $this->callValidators($result, $field);
712 158
        }
713
714 160
        return $result;
715
    }
716
717
    /**
718
     * Validate an array.
719
     *
720
     * @param mixed $value The value to validate.
721
     * @param ValidationField $field The validation results to add.
722
     * @param bool $sparse Whether or not this is a sparse validation.
723
     * @return array|Invalid Returns an array or invalid if validation fails.
724
     */
725 28
    protected function validateArray($value, ValidationField $field, $sparse = false) {
726 28
        if ((!is_array($value) || (count($value) > 0 && !array_key_exists(0, $value))) && !$value instanceof \Traversable) {
727 6
            $field->addTypeError('array');
728 6
            return Invalid::value();
729
        } else {
730 23
            if ((null !== $minItems = $field->val('minItems')) && count($value) < $minItems) {
731 1
                $field->addError(
732 1
                    'minItems',
733
                    [
734 1
                        'messageCode' => '{field} must contain at least {minItems} {minItems,plural,item}.',
735 1
                        'minItems' => $minItems,
736
                        'status' => 422
737 1
                    ]
738 1
                );
739 1
            }
740 23
            if ((null !== $maxItems = $field->val('maxItems')) && count($value) > $maxItems) {
741 1
                $field->addError(
742 1
                    'maxItems',
743
                    [
744 1
                        'messageCode' => '{field} must contain no more than {maxItems} {maxItems,plural,item}.',
745 1
                        'maxItems' => $maxItems,
746
                        'status' => 422
747 1
                    ]
748 1
                );
749 1
            }
750
751 23
            if ($field->val('items') !== null) {
752 18
                $result = [];
753
754
                // Validate each of the types.
755 18
                $itemValidation = new ValidationField(
756 18
                    $field->getValidation(),
757 18
                    $field->val('items'),
758 18
                    '',
759
                    $sparse
760 18
                );
761
762 18
                $count = 0;
763 18
                foreach ($value as $i => $item) {
764 18
                    $itemValidation->setName($field->getName()."[{$i}]");
765 18
                    $validItem = $this->validateField($item, $itemValidation, $sparse);
766 18
                    if (Invalid::isValid($validItem)) {
767 18
                        $result[] = $validItem;
768 18
                    }
769 18
                    $count++;
770 18
                }
771
772 18
                return empty($result) && $count > 0 ? Invalid::value() : $result;
773
            } else {
774
                // Cast the items into a proper numeric array.
775 5
                $result = is_array($value) ? array_values($value) : iterator_to_array($value);
776 5
                return $result;
777
            }
778
        }
779
    }
780
781
    /**
782
     * Validate a boolean value.
783
     *
784
     * @param mixed $value The value to validate.
785
     * @param ValidationField $field The validation results to add.
786
     * @return bool|Invalid Returns the cleaned value or invalid if validation fails.
787
     */
788 29
    protected function validateBoolean($value, ValidationField $field) {
789 29
        $value = $value === null ? $value : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
790 29
        if ($value === null) {
791 4
            $field->addTypeError('boolean');
792 4
            return Invalid::value();
793
        }
794
795 26
        return $value;
796
    }
797
798
    /**
799
     * Validate a date time.
800
     *
801
     * @param mixed $value The value to validate.
802
     * @param ValidationField $field The validation results to add.
803
     * @return \DateTimeInterface|Invalid Returns the cleaned value or **null** if it isn't valid.
804
     */
805 14
    protected function validateDatetime($value, ValidationField $field) {
806 14
        if ($value instanceof \DateTimeInterface) {
0 ignored issues
show
Unused Code introduced by
This if statement is empty and can be removed.

This check looks for the bodies of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These if bodies can be removed. If you have an empty if but statements in the else branch, consider inverting the condition.

if (rand(1, 6) > 3) {
//print "Check failed";
} else {
    print "Check succeeded";
}

could be turned into

if (rand(1, 6) <= 3) {
    print "Check succeeded";
}

This is much more concise to read.

Loading history...
807
            // do nothing, we're good
808 14
        } elseif (is_string($value) && $value !== '' && !is_numeric($value)) {
809
            try {
810 7
                $dt = new \DateTimeImmutable($value);
811 6
                if ($dt) {
812 6
                    $value = $dt;
813 6
                } else {
814
                    $value = null;
815
                }
816 7
            } catch (\Exception $ex) {
817 1
                $value = Invalid::value();
818
            }
819 11
        } elseif (is_int($value) && $value > 0) {
820 1
            $value = new \DateTimeImmutable('@'.(string)round($value));
821 1
        } else {
822 3
            $value = Invalid::value();
823
        }
824
825 14
        if (Invalid::isInvalid($value)) {
826 4
            $field->addTypeError('datetime');
827 4
        }
828 14
        return $value;
829
    }
830
831
    /**
832
     * Validate a float.
833
     *
834
     * @param mixed $value The value to validate.
835
     * @param ValidationField $field The validation results to add.
836
     * @return float|Invalid Returns a number or **null** if validation fails.
837
     */
838 13
    protected function validateNumber($value, ValidationField $field) {
839 13
        $result = filter_var($value, FILTER_VALIDATE_FLOAT);
840 13
        if ($result === false) {
841 4
            $field->addTypeError('number');
842 4
            return Invalid::value();
843
        }
844 9
        return $result;
845
    }
846
    /**
847
     * Validate and integer.
848
     *
849
     * @param mixed $value The value to validate.
850
     * @param ValidationField $field The validation results to add.
851
     * @return int|Invalid Returns the cleaned value or **null** if validation fails.
852
     */
853 33
    protected function validateInteger($value, ValidationField $field) {
854 33
        $result = filter_var($value, FILTER_VALIDATE_INT);
855
856 33
        if ($result === false) {
857 8
            $field->addTypeError('integer');
858 8
            return Invalid::value();
859
        }
860 28
        return $result;
861
    }
862
863
    /**
864
     * Validate an object.
865
     *
866
     * @param mixed $value The value to validate.
867
     * @param ValidationField $field The validation results to add.
868
     * @param bool $sparse Whether or not this is a sparse validation.
869
     * @return object|Invalid Returns a clean object or **null** if validation fails.
0 ignored issues
show
Documentation introduced by
Should the return type not be Invalid|array?

This check compares the return type specified in the @return annotation of a function or method doc comment with the types returned by the function and raises an issue if they mismatch.

Loading history...
870
     */
871 88
    protected function validateObject($value, ValidationField $field, $sparse = false) {
872 88
        if (!$this->isArray($value) || isset($value[0])) {
873 6
            $field->addTypeError('object');
874 6
            return Invalid::value();
875 88
        } elseif (is_array($field->val('properties'))) {
876
            // Validate the data against the internal schema.
877 86
            $value = $this->validateProperties($value, $field, $sparse);
878 86
        } elseif (!is_array($value)) {
879
            $value = $this->toArray($value);
880
        }
881 86
        return $value;
882
    }
883
884
    /**
885
     * Validate data against the schema and return the result.
886
     *
887
     * @param array|\ArrayAccess $data The data to validate.
888
     * @param ValidationField $field This argument will be filled with the validation result.
889
     * @param bool $sparse Whether or not this is a sparse validation.
890
     * @return array|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types.
891
     * or invalid if there are no valid properties.
892
     */
893 86
    protected function validateProperties($data, ValidationField $field, $sparse = false) {
894 86
        $properties = $field->val('properties', []);
895 86
        $required = array_flip($field->val('required', []));
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
896
897 86
        if (is_array($data)) {
898 85
            $keys = array_keys($data);
899 85
        } else {
900 1
            $keys = array_keys(iterator_to_array($data));
901
        }
902 86
        $keys = array_combine(array_map('strtolower', $keys), $keys);
903
904 86
        $propertyField = new ValidationField($field->getValidation(), [], null, $sparse);
905
906
        // Loop through the schema fields and validate each one.
907 86
        $clean = [];
908 86
        foreach ($properties as $propertyName => $property) {
909
            $propertyField
910 86
                ->setField($property)
911 86
                ->setName(ltrim($field->getName().".$propertyName", '.'));
912
913 86
            $lName = strtolower($propertyName);
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 6 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
914 86
            $isRequired = isset($required[$propertyName]);
915
916
            // First check for required fields.
917 86
            if (!array_key_exists($lName, $keys)) {
918 23
                if ($sparse) {
0 ignored issues
show
Unused Code introduced by
This if statement is empty and can be removed.

This check looks for the bodies of if statements that have no statements or where all statements have been commented out. This may be the result of changes for debugging or the code may simply be obsolete.

These if bodies can be removed. If you have an empty if but statements in the else branch, consider inverting the condition.

if (rand(1, 6) > 3) {
//print "Check failed";
} else {
    print "Check succeeded";
}

could be turned into

if (rand(1, 6) <= 3) {
    print "Check succeeded";
}

This is much more concise to read.

Loading history...
919
                    // Sparse validation can leave required fields out.
920 23
                } elseif ($propertyField->hasVal('default')) {
921 2
                    $clean[$propertyName] = $propertyField->val('default');
922 23
                } elseif ($isRequired) {
923 6
                    $propertyField->addError('missingField', ['messageCode' => '{field} is required.']);
924 6
                }
925 23
            } else {
926 84
                $value = $data[$keys[$lName]];
927
928 84
                if (in_array($value, [null, ''], true) && !$isRequired && !$propertyField->val('allowNull')) {
929 13
                    if ($propertyField->getType() !== 'string' || $value === null) {
930 10
                        continue;
931
                    }
932 3
                }
933
934 74
                $clean[$propertyName] = $this->validateField($value, $propertyField, $sparse);
935
            }
936
937 84
            unset($keys[$lName]);
938 86
        }
939
940
        // Look for extraneous properties.
941 86
        if (!empty($keys)) {
942 17
            if ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_NOTICE)) {
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

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

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

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

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

Loading history...
943 2
                $msg = sprintf("%s has unexpected field(s): %s.", $field->getName() ?: 'value', implode(', ', $keys));
944 2
                trigger_error($msg, E_USER_NOTICE);
945
            }
946
947 15
            if ($this->hasFlag(Schema::VALIDATE_EXTRA_PROPERTY_EXCEPTION)) {
0 ignored issues
show
Coding Style introduced by
As per coding style, self should be used for accessing local static members.

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

<?php

class Certificate {
    const TRIPLEDES_CBC = 'ASDFGHJKL';

    private $key;

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

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

Loading history...
948 2
                $field->addError('invalid', [
949 2
                    'messageCode' => '{field} has {extra,plural,an unexpected field,unexpected fields}: {extra}.',
950 2
                    'extra' => array_values($keys),
951
                    'status' => 422
952 2
                ]);
953 2
            }
954 15
        }
955
956 84
        return $clean;
957
    }
958
959
    /**
960
     * Validate a string.
961
     *
962
     * @param mixed $value The value to validate.
963
     * @param ValidationField $field The validation results to add.
964
     * @return string|Invalid Returns the valid string or **null** if validation fails.
965
     */
966 60
    protected function validateString($value, ValidationField $field) {
967 60
        if (is_string($value) || is_numeric($value)) {
968 58
            $value = $result = (string)$value;
969 58
        } else {
970 5
            $field->addTypeError('string');
971 5
            return Invalid::value();
972
        }
973
974 58
        $errorCount = $field->getErrorCount();
0 ignored issues
show
Unused Code introduced by
$errorCount is not used, you could remove the assignment.

This check looks for variable assignements that are either overwritten by other assignments or where the variable is not used subsequently.

$myVar = 'Value';
$higher = false;

if (rand(1, 6) > 3) {
    $higher = true;
} else {
    $higher = false;
}

Both the $myVar assignment in line 1 and the $higher assignment in line 2 are dead. The first because $myVar is never used and the second because $higher is always overwritten for every possible time line.

Loading history...
975 58
        if (($minLength = $field->val('minLength', 0)) > 0 && mb_strlen($value) < $minLength) {
976 4
            if (!empty($field->getName()) && $minLength === 1) {
977 2
                $field->addError('missingField', ['messageCode' => '{field} is required.', 'status' => 422]);
978 2
            } else {
979 2
                $field->addError(
980 2
                    'minLength',
981
                    [
982 2
                        'messageCode' => '{field} should be at least {minLength} {minLength,plural,character} long.',
983 2
                        'minLength' => $minLength,
984
                        'status' => 422
985 2
                    ]
986 2
                );
987
            }
988 4
        }
989 58
        if (($maxLength = $field->val('maxLength', 0)) > 0 && mb_strlen($value) > $maxLength) {
990 1
            $field->addError(
991 1
                'maxLength',
992
                [
993 1
                    'messageCode' => '{field} is {overflow} {overflow,plural,characters} too long.',
994 1
                    'maxLength' => $maxLength,
995 1
                    'overflow' => mb_strlen($value) - $maxLength,
996
                    'status' => 422
997 1
                ]
998 1
            );
999 1
        }
1000 58
        if ($pattern = $field->val('pattern')) {
1001 4
            $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`';
1002
1003 4
            if (!preg_match($regex, $value)) {
1004 2
                $field->addError(
1005 2
                    'invalid',
1006
                    [
1007 2
                        'messageCode' => '{field} is in the incorrect format.',
1008
                        'status' => 422
1009 2
                    ]
1010 2
                );
1011 2
            }
1012 4
        }
1013 58
        if ($format = $field->val('format')) {
1014 15
            $type = $format;
1015
            switch ($format) {
1016 15
                case 'date-time':
1017 4
                    $result = $this->validateDatetime($result, $field);
1018 4
                    if ($result instanceof \DateTimeInterface) {
1019 4
                        $result = $result->format(\DateTime::RFC3339);
1020 4
                    }
1021 4
                    break;
1022 11
                case 'email':
1023 1
                    $result = filter_var($result, FILTER_VALIDATE_EMAIL);
1024 1
                    break;
1025 10
                case 'ipv4':
1026 1
                    $type = 'IPv4 address';
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
1027 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
1028 1
                    break;
1029 9
                case 'ipv6':
1030 1
                    $type = 'IPv6 address';
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
1031 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
1032 1
                    break;
1033 8
                case 'ip':
1034 1
                    $type = 'IP address';
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
1035 1
                    $result = filter_var($result, FILTER_VALIDATE_IP);
1036 1
                    break;
1037 7
                case 'uri':
1038 7
                    $type = 'URI';
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
1039 7
                    $result = filter_var($result, FILTER_VALIDATE_URL, FILTER_FLAG_HOST_REQUIRED | FILTER_FLAG_SCHEME_REQUIRED);
1040 7
                    break;
1041
                default:
1042
                    trigger_error("Unrecognized format '$format'.", E_USER_NOTICE);
1043
            }
1044 15
            if ($result === false) {
1045 5
                $field->addTypeError($type);
1046 5
            }
1047 15
        }
1048
1049 58
        if ($field->isValid()) {
1050 50
            return $result;
1051
        } else {
1052 12
            return Invalid::value();
1053
        }
1054
    }
1055
1056
    /**
1057
     * Validate a unix timestamp.
1058
     *
1059
     * @param mixed $value The value to validate.
1060
     * @param ValidationField $field The field being validated.
1061
     * @return int|Invalid Returns a valid timestamp or invalid if the value doesn't validate.
1062
     */
1063 8
    protected function validateTimestamp($value, ValidationField $field) {
1064 8
        if (is_numeric($value) && $value > 0) {
1065 3
            $result = (int)$value;
1066 8
        } elseif (is_string($value) && $ts = strtotime($value)) {
1067 1
            $result = $ts;
1068 1
        } else {
1069 4
            $field->addTypeError('timestamp');
1070 4
            $result = Invalid::value();
1071
        }
1072 8
        return $result;
1073
    }
1074
1075
    /**
1076
     * Validate a null value.
1077
     *
1078
     * @param mixed $value The value to validate.
1079
     * @param ValidationField $field The error collector for the field.
1080
     * @return null|Invalid Returns **null** or invalid.
1081
     */
1082
    protected function validateNull($value, ValidationField $field) {
1083
        if ($value === null) {
1084
            return null;
1085
        }
1086
        $field->addError('invalid', ['messageCode' => '{field} should be null.', 'status' => 422]);
1087
        return Invalid::value();
1088
    }
1089
1090
    /**
1091
     * Validate a value against an enum.
1092
     *
1093
     * @param mixed $value The value to test.
1094
     * @param ValidationField $field The validation object for adding errors.
1095
     * @return mixed|Invalid Returns the value if it is one of the enumerated values or invalid otherwise.
1096
     */
1097 158
    protected function validateEnum($value, ValidationField $field) {
1098 158
        $enum = $field->val('enum');
1099 158
        if (empty($enum)) {
1100 157
            return $value;
1101
        }
1102
1103 1
        if (!in_array($value, $enum, true)) {
1104 1
            $field->addError(
1105 1
                'invalid',
1106
                [
1107 1
                    'messageCode' => '{field} must be one of: {enum}.',
1108 1
                    'enum' => $enum,
1109
                    'status' => 422
1110 1
                ]
1111 1
            );
1112 1
            return Invalid::value();
1113
        }
1114 1
        return $value;
1115
    }
1116
1117
    /**
1118
     * Call all of the filters attached to a field.
1119
     *
1120
     * @param mixed $value The field value being filtered.
1121
     * @param ValidationField $field The validation object.
1122
     * @return mixed Returns the filtered value. If there are no filters for the field then the original value is returned.
1123
     */
1124 160
    protected function callFilters($value, ValidationField $field) {
1125
        // Strip array references in the name except for the last one.
1126 160
        $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName());
1127 160
        if (!empty($this->filters[$key])) {
1128 1
            foreach ($this->filters[$key] as $filter) {
1129 1
                $value = call_user_func($filter, $value, $field);
1130 1
            }
1131 1
        }
1132 160
        return $value;
1133
    }
1134
1135
    /**
1136
     * Call all of the validators attached to a field.
1137
     *
1138
     * @param mixed $value The field value being validated.
1139
     * @param ValidationField $field The validation object to add errors.
1140
     */
1141 158
    protected function callValidators($value, ValidationField $field) {
1142 158
        $valid = true;
1143
1144
        // Strip array references in the name except for the last one.
1145 158
        $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName());
1146 158
        if (!empty($this->validators[$key])) {
1147 2
            foreach ($this->validators[$key] as $validator) {
1148 2
                $r = call_user_func($validator, $value, $field);
1149
1150 2
                if ($r === false || Invalid::isInvalid($r)) {
1151 1
                    $valid = false;
1152 1
                }
1153 2
            }
1154 2
        }
1155
1156
        // Add an error on the field if the validator hasn't done so.
1157 158
        if (!$valid && $field->isValid()) {
1158
            $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]);
1159
        }
1160 158
    }
1161
1162
    /**
1163
     * Specify data which should be serialized to JSON.
1164
     *
1165
     * This method specifically returns data compatible with the JSON schema format.
1166
     *
1167
     * @return mixed Returns data which can be serialized by **json_encode()**, which is a value of any type other than a resource.
1168
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
1169
     * @link http://json-schema.org/
1170
     */
1171
    public function jsonSerialize() {
1172 14
        $fix = function ($schema) use (&$fix) {
1173 14
            if ($schema instanceof Schema) {
1174 1
                return $schema->jsonSerialize();
1175
            }
1176
1177 14
            if (!empty($schema['type'])) {
1178
                // Swap datetime and timestamp to other types with formats.
1179 13
                if ($schema['type'] === 'datetime') {
1180 3
                    $schema['type'] = 'string';
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
1181 3
                    $schema['format'] = 'date-time';
1182 13
                } elseif ($schema['type'] === 'timestamp') {
1183 3
                    $schema['type'] = 'integer';
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
1184 3
                    $schema['format'] = 'timestamp';
1185 3
                }
1186 13
            }
1187
1188 14
            if (!empty($schema['items'])) {
1189 4
                $schema['items'] = $fix($schema['items']);
1190 4
            }
1191 14
            if (!empty($schema['properties'])) {
1192 10
                $properties = [];
1193 10
                foreach ($schema['properties'] as $key => $property) {
1194 10
                    $properties[$key] = $fix($property);
1195 10
                }
1196 10
                $schema['properties'] = $properties;
1197 10
            }
1198
1199 14
            return $schema;
1200 14
        };
1201
1202 14
        $result = $fix($this->schema);
1203
1204 14
        return $result;
1205
    }
1206
1207
    /**
1208
     * Look up a type based on its alias.
1209
     *
1210
     * @param string $alias The type alias or type name to lookup.
1211
     * @return mixed
1212
     */
1213 150
    protected function getType($alias) {
1214 150
        if (isset(self::$types[$alias])) {
1215
            return $alias;
1216
        }
1217 150
        foreach (self::$types as $type => $aliases) {
1218 150
            if (in_array($alias, $aliases, true)) {
1219 150
                return $type;
1220
            }
1221 150
        }
1222 9
        return null;
1223
    }
1224
1225
    /**
1226
     * Get the class that's used to contain validation information.
1227
     *
1228
     * @return Validation|string Returns the validation class.
1229
     */
1230 160
    public function getValidationClass() {
1231 160
        return $this->validationClass;
1232
    }
1233
1234
    /**
1235
     * Set the class that's used to contain validation information.
1236
     *
1237
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
1238
     * @return $this
1239
     */
1240 1
    public function setValidationClass($class) {
1241 1
        if (!is_a($class, Validation::class, true)) {
1242
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
1243
        }
1244
1245 1
        $this->validationClass = $class;
1246 1
        return $this;
1247
    }
1248
1249
    /**
1250
     * Create a new validation instance.
1251
     *
1252
     * @return Validation Returns a validation object.
1253
     */
1254 160
    protected function createValidation() {
1255 160
        $class = $this->getValidationClass();
1256
1257 160
        if ($class instanceof Validation) {
1258 1
            $result = clone $class;
1259 1
        } else {
1260 160
            $result = new $class;
1261
        }
1262 160
        return $result;
1263
    }
1264
1265
    /**
1266
     * Check whether or not a value is an array or accessible like an array.
1267
     *
1268
     * @param mixed $value The value to check.
1269
     * @return bool Returns **true** if the value can be used like an array or **false** otherwise.
1270
     */
1271 88
    private function isArray($value) {
1272 88
        return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable);
1273
    }
1274
1275
    /**
1276
     * Cast a value to an array.
1277
     *
1278
     * @param \Traversable $value The value to convert.
1279
     * @return array Returns an array.
1280
     */
1281
    private function toArray(\Traversable $value) {
1282
        if ($value instanceof \ArrayObject) {
1283
            return $value->getArrayCopy();
1284
        }
1285
        return iterator_to_array($value);
1286
    }
1287
1288
    /**
1289
     * Return a sparse version of this schema.
1290
     *
1291
     * A sparse schema has no required properties.
1292
     *
1293
     * @return Schema Returns a new sparse schema.
1294
     */
1295 2
    public function withSparse() {
1296 2
        $sparseSchema = $this->withSparseInternal($this, new \SplObjectStorage());
1297 2
        return $sparseSchema;
1298
    }
1299
1300
    /**
1301
     * The internal implementation of `Schema::withSparse()`.
1302
     *
1303
     * @param array|Schema $schema The schema to make sparse.
1304
     * @param \SplObjectStorage $schemas Collected sparse schemas that have already been made.
1305
     * @return mixed
1306
     */
1307 2
    private function withSparseInternal($schema, \SplObjectStorage $schemas) {
1308 2
        if ($schema instanceof Schema) {
1309 2
            if ($schemas->contains($schema)) {
1310 1
                return $schemas[$schema];
1311
            } else {
1312 2
                $schemas[$schema] = $sparseSchema = new Schema();
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 5 spaces but found 1 space

This check looks for multiple assignments in successive lines of code. It will report an issue if the operators are not in a straight line.

To visualize

$a = "a";
$ab = "ab";
$abc = "abc";

will produce issues in the first and second line, while this second example

$a   = "a";
$ab  = "ab";
$abc = "abc";

will produce no issues.

Loading history...
1313 2
                $sparseSchema->schema = $schema->withSparseInternal($schema->schema, $schemas);
1314 2
                if ($id = $sparseSchema->getID()) {
1315
                    $sparseSchema->setID($id.'Sparse');
1316
                }
1317
1318 2
                return $sparseSchema;
1319
            }
1320
        }
1321
1322 2
        unset($schema['required']);
1323
1324 2
        if (isset($schema['items'])) {
1325 1
            $schema['items'] = $this->withSparseInternal($schema['items'], $schemas);
1326 1
        }
1327 2
        if (isset($schema['properties'])) {
1328 2
            foreach ($schema['properties'] as $name => &$property) {
1329 2
                $property = $this->withSparseInternal($property, $schemas);
1330 2
            }
1331 2
        }
1332
1333 2
        return $schema;
1334
    }
1335
1336
    /**
1337
     * Filter a field's value using built in and custom filters.
1338
     *
1339
     * @param mixed $value The original value of the field.
1340
     * @param ValidationField $field The field information for the field.
1341
     * @return mixed Returns the filtered field or the original field value if there are no filters.
1342
     */
1343 160
    private function filterField($value, ValidationField $field) {
1344
        // Check for limited support for Open API style.
1345 160
        if (!empty($field->val('style')) && is_string($value)) {
1346 8
            $doFilter = true;
1347 8
            if ($field->hasType('boolean') && in_array($value, ['true', 'false', '0', '1'], true)) {
1348 4
                $doFilter = false;
1349 8
            } elseif ($field->hasType('integer') || $field->hasType('number') && is_numeric($value)) {
1350
                $doFilter = false;
1351
            }
1352
1353 8
            if ($doFilter) {
1354 4
                switch ($field->val('style')) {
1355 4
                    case 'form':
1356 2
                        $value = explode(',', $value);
1357 2
                        break;
1358 2
                    case 'spaceDelimited':
1359 1
                        $value = explode(' ', $value);
1360 1
                        break;
1361 1
                    case 'pipeDelimited':
1362 1
                        $value = explode('|', $value);
1363 1
                        break;
1364 4
                }
1365 4
            }
1366 8
        }
1367
1368 160
        $value = $this->callFilters($value, $field);
1369
1370 160
        return $value;
1371
    }
1372
1373
    /**
1374
     * Whether a offset exists.
1375
     *
1376
     * @param mixed $offset An offset to check for.
1377
     * @return boolean true on success or false on failure.
1378
     * @link http://php.net/manual/en/arrayaccess.offsetexists.php
1379
     */
1380 3
    public function offsetExists($offset) {
1381 3
        return isset($this->schema[$offset]);
1382
    }
1383
1384
    /**
1385
     * Offset to retrieve.
1386
     *
1387
     * @param mixed $offset The offset to retrieve.
1388
     * @return mixed Can return all value types.
1389
     * @link http://php.net/manual/en/arrayaccess.offsetget.php
1390
     */
1391
    public function offsetGet($offset) {
1392
        return isset($this->schema[$offset]) ? $this->schema[$offset] : null;
1393
    }
1394
1395
    /**
1396
     * Offset to set.
1397
     *
1398
     * @param mixed $offset The offset to assign the value to.
1399
     * @param mixed $value The value to set.
1400
     * @link http://php.net/manual/en/arrayaccess.offsetset.php
1401
     */
1402
    public function offsetSet($offset, $value) {
1403
        $this->schema[$offset] = $value;
1404
    }
1405
1406
    /**
1407
     * Offset to unset.
1408
     *
1409
     * @param mixed $offset The offset to unset.
1410
     * @link http://php.net/manual/en/arrayaccess.offsetunset.php
1411
     */
1412
    public function offsetUnset($offset) {
1413
        unset($this->schema[$offset]);
1414
    }
1415
1416
    /**
1417
     * Validate a field against a single type.
1418
     *
1419
     * @param mixed $value The value to validate.
1420
     * @param string $type The type to validate against.
1421
     * @param ValidationField $field Contains field and validation information.
1422
     * @param bool $sparse Whether or not this should be a sparse validation.
1423
     * @return mixed Returns the valid value or `Invalid`.
1424
     */
1425 160
    protected function validateSingleType($value, $type, ValidationField $field, $sparse) {
1426
        switch ($type) {
1427 160
            case 'boolean':
1428 29
                $result = $this->validateBoolean($value, $field);
1429 29
                break;
1430 140
            case 'integer':
1431 33
                $result = $this->validateInteger($value, $field);
1432 33
                break;
1433 134
            case 'number':
1434 13
                $result = $this->validateNumber($value, $field);
1435 13
                break;
1436 129
            case 'string':
1437 60
                $result = $this->validateString($value, $field);
1438 60
                break;
1439 109
            case 'timestamp':
1440 8
                $result = $this->validateTimestamp($value, $field);
1441 8
                break;
1442 108
            case 'datetime':
1443 10
                $result = $this->validateDatetime($value, $field);
1444 10
                break;
1445 104
            case 'array':
1446 28
                $result = $this->validateArray($value, $field, $sparse);
1447 28
                break;
1448 90
            case 'object':
1449 88
                $result = $this->validateObject($value, $field, $sparse);
1450 86
                break;
1451 3
            case null:
1452
                // No type was specified so we are valid.
1453 3
                $result = $value;
1454 3
                break;
1455
            default:
1456
                throw new \InvalidArgumentException("Unrecognized type $type.", 500);
1457
        }
1458 160
        return $result;
1459
    }
1460
1461
    /**
1462
     * Validate a field against multiple basic types.
1463
     *
1464
     * The first validation that passes will be returned. If no type can be validated against then validation will fail.
1465
     *
1466
     * @param mixed $value The value to validate.
1467
     * @param string[] $types The types to validate against.
1468
     * @param ValidationField $field Contains field and validation information.
1469
     * @param bool $sparse Whether or not this should be a sparse validation.
1470
     * @return mixed Returns the valid value or `Invalid`.
1471
     */
1472 28
    private function validateMultipleTypes($value, array $types, ValidationField $field, $sparse) {
1473
        // First check for an exact type match.
1474 28
        switch (gettype($value)) {
1475 28
            case 'boolean':
1476 4
                if (in_array('boolean', $types)) {
1477 4
                    $singleType = 'boolean';
1478 4
                }
1479 4
                break;
1480 25
            case 'integer':
1481 7
                if (in_array('integer', $types)) {
1482 5
                    $singleType = 'integer';
1483 7
                } elseif (in_array('number', $types)) {
1484 1
                    $singleType = 'number';
1485 1
                }
1486 7
                break;
1487 20
            case 'double':
1488 4
                if (in_array('number', $types)) {
1489 4
                    $singleType = 'number';
1490 4
                } elseif (in_array('integer', $types)) {
1491
                    $singleType = 'integer';
1492
                }
1493 4
                break;
1494 17
            case 'string':
1495 9
                if (in_array('datetime', $types) && preg_match(self::$DATE_REGEX, $value)) {
1496 1
                    $singleType = 'datetime';
1497 9
                } elseif (in_array('string', $types)) {
1498 4
                    $singleType = 'string';
1499 4
                }
1500 9
                break;
1501 9
            case 'array':
1502 9
                if (in_array('array', $types) && in_array('object', $types)) {
1503 1
                    $singleType = isset($value[0]) || empty($value) ? 'array' : 'object';
1504 9
                } elseif (in_array('object', $types)) {
1505
                    $singleType = 'object';
1506 8
                } elseif (in_array('array', $types)) {
1507 8
                    $singleType = 'array';
1508 8
                }
1509 9
                break;
1510 1
            case 'NULL':
1511
                if (in_array('null', $types)) {
1512
                    $singleType = $this->validateSingleType($value, 'null', $field, $sparse);
1513
                }
1514
                break;
1515 28
        }
1516 28
        if (!empty($singleType)) {
1517 24
            return $this->validateSingleType($value, $singleType, $field, $sparse);
1518
        }
1519
1520
        // Clone the validation field to collect errors.
1521 6
        $typeValidation = new ValidationField(new Validation(), $field->getField(), '', $sparse);
1522
1523
        // Try and validate against each type.
1524 6
        foreach ($types as $type) {
1525 6
            $result = $this->validateSingleType($value, $type, $typeValidation, $sparse);
1526 6
            if (Invalid::isValid($result)) {
1527 6
                return $result;
1528
            }
1529
        }
1530
1531
        // Since we got here the value is invalid.
1532
        $field->merge($typeValidation->getValidation());
1533
        return Invalid::value();
1534
    }
1535
}
1536