Completed
Pull Request — master (#20)
by Todd
01:06
created

Schema::parseProperties()   B

Complexity

Conditions 5
Paths 6

Size

Total Lines 26
Code Lines 16

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 18
CRAP Score 5.0035

Importance

Changes 0
Metric Value
dl 0
loc 26
ccs 18
cts 19
cp 0.9474
rs 8.439
c 0
b 0
f 0
cc 5
eloc 16
nc 6
nop 1
crap 5.0035
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
    private $schema = [];
42
43
    /**
44
     * @var int A bitwise combination of the various **Schema::FLAG_*** constants.
45
     */
46
    private $flags = 0;
47
48
    /**
49
     * @var array An array of callbacks that will filter data in the schema.
50
     */
51
    private $filters = [];
52
53
    /**
54
     * @var array An array of callbacks that will custom validate the schema.
55
     */
56
    private $validators = [];
57
58
    /**
59
     * @var string|Validation The name of the class or an instance that will be cloned.
60
     */
61
    private $validationClass = Validation::class;
62
63
64
    /// Methods ///
65
66
    /**
67
     * Initialize an instance of a new {@link Schema} class.
68
     *
69
     * @param array $schema The array schema to validate against.
70
     */
71 168
    public function __construct($schema = []) {
72 168
        $this->schema = $schema;
73 168
    }
74
75
    /**
76
     * Grab the schema's current description.
77
     *
78
     * @return string
79
     */
80 1
    public function getDescription() {
81 1
        return isset($this->schema['description']) ? $this->schema['description'] : '';
82
    }
83
84
    /**
85
     * Set the description for the schema.
86
     *
87
     * @param string $description The new description.
88
     * @throws \InvalidArgumentException Throws an exception when the provided description is not a string.
89
     * @return Schema
90
     */
91 2
    public function setDescription($description) {
92 2
        if (is_string($description)) {
93 1
            $this->schema['description'] = $description;
94 1
        } else {
95 1
            throw new \InvalidArgumentException("The description is not a valid string.", 500);
96
        }
97
98 1
        return $this;
99
    }
100
101
    /**
102
     * Get a schema field.
103
     *
104
     * @param string|array $path The JSON schema path of the field with parts separated by dots.
105
     * @param mixed $default The value to return if the field isn't found.
106
     * @return mixed Returns the field value or `$default`.
107
     */
108 4
    public function getField($path, $default = null) {
109 4
        if (is_string($path)) {
110 4
            $path = explode('.', $path);
111 4
        }
112
113 4
        $value = $this->schema;
114 4
        foreach ($path as $i => $subKey) {
115 4
            if (is_array($value) && isset($value[$subKey])) {
116 4
                $value = $value[$subKey];
117 4
            } elseif ($value instanceof Schema) {
118 1
                return $value->getField(array_slice($path, $i), $default);
119
            } else {
120
                return $default;
121
            }
122 4
        }
123 4
        return $value;
124
    }
125
126
    /**
127
     * Set a schema field.
128
     *
129
     * @param string|array $path The JSON schema path of the field with parts separated by dots.
130
     * @param mixed $value The new value.
131
     * @return $this
132
     */
133 3
    public function setField($path, $value) {
134 3
        if (is_string($path)) {
135 3
            $path = explode('.', $path);
136 3
        }
137
138 3
        $selection = &$this->schema;
139 3
        foreach ($path as $i => $subSelector) {
140 3
            if (is_array($selection)) {
141 3
                if (!isset($selection[$subSelector])) {
142 1
                    $selection[$subSelector] = [];
143 1
                }
144 3
            } elseif ($selection instanceof Schema) {
145 1
                $selection->setField(array_slice($path, $i), $value);
146 1
                return $this;
147
            } else {
148
                $selection = [$subSelector => []];
149
            }
150 3
            $selection = &$selection[$subSelector];
151 3
        }
152
153 3
        $selection = $value;
154 3
        return $this;
155
    }
156
157
    /**
158
     * Get the ID for the schema.
159
     *
160
     * @return string
161
     */
162 3
    public function getID() {
163 3
        return isset($this->schema['id']) ? $this->schema['id'] : '';
164
    }
165
166
    /**
167
     * Set the ID for the schema.
168
     *
169
     * @param string $id The new ID.
170
     * @throws \InvalidArgumentException Throws an exception when the provided ID is not a string.
171
     * @return Schema
172
     */
173 4
    public function setID($id) {
174 4
        if (is_string($id)) {
175 1
            $this->schema['id'] = $id;
176 1
        } else {
177
            throw new \InvalidArgumentException("The ID is not a valid string.", 500);
178
        }
179
180 1
        return $this;
181
    }
182
183
    /**
184
     * Return the validation flags.
185
     *
186
     * @return int Returns a bitwise combination of flags.
187
     */
188 1
    public function getFlags() {
189 1
        return $this->flags;
190
    }
191
192
    /**
193
     * Set the validation flags.
194
     *
195
     * @param int $flags One or more of the **Schema::FLAG_*** constants.
196
     * @return Schema Returns the current instance for fluent calls.
197
     */
198 8
    public function setFlags($flags) {
199 8
        if (!is_int($flags)) {
200 1
            throw new \InvalidArgumentException('Invalid flags.', 500);
201
        }
202 7
        $this->flags = $flags;
203
204 7
        return $this;
205
    }
206
207
    /**
208
     * Whether or not the schema has a flag (or combination of flags).
209
     *
210
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
211
     * @return bool Returns **true** if all of the flags are set or **false** otherwise.
212
     */
213 18
    public function hasFlag($flag) {
214 18
        return ($this->flags & $flag) === $flag;
215
    }
216
217
    /**
218
     * Set a flag.
219
     *
220
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
221
     * @param bool $value Either true or false.
222
     * @return $this
223
     */
224 1
    public function setFlag($flag, $value) {
225 1
        if ($value) {
226 1
            $this->flags = $this->flags | $flag;
227 1
        } else {
228 1
            $this->flags = $this->flags & ~$flag;
229
        }
230 1
        return $this;
231
    }
232
233
    /**
234
     * Merge a schema with this one.
235
     *
236
     * @param Schema $schema A scheme instance. Its parameters will be merged into the current instance.
237
     * @return $this
238
     */
239 3
    public function merge(Schema $schema) {
240 3
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), true, true);
241 3
        return $this;
