Skip to content

Commit 3e7a8c4

Browse files
committed
feat: add more support for draft06 schema
1 parent ff00ecc commit 3e7a8c4

File tree

7 files changed

+298
-9
lines changed

7 files changed

+298
-9
lines changed
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JsonSchema\Constraints\Drafts\Draft06;
6+
7+
use JsonSchema\ConstraintError;
8+
use JsonSchema\Constraints\ConstraintInterface;
9+
use JsonSchema\Entity\ErrorBagProxy;
10+
use JsonSchema\Entity\JsonPointer;
11+
use JsonSchema\Exception\ValidationException;
12+
13+
class AnyOfConstraint implements ConstraintInterface
14+
{
15+
use ErrorBagProxy;
16+
17+
/** @var Factory */
18+
private $factory;
19+
20+
public function __construct(?Factory $factory = null)
21+
{
22+
$this->factory = $factory ?: new Factory();
23+
$this->initialiseErrorBag($this->factory);
24+
}
25+
26+
public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void
27+
{
28+
if (!property_exists($schema, 'anyOf')) {
29+
return;
30+
}
31+
32+
foreach ($schema->anyOf as $anyOfSchema) {
33+
$schemaConstraint = $this->factory->createInstanceFor('schema');
34+
35+
try {
36+
$schemaConstraint->check($value, $anyOfSchema, $path, $i);
37+
38+
if ($schemaConstraint->isValid()) {
39+
return;
40+
}
41+
} catch (ValidationException $e) {}
42+
43+
$this->addError(ConstraintError::ANY_OF(), $path);
44+
}
45+
46+
}
47+
}

src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,12 +17,12 @@ public function __construct()
1717
public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void
1818
{
1919
// Apply defaults
20-
// Required keyword
20+
$this->checkForKeyword('required', $value, $schema, $path, $i);
2121
$this->checkForKeyword('type', $value, $schema, $path, $i);
2222
// Not
2323
$this->checkForKeyword('dependencies', $value, $schema, $path, $i);
2424
// allof
25-
// anyof
25+
$this->checkForKeyword('anyOf', $value, $schema, $path, $i);
2626
// oneof
2727

2828
// array
@@ -43,6 +43,7 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n
4343
$this->checkForKeyword('enum', $value, $schema, $path, $i);
4444
$this->checkForKeyword('const', $value, $schema, $path, $i);
4545
$this->checkForKeyword('multipleOf', $value, $schema, $path, $i);
46+
$this->checkForKeyword('format', $value, $schema, $path, $i);
4647
}
4748

4849
protected function checkForKeyword(string $keyword, $value, $schema = null, ?JsonPointer $path = null, $i = null): void

