Completed
Pull Request — master (#1)
by Todd
01:55
created

Schema::validateTimestamp()   B

Complexity

Conditions 5
Paths 3

Size

Total Lines 11
Code Lines 9

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 9
CRAP Score 5

Importance

Changes 0
Metric Value
dl 0
loc 11
ccs 9
cts 9
cp 1
rs 8.8571
c 0
b 0
f 0
cc 5
eloc 9
nc 3
nop 2
crap 5
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 {
14
    /**
15
     * Throw a notice when extraneous properties are encountered during validation.
16
     */
17
    const FLAG_EXTRA_PROPERTIES_NOTICE = 0x1;
18
19
    /**
20
     * Throw a ValidationException when extraneous properties are encountered during validation.
21
     */
22
    const FLAG_EXTRA_PROPERTIES_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
        'a' => 'array',
31
        'o' => 'object',
32
        'i' => 'integer',
33
        'int' => 'integer',
34
        's' => 'string',
35
        'str' => 'string',
36
        'f' => 'number',
37
        'float' => 'number',
38
        'b' => 'boolean',
39
        'bool' => 'boolean',
40
        'ts' => 'timestamp',
41
        'dt' => 'datetime'
42
    ];
43
44
    private $schema = [];
45
46
    /**
47
     * @var int A bitwise combination of the various **Schema::FLAG_*** constants.
48
     */
49
    private $flags = 0;
50
51
    /**
52
     * @var array An array of callbacks that will custom validate the schema.
53
     */
54
    private $validators = [];
55
56
    /**
57
     * @var string|Validation The name of the class or an instance that will be cloned.
58
     */
59
    private $validationClass = Validation::class;
60
61
62
    /// Methods ///
63
64
    /**
65
     * Initialize an instance of a new {@link Schema} class.
66
     *
67
     * @param array $schema The array schema to validate against.
68
     */
69 132
    public function __construct($schema = []) {
70 132
        $this->schema = $this->parse($schema);
71 132
    }
72
73
    /**
74
     * Grab the schema's current description.
75
     *
76
     * @return string
77
     */
78 1
    public function getDescription() {
79 1
        return isset($this->schema['description']) ? $this->schema['description'] : '';
80
    }
81
82
    /**
83
     * Set the description for the schema.
84
     *
85
     * @param string $description The new description.
86
     * @throws \InvalidArgumentException Throws an exception when the provided description is not a string.
87
     * @return Schema
88
     */
89 2
    public function setDescription($description) {
90 2
        if (is_string($description)) {
91 1
            $this->schema['description'] = $description;
92 1
        } else {
93 1
            throw new \InvalidArgumentException("The description is not a valid string.", 500);
94
        }
95
96 1
        return $this;
97
    }
98
99
    /**
100
     * Return the validation flags.
101
     *
102
     * @return int Returns a bitwise combination of flags.
103
     */
104 1
    public function getFlags() {
105 1
        return $this->flags;
106
    }
107
108
    /**
109
     * Set the validation flags.
110
     *
111
     * @param int $flags One or more of the **Schema::FLAG_*** constants.
112
     * @return Schema Returns the current instance for fluent calls.
113
     */
114 8
    public function setFlags($flags) {
115 8
        if (!is_int($flags)) {
116 1
            throw new \InvalidArgumentException('Invalid flags.', 500);
117
        }
118 7
        $this->flags = $flags;
119
120 7
        return $this;
121
    }
122
123
    /**
124
     * Whether or not the schema has a flag (or combination of flags).
125
     *
126
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
127
     * @return bool Returns **true** if all of the flags are set or **false** otherwise.
128
     */
129 8
    public function hasFlag($flag) {
130 8
        return ($this->flags & $flag) === $flag;
131
    }
132
133
    /**
134
     * Set a flag.
135
     *
136
     * @param int $flag One or more of the **Schema::VALIDATE_*** constants.
137
     * @param bool $value Either true or false.
138
     * @return $this
139
     */
140 1
    public function setFlag($flag, $value) {
141 1
        if ($value) {
142 1
            $this->flags = $this->flags | $flag;
143 1
        } else {
144 1
            $this->flags = $this->flags & ~$flag;
145
        }
146 1
        return $this;
147
    }
148
149
    /**
150
     * Merge a schema with this one.
151
     *
152
     * @param Schema $schema A scheme instance. Its parameters will be merged into the current instance.
153
     */
154 2
    public function merge(Schema $schema) {
155
        $fn = function (array &$target, array $source) use (&$fn) {
156 2
            foreach ($source as $key => $val) {
157 2
                if (is_array($val) && array_key_exists($key, $target) && is_array($target[$key])) {
158 2
                    if (isset($val[0]) || isset($target[$key][0])) {
159
                        // This is a numeric array, so just do a merge.
160 1
                        $merged = array_merge($target[$key], $val);
161 1
                        if (is_string($merged[0])) {
162 1
                            $merged = array_keys(array_flip($merged));
163 1
                        }
164 1
                        $target[$key] = $merged;
165 1
                    } else {
166 2
                        $target[$key] = $fn($target[$key], $val);
167
                    }
168 2
                } else {
169 2
                    $target[$key] = $val;
170
                }
171 2
            }
172
173 2
            return $target;
174 2
        };
175
176 2
        $fn($this->schema, $schema->getSchemaArray());
177 2
    }
178
179
    /**
180
     * Returns the internal schema array.
181
     *
182
     * @return array
183
     * @see Schema::jsonSerialize()
184
     */
185 11
    public function getSchemaArray() {
186 11
        return $this->schema;
187
    }
188
189
    /**
190
     * Parse a schema in short form into a full schema array.
191
     *
192
     * @param array $arr The array to parse into a schema.
193
     * @return array The full schema array.
194
     * @throws \InvalidArgumentException Throws an exception when an item in the schema is invalid.
195
     */
196 132
    protected function parse(array $arr) {
197 132
        if (empty($arr)) {
198
            // An empty schema validates to anything.
199 6
            return [];
200 127
        } elseif (isset($arr['type'])) {
201
            // This is a long form schema and can be parsed as the root.
202 6
            return $this->parseNode($arr);
203
        } else {
204
            // Check for a root schema.
205 123
            $value = reset($arr);
206 123
            $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...
207 123
            if (is_int($key)) {
208 74
                $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...
209 74
                $value = null;
210 74
            }
211 123
            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...
212 123
            if (empty($name)) {
213 42
                return $this->parseNode($param, $value);
214
            }
215
        }
216
217
        // If we are here then this is n object schema.
218 83
        list($properties, $required) = $this->parseProperties($arr);
219
220
        $result = [
221 83
            'type' => 'object',
222 83
            'properties' => $properties,
223
            'required' => $required
224 83
        ];
225
226 83
        return array_filter($result);
227
    }
228
229
    /**
230
     * Parse a schema node.
231
     *
232
     * @param array $node The node to parse.
233
     * @param mixed $value Additional information from the node.
234
     * @return array Returns a JSON schema compatible node.
235
     */
236 127
    private function parseNode($node, $value = null) {
237 127
        if (is_array($value)) {
238
            // The value describes a bit more about the schema.
239 50
            switch ($node['type']) {
240 50
                case 'array':
241 6
                    if (isset($value['items'])) {
242
                        // The value includes array schema information.
243 1
                        $node = array_replace($node, $value);
244 1
                    } else {
245 5
                        $node['items'] = $this->parse($value);
246
                    }
247 6
                    break;
248 44
                case 'object':
249
                    // The value is a schema of the object.
250 9
                    if (isset($value['properties'])) {
251 1
                        list($node['properties']) = $this->parseProperties($value['properties']);
252 1
                    } else {
253 9
                        list($node['properties'], $required) = $this->parseProperties($value);
254 9
                        if (!empty($required)) {
255 9
                            $node['required'] = $required;
256 9
                        }
257
                    }
258 9
                    break;
259 36
                default:
260 36
                    $node = array_replace($node, $value);
261 36
                    break;
262 50
            }
263 127
        } elseif (is_string($value)) {
264 77
            if ($node['type'] === 'array' && $arrType = $this->getType($value)) {
265 2
                $node['items'] = ['type' => $arrType];
266 77
            } elseif (!empty($value)) {
267 22
                $node['description'] = $value;
268 22
            }
269 96
        } elseif ($value === null) {
270
            // Parse child elements.
271 22
            if ($node['type'] === 'array' && isset($node['items'])) {
272
                // The value includes array schema information.
273
                $node['items'] = $this->parse($node['items']);
274 22
            } elseif ($node['type'] === 'object' && isset($node['properties'])) {
275 4
                list($node['properties']) = $this->parseProperties($node['properties']);
276
277 4
            }
278 22
        }
279
280
281 127
        return $node;
282
    }
283
284
    /**
285
     * Parse the schema for an object's properties.
286
     *
287
     * @param array $arr An object property schema.
288
     * @return array Returns a schema array suitable to be placed in the **properties** key of a schema.
289
     */
290 85
    private function parseProperties(array $arr) {
291 85
        $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...
292 85
        $requiredProperties = [];
293 85
        foreach ($arr as $key => $value) {
294
            // Fix a schema specified as just a value.
295 85
            if (is_int($key)) {
296 61
                if (is_string($value)) {
297 61
                    $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...
298 61
                    $value = '';
299 61
                } else {
300
                    throw new \InvalidArgumentException("Schema at position $key is not a valid parameter.", 500);
301
                }
302 61
            }
303
304
            // The parameter is defined in the key.
305 85
            list($name, $param, $required) = $this->parseShortParam($key, $value);
306
307 85
            $node = $this->parseNode($param, $value);
308
309 85
            $properties[$name] = $node;
310 85
            if ($required) {
311 44
                $requiredProperties[] = $name;
312 44
            }
313 85
        }
314 85
        return array($properties, $requiredProperties);
315
    }
316
317
    /**
318
     * Parse a short parameter string into a full array parameter.
319
     *
320
     * @param string $key The short parameter string to parse.
321
     * @param array $value An array of other information that might help resolve ambiguity.
322
     * @return array Returns an array in the form `[string name, array param, bool required]`.
323
     * @throws \InvalidArgumentException Throws an exception if the short param is not in the correct format.
324
     */
325 125
    public function parseShortParam($key, $value = []) {
326
        // Is the parameter optional?
327 125
        if (substr($key, -1) === '?') {
328 59
            $required = false;
329 59
            $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...
330 59
        } else {
331 84
            $required = true;
332
        }
333
334
        // Check for a type.
335 125
        $parts = explode(':', $key);
336 125
        $name = $parts[0];
1 ignored issue
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 2 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...
337 125
        $type = !empty($parts[1]) && isset(self::$types[$parts[1]]) ? self::$types[$parts[1]] : null;
1 ignored issue
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 2 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...
338
339 125
        if ($value instanceof Schema) {
340 2
            if ($type === 'array') {
341 1
                $param = ['type' => $type, 'items' => $value];
342 1
            } else {
343 1
                $param = $value;
344
            }
345 125
        } elseif (isset($value['type'])) {
346 4
            $param = $value;
347
348 4
            if (!empty($type) && $type !== $param['type']) {
349
                throw new \InvalidArgumentException("Type mismatch between $type and {$param['type']} for field $name.", 500);
350
            }
351 4
        } else {
352 123
            if (empty($type) && !empty($parts[1])) {
353
                throw new \InvalidArgumentException("Invalid type {$parts[1]} for field $name.", 500);
354
            }
355 123
            $param = ['type' => $type];
356
357
            // Parsed required strings have a minimum length of 1.
358 123
            if ($type === 'string' && !empty($name) && $required) {
359 27
                $param['minLength'] = 1;
360 27
            }
361
        }
362
363 125
        return [$name, $param, $required];
364
    }
365
366
    /**
367
     * Add a custom validator to to validate the schema.
368
     *
369
     * @param string $fieldname The name of the field to validate, if any.
370
     *
371
     * If you are adding a validator to a deeply nested field then separate the path with dots.
372
     * @param callable $callback The callback to validate with.
373
     * @return Schema Returns `$this` for fluent calls.
374
     */
375 2
    public function addValidator($fieldname, callable $callback) {
376 2
        $this->validators[$fieldname][] = $callback;
377 2
        return $this;
378
    }
379
380
    /**
381
     * Require one of a given set of fields in the schema.
382
     *
383
     * @param array $required The field names to require.
384
     * @param string $fieldname The name of the field to attach to.
385
     * @param int $count The count of required items.
386
     * @return Schema Returns `$this` for fluent calls.
387
     */
388 1
    public function requireOneOf(array $required, $fieldname = '', $count = 1) {
389 1
        $result = $this->addValidator(
390 1
            $fieldname,
391
            function ($data, ValidationField $field) use ($required, $count) {
392 1
                $hasCount = 0;
1 ignored issue
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 2 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...
393 1
                $flattened = [];
394
395 1
                foreach ($required as $name) {
396 1
                    $flattened = array_merge($flattened, (array)$name);
397
398 1
                    if (is_array($name)) {
399
                        // This is an array of required names. They all must match.
400 1
                        $hasCountInner = 0;
401 1
                        foreach ($name as $nameInner) {
402 1
                            if (isset($data[$nameInner]) && $data[$nameInner]) {
403 1
                                $hasCountInner++;
404 1
                            } else {
405 1
                                break;
406
                            }
407 1
                        }
408 1
                        if ($hasCountInner >= count($name)) {
409 1
                            $hasCount++;
410 1
                        }
411 1
                    } elseif (isset($data[$name]) && $data[$name]) {
412 1
                        $hasCount++;
413 1
                    }
414
415 1
                    if ($hasCount >= $count) {
416 1
                        return true;
417
                    }
418 1
                }
419
420 1
                if ($count === 1) {
421 1
                    $message = 'One of {required} are required.';
422 1
                } else {
423
                    $message = '{count} of {required} are required.';
424
                }
425
426 1
                $field->addError('missingField', [
427 1
                    'messageCode' => $message,
428 1
                    'required' => $required,
429
                    'count' => $count
430 1
                ]);
431 1
                return false;
432
            }
433 1
        );
434
435 1
        return $result;
436
    }
437
438
    /**
439
     * Validate data against the schema.
440
     *
441
     * @param mixed $data The data to validate.
442
     * @param bool $sparse Whether or not this is a sparse validation.
443
     * @return mixed Returns a cleaned version of the data.
444
     * @throws ValidationException Throws an exception when the data does not validate against the schema.
445
     */
446 106
    public function validate($data, $sparse = false) {
447 106
        $validation = new ValidationField($this->createValidation(), $this->schema, '');
448
449 106
        $clean = $this->validateField($data, $validation, $sparse);
450
451 104
        if (!$validation->getValidation()->isValid()) {
452 55
            throw new ValidationException($validation->getValidation());
453
        }
454
455 60
        return $clean;
456
    }
457
458
    /**
459
     * Validate data against the schema and return the result.
460
     *
461
     * @param mixed $data The data to validate.
462
     * @param bool $sparse Whether or not to do a sparse validation.
463
     * @return bool Returns true if the data is valid. False otherwise.
464
     */
465 33
    public function isValid($data, $sparse = false) {
466
        try {
467 33
            $this->validate($data, $sparse);
468 23
            return true;
469 16
        } catch (ValidationException $ex) {
470 16
            return false;
471
        }
472
    }
473
474
    /**
475
     * Validate a field.
476
     *
477
     * @param mixed $value The value to validate.
478
     * @param ValidationField $field A validation object to add errors to.
479
     * @param bool $sparse Whether or not this is a sparse validation.
480
     * @return mixed Returns a clean version of the value with all extra fields stripped out.
481
     */
482 106
    private function validateField($value, ValidationField $field, $sparse = false) {
483 106
        $result = $value;
484 106
        if ($field->getField() instanceof Schema) {
485
            try {
486 1
                $result = $field->getField()->validate($value, $sparse);
487 1
            } catch (ValidationException $ex) {
488
                // The validation failed, so merge the validations together.
489 1
                $field->getValidation()->merge($ex->getValidation(), $field->getName());
490
            }
491 1
        } else {
492
            // Validate the field's type.
493 106
            $type = $field->getType();
494
            switch ($type) {
495 106
                case 'boolean':
496 19
                    $result = $this->validateBoolean($value, $field);
497 19
                    break;
498 94
                case 'integer':
499 20
                    $result = $this->validateInteger($value, $field);
500 20
                    break;
501 93
                case 'number':
502 7
                    $result = $this->validateNumber($value, $field);
503 7
                    break;
504 93
                case 'string':
505 49
                    $result = $this->validateString($value, $field);
506 49
                    break;
507 77
                case 'timestamp':
508 6
                    $result = $this->validateTimestamp($value, $field);
509 6
                    break;
510 77
                case 'datetime':
511 7
                    $result = $this->validateDatetime($value, $field);
512 7
                    break;
513 75
                case 'array':
514 10
                    $result = $this->validateArray($value, $field, $sparse);
515 10
                    break;
516 74
                case 'object':
517 73
                    $result = $this->validateObject($value, $field, $sparse);
518 71
                    break;
519 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...
520
                    // No type was specified so we are valid.
521 2
                    $result = $value;
522 2
                    break;
523
                default:
524
                    throw new \InvalidArgumentException("Unrecognized type $type.", 500);
525
            }
526 106
            if ($result !== null && !$this->validateEnum($value, $field)) {
527 1
                $result = null;
528 1
            }
529
        }
530
531
        // Validate a custom field validator.
532 106
        if ($result !== null) {
533 105
            $this->callValidators($result, $field);
534 105
        }
535
536 106
        return $result;
537
    }
538
539
    /**
540
     * Call all of the validators attached to a field.
541
     *
542
     * @param mixed $value The field value being validated.
543
     * @param ValidationField $field The validation object to add errors.
544
     */
545 105
    private function callValidators($value, ValidationField $field) {
546
        // Strip array references in the name except for the last one.
547 105
        $key = preg_replace(['`\[\d+\]$`', '`\[\d+\]`'], ['[]', ''], $field->getName());
548 105
        if (!empty($this->validators[$key])) {
549 2
            foreach ($this->validators[$key] as $validator) {
550 2
                call_user_func($validator, $value, $field);
551 2
            }
552 2
        }
553 105
    }
554
555
    /**
556
     * Validate an array.
557
     *
558
     * @param mixed $value The value to validate.
559
     * @param ValidationField $field The validation results to add.
560
     * @param bool $sparse Whether or not this is a sparse validation.
561
     * @return array|null Returns an array or **null** if validation fails.
562
     */
563 10
    private function validateArray($value, ValidationField $field, $sparse = false) {
564 10
        if (!is_array($value) || (count($value) > 0 && !array_key_exists(0, $value))) {
565 6
            $field->addTypeError('array');
566 6
            return null;
567 5
        } elseif (empty($value)) {
568 1
            return [];
569 5
        } elseif ($field->val('items') !== null) {
570 4
            $result = [];
571
572
            // Validate each of the types.
573 4
            $itemValidation = new ValidationField(
574 4
                $field->getValidation(),
575 4
                $field->val('items'),
576
                ''
577 4
            );
578
579 4
            foreach ($value as $i => &$item) {
580 4
                $itemValidation->setName($field->getName()."[{$i}]");
581 4
                $validItem = $this->validateField($item, $itemValidation, $sparse);
582 4
                if ($validItem !== null) {
583 4
                    $result[] = $validItem;
584 4
                }
585 4
            }
586 4
        } else {
587
            // Cast the items into a proper numeric array.
588 1
            $result = array_values($value);
589
        }
590
591 5
        return $result;
592
    }
593
594
    /**
595
     * Validate a boolean value.
596
     *
597
     * @param mixed $value The value to validate.
598
     * @param ValidationField $field The validation results to add.
599
     * @return bool|null Returns the cleaned value or **null** if validation fails.
600
     */
601 19
    private function validateBoolean($value, ValidationField $field) {
602 19
        $value = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE);
603 19
        if ($value === null) {
604 3
            $field->addTypeError('boolean');
605 3
        }
606 19
        return $value;
607
    }