242
    }
243
244
    /**
245
     * Add another schema to this one.
246
     *
247
     * Adding schemas together is analogous to array addition. When you add a schema it will only add missing information.
248
     *
249
     * @param Schema $schema The schema to add.
250
     * @param bool $addProperties Whether to add properties that don't exist in this schema.
251
     * @return $this
252
     */
253 3
    public function add(Schema $schema, $addProperties = false) {
254 3
        $this->mergeInternal($this->schema, $schema->getSchemaArray(), false, $addProperties);
255 3
        return $this;
256
    }
257
258
    /**
259
     * The internal implementation of schema merging.
260
     *
261
     * @param array &$target The target of the merge.
262
     * @param array $source The source of the merge.
263
     * @param bool $overwrite Whether or not to replace values.
264
     * @param bool $addProperties Whether or not to add object properties to the target.
265
     * @return array
266
     */
267 6
    private function mergeInternal(array &$target, array $source, $overwrite = true, $addProperties = true) {
268
        // We need to do a fix for required properties here.
269 6
        if (isset($target['properties']) && !empty($source['required'])) {
270 4
            $required = isset($target['required']) ? $target['required'] : [];
271
272 4
            if (isset($source['required']) && $addProperties) {
273 3
                $newProperties = array_diff(array_keys($source['properties']), array_keys($target['properties']));
274 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...
275
276 3
                $required = array_merge($required, $newRequired);
277 3
            }
278 4
        }
279
280
281 6
        foreach ($source as $key => $val) {
282 6
            if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) {
283 6
                if ($key === 'properties' && !$addProperties) {
284
                    // We just want to merge the properties that exist in the destination.
285 1
                    foreach ($val as $name => $prop) {
286 1
                        if (isset($target[$key][$name])) {
287 1
                            $this->mergeInternal($target[$key][$name], $prop, $overwrite, $addProperties);
288 1
                        }
289 1
                    }
290 6
                } elseif (isset($val[0]) || isset($target[$key][0])) {
291 4
                    if ($overwrite) {
292
                        // This is a numeric array, so just do a merge.
293 2
                        $merged = array_merge($target[$key], $val);
294 2
                        if (is_string($merged[0])) {
295 2
                            $merged = array_keys(array_flip($merged));
296 2
                        }
297 2
                        $target[$key] = $merged;
298 2
                    }
299 4
                } else {
300 3
                    $target[$key] = $this->mergeInternal($target[$key], $val, $overwrite, $addProperties);
301
                }
302 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...
303
                // Do nothing, we aren't replacing.
304 3
            } else {
305 5
                $target[$key] = $val;
306
            }
307 6
        }
308
309 6
        if (isset($required)) {
310 4
            if (empty($required)) {
311 1
                unset($target['required']);
312 1
            } else {
313 4
                $target['required'] = $required;
314
            }
315 4
        }
316
317 6
        return $target;
318
    }
319
320
//    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...
321
322
    /**
323
     * Returns the internal schema array.
324
     *
325
     * @return array
326
     * @see Schema::jsonSerialize()
327
     */
328 15
    public function getSchemaArray() {
329 15
        return $this->schema;
330
    }
331
332
    /**
333
     * Parse a short schema and return the associated schema.
334
     *
335
     * @param array $arr The schema array.
336
     * @param mixed ...$args Constructor arguments for the schema instance.
337
     * @return static Returns a new schema.
338
     */
339 160
    public static function parse(array $arr, ...$args) {
340 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...
341 160
        $schema->schema = $schema->parseInternal($arr);
342 160
        return $schema;
343
    }
344
345
    /**
346
     * Parse a schema in short form into a full schema array.
347
     *
348
     * @param array $arr The array to parse into a schema.
349
     * @return array The full schema array.
350
     * @throws \InvalidArgumentException Throws an exception when an item in the schema is invalid.
351
     */
352 160
    protected function parseInternal(array $arr) {
353 160
        if (empty($arr)) {
354
            // An empty schema validates to anything.
355 7
            return [];
356 154
        } elseif (isset($arr['type'])) {
357
            // This is a long form schema and can be parsed as the root.
358
            return $this->parseNode($arr);
359
        } else {
360
            // Check for a root schema.
361 154
            $value = reset($arr);
362 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...
363 154
            if (is_int($key)) {
364 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...
365 95
                $value = null;
366 95
            }
367 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...
368 154
            if (empty($name)) {
369 58
                return $this->parseNode($param, $value);
370
            }
371
        }
372
373
        // If we are here then this is n object schema.
374 99
        list($properties, $required) = $this->parseProperties($arr);
375
376
        $result = [
377 99
            'type' => 'object',
378 99
            'properties' => $properties,
379
            'required' => $required
380 99
        ];
381
382 99
        return array_filter($result);
383
    }
384
385
    /**
386
     * Parse a schema node.
387
     *
388
     * @param array $node The node to parse.
389
     * @param mixed $value Additional information from the node.
390
     * @return array Returns a JSON schema compatible node.
391
     */
