Completed
Push — master ( b8b8ad...305c23 )
by Todd
01:50
created

Schema::__construct()   A

Complexity

Conditions 1
Paths 1

Size

Total Lines 3
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 1

Importance

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