608
609
    /**
610
     * Validate a date time.
611
     *
612
     * @param mixed $value The value to validate.
613
     * @param ValidationField $field The validation results to add.
614
     * @return \DateTimeInterface|null Returns the cleaned value or **null** if it isn't valid.
615
     */
616 11
    private function validateDatetime($value, ValidationField $field) {
617 11
        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...
618
            // do nothing, we're good
619 11
        } elseif (is_string($value) && $value !== '') {
620
            try {
621 7
                $dt = new \DateTimeImmutable($value);
622 5
                if ($dt) {
623 5
                    $value = $dt;
624 5
                } else {
625
                    $value = null;
626
                }
627 7
            } catch (\Exception $ex) {
628 2
                $value = null;
629
            }
630 10
        } elseif (is_int($value) && $value > 0) {
631 1
            $value = new \DateTimeImmutable('@'.(string)round($value));
632 1
        } else {
633 2
            $value = null;
634
        }
635
636 11
        if ($value === null) {
637 4
            $field->addTypeError('datetime');
638 4
        }
639 11
        return $value;
640
    }
641
642
    /**
643
     * Validate a float.
644
     *
645
     * @param mixed $value The value to validate.
646
     * @param ValidationField $field The validation results to add.
647
     * @return float|int|null Returns a number or **null** if validation fails.
648
     */