392 154
    private function parseNode($node, $value = null) {
393 154
        if (is_array($value)) {
394
            // The value describes a bit more about the schema.
395 57
            switch ($node['type']) {
396 57
                case 'array':
397 11
                    if (isset($value['items'])) {
398
                        // The value includes array schema information.
399 4
                        $node = array_replace($node, $value);
400 4
                    } else {
401 7
                        $node['items'] = $this->parseInternal($value);
402
                    }
403 11
                    break;
404 47
                case 'object':
405
                    // The value is a schema of the object.
406 11
                    if (isset($value['properties'])) {
407
                        list($node['properties']) = $this->parseProperties($value['properties']);
408
                    } else {
409 11
                        list($node['properties'], $required) = $this->parseProperties($value);
410 11
                        if (!empty($required)) {
411 11
                            $node['required'] = $required;
412 11
                        }
413
                    }
414 11
                    break;
415 36
                default:
416 36
                    $node = array_replace($node, $value);
417 36
                    break;
418 57
            }
419 154
        } elseif (is_string($value)) {
420 93
            if ($node['type'] === 'array' && $arrType = $this->getType($value)) {
421 5
                $node['items'] = ['type' => $arrType];
422 93
            } elseif (!empty($value)) {
423 23
                $node['description'] = $value;
424 23
            }
425 116
        } elseif ($value === null) {
426
            // Parse child elements.
427 24
            if ($node['type'] === 'array' && isset($node['items'])) {
428
                // The value includes array schema information.
429
                $node['items'] = $this->parseInternal($node['items']);
430 24
            } elseif ($node['type'] === 'object' && isset($node['properties'])) {
431
                list($node['properties']) = $this->parseProperties($node['properties']);
432
433
            }
434 24
        }
435
436 154
        if (is_array($node) && $node['type'] === null) {
437 3
            unset($node['type']);
438 3
        }
439
440 154
        return $node;
441
    }
442
443
    /**
444
     * Parse the schema for an object's properties.
445
     *
446
     * @param array $arr An object property schema.
447
     * @return array Returns a schema array suitable to be placed in the **properties** key of a schema.
448
     */
449 99
    private function parseProperties(array $arr) {
450 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...
451 99
        $requiredProperties = [];
452 99
        foreach ($arr as $key => $value) {
453
            // Fix a schema specified as just a value.
454 99
            if (is_int($key)) {
455 75
                if (is_string($value)) {
456 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...
457 75
                    $value = '';
458 75
                } else {
459
                    throw new \InvalidArgumentException("Schema at position $key is not a valid parameter.", 500);
460
                }
461 75
            }
462
463
            // The parameter is defined in the key.
464 99
            list($name, $param, $required) = $this->parseShortParam($key, $value);
465
466 99
            $node = $this->parseNode($param, $value);
467
468 99
            $properties[$name] = $node;
469 99
            if ($required) {
470 53
                $requiredProperties[] = $name;
471 53
            }
472 99
        }
473 99
        return array($properties, $requiredProperties);
474
    }
475
476
    /**
477
     * Parse a short parameter string into a full array parameter.
478
     *
479
     * @param string $key The short parameter string to parse.
480
     * @param array $value An array of other information that might help resolve ambiguity.
481
     * @return array Returns an array in the form `[string name, array param, bool required]`.
482
     * @throws \InvalidArgumentException Throws an exception if the short param is not in the correct format.
483
     */
484 154
    public function parseShortParam($key, $value = []) {
485
        // Is the parameter optional?
486 154
        if (substr($key, -1) === '?') {
487 67
            $required = false;
488 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...
489 67
        } else {
490 108
            $required = true;
491
        }
492
493
        // Check for a type.
494 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...
495 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...
496 154
        $allowNull = false;
497 154
        if (!empty($parts[1])) {
498 150
            $types = explode('|', $parts[1]);
499 150
            foreach ($types as $alias) {
500 150
                $found = $this->getType($alias);
501 150
                if ($found === null) {
502
                    throw new \InvalidArgumentException("Unknown type '$alias'", 500);
503 150
                } elseif ($found === 'null') {
504 11
                    $allowNull = true;
505 11
                } else {
506 150
                    $type = $found;
507
                }
508 150
            }
509 150
        } else {
510 8
            $type = null;
511
        }
512
513 154
        if ($value instanceof Schema) {
514 3
            if ($type === 'array') {
515 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...
516 1
            } else {
517 2
                $param = $value;
518
            }
519 154
        } elseif (isset($value['type'])) {
520 3
            $param = $value;
521
522 3
            if (!empty($type) && $type !== $param['type']) {
523
                throw new \InvalidArgumentException("Type mismatch between $type and {$param['type']} for field $name.", 500);
524
            }
525 3
        } else {
526 151
            if (empty($type) && !empty($parts[1])) {
527
                throw new \InvalidArgumentException("Invalid type {$parts[1]} for field $name.", 500);
528
            }
529 151
            $param = ['type' => $type];
530
531
            // Parsed required strings have a minimum length of 1.
532 151
            if ($type === 'string' && !empty($name) && $required && (!isset($value['default']) || $value['default'] !== '')) {
533 34
                $param['minLength'] = 1;
534 34
            }
535
        }
536 154
        if ($allowNull) {
537 11
            $param['allowNull'] = true;
538 11
        }
539
540 154
        return [$name, $param, $required];
541
    }
542
543
    /**
544
     * Add a custom filter to change data before validation.
545
     *
546
     * @param string $fieldname The name of the field to filter, if any.
547
     *
548
     * If you are adding a filter to a deeply nested field then separate the path with dots.
549
     * @param callable $callback The callback to filter the field.
550
     * @return $this
551
     */
552 1
    public function addFilter($fieldname, callable $callback) {
553 1
        $this->filters[$fieldname][] = $callback;
554 1
        return $this;
555
    }
556
557
    /**
558
     * Add a custom validator to to validate the schema.
559
     *
560
     * @param string $fieldname The name of the field to validate, if any.
561
     *
562
     * If you are adding a validator to a deeply nested field then separate the path with dots.
563
     * @param callable $callback The callback to validate with.
564
     * @return Schema Returns `$this` for fluent calls.
565
     */
566 2
    public function addValidator($fieldname, callable $callback) {
567 2
        $this->validators[$fieldname][] = $callback;
568 2
        return $this;
569
    }
570
571
    /**
572
     * Require one of a given set of fields in the schema.
573
     *
574
     * @param array $required The field names to require.
575
     * @param string $fieldname The name of the field to attach to.
576
     * @param int $count The count of required items.
577
     * @return Schema Returns `$this` for fluent calls.
578
     */
