diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 19dba14a..3b9e68d2 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -66,17 +66,12 @@ parameters: path: src/JsonSchema/Constraints/Factory.php - - message: "#^Method JsonSchema\\\\Constraints\\\\Factory\\:\\:getConfig\\(\\) should return int\\<0, 511\\> but returns int\\.$#" + message: "#^Method JsonSchema\\\\Constraints\\\\Factory\\:\\:getConfig\\(\\) should return int\\<0, 1023\\> but returns int\\.$#" count: 1 path: src/JsonSchema/Constraints/Factory.php - - message: "#^Method JsonSchema\\\\Constraints\\\\Factory\\:\\:getErrorContext\\(\\) should return 1\\|2 but returns int\\.$#" - count: 1 - path: src/JsonSchema/Constraints/Factory.php - - - - message: "#^Property JsonSchema\\\\Constraints\\\\Factory\\:\\:\\$checkMode \\(int\\<0, 511\\>\\) does not accept int\\.$#" + message: "#^Property JsonSchema\\\\Constraints\\\\Factory\\:\\:\\$checkMode \\(int\\<0, 1023\\>\\) does not accept int\\.$#" count: 2 path: src/JsonSchema/Constraints/Factory.php @@ -630,16 +625,6 @@ parameters: count: 1 path: src/JsonSchema/Iterator/ObjectIterator.php - - - message: "#^Offset 4 does not exist on array\\{0\\: string, 1\\: non\\-falsy\\-string, 2\\: string, 3\\: non\\-falsy\\-string, 4\\?\\: string, 5\\?\\: non\\-falsy\\-string&numeric\\-string\\}\\.$#" - count: 1 - path: src/JsonSchema/Rfc3339.php - - - - message: "#^Offset 5 does not exist on array\\{0\\: string, 1\\: non\\-falsy\\-string, 2\\: string, 3\\: non\\-falsy\\-string, 4\\?\\: string, 5\\?\\: non\\-falsy\\-string&numeric\\-string\\}\\.$#" - count: 1 - path: src/JsonSchema/Rfc3339.php - - message: "#^Access to an undefined property object\\:\\:\\$properties\\.$#" count: 3 @@ -656,7 +641,7 @@ parameters: path: src/JsonSchema/SchemaStorage.php - - message: "#^Call to function is_array\\(\\) with object will always evaluate to false\\.$#" + message: "#^Call to function is_array\\(\\) with bool|object will always evaluate to false\\.$#" count: 1 path: src/JsonSchema/SchemaStorage.php diff --git a/src/JsonSchema/ConstraintError.php b/src/JsonSchema/ConstraintError.php index c17cfeff..ed6cf03e 100644 --- a/src/JsonSchema/ConstraintError.php +++ b/src/JsonSchema/ConstraintError.php @@ -17,8 +17,10 @@ class ConstraintError extends Enum public const DIVISIBLE_BY = 'divisibleBy'; public const ENUM = 'enum'; public const CONSTANT = 'const'; + public const CONTAINS = 'contains'; public const EXCLUSIVE_MINIMUM = 'exclusiveMinimum'; public const EXCLUSIVE_MAXIMUM = 'exclusiveMaximum'; + public const FALSE = 'false'; public const FORMAT_COLOR = 'colorFormat'; public const FORMAT_DATE = 'dateFormat'; public const FORMAT_DATE_TIME = 'dateTimeFormat'; @@ -26,10 +28,12 @@ class ConstraintError extends Enum public const FORMAT_EMAIL = 'emailFormat'; public const FORMAT_HOSTNAME = 'styleHostName'; public const FORMAT_IP = 'ipFormat'; + public const FORMAT_JSON_POINTER = 'jsonPointerFormat'; public const FORMAT_PHONE = 'phoneFormat'; public const FORMAT_REGEX= 'regexFormat'; public const FORMAT_STYLE = 'styleFormat'; public const FORMAT_TIME = 'timeFormat'; + public const FORMAT_URI_TEMPLATE = 'uriTemplateFormat'; public const FORMAT_URL = 'urlFormat'; public const FORMAT_URL_REF = 'urlRefFormat'; public const INVALID_SCHEMA = 'invalidSchema'; @@ -51,6 +55,7 @@ class ConstraintError extends Enum public const PREGEX_INVALID = 'pregrex'; public const PROPERTIES_MIN = 'minProperties'; public const PROPERTIES_MAX = 'maxProperties'; + public const PROPERTY_NAMES = 'propertyNames'; public const TYPE = 'type'; public const UNIQUE_ITEMS = 'uniqueItems'; @@ -70,8 +75,10 @@ public function getMessage() self::DIVISIBLE_BY => 'Is not divisible by %d', self::ENUM => 'Does not have a value in the enumeration %s', self::CONSTANT => 'Does not have a value equal to %s', + self::CONTAINS => 'Does not have a value valid to contains schema', self::EXCLUSIVE_MINIMUM => 'Must have a minimum value greater than %d', self::EXCLUSIVE_MAXIMUM => 'Must have a maximum value less than %d', + self::FALSE => 'Boolean schema false', self::FORMAT_COLOR => 'Invalid color', self::FORMAT_DATE => 'Invalid date %s, expected format YYYY-MM-DD', self::FORMAT_DATE_TIME => 'Invalid date-time %s, expected format YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DDThh:mm:ss+hh:mm', @@ -79,10 +86,12 @@ public function getMessage() self::FORMAT_EMAIL => 'Invalid email', self::FORMAT_HOSTNAME => 'Invalid hostname', self::FORMAT_IP => 'Invalid IP address', + self::FORMAT_JSON_POINTER => 'Invalid JSON pointer', self::FORMAT_PHONE => 'Invalid phone number', self::FORMAT_REGEX=> 'Invalid regex format %s', self::FORMAT_STYLE => 'Invalid style', self::FORMAT_TIME => 'Invalid time %s, expected format hh:mm:ss', + self::FORMAT_URI_TEMPLATE => 'Invalid URI template format', self::FORMAT_URL => 'Invalid URL format', self::FORMAT_URL_REF => 'Invalid URL reference format', self::LENGTH_MAX => 'Must be at most %d characters long', @@ -104,6 +113,7 @@ public function getMessage() self::PREGEX_INVALID => 'The pattern %s is invalid', self::PROPERTIES_MIN => 'Must contain a minimum of %d properties', self::PROPERTIES_MAX => 'Must contain no more than %d properties', + self::PROPERTY_NAMES => 'Property name %s is invalid', self::TYPE => '%s value found, but %s is required', self::UNIQUE_ITEMS => 'There are no duplicates allowed in the array' ]; diff --git a/src/JsonSchema/Constraints/Constraint.php b/src/JsonSchema/Constraints/Constraint.php index 8e818f0a..3c7e824b 100644 --- a/src/JsonSchema/Constraints/Constraint.php +++ b/src/JsonSchema/Constraints/Constraint.php @@ -21,6 +21,7 @@ abstract class Constraint extends BaseConstraint implements ConstraintInterface public const CHECK_MODE_EARLY_COERCE = 0x00000040; public const CHECK_MODE_ONLY_REQUIRED_DEFAULTS = 0x00000080; public const CHECK_MODE_VALIDATE_SCHEMA = 0x00000100; + public const CHECK_MODE_STRICT = 0x00000200; /** * Bubble down the path diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php new file mode 100644 index 00000000..d1adbce8 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalItemsConstraint.php @@ -0,0 +1,61 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'additionalItems')) { + return; + } + + if ($schema->additionalItems === true) { + return; + } + if ($schema->additionalItems === false && !property_exists($schema, 'items')) { + return; + } + + if (!is_array($value)) { + return; + } + if (!property_exists($schema, 'items')) { + return; + } + if (property_exists($schema, 'items') && is_object($schema->items)) { + return; + } + + $additionalItems = array_diff_key($value, property_exists($schema, 'items') ? $schema->items : []); + + foreach ($additionalItems as $propertyName => $propertyValue) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($propertyValue, $schema->additionalItems, $path, $i); + + if ($schemaConstraint->isValid()) { + continue; + } + + $this->addError(ConstraintError::ADDITIONAL_ITEMS(), $path, ['item' => $i, 'property' => $propertyName, 'additionalItems' => $schema->additionalItems]); + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php new file mode 100644 index 00000000..5f244896 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.php @@ -0,0 +1,93 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'additionalProperties')) { + return; + } + + if ($schema->additionalProperties === true) { + return; + } + + if (!is_object($value)) { + return; + } + + $additionalProperties = get_object_vars($value); + + if (isset($schema->properties)) { + $additionalProperties = array_diff_key($additionalProperties, (array) $schema->properties); + } + + if (isset($schema->patternProperties)) { + $patterns = array_keys(get_object_vars($schema->patternProperties)); + + foreach ($additionalProperties as $key => $_) { + foreach ($patterns as $pattern) { + if (preg_match($this->createPregMatchPattern($pattern), (string) $key)) { + unset($additionalProperties[$key]); + break; + } + } + } + } + + if (is_object($schema->additionalProperties)) { + foreach ($additionalProperties as $key => $additionalPropertiesValue) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($additionalPropertiesValue, $schema->additionalProperties, $path, $i); // @todo increment path + if ($schemaConstraint->isValid()) { + unset($additionalProperties[$key]); + } + } + } + + foreach ($additionalProperties as $key => $additionalPropertiesValue) { + $this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), $path, ['found' => $additionalPropertiesValue]); + } + } + + private function createPregMatchPattern(string $pattern): string + { + $replacements = [ +// '\D' => '[^0-9]', +// '\d' => '[0-9]', + '\p{digit}' => '\p{Nd}', +// '\w' => '[A-Za-z0-9_]', +// '\W' => '[^A-Za-z0-9_]', +// '\s' => '[\s\x{200B}]' // Explicitly include zero width white space, + '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations + ]; + + $pattern = str_replace( + array_keys($replacements), + array_values($replacements), + $pattern + ); + + return '/' . str_replace('/', '\/', $pattern) . '/u'; + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AllOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AllOfConstraint.php new file mode 100644 index 00000000..5a69f657 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AllOfConstraint.php @@ -0,0 +1,42 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'allOf')) { + return; + } + + foreach ($schema->allOf as $allOfSchema) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($value, $allOfSchema, $path, $i); + + if ($schemaConstraint->isValid()) { + continue; + } + $this->addError(ConstraintError::ALL_OF(), $path); + $this->addErrors($schemaConstraint->getErrors()); + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php new file mode 100644 index 00000000..29b0f29b --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.php @@ -0,0 +1,51 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'anyOf')) { + return; + } + + foreach ($schema->anyOf as $anyOfSchema) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + + try { + $schemaConstraint->check($value, $anyOfSchema, $path, $i); + + if ($schemaConstraint->isValid()) { + $this->errorBag()->reset(); + + return; + } + + $this->addErrors($schemaConstraint->getErrors()); + } catch (ValidationException $e) { + } + } + + $this->addError(ConstraintError::ANY_OF(), $path); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ConstConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ConstConstraint.php new file mode 100644 index 00000000..563bd42c --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ConstConstraint.php @@ -0,0 +1,35 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'const')) { + return; + } + + if (DeepComparer::isEqual($value, $schema->const)) { + return; + } + + $this->addError(ConstraintError::CONSTANT(), $path, ['const' => $schema->const]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php new file mode 100644 index 00000000..45f0785d --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ContainsConstraint.php @@ -0,0 +1,47 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'contains')) { + return; + } + + $properties = []; + if (!is_array($value)) { + return; + } + + foreach ($value as $propertyName => $propertyValue) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + + $schemaConstraint->check($propertyValue, $schema->contains, $path, $i); + if ($schemaConstraint->isValid()) { + return; + } + } + + $this->addError(ConstraintError::CONTAINS(), $path, ['contains' => $schema->contains]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php new file mode 100644 index 00000000..5b8e86b4 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/DependenciesConstraint.php @@ -0,0 +1,64 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'dependencies')) { + return; + } + + if (!is_object($value)) { + return; + } + + foreach ($schema->dependencies as $dependant => $dependencies) { + if (!property_exists($value, $dependant)) { + continue; + } + if ($dependencies === true) { + continue; + } + if ($dependencies === false) { + $this->addError(ConstraintError::FALSE(), $path, ['dependant' => $dependant]); + continue; + } + + if (is_array($dependencies)) { + foreach ($dependencies as $dependency) { + if (property_exists($value, $dependant) && !property_exists($value, $dependency)) { + $this->addError(ConstraintError::DEPENDENCIES(), $path, ['dependant' => $dependant, 'dependency' => $dependency]); + } + } + } + + if (is_object($dependencies)) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($value, $dependencies, $path, $i); + if (!$schemaConstraint->isValid()) { + $this->addErrors($schemaConstraint->getErrors()); + } + } + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php new file mode 100644 index 00000000..bd6efac6 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php @@ -0,0 +1,81 @@ +getSchemaStorage() : new SchemaStorage(), + $factory ? $factory->getUriRetriever() : new UriRetriever(), + $factory ? $factory->getConfig() : Constraint::CHECK_MODE_NORMAL + )); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (is_bool($schema)) { + if ($schema === false) { + $this->addError(ConstraintError::FALSE(), $path, []); + } + + return; + } + + // Apply defaults + $this->checkForKeyword('ref', $value, $schema, $path, $i); + $this->checkForKeyword('required', $value, $schema, $path, $i); + $this->checkForKeyword('contains', $value, $schema, $path, $i); + $this->checkForKeyword('properties', $value, $schema, $path, $i); + $this->checkForKeyword('propertyNames', $value, $schema, $path, $i); + $this->checkForKeyword('patternProperties', $value, $schema, $path, $i); + $this->checkForKeyword('type', $value, $schema, $path, $i); + $this->checkForKeyword('not', $value, $schema, $path, $i); + $this->checkForKeyword('dependencies', $value, $schema, $path, $i); + $this->checkForKeyword('allOf', $value, $schema, $path, $i); + $this->checkForKeyword('anyOf', $value, $schema, $path, $i); + $this->checkForKeyword('oneOf', $value, $schema, $path, $i); + + $this->checkForKeyword('additionalProperties', $value, $schema, $path, $i); + $this->checkForKeyword('items', $value, $schema, $path, $i); + $this->checkForKeyword('additionalItems', $value, $schema, $path, $i); + $this->checkForKeyword('uniqueItems', $value, $schema, $path, $i); + $this->checkForKeyword('minItems', $value, $schema, $path, $i); + $this->checkForKeyword('minProperties', $value, $schema, $path, $i); + $this->checkForKeyword('maxProperties', $value, $schema, $path, $i); + $this->checkForKeyword('minimum', $value, $schema, $path, $i); + $this->checkForKeyword('maximum', $value, $schema, $path, $i); + $this->checkForKeyword('minLength', $value, $schema, $path, $i); + $this->checkForKeyword('exclusiveMinimum', $value, $schema, $path, $i); + $this->checkForKeyword('maxItems', $value, $schema, $path, $i); + $this->checkForKeyword('maxLength', $value, $schema, $path, $i); + $this->checkForKeyword('exclusiveMaximum', $value, $schema, $path, $i); + $this->checkForKeyword('enum', $value, $schema, $path, $i); + $this->checkForKeyword('const', $value, $schema, $path, $i); + $this->checkForKeyword('multipleOf', $value, $schema, $path, $i); + $this->checkForKeyword('format', $value, $schema, $path, $i); + $this->checkForKeyword('pattern', $value, $schema, $path, $i); + } + + /** + * @param mixed $value + * @param mixed $schema + * @param mixed $i + */ + protected function checkForKeyword(string $keyword, $value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + $validator = $this->factory->createInstanceFor($keyword); + $validator->check($value, $schema, $path, $i); + + $this->addErrors($validator->getErrors()); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/EnumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/EnumConstraint.php new file mode 100644 index 00000000..1ed3b65a --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/EnumConstraint.php @@ -0,0 +1,41 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'enum')) { + return; + } + + foreach ($schema->enum as $enumCase) { + if (DeepComparer::isEqual($value, $enumCase)) { + return; + } + + if (is_numeric($value) && is_numeric($enumCase) && DeepComparer::isEqual((float) $value, (float) $enumCase)) { + return; + } + } + + $this->addError(ConstraintError::ENUM(), $path, ['enum' => $schema->enum]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMaximumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMaximumConstraint.php new file mode 100644 index 00000000..2d29a175 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMaximumConstraint.php @@ -0,0 +1,38 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'exclusiveMaximum')) { + return; + } + + if (!is_numeric($value)) { + return; + } + + if ($value < $schema->exclusiveMaximum) { + return; + } + + $this->addError(ConstraintError::EXCLUSIVE_MAXIMUM(), $path, ['exclusiveMaximum' => $schema->exclusiveMaximum, 'found' => $value]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMinimumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMinimumConstraint.php new file mode 100644 index 00000000..b167d198 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ExclusiveMinimumConstraint.php @@ -0,0 +1,38 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'exclusiveMinimum')) { + return; + } + + if (!is_numeric($value)) { + return; + } + + if ($value > $schema->exclusiveMinimum) { + return; + } + + $this->addError(ConstraintError::EXCLUSIVE_MINIMUM(), $path, ['exclusiveMinimum' => $schema->exclusiveMinimum, 'found' => $value]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php new file mode 100644 index 00000000..1d23f9ff --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Factory.php @@ -0,0 +1,46 @@ + + */ + protected $constraintMap = [ + 'schema' => Draft06Constraint::class, + 'additionalProperties' => AdditionalPropertiesConstraint::class, + 'additionalItems' => AdditionalItemsConstraint::class, + 'dependencies' => DependenciesConstraint::class, + 'type' => TypeConstraint::class, + 'const' => ConstConstraint::class, + 'enum' => EnumConstraint::class, + 'uniqueItems' => UniqueItemsConstraint::class, + 'minItems' => MinItemsConstraint::class, + 'minProperties' => MinPropertiesConstraint::class, + 'maxProperties' => MaxPropertiesConstraint::class, + 'minimum' => MinimumConstraint::class, + 'maximum' => MaximumConstraint::class, + 'exclusiveMinimum' => ExclusiveMinimumConstraint::class, + 'minLength' => MinLengthConstraint::class, + 'maxLength' => MaxLengthConstraint::class, + 'maxItems' => MaxItemsConstraint::class, + 'exclusiveMaximum' => ExclusiveMaximumConstraint::class, + 'multipleOf' => MultipleOfConstraint::class, + 'required' => RequiredConstraint::class, + 'format' => FormatConstraint::class, + 'anyOf' => AnyOfConstraint::class, + 'allOf' => AllOfConstraint::class, + 'oneOf' => OneOfConstraint::class, + 'not' => NotConstraint::class, + 'contains' => ContainsConstraint::class, + 'propertyNames' => PropertiesNamesConstraint::class, + 'patternProperties' => PatternPropertiesConstraint::class, + 'pattern' => PatternConstraint::class, + 'properties' => PropertiesConstraint::class, + 'items' => ItemsConstraint::class, + 'ref' => RefConstraint::class, + ]; +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php new file mode 100644 index 00000000..578a27c3 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/FormatConstraint.php @@ -0,0 +1,220 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'format')) { + return; + } + + if (!is_string($value)) { + return; + } + + switch ($schema->format) { + case 'date': + if (!$this->validateDateTime($value, 'Y-m-d')) { + $this->addError(ConstraintError::FORMAT_DATE(), $path, ['date' => $value, 'format' => $schema->format]); + } + break; + case 'time': + if (!$this->validateDateTime($value, 'H:i:s')) { + $this->addError(ConstraintError::FORMAT_TIME(), $path, ['time' => $value, 'format' => $schema->format]); + } + break; + case 'date-time': + if (!$this->validateRfc3339DateTime($value)) { + $this->addError(ConstraintError::FORMAT_DATE_TIME(), $path, ['dateTime' => $value, 'format' => $schema->format]); + } + break; + case 'utc-millisec': + if (!$this->validateDateTime($value, 'U')) { + $this->addError(ConstraintError::FORMAT_DATE_UTC(), $path, ['value' => $value, 'format' => $schema->format]); + } + break; + case 'regex': + if (!$this->validateRegex($value)) { + $this->addError(ConstraintError::FORMAT_REGEX(), $path, ['value' => $value, 'format' => $schema->format]); + } + break; + case 'ip-address': + case 'ipv4': + if (filter_var($value, FILTER_VALIDATE_IP, FILTER_NULL_ON_FAILURE | FILTER_FLAG_IPV4) === null) { + $this->addError(ConstraintError::FORMAT_IP(), $path, ['format' => $schema->format]); + } + break; + case 'ipv6': + if (filter_var($value, FILTER_VALIDATE_IP, FILTER_NULL_ON_FAILURE | FILTER_FLAG_IPV6) === null) { + $this->addError(ConstraintError::FORMAT_IP(), $path, ['format' => $schema->format]); + } + break; + case 'color': + if (!$this->validateColor($value)) { + $this->addError(ConstraintError::FORMAT_COLOR(), $path, ['format' => $schema->format]); + } + break; + case 'style': + if (!$this->validateStyle($value)) { + $this->addError(ConstraintError::FORMAT_STYLE(), $path, ['format' => $schema->format]); + } + break; + case 'phone': + if (!$this->validatePhone($value)) { + $this->addError(ConstraintError::FORMAT_PHONE(), $path, ['format' => $schema->format]); + } + break; + case 'uri': + if (!UriValidator::isValid($value)) { + $this->addError(ConstraintError::FORMAT_URL(), $path, ['format' => $schema->format]); + } + break; + + case 'uriref': + case 'uri-reference': + if (!(UriValidator::isValid($value) || RelativeReferenceValidator::isValid($value))) { + $this->addError(ConstraintError::FORMAT_URL(), $path, ['format' => $schema->format]); + } + break; + case 'uri-template': + if (!$this->validateUriTemplate($value)) { + $this->addError(ConstraintError::FORMAT_URI_TEMPLATE(), $path, ['format' => $schema->format]); + } + break; + + case 'email': + if (filter_var($value, FILTER_VALIDATE_EMAIL, FILTER_NULL_ON_FAILURE | FILTER_FLAG_EMAIL_UNICODE) === null) { + $this->addError(ConstraintError::FORMAT_EMAIL(), $path, ['format' => $schema->format]); + } + break; + case 'host-name': + case 'hostname': + if (!$this->validateHostname($value)) { + $this->addError(ConstraintError::FORMAT_HOSTNAME(), $path, ['format' => $schema->format]); + } + break; + case 'json-pointer': + if (!$this->validateJsonPointer($value)) { + $this->addError(ConstraintError::FORMAT_JSON_POINTER(), $path, ['format' => $schema->format]); + } + break; + default: + break; + } + } + + private function validateDateTime(string $datetime, string $format): bool + { + $dt = \DateTime::createFromFormat($format, $datetime); + + if (!$dt) { + return false; + } + + return $datetime === $dt->format($format); + } + + private function validateRegex(string $regex): bool + { + return preg_match(self::jsonPatternToPhpRegex($regex), '') !== false; + } + + /** + * Transform a JSON pattern into a PCRE regex + */ + private static function jsonPatternToPhpRegex(string $pattern): string + { + return '~' . str_replace('~', '\\~', $pattern) . '~u'; + } + + private function validateColor(string $color): bool + { + if (in_array(strtolower($color), ['aqua', 'black', 'blue', 'fuchsia', + 'gray', 'green', 'lime', 'maroon', 'navy', 'olive', 'orange', 'purple', + 'red', 'silver', 'teal', 'white', 'yellow'])) { + return true; + } + + return preg_match('/^#([a-f0-9]{3}|[a-f0-9]{6})$/i', $color) !== false; + } + + private function validateStyle(string $style): bool + { + $properties = explode(';', rtrim($style, ';')); + $invalidEntries = preg_grep('/^\s*[-a-z]+\s*:\s*.+$/i', $properties, PREG_GREP_INVERT); + + return empty($invalidEntries); + } + + private function validatePhone(string $phone): bool + { + return preg_match('/^\+?(\(\d{3}\)|\d{3}) \d{3} \d{4}$/', $phone) !== false; + } + + private function validateHostname(string $host): bool + { + $hostnameRegex = '/^(?!-)(?!.*?[^A-Za-z0-9\-\.])(?:(?!-)[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?\.)*(?!-)[A-Za-z0-9](?:[A-Za-z0-9\-]{0,61}[A-Za-z0-9])?$/'; + + return preg_match($hostnameRegex, $host) === 1; + } + + private function validateJsonPointer(string $value): bool + { + // Must be empty or start with a forward slash + if ($value !== '' && $value[0] !== '/') { + return false; + } + + // Split into reference tokens and check for invalid escape sequences + $tokens = explode('/', $value); + array_shift($tokens); // remove leading empty part due to leading slash + + foreach ($tokens as $token) { + // "~" must only be followed by "0" or "1" + if (preg_match('/~(?![01])/', $token)) { + return false; + } + } + + return true; + } + + private function validateRfc3339DateTime(string $value): bool + { + $dateTime = Rfc3339::createFromString($value); + if (is_null($dateTime)) { + return false; + } + + // Compare value and date result to be equal + return true; + } + + private function validateUriTemplate(string $value): bool + { + return preg_match( + '/^(?:[^\{\}]*|\{[a-zA-Z0-9_:%\/\.~\-\+\*]+\})*$/', + $value + ) === 1; + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php new file mode 100644 index 00000000..21259e1a --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/ItemsConstraint.php @@ -0,0 +1,52 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'items')) { + return; + } + + if (!is_array($value)) { + return; + } + + foreach ($value as $propertyName => $propertyValue) { + $itemSchema = $schema->items; + if (is_array($itemSchema)) { + if (!array_key_exists($propertyName, $itemSchema)) { + continue; + } + + $itemSchema = $itemSchema[$propertyName]; + } + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($propertyValue, $itemSchema, $path, $i); + if ($schemaConstraint->isValid()) { + continue; + } + + $this->addErrors($schemaConstraint->getErrors()); + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MaxItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MaxItemsConstraint.php new file mode 100644 index 00000000..d7ad2649 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MaxItemsConstraint.php @@ -0,0 +1,39 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'maxItems')) { + return; + } + + if (!is_array($value)) { + return; + } + + $count = count($value); + if ($count <= $schema->maxItems) { + return; + } + + $this->addError(ConstraintError::MAX_ITEMS(), $path, ['maxItems' => $schema->maxItems, 'found' => $count]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MaxLengthConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MaxLengthConstraint.php new file mode 100644 index 00000000..5243f488 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MaxLengthConstraint.php @@ -0,0 +1,39 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'maxLength')) { + return; + } + + if (!is_string($value)) { + return; + } + + $length = mb_strlen($value); + if ($length <= $schema->maxLength) { + return; + } + + $this->addError(ConstraintError::LENGTH_MAX(), $path, ['maxLength' => $schema->maxLength, 'found' => $length]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MaxPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MaxPropertiesConstraint.php new file mode 100644 index 00000000..b881a2d5 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MaxPropertiesConstraint.php @@ -0,0 +1,39 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'maxProperties')) { + return; + } + + if (!is_object($value)) { + return; + } + + $count = count(get_object_vars($value)); + if ($count <= $schema->maxProperties) { + return; + } + + $this->addError(ConstraintError::PROPERTIES_MAX(), $path, ['maxProperties' => $schema->maxProperties, 'found' => $count]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MaximumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MaximumConstraint.php new file mode 100644 index 00000000..bdd1db13 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MaximumConstraint.php @@ -0,0 +1,38 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'maximum')) { + return; + } + + if (!is_numeric($value)) { + return; + } + + if ($value <= $schema->maximum) { + return; + } + + $this->addError(ConstraintError::MAXIMUM(), $path, ['maximum' => $schema->maximum, 'found' => $value]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MinItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MinItemsConstraint.php new file mode 100644 index 00000000..b17cd894 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MinItemsConstraint.php @@ -0,0 +1,39 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'minItems')) { + return; + } + + if (!is_array($value)) { + return; + } + + $count = count($value); + if ($count >= $schema->minItems) { + return; + } + + $this->addError(ConstraintError::MIN_ITEMS(), $path, ['minItems' => $schema->minItems, 'found' => $count]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MinLengthConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MinLengthConstraint.php new file mode 100644 index 00000000..d9c516a3 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MinLengthConstraint.php @@ -0,0 +1,39 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'minLength')) { + return; + } + + if (!is_string($value)) { + return; + } + + $length = mb_strlen($value); + if ($length >= $schema->minLength) { + return; + } + + $this->addError(ConstraintError::LENGTH_MIN(), $path, ['minLength' => $schema->minLength, 'found' => $length]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MinPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MinPropertiesConstraint.php new file mode 100644 index 00000000..148b4055 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MinPropertiesConstraint.php @@ -0,0 +1,39 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'minProperties')) { + return; + } + + if (!is_object($value)) { + return; + } + + $count = count(get_object_vars($value)); + if ($count >= $schema->minProperties) { + return; + } + + $this->addError(ConstraintError::PROPERTIES_MIN(), $path, ['minProperties' => $schema->minProperties, 'found' => $count]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MinimumConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MinimumConstraint.php new file mode 100644 index 00000000..b083b8f6 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MinimumConstraint.php @@ -0,0 +1,38 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'minimum')) { + return; + } + + if (!is_numeric($value)) { + return; + } + + if ($value >= $schema->minimum) { + return; + } + + $this->addError(ConstraintError::MINIMUM(), $path, ['minimum' => $schema->minimum, 'found' => $value]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php new file mode 100644 index 00000000..f82440df --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.php @@ -0,0 +1,54 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'multipleOf')) { + return; + } + + if (!is_int($schema->multipleOf) && !is_float($schema->multipleOf) && $schema->multipleOf <= 0.0) { + return; + } + + if (!is_int($value) && !is_float($value)) { + return; + } + + if ($this->isMultipleOf($value, $schema->multipleOf)) { + return; + } + + $this->addError(ConstraintError::MULTIPLE_OF(), $path, ['multipleOf' => $schema->multipleOf, 'found' => $value]); + } + + /** + * @param int|float $number1 + * @param int|float $number2 + */ + private function isMultipleOf($number1, $number2): bool + { + $modulus = ($number1 - round($number1 / $number2) * $number2); + $precision = 0.0000000001; + + return -$precision < $modulus && $modulus < $precision; + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php new file mode 100644 index 00000000..2a8268e1 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/NotConstraint.php @@ -0,0 +1,40 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'not')) { + return; + } + + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($value, $schema->not, $path, $i); + + if (!$schemaConstraint->isValid()) { + return; + } + + $this->addError(ConstraintError::NOT(), $path); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/OneOfConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/OneOfConstraint.php new file mode 100644 index 00000000..cd8efd94 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/OneOfConstraint.php @@ -0,0 +1,50 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'oneOf')) { + return; + } + + $matchedSchema = 0; + foreach ($schema->oneOf as $oneOfSchema) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($value, $oneOfSchema, $path, $i); + + if ($schemaConstraint->isValid()) { + $matchedSchema++; + continue; + } + + $this->addErrors($schemaConstraint->getErrors()); + } + + if ($matchedSchema !== 1) { + $this->addError(ConstraintError::ONE_OF(), $path); + } else { + $this->errorBag()->reset(); + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PatternConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PatternConstraint.php new file mode 100644 index 00000000..5c705f4a --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PatternConstraint.php @@ -0,0 +1,63 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'pattern')) { + return; + } + + if (!is_string($value)) { + return; + } + + $matchPattern = $this->createPregMatchPattern($schema->pattern); + if (preg_match($matchPattern, $value) === 1) { + return; + } + + $this->addError(ConstraintError::PATTERN(), $path, ['found' => $value, 'pattern' => $schema->pattern]); + } + + private function createPregMatchPattern(string $pattern): string + { + $replacements = [ + '\D' => '[^0-9]', + '\d' => '[0-9]', + '\p{digit}' => '[0-9]', + '\w' => '[A-Za-z0-9_]', + '\W' => '[^A-Za-z0-9_]', + '\s' => '[\s\x{200B}]', // Explicitly include zero width white space + '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations + ]; + + $pattern = str_replace( + array_keys($replacements), + array_values($replacements), + $pattern + ); + + return '/' . str_replace('/', '\/', $pattern) . '/u'; + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php new file mode 100644 index 00000000..969736bf --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php @@ -0,0 +1,72 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'patternProperties')) { + return; + } + + if (!is_object($value)) { + return; + } + + $properties = get_object_vars($value); + + foreach ($properties as $propertyName => $propertyValue) { + foreach ($schema->patternProperties as $patternPropertyRegex => $patternPropertySchema) { + $matchPattern = $this->createPregMatchPattern($patternPropertyRegex); + if (preg_match($matchPattern, (string) $propertyName)) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($propertyValue, $patternPropertySchema, $path, $i); + if ($schemaConstraint->isValid()) { + continue; + } + + $this->addErrors($schemaConstraint->getErrors()); + } + } + } + } + + private function createPregMatchPattern(string $pattern): string + { + $replacements = [ +// '\D' => '[^0-9]', + '\d' => '[0-9]', + '\p{digit}' => '[0-9]', +// '\w' => '[A-Za-z0-9_]', +// '\W' => '[^A-Za-z0-9_]', +// '\s' => '[\s\x{200B}]' // Explicitly include zero width white space + '\p{Letter}' => '\p{L}', // Map ECMA long property name to PHP (PCRE) Unicode property abbreviations + ]; + + $pattern = str_replace( + array_keys($replacements), + array_values($replacements), + $pattern + ); + + return '/' . str_replace('/', '\/', $pattern) . '/u'; + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php new file mode 100644 index 00000000..4a9c4808 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesConstraint.php @@ -0,0 +1,48 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'properties')) { + return; + } + + if (!is_object($value)) { + return; + } + + foreach ($schema->properties as $propertyName => $propertySchema) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + if (!property_exists($value, $propertyName)) { + continue; + } + + $schemaConstraint->check($value->{$propertyName}, $propertySchema, $path, $i); + if ($schemaConstraint->isValid()) { + continue; + } + + $this->addErrors($schemaConstraint->getErrors()); + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php new file mode 100644 index 00000000..d621ecf2 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PropertiesNamesConstraint.php @@ -0,0 +1,65 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'propertyNames')) { + return; + } + + if (!is_object($value)) { + return; + } + if ($schema->propertyNames === true) { + return; + } + + $propertyNames = get_object_vars($value); + + if ($schema->propertyNames === false) { + foreach ($propertyNames as $propertyName => $_) { + $this->addError(ConstraintError::PROPERTY_NAMES(), $path, ['propertyNames' => $schema->propertyNames, 'violating' => 'false', 'name' => $propertyName]); + } + + return; + } + + if (property_exists($schema->propertyNames, 'maxLength')) { + foreach ($propertyNames as $propertyName => $_) { + $length = mb_strlen($propertyName); + if ($length > $schema->propertyNames->maxLength) { + $this->addError(ConstraintError::PROPERTY_NAMES(), $path, ['propertyNames' => $schema->propertyNames, 'violating' => 'maxLength', 'length' => $length, 'name' => $propertyName]); + } + } + } + + if (property_exists($schema->propertyNames, 'pattern')) { + foreach ($propertyNames as $propertyName => $_) { + if (!preg_match('/' . str_replace('/', '\/', $schema->propertyNames->pattern) . '/', $propertyName)) { + $this->addError(ConstraintError::PROPERTY_NAMES(), $path, ['propertyNames' => $schema->propertyNames, 'violating' => 'pattern', 'name' => $propertyName]); + } + } + } + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/RefConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/RefConstraint.php new file mode 100644 index 00000000..f52bcda0 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/RefConstraint.php @@ -0,0 +1,45 @@ +factory = $factory ?: new Factory(); + $this->initialiseErrorBag($this->factory); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, '$ref')) { + return; + } + + try { + $refSchema = $this->factory->getSchemaStorage()->resolveRefSchema($schema); + } catch (\Exception $e) { + return; + } + + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($value, $refSchema, $path, $i); + + if ($schemaConstraint->isValid()) { + return; + } + + $this->addErrors($schemaConstraint->getErrors()); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/RequiredConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/RequiredConstraint.php new file mode 100644 index 00000000..9e56ef22 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/RequiredConstraint.php @@ -0,0 +1,58 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'required')) { + return; + } + + if (!is_object($value)) { + return; + } + + foreach ($schema->required as $required) { + if (property_exists($value, $required)) { + continue; + } + + $this->addError(ConstraintError::REQUIRED(), $this->incrementPath($path, $required), ['property' => $required]); + } + } + + /** + * @todo refactor as this was only copied from UndefinedConstraint + * Bubble down the path + * + * @param JsonPointer|null $path Current path + * @param mixed $i What to append to the path + */ + protected function incrementPath(?JsonPointer $path, $i): JsonPointer + { + $path = $path ?? new JsonPointer(''); + + if ($i === null || $i === '') { + return $path; + } + + return $path->withPropertyPaths(array_merge($path->getPropertyPaths(), [$i])); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php new file mode 100644 index 00000000..531a4f95 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/TypeConstraint.php @@ -0,0 +1,50 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'type')) { + return; + } + + $schemaTypes = (array) $schema->type; + $valueType = strtolower(gettype($value)); + // All specific number types are a number + $valueIsNumber = $valueType === 'double' || $valueType === 'integer'; + // A float with zero fractional part is an integer + $isInteger = $valueIsNumber && fmod($value, 1.0) === 0.0; + + foreach ($schemaTypes as $type) { + if ($valueType === $type) { + return; + } + + if ($type === 'number' && $valueIsNumber) { + return; + } + if ($type === 'integer' && $isInteger) { + return; + } + } + + $this->addError(ConstraintError::TYPE(), $path, ['found' => $valueType, 'expected' => implode(', ', $schemaTypes)]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php new file mode 100644 index 00000000..93453401 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/UniqueItemsConstraint.php @@ -0,0 +1,48 @@ +initialiseErrorBag($factory ?: new Factory()); + } + + public function check(&$value, $schema = null, ?JsonPointer $path = null, $i = null): void + { + if (!property_exists($schema, 'uniqueItems')) { + return; + } + if (!is_array($value)) { + return; + } + + if ($schema->uniqueItems !== true) { + // If unique items not is true duplicates are allowed. + return; + } + + $count = count($value); + for ($x = 0; $x < $count - 1; $x++) { + for ($y = $x + 1; $y < $count; $y++) { + if (DeepComparer::isEqual($value[$x], $value[$y])) { + $this->addError(ConstraintError::UNIQUE_ITEMS(), $path); + + return; + } + } + } + } +} diff --git a/src/JsonSchema/Constraints/Factory.php b/src/JsonSchema/Constraints/Factory.php index b9220b9d..8a2929a1 100644 --- a/src/JsonSchema/Constraints/Factory.php +++ b/src/JsonSchema/Constraints/Factory.php @@ -11,6 +11,7 @@ namespace JsonSchema\Constraints; +use JsonSchema\DraftIdentifiers; use JsonSchema\Exception\InvalidArgumentException; use JsonSchema\SchemaStorage; use JsonSchema\SchemaStorageInterface; @@ -46,10 +47,13 @@ class Factory private $typeCheck = []; /** - * @var int Validation context + * @var int-mask-of Validation context */ protected $errorContext = Validator::ERROR_DOCUMENT_VALIDATION; + /** @var string */ + private $defaultDialect = DraftIdentifiers::DRAFT_6; + /** * @var array */ @@ -65,7 +69,8 @@ class Factory 'const' => 'JsonSchema\Constraints\ConstConstraint', 'format' => 'JsonSchema\Constraints\FormatConstraint', 'schema' => 'JsonSchema\Constraints\SchemaConstraint', - 'validator' => 'JsonSchema\Validator' + 'validator' => 'JsonSchema\Validator', + 'draft06' => Drafts\Draft06\Draft06Constraint::class, ]; /** @@ -198,7 +203,7 @@ public function createInstanceFor($constraintName) /** * Get the error context * - * @phpstan-return Validator::ERROR_DOCUMENT_VALIDATION|Validator::ERROR_SCHEMA_VALIDATION + * @return int-mask-of */ public function getErrorContext(): int { @@ -208,10 +213,20 @@ public function getErrorContext(): int /** * Set the error context * - * @phpstan-param Validator::ERROR_DOCUMENT_VALIDATION|Validator::ERROR_SCHEMA_VALIDATION $errorContext + * @param int-mask-of $errorContext */ public function setErrorContext(int $errorContext): void { $this->errorContext = $errorContext; } + + public function getDefaultDialect(): string + { + return $this->defaultDialect; + } + + public function setDefaultDialect(string $defaultDialect): void + { + $this->defaultDialect = $defaultDialect; + } } diff --git a/src/JsonSchema/Constraints/SchemaConstraint.php b/src/JsonSchema/Constraints/SchemaConstraint.php index 7852e851..28d15da9 100644 --- a/src/JsonSchema/Constraints/SchemaConstraint.php +++ b/src/JsonSchema/Constraints/SchemaConstraint.php @@ -12,6 +12,7 @@ namespace JsonSchema\Constraints; use JsonSchema\ConstraintError; +use JsonSchema\DraftIdentifiers; use JsonSchema\Entity\JsonPointer; use JsonSchema\Exception\InvalidArgumentException; use JsonSchema\Exception\InvalidSchemaException; @@ -26,7 +27,7 @@ */ class SchemaConstraint extends Constraint { - private const DEFAULT_SCHEMA_SPEC = 'http://json-schema.org/draft-04/schema#'; + private const DEFAULT_SCHEMA_SPEC = DraftIdentifiers::DRAFT_4; /** * {@inheritdoc} diff --git a/src/JsonSchema/Constraints/UndefinedConstraint.php b/src/JsonSchema/Constraints/UndefinedConstraint.php index 1ea0a7bc..bb0946d8 100644 --- a/src/JsonSchema/Constraints/UndefinedConstraint.php +++ b/src/JsonSchema/Constraints/UndefinedConstraint.php @@ -239,12 +239,19 @@ protected function applyDefaultValues(&$value, $schema, $path): void return; } + if (is_bool($schema)) { + return; + } + // apply defaults if appropriate $requiredOnly = (bool) $this->factory->getConfig(self::CHECK_MODE_ONLY_REQUIRED_DEFAULTS); if (isset($schema->properties) && LooseTypeCheck::isObject($value)) { // $value is an object or assoc array, and properties are defined - treat as an object foreach ($schema->properties as $currentProperty => $propertyDefinition) { $propertyDefinition = $this->factory->getSchemaStorage()->resolveRefSchema($propertyDefinition); + if (is_bool($propertyDefinition)) { + continue; + } if ( !LooseTypeCheck::propertyExists($value, $currentProperty) && property_exists($propertyDefinition, 'default') @@ -269,6 +276,10 @@ protected function applyDefaultValues(&$value, $schema, $path): void // $value is an array, and items are defined - treat as plain array foreach ($items as $currentItem => $itemDefinition) { $itemDefinition = $this->factory->getSchemaStorage()->resolveRefSchema($itemDefinition); + if (is_bool($itemDefinition)) { + continue; + } + if ( !array_key_exists($currentItem, $value) && property_exists($itemDefinition, 'default') diff --git a/src/JsonSchema/DraftIdentifiers.php b/src/JsonSchema/DraftIdentifiers.php new file mode 100644 index 00000000..d0e05e78 --- /dev/null +++ b/src/JsonSchema/DraftIdentifiers.php @@ -0,0 +1,48 @@ +getValue()) { + case self::DRAFT_3: + return 'draft03'; + case self::DRAFT_4: + return 'draft04'; + case self::DRAFT_6: + return 'draft06'; + case self::DRAFT_7: + return 'draft07'; + case self::DRAFT_2019_09: + return 'draft2019-09'; + case self::DRAFT_2020_12: + return 'draft2020-12'; + default: + throw new \Exception('Unsupported schema URI: ' . $this->getValue()); + } + } + + public function withoutFragment(): string + { + return rtrim($this->getValue(), '#'); + } +} diff --git a/src/JsonSchema/Entity/ErrorBag.php b/src/JsonSchema/Entity/ErrorBag.php new file mode 100644 index 00000000..e68ece4d --- /dev/null +++ b/src/JsonSchema/Entity/ErrorBag.php @@ -0,0 +1,109 @@ +}, + * "context": int-mask-of + * } + * @phpstan-type ErrorList list + */ +class ErrorBag +{ + /** @var Factory */ + private $factory; + + /** @var ErrorList */ + private $errors = []; + + /** + * @var int-mask-of All error types that have occurred + */ + protected $errorMask = Validator::ERROR_NONE; + + public function __construct(Factory $factory) + { + $this->factory = $factory; + } + + public function reset(): void + { + $this->errors = []; + $this->errorMask = Validator::ERROR_NONE; + } + + /** @return ErrorList */ + public function getErrors(): array + { + return $this->errors; + } + + /** @param array $more */ + public function addError(ConstraintError $constraint, ?JsonPointer $path = null, array $more = []): void + { + $message = $constraint->getMessage(); + $name = $constraint->getValue(); + /** @var Error $error */ + $error = [ + 'property' => $this->convertJsonPointerIntoPropertyPath($path ?: new JsonPointer('')), + 'pointer' => ltrim((string) ($path ?: new JsonPointer('')), '#'), + 'message' => ucfirst(vsprintf($message, array_map(static function ($val) { + if (is_scalar($val)) { + return is_bool($val) ? var_export($val, true) : $val; + } + + return json_encode($val); + }, array_values($more)))), + 'constraint' => [ + 'name' => $name, + 'params' => $more + ], + 'context' => $this->factory->getErrorContext(), + ]; + + if ($this->factory->getConfig(Constraint::CHECK_MODE_EXCEPTIONS)) { + throw new ValidationException(sprintf('Error validating %s: %s', $error['pointer'], $error['message'])); + } + $this->errors[] = $error; + /* @see https://github.com/phpstan/phpstan/issues/9384 */ + $this->errorMask |= $error['context']; // @phpstan-ignore assign.propertyType + } + + /** @param ErrorList $errors */ + public function addErrors(array $errors): void + { + if (!$errors) { + return; + } + + $this->errors = array_merge($this->errors, $errors); + $errorMask = &$this->errorMask; + array_walk($errors, static function ($error) use (&$errorMask) { + $errorMask |= $error['context']; + }); + } + + private function convertJsonPointerIntoPropertyPath(JsonPointer $pointer): string + { + $result = array_map( + static function ($path) { + return sprintf(is_numeric($path) ? '[%d]' : '.%s', $path); + }, + $pointer->getPropertyPaths() + ); + + return trim(implode('', $result), '.'); + } +} diff --git a/src/JsonSchema/Entity/ErrorBagProxy.php b/src/JsonSchema/Entity/ErrorBagProxy.php new file mode 100644 index 00000000..e9d58412 --- /dev/null +++ b/src/JsonSchema/Entity/ErrorBagProxy.php @@ -0,0 +1,66 @@ +errorBag()->getErrors(); + } + + /** @param ErrorList $errors */ + public function addErrors(array $errors): void + { + $this->errorBag()->addErrors($errors); + } + + /** + * @param array $more more array elements to add to the error + */ + public function addError(ConstraintError $constraint, ?JsonPointer $path = null, array $more = []): void + { + $this->errorBag()->addError($constraint, $path, $more); + } + + public function isValid(): bool + { + return $this->errorBag()->getErrors() === []; + } + + protected function initialiseErrorBag(Factory $factory): ErrorBag + { + if (is_null($this->errorBag)) { + $this->errorBag = new ErrorBag($factory); + } + + return $this->errorBag; + } + + protected function errorBag(): ErrorBag + { + if (is_null($this->errorBag)) { + throw new \RuntimeException('ErrorBag not initialized'); + } + + return $this->errorBag; + } + + public function __clone() + { + $this->errorBag()->reset(); + } +} diff --git a/src/JsonSchema/Rfc3339.php b/src/JsonSchema/Rfc3339.php index 3524f681..2a07060d 100644 --- a/src/JsonSchema/Rfc3339.php +++ b/src/JsonSchema/Rfc3339.php @@ -6,27 +6,55 @@ class Rfc3339 { - private const REGEX = '/^(\d{4}-\d{2}-\d{2}[T ]{1}\d{2}:\d{2}:\d{2})(\.\d+)?(Z|([+-]\d{2}):?(\d{2}))$/'; + private const REGEX = '/^(\d{4}-\d{2}-\d{2}[T ](0[0-9]|1[0-9]|2[0-3]):([0-5][0-9]):((?:[0-5][0-9]|60)))(\.\d+)?(Z|([+-](0[0-9]|1[0-9]|2[0-3]))(:)?([0-5][0-9]))$/'; /** * Try creating a DateTime instance * - * @param string $string + * @param string $input * * @return \DateTime|null */ - public static function createFromString($string) + public static function createFromString($input): ?\DateTime { - if (!preg_match(self::REGEX, strtoupper($string), $matches)) { + if (!preg_match(self::REGEX, strtoupper($input), $matches)) { return null; } + $input = strtoupper($input); // Cleanup for lowercase t and z + $inputHasTSeparator = strpos($input, 'T'); + $dateAndTime = $matches[1]; - $microseconds = $matches[2] ?: '.000000'; - $timeZone = 'Z' !== $matches[3] ? $matches[4] . ':' . $matches[5] : '+00:00'; - $dateFormat = strpos($dateAndTime, 'T') === false ? 'Y-m-d H:i:s.uP' : 'Y-m-d\TH:i:s.uP'; - $dateTime = \DateTime::createFromFormat($dateFormat, $dateAndTime . $microseconds . $timeZone, new \DateTimeZone('UTC')); + $microseconds = $matches[5] ?: '.000000'; + $timeZone = 'Z' !== $matches[6] ? $matches[6] : '+00:00'; + $dateFormat = $inputHasTSeparator === false ? 'Y-m-d H:i:s.uP' : 'Y-m-d\TH:i:s.uP'; + $dateTime = \DateTimeImmutable::createFromFormat($dateFormat, $dateAndTime . $microseconds . $timeZone, new \DateTimeZone('UTC')); + + if ($dateTime === false) { + return null; + } + + $utcDateTime = $dateTime->setTimezone(new \DateTimeZone('+00:00')); + $oneSecond = new \DateInterval('PT1S'); + + // handle leap seconds + if ($matches[4] === '60' && $utcDateTime->sub($oneSecond)->format('H:i:s') === '23:59:59') { + $dateTime = $dateTime->sub($oneSecond); + $matches[1] = str_replace(':60', ':59', $matches[1]); + } + + // Ensure we still have the same year, month, day, hour, minutes and seconds to ensure no rollover took place. + if ($dateTime->format($inputHasTSeparator ? 'Y-m-d\TH:i:s' : 'Y-m-d H:i:s') !== $matches[1]) { + return null; + } + + $mutable = \DateTime::createFromFormat('U.u', $dateTime->format('U.u')); + if ($mutable === false) { + throw new \RuntimeException('Unable to create DateTime from DateTimeImmutable'); + } + + $mutable->setTimezone($dateTime->getTimezone()); - return $dateTime ?: null; + return $mutable; } } diff --git a/src/JsonSchema/SchemaStorage.php b/src/JsonSchema/SchemaStorage.php index e7e48ef0..f818aecd 100644 --- a/src/JsonSchema/SchemaStorage.php +++ b/src/JsonSchema/SchemaStorage.php @@ -62,9 +62,9 @@ public function addSchema(string $id, $schema = null): void // workaround for bug in draft-03 & draft-04 meta-schemas (id & $ref defined with incorrect format) // see https://github.com/json-schema-org/JSON-Schema-Test-Suite/issues/177#issuecomment-293051367 if (is_object($schema) && property_exists($schema, 'id')) { - if ($schema->id === 'http://json-schema.org/draft-04/schema#') { + if ($schema->id === DraftIdentifiers::DRAFT_4) { $schema->properties->id->format = 'uri-reference'; - } elseif ($schema->id === 'http://json-schema.org/draft-03/schema#') { + } elseif ($schema->id === DraftIdentifiers::DRAFT_3) { $schema->properties->id->format = 'uri-reference'; $schema->properties->{'$ref'}->format = 'uri-reference'; } @@ -106,9 +106,10 @@ private function expandRefs(&$schema, ?string $parentId = null): void continue; } + $schemaId = $this->findSchemaIdInObject($schema); $childId = $parentId; - if (property_exists($schema, 'id') && is_string($schema->id) && $childId !== $schema->id) { - $childId = $this->uriResolver->resolve($schema->id, $childId); + if (is_string($schemaId) && $childId !== $schemaId) { + $childId = $this->uriResolver->resolve($schemaId, $childId); } $this->expandRefs($member, $childId); @@ -146,6 +147,7 @@ public function resolveRef(string $ref, $resolveStack = []) // get & process the schema $refSchema = $this->getSchema($fileName); foreach ($jsonPointer->getPropertyPaths() as $path) { + $path = urldecode($path); if (is_object($refSchema) && property_exists($refSchema, $path)) { $refSchema = $this->resolveRefSchema($refSchema->{$path}, $resolveStack); } elseif (is_array($refSchema) && array_key_exists($path, $refSchema)) { @@ -179,6 +181,10 @@ public function resolveRefSchema($refSchema, $resolveStack = []) return $this->resolveRef($refSchema->{'$ref'}, $resolveStack); } + if (is_object($refSchema) && array_keys(get_object_vars($refSchema)) === ['']) { + $refSchema = get_object_vars($refSchema)['']; + } + return $refSchema; } @@ -196,17 +202,35 @@ private function scanForSubschemas($schema, string $parentId): void continue; } - if (property_exists($potentialSubSchema, 'id') && is_string($potentialSubSchema->id) && property_exists($potentialSubSchema, 'type')) { + $potentialSubSchemaId = $this->findSchemaIdInObject($potentialSubSchema); + if (is_string($potentialSubSchemaId) && property_exists($potentialSubSchema, 'type')) { // Enum and const don't allow id as a keyword, see https://github.com/json-schema-org/JSON-Schema-Test-Suite/pull/471 if (in_array($propertyName, ['enum', 'const'])) { continue; } + // $id in unknow keywords is not valid + if (in_array($propertyName, [])) { + continue; + } + // Found sub schema - $this->addSchema($this->uriResolver->resolve($potentialSubSchema->id, $parentId), $potentialSubSchema); + $this->addSchema($this->uriResolver->resolve($potentialSubSchemaId, $parentId), $potentialSubSchema); } $this->scanForSubschemas($potentialSubSchema, $parentId); } } + + private function findSchemaIdInObject(object $schema): ?string + { + if (property_exists($schema, 'id') && is_string($schema->id)) { + return $schema->id; + } + if (property_exists($schema, '$id') && is_string($schema->{'$id'})) { + return $schema->{'$id'}; + } + + return null; + } } diff --git a/src/JsonSchema/SchemaStorageInterface.php b/src/JsonSchema/SchemaStorageInterface.php index f625cdd2..e0aaf237 100644 --- a/src/JsonSchema/SchemaStorageInterface.php +++ b/src/JsonSchema/SchemaStorageInterface.php @@ -9,21 +9,21 @@ interface SchemaStorageInterface /** * Adds schema with given identifier * - * @param object $schema + * @param object|bool $schema */ public function addSchema(string $id, $schema = null): void; /** * Returns schema for given identifier, or null if it does not exist * - * @return object + * @return object|bool */ public function getSchema(string $id); /** * Returns schema for given reference with all sub-references resolved * - * @return object + * @return object|bool */ public function resolveRef(string $ref); @@ -32,7 +32,7 @@ public function resolveRef(string $ref); * * @param mixed $refSchema * - * @return object + * @return object|bool */ public function resolveRefSchema($refSchema); } diff --git a/src/JsonSchema/Tool/Validator/RelativeReferenceValidator.php b/src/JsonSchema/Tool/Validator/RelativeReferenceValidator.php index 2409f144..fd95f7bf 100644 --- a/src/JsonSchema/Tool/Validator/RelativeReferenceValidator.php +++ b/src/JsonSchema/Tool/Validator/RelativeReferenceValidator.php @@ -16,6 +16,10 @@ public static function isValid(string $ref): bool } // Additional checks for invalid cases + if (strpos($ref, '\\') !== false) { + return false; // Backslashes are not allowed in URI references + } + if (preg_match('/^(http|https):\/\//', $ref)) { return false; // Absolute URI } diff --git a/src/JsonSchema/Tool/Validator/UriValidator.php b/src/JsonSchema/Tool/Validator/UriValidator.php index d7ed0a83..b761f91f 100644 --- a/src/JsonSchema/Tool/Validator/UriValidator.php +++ b/src/JsonSchema/Tool/Validator/UriValidator.php @@ -19,12 +19,16 @@ public static function isValid(string $uri): bool (\#(.*))? # Optional fragment $/ix'; - // RFC 3986: Non-Hierarchical URIs (mailto, data, urn) + // RFC 3986: Non-Hierarchical URIs (mailto, data, urn, news) $nonHierarchicalPattern = '/^ - (mailto|data|urn): # Only allow known non-hierarchical schemes - (.+) # Must contain at least one character after scheme + (mailto|data|urn|news|tel): # Only allow known non-hierarchical schemes + (.+) # Must contain at least one character after scheme $/ix'; + // Validation for newsgroup name (alphanumeric + dots, no empty segments) + $newsGroupPattern = '/^[a-z0-9]+(\.[a-z0-9]+)*$/i'; + $telPattern = '/^\+?[0-9.\-() ]+$/'; // Allows +, digits, separators + // RFC 5322-compliant email validation for `mailto:` URIs $emailPattern = '/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/'; @@ -40,7 +44,7 @@ public static function isValid(string $uri): bool return false; } - // Validate path (reject illegal characters: < > { } | \ ^ `) + // Validate the path (reject illegal characters: < > { } | \ ^ `) if (!empty($matches[6]) && preg_match('/[<>{}|\\\^`]/', $matches[6])) { return false; } @@ -57,6 +61,14 @@ public static function isValid(string $uri): bool return preg_match($emailPattern, $matches[2]) === 1; } + if ($scheme === 'news') { + return preg_match($newsGroupPattern, $matches[2]) === 1; + } + + if ($scheme === 'tel') { + return preg_match($telPattern, $matches[2]) === 1; + } + return true; // Valid non-hierarchical URI } diff --git a/src/JsonSchema/Uri/Retrievers/FileGetContents.php b/src/JsonSchema/Uri/Retrievers/FileGetContents.php index a4f96593..13f9c5cc 100644 --- a/src/JsonSchema/Uri/Retrievers/FileGetContents.php +++ b/src/JsonSchema/Uri/Retrievers/FileGetContents.php @@ -33,6 +33,12 @@ public function retrieve($uri) set_error_handler(function ($errno, $errstr) use (&$errorMessage) { $errorMessage = $errstr; }); + + // Ensure clean state for http_get_last_response_headers + if (function_exists('http_clear_last_response_headers')) { + http_clear_last_response_headers(); + } + $response = file_get_contents($uri); restore_error_handler(); diff --git a/src/JsonSchema/Uri/UriRetriever.php b/src/JsonSchema/Uri/UriRetriever.php index 4094cccc..e2d7911c 100644 --- a/src/JsonSchema/Uri/UriRetriever.php +++ b/src/JsonSchema/Uri/UriRetriever.php @@ -31,7 +31,7 @@ class UriRetriever implements BaseUriRetrieverInterface */ protected $translationMap = [ // use local copies of the spec schemas - '|^https?://json-schema.org/draft-(0[34])/schema#?|' => 'package://dist/schema/json-schema-draft-$1.json' + '|^https?://json-schema.org/draft-(0[346])/schema#?|' => 'package://dist/schema/json-schema-draft-$1.json' ]; /** @@ -91,7 +91,7 @@ public function confirmMediaType($uriRetriever, $uri) } } - throw new InvalidSchemaMediaTypeException(sprintf('Media type %s expected', Validator::SCHEMA_MEDIA_TYPE)); + throw new InvalidSchemaMediaTypeException(sprintf('Media type %s expected, %s was given for %s', Validator::SCHEMA_MEDIA_TYPE, $contentType, $uri)); } /** diff --git a/src/JsonSchema/Validator.php b/src/JsonSchema/Validator.php index 0845b0cb..b90f17aa 100644 --- a/src/JsonSchema/Validator.php +++ b/src/JsonSchema/Validator.php @@ -61,13 +61,35 @@ public function validate(&$value, $schema = null, ?int $checkMode = null): int if (LooseTypeCheck::propertyExists($schema, 'id')) { $schemaURI = LooseTypeCheck::propertyGet($schema, 'id'); } + if (LooseTypeCheck::propertyExists($schema, '$id')) { + $schemaURI = LooseTypeCheck::propertyGet($schema, '$id'); + } $this->factory->getSchemaStorage()->addSchema($schemaURI, $schema); $validator = $this->factory->createInstanceFor('schema'); - $validator->check( - $value, - $this->factory->getSchemaStorage()->getSchema($schemaURI) - ); + $schema = $this->factory->getSchemaStorage()->getSchema($schemaURI); + + // Boolean schema requires no further validation + if (is_bool($schema)) { + if ($schema === false) { + $this->addError(ConstraintError::FALSE()); + } + + return $this->getErrorMask(); + } + + if ($this->factory->getConfig(Constraint::CHECK_MODE_STRICT)) { + $dialect = $this->factory->getDefaultDialect(); + if (property_exists($schema, '$schema')) { + $dialect = $schema->{'$schema'}; + } + + $validator = $this->factory->createInstanceFor( + DraftIdentifiers::byValue($dialect)->toConstraintName() + ); + } + + $validator->check($value, $schema); $this->factory->setConfig($initialCheckMode); diff --git a/tests/Constraints/BaseTestCase.php b/tests/Constraints/BaseTestCase.php index 63b63a42..3cdb6e09 100644 --- a/tests/Constraints/BaseTestCase.php +++ b/tests/Constraints/BaseTestCase.php @@ -7,13 +7,14 @@ use Generator; use JsonSchema\Constraints\Constraint; use JsonSchema\Constraints\Factory; +use JsonSchema\DraftIdentifiers; use JsonSchema\SchemaStorage; use JsonSchema\Uri\UriResolver; use JsonSchema\Validator; abstract class BaseTestCase extends VeryBaseTestCase { - protected $schemaSpec = 'http://json-schema.org/draft-04/schema#'; + protected $schemaSpec = DraftIdentifiers::DRAFT_4; protected $validateSchema = false; /** @@ -88,7 +89,7 @@ public function testInvalidCasesUsingAssoc(string $input, string $schema, ?int $ * * @param ?int-mask-of $checkMode */ - public function testValidCases(string $input, string $schema, ?int $checkMode = Constraint::CHECK_MODE_NORMAL): void + public function testValidCases(string $input, string $schema, int $checkMode = Constraint::CHECK_MODE_NORMAL): void { if ($this->validateSchema) { $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; @@ -152,7 +153,7 @@ public function getInvalidForAssocTests(): Generator yield from $this->getInvalidTests(); } - private function validatorErrorsToString(Validator $validator): string + protected function validatorErrorsToString(Validator $validator): string { return implode( ', ', diff --git a/tests/Constraints/BasicTypesTest.php b/tests/Constraints/BasicTypesTest.php index 497cb578..69f14ed3 100644 --- a/tests/Constraints/BasicTypesTest.php +++ b/tests/Constraints/BasicTypesTest.php @@ -4,10 +4,12 @@ namespace JsonSchema\Tests\Constraints; +use JsonSchema\DraftIdentifiers; + class BasicTypesTest extends BaseTestCase { /** @var string */ - protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; + protected $schemaSpec = DraftIdentifiers::DRAFT_3; /** @var bool */ protected $validateSchema = true; diff --git a/tests/Constraints/ConstTest.php b/tests/Constraints/ConstTest.php index 25235f0c..2235fd9b 100644 --- a/tests/Constraints/ConstTest.php +++ b/tests/Constraints/ConstTest.php @@ -4,10 +4,12 @@ namespace JsonSchema\Tests\Constraints; +use JsonSchema\DraftIdentifiers; + class ConstTest extends BaseTestCase { /** @var string */ - protected $schemaSpec = 'http://json-schema.org/draft-06/schema#'; + protected $schemaSpec = DraftIdentifiers::DRAFT_6; /** @var bool */ protected $validateSchema = true; diff --git a/tests/Constraints/DependenciesTest.php b/tests/Constraints/DependenciesTest.php index ca0dc63a..34884bc8 100644 --- a/tests/Constraints/DependenciesTest.php +++ b/tests/Constraints/DependenciesTest.php @@ -4,10 +4,12 @@ namespace JsonSchema\Tests\Constraints; +use JsonSchema\DraftIdentifiers; + class DependenciesTest extends BaseTestCase { /** @var string */ - protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; + protected $schemaSpec = DraftIdentifiers::DRAFT_3; /** @var bool */ protected $validateSchema = true; diff --git a/tests/Constraints/DisallowTest.php b/tests/Constraints/DisallowTest.php index 943c94aa..9b1f845d 100644 --- a/tests/Constraints/DisallowTest.php +++ b/tests/Constraints/DisallowTest.php @@ -4,6 +4,8 @@ namespace JsonSchema\Tests\Constraints; +use JsonSchema\DraftIdentifiers; + /** * Schemas in these tests look like draft-03, but the 'disallow' patterns provided are in * violation of the spec - 'disallow' as defined in draft-03 accepts the same values as the @@ -14,7 +16,7 @@ class DisallowTest extends BaseTestCase { /** @var string */ - protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; + protected $schemaSpec = DraftIdentifiers::DRAFT_3; public function getInvalidTests(): \Generator { diff --git a/tests/Constraints/EnumTest.php b/tests/Constraints/EnumTest.php index 69f1de10..a805a94b 100644 --- a/tests/Constraints/EnumTest.php +++ b/tests/Constraints/EnumTest.php @@ -4,10 +4,12 @@ namespace JsonSchema\Tests\Constraints; +use JsonSchema\DraftIdentifiers; + class EnumTest extends BaseTestCase { /** @var string */ - protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; + protected $schemaSpec = DraftIdentifiers::DRAFT_3; /** @var bool */ protected $validateSchema = true; diff --git a/tests/Constraints/ExtendsTest.php b/tests/Constraints/ExtendsTest.php index 2fd0adc1..58925f0f 100644 --- a/tests/Constraints/ExtendsTest.php +++ b/tests/Constraints/ExtendsTest.php @@ -4,10 +4,12 @@ namespace JsonSchema\Tests\Constraints; +use JsonSchema\DraftIdentifiers; + class ExtendsTest extends BaseTestCase { /** @var string */ - protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; + protected $schemaSpec = DraftIdentifiers::DRAFT_3; /** @var bool */ protected $validateSchema = true; diff --git a/tests/Constraints/RequiredPropertyTest.php b/tests/Constraints/RequiredPropertyTest.php index 06fdedbd..482f8cf5 100644 --- a/tests/Constraints/RequiredPropertyTest.php +++ b/tests/Constraints/RequiredPropertyTest.php @@ -6,6 +6,7 @@ use JsonSchema\Constraints\Constraint; use JsonSchema\Constraints\UndefinedConstraint; +use JsonSchema\DraftIdentifiers; class RequiredPropertyTest extends BaseTestCase { @@ -16,7 +17,7 @@ class RequiredPropertyTest extends BaseTestCase * * @var string * */ - protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; + protected $schemaSpec = DraftIdentifiers::DRAFT_3; public function testErrorPropertyIsPopulatedForRequiredIfMissingInInput(): void { diff --git a/tests/Constraints/VeryBaseTestCase.php b/tests/Constraints/VeryBaseTestCase.php index f468ac0b..1c209085 100644 --- a/tests/Constraints/VeryBaseTestCase.php +++ b/tests/Constraints/VeryBaseTestCase.php @@ -4,6 +4,7 @@ namespace JsonSchema\Tests\Constraints; +use JsonSchema\DraftIdentifiers; use JsonSchema\UriRetrieverInterface; use PHPUnit\Framework\TestCase; use Prophecy\Argument; @@ -17,7 +18,12 @@ abstract class VeryBaseTestCase extends TestCase /** @var array */ private $draftSchemas = []; - protected function getUriRetrieverMock(?object $schema): object + /** + * @param object|bool|null $schema + * + * @return object + */ + protected function getUriRetrieverMock($schema): object { $uriRetriever = $this->prophesize(UriRetrieverInterface::class); $uriRetriever->retrieve($schema->id ?? 'http://www.my-domain.com/schema.json') @@ -27,14 +33,14 @@ protected function getUriRetrieverMock(?object $schema): object $that = $this; $uriRetriever->retrieve(Argument::any()) ->will(function ($args) use ($that): stdClass { - if (strpos($args[0], 'http://json-schema.org/draft-03/schema') === 0) { + if (strpos($args[0], DraftIdentifiers::DRAFT_3()->withoutFragment()) === 0) { return $that->getDraftSchema('json-schema-draft-03.json'); } - if (strpos($args[0], 'http://json-schema.org/draft-04/schema') === 0) { + if (strpos($args[0], DraftIdentifiers::DRAFT_4()->withoutFragment()) === 0) { return $that->getDraftSchema('json-schema-draft-04.json'); } - if (strpos($args[0], 'http://json-schema.org/draft-06/schema') === 0) { + if (strpos($args[0], DraftIdentifiers::DRAFT_6()->withoutFragment()) === 0) { return $that->getDraftSchema('json-schema-draft-06.json'); } diff --git a/tests/Drafts/Draft3Test.php b/tests/Drafts/Draft3Test.php index 2d1202e7..1edee6b0 100644 --- a/tests/Drafts/Draft3Test.php +++ b/tests/Drafts/Draft3Test.php @@ -5,13 +5,14 @@ namespace JsonSchema\Tests\Drafts; use JsonSchema\Constraints\Factory; +use JsonSchema\DraftIdentifiers; use JsonSchema\SchemaStorage; use JsonSchema\Validator; class Draft3Test extends BaseDraftTestCase { /** @var string */ - protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; + protected $schemaSpec = DraftIdentifiers::DRAFT_3; /** @var bool */ protected $validateSchema = true; diff --git a/tests/JsonSchemaTestSuiteTest.php b/tests/JsonSchemaTestSuiteTest.php index 0c4931bf..3e16500c 100644 --- a/tests/JsonSchemaTestSuiteTest.php +++ b/tests/JsonSchemaTestSuiteTest.php @@ -5,7 +5,9 @@ namespace JsonSchema\Tests; use CallbackFilterIterator; +use JsonSchema\Constraints\Constraint; use JsonSchema\Constraints\Factory; +use JsonSchema\DraftIdentifiers; use JsonSchema\SchemaStorage; use JsonSchema\SchemaStorageInterface; use JsonSchema\Validator; @@ -18,27 +20,29 @@ class JsonSchemaTestSuiteTest extends TestCase /** * @dataProvider casesDataProvider * - * @param mixed $data + * @param \stdClass|bool $schema + * @param mixed $data */ public function testTestCaseValidatesCorrectly( string $testCaseDescription, string $testDescription, - \stdClass $schema, + $schema, $data, + int $checkMode, bool $expectedValidationResult, bool $optional - ): void - { + ): void { $schemaStorage = new SchemaStorage(); - $schemaStorage->addSchema(property_exists($schema, 'id') ? $schema->id : SchemaStorage::INTERNAL_PROVIDED_SCHEMA_URI, $schema); + $id = is_object($schema) && property_exists($schema, 'id') ? $schema->id : SchemaStorage::INTERNAL_PROVIDED_SCHEMA_URI; + $schemaStorage->addSchema($id, $schema); $this->loadRemotesIntoStorage($schemaStorage); $validator = new Validator(new Factory($schemaStorage)); try { - $validator->validate($data, $schema); + $validator->validate($data, $schema, $checkMode); } catch (\Exception $e) { if ($optional) { - $this->markTestSkipped('Optional test case would during validate() invocation'); + $this->markTestSkipped('Optional test case throws exception during validate() invocation: "' . $e->getMessage() . '"'); } throw $e; @@ -48,7 +52,11 @@ public function testTestCaseValidatesCorrectly( $this->markTestSkipped('Optional test case would fail'); } - self::assertEquals($expectedValidationResult, count($validator->getErrors()) === 0); + self::assertEquals( + $expectedValidationResult, + count($validator->getErrors()) === 0, + $expectedValidationResult ? print_r($validator->getErrors(), true) : 'Validator returned valid but the testcase indicates it is invalid' + ); } public function casesDataProvider(): \Generator @@ -57,10 +65,11 @@ public function casesDataProvider(): \Generator $drafts = array_filter(glob($testDir . '/*'), static function (string $filename) { return is_dir($filename); }); - $skippedDrafts = ['draft6', 'draft7', 'draft2019-09', 'draft2020-12', 'draft-next', 'latest']; + $skippedDrafts = ['draft7', 'draft2019-09', 'draft2020-12', 'draft-next', 'latest']; foreach ($drafts as $draft) { - if (in_array(basename($draft), $skippedDrafts, true)) { + $baseDraftName = basename($draft); + if (in_array($baseDraftName, $skippedDrafts, true)) { continue; } @@ -76,6 +85,11 @@ function ($file) { foreach ($files as $file) { $contents = json_decode(file_get_contents($file->getPathname()), false); foreach ($contents as $testCase) { + // Since draft6 can only be validated using the strict check mode we need to ensure the $schema + // property is set in the test schema + if ($baseDraftName === 'draft6' && is_object($testCase->schema)) { + $testCase->schema->{'$schema'} = DraftIdentifiers::DRAFT_6; + } foreach ($testCase->tests as $test) { $name = sprintf( '[%s/%s%s]: %s: %s is expected to be %s', @@ -96,11 +110,11 @@ function ($file) { 'testDescription' => $test->description, 'schema' => $testCase->schema, 'data' => $test->data, + 'checkMode' => $this->getCheckModeForDraft($baseDraftName), 'expectedValidationResult' => $test->valid, 'optional' => str_contains($file->getPathname(), '/optional/') ]; } - } } } @@ -140,6 +154,27 @@ private function shouldNotYieldTest(string $name): bool '[draft4/refRemote.json]: base URI change - change folder: string is invalid is expected to be invalid', // Test case was added after v1.2.0, skip test for now. '[draft4/refRemote.json]: Location-independent identifier in remote ref: integer is valid is expected to be valid', // Test case was added after v1.2.0, skip test for now. '[draft4/refRemote.json]: Location-independent identifier in remote ref: string is invalid is expected to be invalid', // Test case was added after v1.2.0, skip test for now. + '[draft6/ref.json]: Location-independent identifier with base URI change in subschema: mismatch is expected to be invalid', // Test case was added after v1.2.0, skip test for now. + '[draft6/ref.json]: Location-independent identifier: mismatch is expected to be invalid', // Same test case is skipped for draft4, skip for now as well. + '[draft6/ref.json]: refs with quote: object with strings is invalid is expected to be invalid', // Same test case is skipped for draft4, skip for now as well. + '[draft6/ref.json]: empty tokens in $ref json-pointer: non-number is invalid is expected to be invalid', // Same test case is skipped for draft4, skip for now as well. + '[draft6/refRemote.json]: base URI change - change folder: string is invalid is expected to be invalid', // Same test case is skipped for draft4, skip for now as well. + '[draft6/refRemote.json]: Location-independent identifier in remote ref: string is invalid is expected to be invalid', // Same test case is skipped for draft4, skip for now as well. + // Skipping complex edge cases for now + '[draft6/unknownKeyword.json]: $id inside an unknown keyword is not a real identifier: type matches second anyOf, which has a real schema in it is expected to be valid', + '[draft6/unknownKeyword.json]: $id inside an unknown keyword is not a real identifier: type matches non-schema in third anyOf is expected to be invalid', + '[draft6/refRemote.json]: $ref to $ref finds location-independent $id: non-number is invalid is expected to be invalid', + '[draft6/ref.json]: ref overrides any sibling keywords: ref valid, maxItems ignored is expected to be valid', + '[draft6/ref.json]: Reference an anchor with a non-relative URI: mismatch is expected to be invalid', + '[draft6/ref.json]: refs with relative uris and defs: invalid on inner field is expected to be invalid', + '[draft6/ref.json]: refs with relative uris and defs: invalid on outer field is expected to be invalid', + '[draft6/ref.json]: relative refs with absolute uris and defs: invalid on inner field is expected to be invalid', + '[draft6/ref.json]: relative refs with absolute uris and defs: invalid on outer field is expected to be invalid', + '[draft6/ref.json]: simple URN base URI with JSON pointer: a non-string is invalid is expected to be invalid', + '[draft6/ref.json]: URN base URI with NSS: a non-string is invalid is expected to be invalid', + '[draft6/ref.json]: URN base URI with r-component: a non-string is invalid is expected to be invalid', + '[draft6/ref.json]: URN base URI with q-component: a non-string is invalid is expected to be invalid', + '[draft6/ref.json]: URN base URI with URN and anchor ref: a non-string is invalid is expected to be invalid', ]; if ($this->is32Bit()) { @@ -154,4 +189,16 @@ private function is32Bit(): bool return PHP_INT_SIZE === 4; } + /** + * @phpstan-return int-mask-of + */ + private function getCheckModeForDraft(string $draft): int + { + switch ($draft) { + case 'draft6': + return Constraint::CHECK_MODE_NORMAL | Constraint::CHECK_MODE_STRICT; + default: + return Constraint::CHECK_MODE_NORMAL; + } + } } diff --git a/tests/SchemaStorageTest.php b/tests/SchemaStorageTest.php index 942aa43b..f674b178 100644 --- a/tests/SchemaStorageTest.php +++ b/tests/SchemaStorageTest.php @@ -4,6 +4,7 @@ namespace JsonSchema\Tests; +use JsonSchema\DraftIdentifiers; use JsonSchema\SchemaStorage; use JsonSchema\Uri\UriRetriever; use JsonSchema\Validator; @@ -125,7 +126,7 @@ private function getMainSchema(): object { return (object) [ 'version' => 'v1', - '$schema' => 'http://json-schema.org/draft-04/schema#', + '$schema' => DraftIdentifiers::DRAFT_4, 'id' => 'http://www.example.com/schema.json', 'type' => 'object', 'additionalProperties' => true, @@ -184,7 +185,7 @@ private function getSchema2(): object { return (object) [ 'version' => 'v1', - '$schema' => 'http://json-schema.org/draft-04/schema#', + '$schema' => DraftIdentifiers::DRAFT_4, 'id' => 'http://www.my-domain.com/schema2.json', 'definitions' => (object) [ 'car' => (object) [ @@ -211,7 +212,7 @@ private function getSchema3(): object { return (object) [ 'version' => 'v1', - '$schema' => 'http://json-schema.org/draft-04/schema#', + '$schema' => DraftIdentifiers::DRAFT_4, 'title' => 'wheel', 'wheel' => (object) [ 'properties' => (object) [ @@ -233,7 +234,7 @@ private function getInvalidSchema(): object { return (object) [ 'version' => 'v1', - '$schema' => 'http://json-schema.org/draft-04/schema#', + '$schema' => DraftIdentifiers::DRAFT_4, 'type' => 'object', 'properties' => (object) [ 'spokes' => (object) [ @@ -258,24 +259,24 @@ private function getInvalidSchema(): object public function testGetUriRetriever(): void { $s = new SchemaStorage(); - $s->addSchema('http://json-schema.org/draft-04/schema#'); + $s->addSchema(DraftIdentifiers::DRAFT_4); $this->assertInstanceOf(\JsonSchema\Uri\UriRetriever::class, $s->getUriRetriever()); } public function testGetUriResolver(): void { $s = new SchemaStorage(); - $s->addSchema('http://json-schema.org/draft-04/schema#'); + $s->addSchema(DraftIdentifiers::DRAFT_4); $this->assertInstanceOf(\JsonSchema\Uri\UriResolver::class, $s->getUriResolver()); } public function testMetaSchemaFixes(): void { $s = new SchemaStorage(); - $s->addSchema('http://json-schema.org/draft-03/schema#'); - $s->addSchema('http://json-schema.org/draft-04/schema#'); - $draft_03 = $s->getSchema('http://json-schema.org/draft-03/schema#'); - $draft_04 = $s->getSchema('http://json-schema.org/draft-04/schema#'); + $s->addSchema(DraftIdentifiers::DRAFT_3); + $s->addSchema(DraftIdentifiers::DRAFT_4); + $draft_03 = $s->getSchema(DraftIdentifiers::DRAFT_3); + $draft_04 = $s->getSchema(DraftIdentifiers::DRAFT_4); $this->assertEquals('uri-reference', $draft_03->properties->id->format); $this->assertEquals('uri-reference', $draft_03->properties->{'$ref'}->format); diff --git a/tests/Uri/UriRetrieverTest.php b/tests/Uri/UriRetrieverTest.php index 9d41b3c8..4e791768 100644 --- a/tests/Uri/UriRetrieverTest.php +++ b/tests/Uri/UriRetrieverTest.php @@ -4,6 +4,7 @@ namespace JsonSchema\Tests\Uri; +use JsonSchema\DraftIdentifiers; use JsonSchema\Exception\InvalidSchemaMediaTypeException; use JsonSchema\Exception\JsonDecodingException; use JsonSchema\Exception\ResourceNotFoundException; @@ -306,12 +307,12 @@ public function testDefaultDistTranslations(): void $this->assertEquals( $root . 'json-schema-draft-03.json', - $retriever->translate('http://json-schema.org/draft-03/schema#') + $retriever->translate(DraftIdentifiers::DRAFT_3) ); $this->assertEquals( $root . 'json-schema-draft-04.json', - $retriever->translate('http://json-schema.org/draft-04/schema#') + $retriever->translate(DraftIdentifiers::DRAFT_4) ); }