src/JsonSchema/Constraints/Drafts/Draft06/Factory.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ class Factory extends \JsonSchema\Constraints\Factory
1010
* @var array<string, class-string>
1111
*/
1212
protected $constraintMap = [
13+
'schema' => Draft06Constraint::class,
1314
'additionalProperties' => AdditionalPropertiesConstraint::class,
1415
'dependencies' => DependenciesConstraint::class,
1516
'type' => TypeConstraint::class,
@@ -27,5 +28,8 @@ class Factory extends \JsonSchema\Constraints\Factory
2728
'maxItems' => MaxItemsConstraint::class,
2829
'exclusiveMaximum' => ExclusiveMaximumConstraint::class,
2930
'multipleOf' => MultipleOfConstraint::class,
31+
'required' => RequiredConstraint::class,
32+
'format' => FormatConstraint::class,
33+
'anyOf' => AnyOfConstraint::class,
3034
];
3135
}
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JsonSchema\Constraints\Drafts\Draft06;
6+
7+
use JsonSchema\ConstraintError;
8+
use JsonSchema\Constraints\ConstraintInterface;
9+
use JsonSchema\Constraints\Factory;
10+
use JsonSchema\Entity\ErrorBagProxy;
11+
use JsonSchema\Entity\JsonPointer;
12+
use JsonSchema\Rfc3339;
13+
use JsonSchema\Tool\Validator\RelativeReferenceValidator;
14+
use JsonSchema\Tool\Validator\UriValidator;
15+
16+
class FormatConstraint implements ConstraintInterface
17+
{
18+
use ErrorBagProxy;
19+
20+
public function __construct(?Factory $factory = null)
21+
{
22+
$this->initialiseErrorBag($factory ?: new Factory());
23+
}
24+
25+
public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void
26+
{
27+
if (!property_exists($schema, 'format')) {
28+
return;
29+
}
30+
31+
if (!is_string($value)) {
32+
return;
33+
}
34+
35+
switch ($schema->format) {
36+
case 'date':
37+
if (!$this->validateDateTime($value, 'Y-m-d')) {
38+
$this->addError(ConstraintError::FORMAT_DATE(), $path, ['date' => $value, 'format' => $schema->format]);
39+
}
40+
break;
41+
case 'time':
42+
if (!$this->validateDateTime($value, 'H:i:s')) {
43+
$this->addError(ConstraintError::FORMAT_TIME(), $path, ['time' => $value, 'format' => $schema->format]);
44+
}
45+
break;
46+
case 'date-time':
47+
if (Rfc3339::createFromString($value) === null) {
48+
$this->addError(ConstraintError::FORMAT_DATE_TIME(), $path, ['dateTime' => $value, 'format' => $schema->format]);
49+
}
50+
break;
51+
case 'utc-millisec':
52+
if (!$this->validateDateTime($value, 'U')) {
53+
$this->addError(ConstraintError::FORMAT_DATE_UTC(), $path, ['value' => $value, 'format' => $schema->format]);
54+
}
55+
break;
56+
case 'regex':
57+
if (!$this->validateRegex($value)) {
58+
$this->addError(ConstraintError::FORMAT_REGEX(), $path, ['value' => $value, 'format' => $schema->format]);
59+
}
60+
break;
61+
case 'ip-address':
62+
case 'ipv4':
63+
if (filter_var($value, FILTER_VALIDATE_IP, FILTER_NULL_ON_FAILURE | FILTER_FLAG_IPV4) === null) {
64+
$this->addError(ConstraintError::FORMAT_IP(), $path, ['format' => $schema->format]);
65+
}
66+
break;
67+
case 'ipv6':
68+
if (filter_var($value, FILTER_VALIDATE_IP, FILTER_NULL_ON_FAILURE | FILTER_FLAG_IPV6) === null) {
69+
$this->addError(ConstraintError::FORMAT_IP(), $path, ['format' => $schema->format]);
70+
}
71+
break;
72+
case 'color':
73+
if (!$this->validateColor($value)) {
74+
$this->addError(ConstraintError::FORMAT_COLOR(), $path, ['format' => $schema->format]);
75+
}
76+
break;
77+
case 'style':
78+
if (!$this->validateStyle($value)) {
79+
$this->addError(ConstraintError::FORMAT_STYLE(), $path, ['format' => $schema->format]);
80+
}
81+
break;
82+
case 'phone':
83+
if (!$this->validatePhone($value)) {
84+
$this->addError(ConstraintError::FORMAT_PHONE(), $path, ['format' => $schema->format]);
85+
}
86+
break;
87+
case 'uri':
88+
if (!UriValidator::isValid($value)) {
89+
$this->addError(ConstraintError::FORMAT_URL(), $path, ['format' => $schema->format]);
90+
}
91+
break;
92+
93+
case 'uriref':
94+
case 'uri-reference':
95+
if (!(UriValidator::isValid($value) || RelativeReferenceValidator::isValid($value))) {
96+
$this->addError(ConstraintError::FORMAT_URL(), $path, ['format' => $schema->format]);
97+
}
98+
break;
99+
100+
case 'email':
101+
if (filter_var($value, FILTER_VALIDATE_EMAIL, FILTER_NULL_ON_FAILURE | FILTER_FLAG_EMAIL_UNICODE) === null) {
102+
$this->addError(ConstraintError::FORMAT_EMAIL(), $path, ['format' => $schema->format]);
103+
}
104+
break;
105+
case 'host-name':
106+
case 'hostname':
107+
if (!$this->validateHostname($value)) {
108+
$this->addError(ConstraintError::FORMAT_HOSTNAME(), $path, ['format' => $schema->format]);
109+
}
110+
break;
111+
default:
112+
break;
113+
}
114+
115+
}
116+
117+
private function validateDateTime(string $datetime, string $format): bool
118+
{
119+
$dt = \DateTime::createFromFormat($format, $datetime);
120+
121+
if (!$dt) {
122+
return false;
123+
}
124+
125+
return $datetime === $dt->format($format);
126+
}
127+
128+
private function validateRegex(string $regex): bool
129+
{
130+
return preg_match(self::jsonPatternToPhpRegex($regex), '') !== false;
131+
}
132+
133+
/**
134+
* Transform a JSON pattern into a PCRE regex
135+
*/
136+
private static function jsonPatternToPhpRegex(string $pattern): string
137+
{
138+
return '~' . str_replace('~', '\\~', $pattern) . '~u';
139+
}
140+
141+
private function validateColor(string $color): bool
142+
{
143+
if (in_array(strtolower($color), ['aqua', 'black', 'blue', 'fuchsia',
144+
'gray', 'green', 'lime', 'maroon', 'navy', 'olive', 'orange', 'purple',
145+
'red', 'silver', 'teal', 'white', 'yellow'])) {
146+
return true;
147+
}
148+
149+
return preg_match('/^#([a-f0-9]{3}|[a-f0-9]{6})$/i', $color) !== false;
150+
}
151+
152+
private function validateStyle(string $style): bool
153+
{
154+
$properties = explode(';', rtrim($style, ';'));
155+
$invalidEntries = preg_grep('/^\s*[-a-z]+\s*:\s*.+$/i', $properties, PREG_GREP_INVERT);
156+
157+
return empty($invalidEntries);
158+
}
159+
160+
private function validatePhone($phone): bool
161+
{
162+
return preg_match('/^\+?(\(\d{3}\)|\d{3}) \d{3} \d{4}$/', $phone) !== false;
163+
}
164+
165+
private function validateHostname(string $host): bool
166+
{
167+
$hostnameRegex = '/^(([a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9\-]*[a-zA-Z0-9])\.)*([A-Za-z0-9]|[A-Za-z0-9][A-Za-z0-9\-]*[A-Za-z0-9])$/i';
168+
169+
return preg_match($hostnameRegex, $host) !== false;
170+
}
171+
}
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace JsonSchema\Constraints\Drafts\Draft06;
6+
7+
use JsonSchema\ConstraintError;
8+
use JsonSchema\Constraints\ConstraintInterface;
9+
use JsonSchema\Constraints\Factory;
10+
use JsonSchema\Entity\ErrorBagProxy;
11+
use JsonSchema\Entity\JsonPointer;
12+
13+
class RequiredConstraint implements ConstraintInterface
14+
{
15+
use ErrorBagProxy;
16+
17+
public function __construct(?Factory $factory = null)
18+
{
19+
$this->initialiseErrorBag($factory ?: new Factory());
20+
}
21+
22+
public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void
23+
{
24+
if (!property_exists($schema, 'required')) {
25+
return;
26+
}
27+
28+
if (!is_object($value)) {
29+
return;
30+
}
31+
32+
foreach ($schema->required as $required) {
33+
if (isset($value->{$required})) {
34+
continue;
35+
}
36+
37+
$this->addError(ConstraintError::REQUIRED(), $this->incrementPath($path, $required), ['property' => $required]);
38+
}
39+
}
40+
41+
/**
42+
* @todo refactor as this was only copied from UndefinedConstraint
43+
* Bubble down the path
44+
*
45+
* @param JsonPointer|null $path Current path
46+
* @param mixed $i What to append to the path
47+
*/
48+
protected function incrementPath(?JsonPointer $path, $i): JsonPointer
49+
{
50+
$path = $path ?? new JsonPointer('');
51+
52+
if ($i === null || $i === '') {
53+
return $path;
54+
}
55+
56+
return $path->withPropertyPaths(array_merge($path->getPropertyPaths(), [$i]));
57+
}
58+
}