579 1
    public function requireOneOf(array $required, $fieldname = '', $count = 1) {
580 1
        $result = $this->addValidator(
581 1
            $fieldname,
582
            function ($data, ValidationField $field) use ($required, $count) {
583 1
                $hasCount = 0;
584 1
                $flattened = [];
585
586 1
                foreach ($required as $name) {
587 1
                    $flattened = array_merge($flattened, (array)$name);
588
589 1
                    if (is_array($name)) {
590
                        // This is an array of required names. They all must match.
591 1
                        $hasCountInner = 0;
592 1
                        foreach ($name as $nameInner) {
593 1
                            if (isset($data[$nameInner]) && $data[$nameInner]) {
594 1
                                $hasCountInner++;
595 1
                            } else {
596 1
                                break;
597
                            }
598 1
                        }
599 1
                        if ($hasCountInner >= count($name)) {
600 1
                            $hasCount++;
601 1
                        }
602 1
                    } elseif (isset($data[$name]) && $data[$name]) {
603 1
                        $hasCount++;
604 1
                    }
605
606 1
                    if ($hasCount >= $count) {
607 1
                        return true;
608
                    }
609 1
                }
610
611 1
                if ($count === 1) {
612 1
                    $message = 'One of {required} are required.';
613 1
                } else {
614
                    $message = '{count} of {required} are required.';
615
                }
616
617 1
                $field->addError('missingField', [
618 1
                    'messageCode' => $message,
619 1
                    'required' => $required,
620
                    'count' => $count
621 1
                ]);
622 1
                return false;
623
            }
624 1
        );
625
626 1
        return $result;
627
    }
628
629
    /**
630
     * Validate data against the schema.
631
     *
632
     * @param mixed $data The data to validate.
633
     * @param bool $sparse Whether or not this is a sparse validation.
634
     * @return mixed Returns a cleaned version of the data.
635
     * @throws ValidationException Throws an exception when the data does not validate against the schema.
636
     */
637 132
    public function validate($data, $sparse = false) {
638 132
        $field = new ValidationField($this->createValidation(), $this->schema, '', $sparse);
639
640 132
        $clean = $this->validateField($data, $field, $sparse);
641
642 130
        if (Invalid::isInvalid($clean) && $field->isValid()) {
643
            // This really shouldn't happen, but we want to protect against seeing the invalid object.
644
            $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]);
645
        }
646
647 130
        if (!$field->getValidation()->isValid()) {
648 57
            throw new ValidationException($field->getValidation());
649
        }
650
651 86
        return $clean;
652
    }
653
654
    /**
655
     * Validate data against the schema and return the result.
656
     *
657
     * @param mixed $data The data to validate.
658
     * @param bool $sparse Whether or not to do a sparse validation.
659
     * @return bool Returns true if the data is valid. False otherwise.
660
     */
661 35
    public function isValid($data, $sparse = false) {
662
        try {
663 35
            $this->validate($data, $sparse);
664 25
            return true;
665 18
        } catch (ValidationException $ex) {
666 18
            return false;
667
        }
668
    }
669
670
    /**
671
     * Validate a field.
672
     *
673
     * @param mixed $value The value to validate.
674
     * @param ValidationField $field A validation object to add errors to.
675
     * @param bool $sparse Whether or not this is a sparse validation.
676
     * @return mixed|Invalid Returns a clean version of the value with all extra fields stripped out or invalid if the value
677
     * is completely invalid.
678
     */
679 132
    protected function validateField($value, ValidationField $field, $sparse = false) {
680 132
        $result = $value = $this->filterField($value, $field);
681
682 132
        if ($field->getField() instanceof Schema) {
683
            try {
684 3
                $result = $field->getField()->validate($value, $sparse);
685 3
            } catch (ValidationException $ex) {
686
                // The validation failed, so merge the validations together.
687 1
                $field->getValidation()->merge($ex->getValidation(), $field->getName());
688
            }
689 132
        } elseif (($value === null || ($value === '' && $field->getType() !== 'string')) && $field->val('allowNull', false)) {
690 11
            $result = null;
691 11
        } else {
692
            // Validate the field's type.
693 132
            $type = $field->getType();
694
            switch ($type) {
695 132
                case 'boolean':
696 21
                    $result = $this->validateBoolean($value, $field);
697 21
                    break;
698 119
                case 'integer':
699 28
                    $result = $this->validateInteger($value, $field);
700 28
                    break;
701 117
                case 'number':
702 8
                    $result = $this->validateNumber($value, $field);
703 8
                    break;
704 116
                case 'string':
705 56
                    $result = $this->validateString($value, $field);
706 56
                    break;
707 99
                case 'timestamp':
708 7
                    $result = $this->validateTimestamp($value, $field);
709 7
                    break;
710 98
                case 'datetime':
711 8
                    $result = $this->validateDatetime($value, $field);
712 8
                    break;
713 95
                case 'array':
714 19
                    $result = $this->validateArray($value, $field, $sparse);
715 19
                    break;
716 88
                case 'object':
717 87
                    $result = $this->validateObject($value, $field, $sparse);
718 85
                    break;
719 2
                case null:
0 ignored issues
show
Bug introduced by
It seems like you are loosely comparing $type of type string|null against null; this is ambiguous if the string can be empty. Consider using a strict comparison === instead.
Loading history...
720
                    // No type was specified so we are valid.
721 2
                    $result = $value;
722 2
                    break;
723
                default:
724
                    throw new \InvalidArgumentException("Unrecognized type $type.", 500);
725
            }
726 132
            if (Invalid::isValid($result)) {
727 130
                $result = $this->validateEnum($result, $field);
728 130
            }
729
        }
730
731
        // Validate a custom field validator.
732 132
        if (Invalid::isValid($result)) {
733 130
            $this->callValidators($result, $field);
734 130
        }
735
736 132
        return $result;
737
    }
738
739
    /**
740
     * Validate an array.
741
     *
742
     * @param mixed $value The value to validate.
743
     * @param ValidationField $field The validation results to add.
744
     * @param bool $sparse Whether or not this is a sparse validation.
745
     * @return array|Invalid Returns an array or invalid if validation fails.
746
     */