649 7
    private function validateNumber($value, ValidationField $field) {
650 7
        $result = filter_var($value, FILTER_VALIDATE_FLOAT);
651 7
        if ($result === false) {
652 4
            $field->addTypeError('number');
653 4
            return null;
654
        }
655 3
        return $result;
656
    }
657
658
    /**
659
     * Validate and integer.
660
     *
661
     * @param mixed $value The value to validate.
662
     * @param ValidationField $field The validation results to add.
663
     * @return int|null Returns the cleaned value or **null** if validation fails.
664
     */
665 20
    private function validateInteger($value, ValidationField $field) {
666 20
        $result = filter_var($value, FILTER_VALIDATE_INT);
667
668 20
        if ($result === false) {
669 8
            $field->addTypeError('integer');
670 8
            return null;
671
        }
672 15
        return $result;
673
    }
674
675
    /**
676
     * Validate an object.
677
     *
678
     * @param mixed $value The value to validate.
679
     * @param ValidationField $field The validation results to add.
680
     * @param bool $sparse Whether or not this is a sparse validation.
681
     * @return object|null Returns a clean object or **null** if validation fails.
0 ignored issues
show
Documentation introduced by
Should the return type not be null|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...
682
     */
683 73
    private function validateObject($value, ValidationField $field, $sparse = false) {
684 73
        if (!is_array($value) || isset($value[0])) {
685 6
            $field->addTypeError('object');
686 6
            return null;
687 73
        } elseif (is_array($field->val('properties'))) {
688
            // Validate the data against the internal schema.
689 73
            $value = $this->validateProperties($value, $field, $sparse);
690 71
        }
691 71
        return $value;
692
    }