src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,16 +27,22 @@ public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = n
2727

2828
$schemaTypes = (array) $schema->type;
2929
$valueType = strtolower(gettype($value));
30-
if ($valueType === 'double' || $valueType === 'integer') {
31-
$valueType = 'number';
32-
}
33-
// @todo 1.0 is considered an integer but also number
34-
30+
// All specific number types are a number
31+
$valueIsNumber = $valueType === 'double' || $valueType === 'integer';
32+
// A float with zero fractional part is an integer
33+
$isInteger = $valueIsNumber && fmod($value, 1.0) === 0.0;
3534

3635
foreach ($schemaTypes as $type) {
3736
if ($valueType === $type) {
3837
return;
3938
}
39+
40+
if ($type === 'number' && $valueIsNumber) {
41+
return;
42+
}
43+
if ($type === 'integer' && $isInteger) {
44+
return;
45+
}
4046
}
4147

4248
$this->addError(ConstraintError::TYPE(), $path, ['found' => $valueType, 'expected' => implode(', ', $schemaTypes)]);

tests/JsonSchemaTestSuiteTest.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,19 +19,21 @@ class JsonSchemaTestSuiteTest extends TestCase
1919
/**
2020
* @dataProvider casesDataProvider
2121
*
22+
* @param \stdClass|bool $schema
2223
* @param mixed $data
2324
*/
2425
public function testTestCaseValidatesCorrectly(
2526
string $testCaseDescription,
2627
string $testDescription,
27-
\stdClass $schema,
28+
$schema,
2829
$data,
2930
bool $expectedValidationResult,
3031
bool $optional
3132
): void
3233
{
3334
$schemaStorage = new SchemaStorage();
34-
$schemaStorage->addSchema(property_exists($schema, 'id') ? $schema->id : SchemaStorage::INTERNAL_PROVIDED_SCHEMA_URI, $schema);
35+
$id = is_object($schema) && property_exists($schema, 'id') ? $schema->id : SchemaStorage::INTERNAL_PROVIDED_SCHEMA_URI;
36+
$schemaStorage->addSchema($id, $schema);
3537
$this->loadRemotesIntoStorage($schemaStorage);
3638
$validator = new Validator(new Factory($schemaStorage));
3739

0 commit comments

Comments
 (0)