747 19
    protected function validateArray($value, ValidationField $field, $sparse = false) {
748 19
        if ((!is_array($value) || (count($value) > 0 && !array_key_exists(0, $value))) && !$value instanceof \Traversable) {
749 6
            $field->addTypeError('array');
750 6
            return Invalid::value();
751
        } else {
752 14
            if ((null !== $minItems = $field->val('minItems')) && count($value) < $minItems) {
753 1
                $field->addError(
754 1
                    'minItems',
755
                    [
756 1
                        'messageCode' => '{field} must contain at least {minItems} {minItems,plural,item}.',
757 1
                        'minItems' => $minItems,
758
                        'status' => 422
759 1
                    ]
760 1
                );
761 1
            }
762 14
            if ((null !== $maxItems = $field->val('maxItems')) && count($value) > $maxItems) {
763 1
                $field->addError(
764 1
                    'maxItems',
765
                    [
766 1
                        'messageCode' => '{field} must contain no more than {maxItems} {maxItems,plural,item}.',
767 1
                        'maxItems' => $maxItems,
768
                        'status' => 422
769 1
                    ]
770 1
                );
771 1
            }
772
773 14
            if ($field->val('items') !== null) {
774 10
                $result = [];
775
776
                // Validate each of the types.
777 10
                $itemValidation = new ValidationField(
778 10
                    $field->getValidation(),
779 10
                    $field->val('items'),
780 10
                    '',
781
                    $sparse
782 10
                );
783
784 10
                $count = 0;
785 10
                foreach ($value as $i => $item) {
786 10
                    $itemValidation->setName($field->getName()."[{$i}]");
787 10
                    $validItem = $this->validateField($item, $itemValidation, $sparse);
788 10
                    if (Invalid::isValid($validItem)) {
789 10
                        $result[] = $validItem;
790 10
                    }
791 10
                    $count++;
792 10
                }
793
794 10
                return empty($result) && $count > 0 ? Invalid::value() : $result;
795
            } else {
796
                // Cast the items into a proper numeric array.
797 4
                $result = is_array($value) ? array_values($value) : iterator_to_array($value);
798 4
                return $result;
799
            }
800
        }
801
    }
802
803
    /**
804
     * Validate a boolean value.
805
     *
806
     * @param mixed $value The value to validate.
807
     * @param ValidationField $field The validation results to add.
808
     * @return bool|Invalid Returns the cleaned value or invalid if validation fails.
809
     */
810 21
    protected function validateBoolean($value, ValidationField $field) {
811 21
        $value = $value === null ? $value : filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
812 21
        if ($value === null) {
813 4
            $field->addTypeError('boolean');
814 4
            return Invalid::value();
815
        }
816 18
        return $value;
817
    }
818
819
    /**
820
     * Validate a date time.
821
     *
822
     * @param mixed $value The value to validate.
823
     * @param ValidationField $field The validation results to add.
824
     * @return \DateTimeInterface|Invalid Returns the cleaned value or **null** if it isn't valid.
825
     */
826 12
    protected function validateDatetime($value, ValidationField $field) {
827 12
        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...
828
            // do nothing, we're good
829 12
        } elseif (is_string($value) && $value !== '' && !is_numeric($value)) {
830
            try {
831 6
                $dt = new \DateTimeImmutable($value);
832 5
                if ($dt) {
833 5
                    $value = $dt;
834 5
                } else {
835
                    $value = null;
836
                }
837 6
            } catch (\Exception $ex) {
838 1
                $value = Invalid::value();
839
            }
840 10
        } elseif (is_int($value) && $value > 0) {
841 1
            $value = new \DateTimeImmutable('@'.(string)round($value));
842 1
        } else {
843 3
            $value = Invalid::value();
844
        }
845
846 12
        if (Invalid::isInvalid($value)) {
847 4
            $field->addTypeError('datetime');
848 4
        }
849 12
        return $value;
850
    }
851
852
    /**
853
     * Validate a float.
854
     *
855
     * @param mixed $value The value to validate.
856
     * @param ValidationField $field The validation results to add.
857
     * @return float|Invalid Returns a number or **null** if validation fails.
858
     */
859 8
    protected function validateNumber($value, ValidationField $field) {
860 8
        $result = filter_var($value, FILTER_VALIDATE_FLOAT);
861 8
        if ($result === false) {
862 4
            $field->addTypeError('number');
863 4
            return Invalid::value();
864
        }
865 4
        return $result;
866
    }
867
868
    /**
869
     * Validate and integer.
870
     *
871
     * @param mixed $value The value to validate.
872
     * @param ValidationField $field The validation results to add.
873
     * @return int|Invalid Returns the cleaned value or **null** if validation fails.
874
     */
875 28
    protected function validateInteger($value, ValidationField $field) {
876 28
        $result = filter_var($value, FILTER_VALIDATE_INT);
877
878 28
        if ($result === false) {
879 8
            $field->addTypeError('integer');
880 8
            return Invalid::value();
881
        }
882 23
        return $result;
883
    }
884
885
    /**
886
     * Validate an object.
887
     *
888
     * @param mixed $value The value to validate.
889
     * @param ValidationField $field The validation results to add.
890
     * @param bool $sparse Whether or not this is a sparse validation.
891
     * @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...
892
     */
893 87
    protected function validateObject($value, ValidationField $field, $sparse = false) {
894 87
        if (!$this->isArray($value) || isset($value[0])) {
895 6
            $field->addTypeError('object');
896 6
            return Invalid::value();
897 87
        } elseif (is_array($field->val('properties'))) {
898
            // Validate the data against the internal schema.
899 86
            $value = $this->validateProperties($value, $field, $sparse);
900 85
        } elseif (!is_array($value)) {
901
            $value = $this->toArray($value);
902
        }
903 85
        return $value;
904
    }
905
906
    /**
907
     * Validate data against the schema and return the result.
908
     *
909
     * @param array|\ArrayAccess $data The data to validate.
910
     * @param ValidationField $field This argument will be filled with the validation result.
911
     * @param bool $sparse Whether or not this is a sparse validation.
912
     * @return array|Invalid Returns a clean array with only the appropriate properties and the data coerced to proper types.
913
     * or invalid if there are no valid properties.
914
     */