693
694
    /**
695
     * Validate data against the schema and return the result.
696
     *
697
     * @param array $data The data to validate.
698
     * @param ValidationField $field This argument will be filled with the validation result.
699
     * @param bool $sparse Whether or not this is a sparse validation.
700
     * @return array Returns a clean array with only the appropriate properties and the data coerced to proper types.
701
     */
702 73
    private function validateProperties(array $data, ValidationField $field, $sparse = false) {
703 73
        $properties = $field->val('properties', []);
704 73
        $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...
705 73
        $keys = array_keys($data);
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 7 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...
706 73
        $keys = array_combine(array_map('strtolower', $keys), $keys);
0 ignored issues
show
Coding Style introduced by
Equals sign not aligned with surrounding assignments; expected 7 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...
707
708 73
        $propertyField = new ValidationField($field->getValidation(), [], null);
709
710
        // Loop through the schema fields and validate each one.
711 73
        $clean = [];
712 73
        foreach ($properties as $propertyName => $property) {
713
            $propertyField
714 73
                ->setField($property)
715 73
                ->setName(ltrim($field->getName().".$propertyName", '.'));
716
717 73
            $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...
718 73
            $isRequired = isset($required[$propertyName]);
719
720
            // First check for required fields.
721 73
            if (!array_key_exists($lName, $keys)) {
722
                // A sparse validation can leave required fields out.
723 18
                if ($isRequired && !$sparse) {
724 6
                    $propertyField->addError('missingField', ['messageCode' => '{field} is required.']);
725 6
                }
726 73
            } elseif ($data[$keys[$lName]] === null) {
727 17
                if ($isRequired) {
728 9
                    $propertyField->addError('missingField', ['messageCode' => '{field} cannot be null.']);
729 9
                } else {
730 8
                    $clean[$propertyName] = null;
731
                }
732 17
            } else {
733 65
                $clean[$propertyName] = $this->validateField($data[$keys[$lName]], $propertyField, $sparse);
734
            }
735
736 73
            unset($keys[$lName]);
737 73
        }
738
739
        // Look for extraneous properties.
740 73
        if (!empty($keys)) {
741 7
            if ($this->hasFlag(Schema::FLAG_EXTRA_PROPERTIES_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...
742 2
                $msg = sprintf("%s has unexpected field(s): %s.", $field->getName() ?: 'value', implode(', ', $keys));
743 2
                trigger_error($msg, E_USER_NOTICE);
744
            }
745
746 5
            if ($this->hasFlag(Schema::FLAG_EXTRA_PROPERTIES_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...
747 2
                $field->addError('invalid', [
748 2
                    'messageCode' => '{field} has {extra,plural,an unexpected field,unexpected fields}: {extra}.',
749 2
                    'extra' => array_values($keys),
750
                    'status' => 422
751 2
                ]);
752 2
            }
753 5
        }
754
755 71
        return $clean;
756
    }
757
758
    /**
759
     * Validate a string.
760
     *
761
     * @param mixed $value The value to validate.
762
     * @param ValidationField $field The validation results to add.
763
     * @return string|null Returns the valid string or **null** if validation fails.
764
     */
765 49
    private function validateString($value, ValidationField $field) {
766 49
        if (is_string($value) || is_numeric($value)) {
767 47
            $value = $result = (string)$value;
768 47
        } else {
769 3
            $field->addTypeError('string');
770 3
            return null;
771
        }
772
773 47
        if (($minLength = $field->val('minLength', 0)) > 0 && mb_strlen($value) < $minLength) {
774 4
            if (!empty($field->getName()) && $minLength === 1) {
775 2
                $field->addError('missingField', ['messageCode' => '{field} is required.', 'status' => 422]);
776 2
            } else {
777 2
                $field->addError(
778 2
                    'minLength',
779
                    [
780 2
                        'messageCode' => '{field} should be at least {minLength} {minLength,plural,character} long.',
781 2
                        'minLength' => $minLength,
782
                        'status' => 422
783 2
                    ]
784 2
                );
785
            }
786 4
            $result = null;
787 4
        }
788 47
        if (($maxLength = $field->val('maxLength', 0)) > 0 && mb_strlen($value) > $maxLength) {
789 1
            $field->addError(
790 1
                'maxLength',
791
                [
792 1
                    'messageCode' => '{field} is {overflow} {overflow,plural,characters} too long.',
793 1
                    'maxLength' => $maxLength,
794 1
                    'overflow' => mb_strlen($value) - $maxLength,
795
                    'status' => 422
796 1
                ]
797 1
            );
798 1
            $result = null;
799 1
        }
800 47
        if ($pattern = $field->val('pattern')) {
801 4
            $regex = '`'.str_replace('`', preg_quote('`', '`'), $pattern).'`';
802
803 4
            if (!preg_match($regex, $value)) {
804 2
                $field->addError(
805 2
                    'invalid',
806
                    [
807 2
                        'messageCode' => '{field} is in the incorrect format.',
808
                        'status' => 422
809 2
                    ]
810 2
                );
811 2
            }
812 4
            $result = null;
813 4
        }
814 47
        if ($format = $field->val('format')) {
815 15
            $type = $format;
816
            switch ($format) {
817 15
                case 'date-time':
818 4
                    $result = $this->validateDatetime($result, $field);
819 4
                    if ($result instanceof \DateTimeInterface) {
820 4
                        $result = $result->format(\DateTime::RFC3339);
821 4
                    }
822 4
                    break;
823 11
                case 'email':
824 1
                    $result = filter_var($result, FILTER_VALIDATE_EMAIL);
825 1
                    break;
826 10
                case 'ipv4':
827 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...
828 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4);
829 1
                    break;
830 9
                case 'ipv6':
831 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...
832 1
                    $result = filter_var($result, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6);
833 1
                    break;
834 8
                case 'ip':
835 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...
836 1
                    $result = filter_var($result, FILTER_VALIDATE_IP);
837 1
                    break;
838 7
                case 'uri':
839 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...
840 7
                    $result = filter_var($result, FILTER_VALIDATE_URL, FILTER_FLAG_HOST_REQUIRED | FILTER_FLAG_SCHEME_REQUIRED);
841 7
                    break;
842
                default:
843
                    trigger_error("Unrecognized format '$format'.", E_USER_NOTICE);
844
            }
845 15
            if ($result === false) {
846 5
                $field->addTypeError($type);
847 5
            }
848 15
        }
849
850 47
        return $result;
851
    }
852
853
    /**
854
     * Validate a unix timestamp.
855
     *
856
     * @param mixed $value The value to validate.
857
     * @param ValidationField $field The field being validated.
858
     * @return int|null Returns a valid timestamp or **null** if the value doesn't validate.
859
     */
860 6
    private function validateTimestamp($value, ValidationField $field) {
861 6
        if (is_numeric($value) && $value > 0) {
862 1
            $result = (int)$value;
863 6
        } elseif (is_string($value) && $ts = strtotime($value)) {
864 1
            $result = $ts;
865 1
        } else {
866 4
            $field->addTypeError('timestamp');
867 4
            $result = null;
868
        }
869 6
        return $result;
870
    }
871
872
    /**
873
     * Validate a value against an enum.
874
     *
875
     * @param mixed $value The value to test.
876
     * @param ValidationField $field The validation object for adding errors.
877
     * @return bool Returns **true** if the value one of the enumerated values or **false** otherwise.
878
     */
879 105
    private function validateEnum($value, ValidationField $field) {
880 105
        $enum = $field->val('enum');
881 105
        if (empty($enum)) {
882 104
            return true;
883
        }
884
885 1
        if (!in_array($value, $enum, true)) {
886 1
            $field->addError(
887 1
                'invalid',
888
                [
889 1
                    'messageCode' => '{field} must be one of: {enum}.',
890 1
                    'enum' => $enum,
891
                    'status' => 422
892 1
                ]
893 1
            );
894 1
            return false;
895
        }
896 1
        return true;
897
    }
898
899
    /**
900
     * Specify data which should be serialized to JSON.
901
     *
902
     * This method specifically returns data compatible with the JSON schema format.
903
     *
904
     * @return mixed Returns data which can be serialized by **json_encode()**, which is a value of any type other than a resource.
905
     * @link http://php.net/manual/en/jsonserializable.jsonserialize.php
906
     * @link http://json-schema.org/
907
     */
908
    public function jsonSerialize() {
909 15
        $fix = function ($schema) use (&$fix) {
910 15
            if ($schema instanceof Schema) {
911 1
                return $schema->jsonSerialize();
912
            }
913
914 15
            if (!empty($schema['type'])) {
915
                // Swap datetime and timestamp to other types with formats.
916 14
                if ($schema['type'] === 'datetime') {
917 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...
918 3
                    $schema['format'] = 'date-time';
919 14
                } elseif ($schema['type'] === 'timestamp') {
920 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...
921 3
                    $schema['format'] = 'timestamp';
922 3
                }
923 14
            }
924
925 15
            if (!empty($schema['items'])) {
926 4
                $schema['items'] = $fix($schema['items']);
927 4
            }
928 15
            if (!empty($schema['properties'])) {
929 11
                $properties = [];
930 11
                foreach ($schema['properties'] as $key => $property) {
931 11
                    $properties[$key] = $fix($property);
932 11
                }
933 11
                $schema['properties'] = $properties;
934 11
            }
935
936 15
            return $schema;
937 15
        };
938
939 15
        $result = $fix($this->schema);
940
941 15
        return $result;
942
    }
943
944
    /**
945
     * Look up a type based on its alias.
946
     *
947
     * @param string $alias The type alias or type name to lookup.
948
     * @return mixed
949
     */
950 9
    private function getType($alias) {
951 9
        if (isset(self::$types[$alias])) {
952 2
            $type = self::$types[$alias];
953 9
        } elseif (array_search($alias, self::$types) !== false) {
954
            $type = $alias;
955
        } else {
956 8
            $type = null;
957
        }
958 9
        return $type;
959
    }
960
961
    /**
962
     * Get the class that's used to contain validation information.
963
     *
964
     * @return Validation|string Returns the validation class.
965
     */
966 106
    public function getValidationClass() {
967 106
        return $this->validationClass;
968
    }
969
970
    /**
971
     * Set the class that's used to contain validation information.
972
     *
973
     * @param Validation|string $class Either the name of a class or a class that will be cloned.
974
     * @return $this
975
     */
976 1
    public function setValidationClass($class) {
977 1
        if (!is_a($class, Validation::class, true)) {
978
            throw new \InvalidArgumentException("$class must be a subclass of ".Validation::class, 500);
979
        }
980
981 1
        $this->validationClass = $class;
982 1
        return $this;
983
    }
984
985
    /**
986
     * Create a new validation instance.
987
     *
988
     * @return Validation Returns a validation object.
989
     */
990 106
    protected function createValidation() {
991 106
        $class = $this->getValidationClass();
992
993 106
        if ($class instanceof Validation) {
994 1
            $result = clone $class;
995 1
        } else {
996 106
            $result = new $class;
997
        }
998 106
        return $result;
999
    }
1000
}
1001