915 86
    protected function validateProperties($data, ValidationField $field, $sparse = false) {
916 86
        $properties = $field->val('properties', []);
917 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...
918
919 86
        if (is_array($data)) {
920 85
            $keys = array_keys($data);
921 85
        } else {
922 1
            $keys = array_keys(iterator_to_array($data));
923
        }
924 86
        $keys = array_combine(array_map('strtolower', $keys), $keys);
925
926 86
        $propertyField = new ValidationField($field->getValidation(), [], null, $sparse);
927
928
        // Loop through the schema fields and validate each one.
929 86
        $clean = [];
930 86
        foreach ($properties as $propertyName => $property) {
931
            $propertyField
932 86
                ->setField($property)
933 86
                ->setName(ltrim($field->getName().".$propertyName", '.'));
934
935 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...
936 86
            $isRequired = isset($required[$propertyName]);
937
938
            // First check for required fields.
939 86
            if (!array_key_exists($lName, $keys)) {
940 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...
941
                    // Sparse validation can leave required fields out.
942 23
                } elseif ($propertyField->hasVal('default')) {
943 2
                    $clean[$propertyName] = $propertyField->val('default');
944 23
                } elseif ($isRequired) {
945 6
                    $propertyField->addError('missingField', ['messageCode' => '{field} is required.']);
946 6
                }
947 23
            } else {
948 84
                $value = $data[$keys[$lName]];
949
950 84
                if (in_array($value, [null, ''], true) && !$isRequired && !$propertyField->val('allowNull')) {
951 13
                    if ($propertyField->getType() !== 'string' || $value === null) {
952 10
                        continue;
953
                    }
954 3
                }
955
956 74
                $clean[$propertyName] = $this->validateField($value, $propertyField, $sparse);
957
            }
958
959 84
            unset($keys[$lName]);
960 86
        }
961
962
        // Look for extraneous properties.
963 86
        if (!empty($keys)) {
964 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...
965 2
                $msg = sprintf("%s has unexpected field(s): %s.", $field->getName() ?: 'value', implode(', ', $keys));
966 2
                trigger_error($msg, E_USER_NOTICE);
967
            }
968
969 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...
970 2
                $field->addError('invalid', [
971 2
                    'messageCode' => '{field} has {extra,plural,an unexpected field,unexpected fields}: {extra}.',
972 2
                    'extra' => array_values($keys),
973
                    'status' => 422
974 2
                ]);
975 2
            }
976 15
        }
977
978 84
        return $clean;
979
    }
980
981
    /**
982
     * Validate a string.
983
     *
984
     * @param mixed $value The value to validate.
985
     * @param ValidationField $field The validation results to add.
986
     * @return string|Invalid Returns the valid string or **null** if validation fails.
987
     */
988 56
    protected function validateString($value, ValidationField $field) {
989 56
        if (is_string($value) || is_numeric($value)) {
990 54
            $value = $result = (string)$value;
991 54
        } else {
992 5
            $field->addTypeError('string');
993 5
            return Invalid::value();
994
        }
995
996 54
        $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...
997 54
        if (($minLength = $field->val('minLength', 0)) > 0 && mb_strlen($value) < $minLength) {
998 4
            if (!empty($field->getName()) && $minLength === 1) {
999 2
                $field->addError('missingField', ['messageCode' => '{field} is required.', 'status' => 422]);
1000 2
            } else {
1001 2
                $field->addError(
1002 2
                    'minLength',
1003
                    [
1004 2
                        'messageCode' => '{field} should be at least {minLength} {minLength,plural,character} long.',
1005 2
                        'minLength' => $minLength,
1006
                        'status' => 422
1007 2
                    ]
1008 2
                );
1009
            }
1010 4
        }
1011 54
        if (($maxLength = $field->val('maxLength', 0)) > 0 && mb_strlen($value) > $maxLength) {
1012 1
            $field->addError(
1013 1
                'maxLength',
1014
                [
1015 1
                    'messageCode' => '{field} is {overflow} {overflow,plural,characters} too long.',
1016 1
                    'maxLength' => $maxLength,
1017 1
                    'overflow' => mb_strlen($value) - $maxLength,
1018
                    'status' => 422
1019 1
                ]
1020 1
            );
1021 1
        }
1022 54
        if ($pattern = $field->val('pattern')) {
1023 4
            $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`';
1024
1025 4
            if (!preg_match($regex, $value)) {
1026 2
                $field->addError(
1027 2
                    'invalid',
1028
                    [
1029 2
                        'messageCode' => '{field} is in the incorrect format.',
1030
                        'status' => 422
1031 2
                    ]
1032 2
                );
1033 2
            }
1034 4
        }
1035 54
        if ($format = $field->val('format')) {
1036 15
            $type = $format;
1037
            switch ($format) {
1038 15
                case 'date-time':
1039 4
                    $result = $this->validateDatetime($result, $field);
1040 4
                    if ($result instanceof \DateTimeInterface) {
1041 4
                        $result = $result->format(\DateTime::RFC3339);
1042 4
                    }
1043 4
                    break;
1044 11
                case 'email':
1045 1
                    $result = filter_var($result, FILTER_VALIDATE_EMAIL);
1046 1
                    break;
1047 10
                case 'ipv4':
1048 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...
1049 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
1050 1
                    break;
1051 9
                case 'ipv6':
1052 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...
1053 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
1054 1
                    break;
1055 8
                case 'ip':
1056 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...
1057 1
                    $result = filter_var($result, FILTER_VALIDATE_IP);
1058 1
                    break;
1059 7
                case 'uri':
1060 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...
1061 7
                    $result = filter_var($result, FILTER_VALIDATE_URL, FILTER_FLAG_HOST_REQUIRED | FILTER_FLAG_SCHEME_REQUIRED);
1062 7
                    break;
1063
                default:
1064
                    trigger_error("Unrecognized format '$format'.", E_USER_NOTICE);
1065
            }
1066 15
            if ($result === false) {
1067 5
                $field->addTypeError($type);
1068 5
            }
1069 15
        }
1070
1071 54
        if ($field->isValid()) {
1072 46
            return $result;
1073
        } else {
1074 12
            return Invalid::value();
1075
        }
1076
    }
1077
1078
    /**
1079
     * Validate a unix timestamp.
1080
     *
1081
     * @param mixed $value The value to validate.
1082
     * @param ValidationField $field The field being validated.
1083
     * @return int|Invalid Returns a valid timestamp or invalid if the value doesn't validate.
1084
     */
1085 7
    protected function validateTimestamp($value, ValidationField $field) {
1086 7
        if (is_numeric($value) && $value > 0) {
1087 2
            $result = (int)$value;
1088 7
        } elseif (is_string($value) && $ts = strtotime($value)) {
1089 1
            $result = $ts;
1090 1
        } else {
1091 4
            $field->addTypeError('timestamp');
1092 4
            $result = Invalid::value();
1093
        }
1094 7
        return $result;
1095
    }
1096
1097
    /**
1098
     * Validate a null value.
1099
     *
1100
     * @param mixed $value The value to validate.
1101
     * @param ValidationField $field The error collector for the field.
1102
     * @return null|Invalid Returns **null** or invalid.
1103
     */
1104
    protected function validateNull($value, ValidationField $field) {
1105
        if ($value === null) {
1106
            return null;
1107
        }
1108
        $field->addError('invalid', ['messageCode' => '{field} should be null.', 'status' => 422]);
1109
        return Invalid::value();
1110
    }
1111
1112
    /**
1113
     * Validate a value against an enum.
1114
     *
1115
     * @param mixed $value The value to test.
1116
     * @param ValidationField $field The validation object for adding errors.
1117
     * @return mixed|Invalid Returns the value if it is one of the enumerated values or invalid otherwise.
1118
     */
1119 130
    protected function validateEnum($value, ValidationField $field) {
1120 130
        $enum = $field->val('enum');
1121 130
        if (empty($enum)) {
1122 129
            return $value;
1123
        }
1124
1125 1
        if (!in_array($value, $enum, true)) {
1126 1
            $field->addError(
1127 1
                'invalid',
1128
                [
1129 1
                    'messageCode' => '{field} must be one of: {enum}.',
1130 1
                    'enum' => $enum,
1131
                    'status' => 422
1132 1
                ]
1133 1
            );
1134 1
            return Invalid::value();
1135
        }
1136 1
        return $value;
1137
    }
1138
1139
    /**
1140
     * Call all of the filters attached to a field.
1141
     *
1142
     * @param mixed $value The field value being filtered.
1143
     * @param ValidationField $field The validation object.
1144
     * @return mixed Returns the filtered value. If there are no filters for the field then the original value is returned.
1145
     */
1146 132
    protected function callFilters($value, ValidationField $field) {
1147
        // Strip array references in the name except for the last one.
1148 132
        $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName());
1149 132
        if (!empty($this->filters[$key])) {
1150 1
            foreach ($this->filters[$key] as $filter) {
1151 1
                $value = call_user_func($filter, $value, $field);
1152 1
            }
1153 1
        }
1154 132
        return $value;
1155
    }
1156
1157
    /**
1158
     * Call all of the validators attached to a field.
1159
     *
1160
     * @param mixed $value The field value being validated.
1161
     * @param ValidationField $field The validation object to add errors.
1162
     */
1163 130
    protected function callValidators($value, ValidationField $field) {
1164 130
        $valid = true;
1165
1166
        // Strip array references in the name except for the last one.
1167 130
        $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName());
1168 130
        if (!empty($this->validators[$key])) {
1169 2
            foreach ($this->validators[$key] as $validator) {
1170 2
                $r = call_user_func($validator, $value, $field);
1171
1172 2
                if ($r === false || Invalid::isInvalid($r)) {
1173 1
                    $valid = false;
1174 1
                }
1175 2
            }
1176 2
        }
1177
1178
        // Add an error on the field if the validator hasn't done so.
1179 130
        if (!$valid && $field->isValid()) {
1180
            $field->addError('invalid', ['messageCode' => '{field} is invalid.', 'status' => 422]);
1181
        }
1182 130
    }
1183
1184
    /**
1185
     * Specify data which should be serialized to JSON.
1186
     *
1187
     * This method specifically returns data compatible with the JSON schema format.
1188
     *
1189
     * @return mixed Returns data which can be serialized by **json_encode()**, which is a value of any type other than a resource.
1190
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
1191
     * @link http://json-schema.org/
1192
     */
1193
    public function jsonSerialize() {
1194 14
        $fix = function ($schema) use (&$fix) {
1195 14
            if ($schema instanceof Schema) {
1196 1
                return $schema->jsonSerialize();
1197
            }
1198
1199 14
            if (!empty($schema['type'])) {
1200
                // Swap datetime and timestamp to other types with formats.
1201 13
                if ($schema['type'] === 'datetime') {
1202 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...
1203 3
                    $schema['format'] = 'date-time';
1204 13
                } elseif ($schema['type'] === 'timestamp') {
1205 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...
1206 3
                    $schema['format'] = 'timestamp';
1207 3
                }
1208 13
            }
1209
1210 14
            if (!empty($schema['items'])) {
1211 4
                $schema['items'] = $fix($schema['items']);
1212 4
            }
1213 14
            if (!empty($schema['properties'])) {
1214 10
                $properties = [];
1215 10
                foreach ($schema['properties'] as $key => $property) {
1216 10
                    $properties[$key] = $fix($property);
1217 10
                }
1218 10
                $schema['properties'] = $properties;
1219 10
            }
1220
1221 14
            return $schema;
1222 14
        };
1223
1224 14
        $result = $fix($this->schema);
1225
1226 14
        return $result;
1227
    }
1228
1229
    /**
1230
     * Look up a type based on its alias.
1231
     *
1232
     * @param string $alias The type alias or type name to lookup.
1233
     * @return mixed
1234
     */
1235 150
    protected function getType($alias) {
1236 150
        if (isset(self::$types[$alias])) {
1237
            return $alias;
1238
        }
1239 150
        foreach (self::$types as $type => $aliases) {
1240 150
            if (in_array($alias, $aliases, true)) {
1241 150
                return $type;
1242
            }
1243 150
        }
1244 9
        return null;
1245
    }
1246
1247
    /**
1248
     * Get the class that's used to contain validation information.
1249
     *
1250
     * @return Validation|string Returns the validation class.
1251
     */
1252 132
    public function getValidationClass() {
1253 132
        return $this->validationClass;
1254
    }
1255
1256
    /**
1257
     * Set the class that's used to contain validation information.
1258
     *
1259
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
1260
     * @return $this
1261
     */
1262 1
    public function setValidationClass($class) {
1263 1
        if (!is_a($class, Validation::class, true)) {
1264
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
1265
        }
1266
1267 1
        $this->validationClass = $class;
1268 1
        return $this;
1269
    }
1270
1271
    /**
1272
     * Create a new validation instance.
1273
     *
1274
     * @return Validation Returns a validation object.
1275
     */
1276 132
    protected function createValidation() {
1277 132
        $class = $this->getValidationClass();
1278
1279 132
        if ($class instanceof Validation) {
1280 1
            $result = clone $class;
1281 1
        } else {
1282 132
            $result = new $class;
1283
        }
1284 132
        return $result;
1285
    }
1286
1287
    /**
1288
     * Check whether or not a value is an array or accessible like an array.
1289
     *
1290
     * @param mixed $value The value to check.
1291
     * @return bool Returns **true** if the value can be used like an array or **false** otherwise.
1292
     */
1293 87
    private function isArray($value) {
1294 87
        return is_array($value) || ($value instanceof \ArrayAccess && $value instanceof \Traversable);
1295
    }
1296
1297
    /**
1298
     * Cast a value to an array.
1299
     *
1300
     * @param \Traversable $value The value to convert.
1301
     * @return array Returns an array.
1302
     */
1303
    private function toArray(\Traversable $value) {
1304
        if ($value instanceof \ArrayObject) {
1305
            return $value->getArrayCopy();
1306
        }
1307
        return iterator_to_array($value);
1308
    }
1309
1310
    /**
1311
     * Return a sparse version of this schema.
1312
     *
1313
     * A sparse schema has no required properties.
1314
     *
1315
     * @return Schema Returns a new sparse schema.
1316
     */
1317 2
    public function withSparse() {
1318 2
        $sparseSchema = $this->withSparseInternal($this, new \SplObjectStorage());
1319 2
        return $sparseSchema;
1320
    }
1321
1322
    /**
1323
     * The internal implementation of `Schema::withSparse()`.
1324
     *
1325
     * @param array|Schema $schema The schema to make sparse.
1326
     * @param \SplObjectStorage $schemas Collected sparse schemas that have already been made.
1327
     * @return mixed
1328
     */
1329 2
    private function withSparseInternal($schema, \SplObjectStorage $schemas) {
1330 2
        if ($schema instanceof Schema) {
1331 2
            if ($schemas->contains($schema)) {
1332 1
                return $schemas[$schema];
1333
            } else {
1334 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...
1335 2
                $sparseSchema->schema = $schema->withSparseInternal($schema->schema, $schemas);
1336 2
                if ($id = $sparseSchema->getID()) {
1337
                    $sparseSchema->setID($id.'Sparse');
1338
                }
1339
1340 2
                return $sparseSchema;
1341
            }
1342
        }
1343
1344 2
        unset($schema['required']);
1345
1346 2
        if (isset($schema['items'])) {
1347 1
            $schema['items'] = $this->withSparseInternal($schema['items'], $schemas);
1348 1
        }
1349 2
        if (isset($schema['properties'])) {
1350 2
            foreach ($schema['properties'] as $name => &$property) {
1351 2
                $property = $this->withSparseInternal($property, $schemas);
1352 2
            }
1353 2
        }
1354
1355 2
        return $schema;
1356
    }
1357
1358
    /**
1359
     * Filter a field's value using built in and custom filters.
1360
     *
1361
     * @param mixed $value The original value of the field.
1362
     * @param ValidationField $field The field information for the field.
1363
     * @return mixed Returns the filtered field or the original field value if there are no filters.
1364
     */
1365 132
    private function filterField($value, ValidationField $field) {
1366
        // Check for limited support for Open API style.
1367 132
        switch ($field->val('style')) {
1368 132
            case 'form':
1369 1
                if (is_string($value)) {
1370 1
                    $value = explode(',', $value);
1371 1
                }
1372 1
                break;
1373 132
            case 'spaceDelimited':
1374 1
                if (is_string($value)) {
1375 1
                    $value = explode(' ', $value);
1376 1
                }
1377 1
                break;
1378 132
            case 'pipeDelimited':
1379 1
                if (is_string($value)) {
1380 1
                    $value = explode('|', $value);
1381 1
                }
1382 1
                break;
1383 132
        }
1384
1385 132
        $value = $this->callFilters($value, $field);
1386
1387 132
        return $value;
1388
    }
1389
1390
    /**
1391
     * Whether a offset exists.
1392
     *
1393
     * @param mixed $offset An offset to check for.
1394
     * @return boolean true on success or false on failure.
1395
     * @link http://php.net/manual/en/arrayaccess.offsetexists.php
1396
     */
1397 3
    public function offsetExists($offset) {
1398 3
        return isset($this->schema[$offset]);
1399
    }
1400
1401
    /**
1402
     * Offset to retrieve.
1403
     *
1404
     * @param mixed $offset The offset to retrieve.
1405
     * @return mixed Can return all value types.
1406
     * @link http://php.net/manual/en/arrayaccess.offsetget.php
1407
     */
1408
    public function offsetGet($offset) {
1409
        return isset($this->schema[$offset]) ? $this->schema[$offset] : null;
1410
    }
1411
1412
    /**
1413
     * Offset to set.
1414
     *
1415
     * @param mixed $offset The offset to assign the value to.
1416
     * @param mixed $value The value to set.
1417
     * @link http://php.net/manual/en/arrayaccess.offsetset.php
1418
     */
1419
    public function offsetSet($offset, $value) {
1420
        $this->schema[$offset] = $value;
1421
    }
1422
1423
    /**
1424
     * Offset to unset.
1425
     *
1426
     * @param mixed $offset The offset to unset.
1427
     * @link http://php.net/manual/en/arrayaccess.offsetunset.php
1428
     */
1429
    public function offsetUnset($offset) {
1430
        unset($this->schema[$offset]);
1431
    }
1432
}
1433