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..4355a89e --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AdditionalPropertiesConstraint.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, '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("/{$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]); + } + } +} 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..f6228454 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/AnyOfConstraint.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, 'anyOf')) { + return; + } + + foreach ($schema->anyOf as $anyOfSchema) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + + try { + $schemaConstraint->check($value, $anyOfSchema, $path, $i); + + if ($schemaConstraint->isValid()) { + return; + } + } 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..b66d76a8 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/Draft06Constraint.php @@ -0,0 +1,82 @@ +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..e5f61b82 --- /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..8cf7d7ef --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/MultipleOfConstraint.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, 'multipleOf')) { + return; + } + + if (!is_numeric($value)) { + return; + } + + if ($this->isMultipleOf($value, $schema->multipleOf)) { + return; + } + + $this->addError(ConstraintError::MULTIPLE_OF(), $path, ['multipleOf' => $schema->multipleOf, 'found' => $value]); + } + + private function isMultipleOf($number1, $number2) + { + $modulus = ($number1 - round($number1 / $number2) * $number2); + $precision = 0.0000000001; + + if (-$precision < $modulus && $modulus < $precision) { + return true; + } + + return false; + } +} 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..fd241842 --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PatternConstraint.php @@ -0,0 +1,41 @@ +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; + } + + if (preg_match('/' . str_replace('/', '\/', $schema->pattern) . '/', $value) === 1) { + return; + } + + $this->addError(ConstraintError::PATTERN(), $path, ['found' => $value, 'pattern' => $schema->pattern]); + } +} diff --git a/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php b/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.php new file mode 100644 index 00000000..58863b8f --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/PatternPropertiesConstraint.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, 'patternProperties')) { + return; + } + + if (!is_object($value)) { + return; + } + + $properties = get_object_vars($value); + + foreach ($properties as $propertyName => $propertyValue) { + foreach ($schema->patternProperties as $patternPropertyRegex => $patternPropertySchema) { + if (preg_match('/' . str_replace('/', '\/', $patternPropertyRegex) . '/', (string) $propertyName)) { + $schemaConstraint = $this->factory->createInstanceFor('schema'); + $schemaConstraint->check($propertyValue, $patternPropertySchema, $path, $i); + if ($schemaConstraint->isValid()) { + continue; + } + + $this->addErrors($schemaConstraint->getErrors()); + } + } + } + } +} 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..681b550b --- /dev/null +++ b/src/JsonSchema/Constraints/Drafts/Draft06/RefConstraint.php @@ -0,0 +1,46 @@ +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..f613e2a0 100644 --- a/src/JsonSchema/Constraints/Factory.php +++ b/src/JsonSchema/Constraints/Factory.php @@ -65,7 +65,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, ]; /** diff --git a/src/JsonSchema/Entity/ErrorBag.php b/src/JsonSchema/Entity/ErrorBag.php new file mode 100644 index 00000000..9f87633b --- /dev/null +++ b/src/JsonSchema/Entity/ErrorBag.php @@ -0,0 +1,111 @@ +}, + * context: int + * } + * @phpstan-type ErrorList list + */ +class ErrorBag +{ + /** @var Factory */ + private $factory; + + /** @var ErrorList */ + private $errors = []; + + /** + * @var int All error types that have occurred + * @phpstan-var int-mask-of + */ + 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(); + $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; + $this->errorMask |= $error['context']; + } + + /** @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) { + if (isset($error['context'])) { + $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..297b4d61 100644 --- a/src/JsonSchema/Rfc3339.php +++ b/src/JsonSchema/Rfc3339.php @@ -6,27 +6,51 @@ 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')); + $mutable->setTimezone($dateTime->getTimezone()); - return $dateTime ?: null; + return $mutable; } } diff --git a/src/JsonSchema/SchemaStorage.php b/src/JsonSchema/SchemaStorage.php index e7e48ef0..8c069947 100644 --- a/src/JsonSchema/SchemaStorage.php +++ b/src/JsonSchema/SchemaStorage.php @@ -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); @@ -196,17 +197,30 @@ 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; } // 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/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/Validator.php b/src/JsonSchema/Validator.php index 0845b0cb..9cb16dff 100644 --- a/src/JsonSchema/Validator.php +++ b/src/JsonSchema/Validator.php @@ -61,13 +61,30 @@ 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)) { + $validator = $this->factory->createInstanceFor( + $this->schemaUriToConstraintName($schema->{'$schema'}) + ); + } + + $validator->check($value, $schema); $this->factory->setConfig($initialCheckMode); @@ -105,4 +122,18 @@ public function coerce(&$value, $schema): int { return $this->validate($value, $schema, Constraint::CHECK_MODE_COERCE_TYPES); } + + private function schemaUriToConstraintName(string $schemaUri): string + { + switch ($schemaUri) { + case 'http://json-schema.org/draft-03/schema#': + return 'draft03'; + case 'http://json-schema.org/draft-04/schema#': + return 'draft04'; + case 'http://json-schema.org/draft-06/schema#': + return 'draft06'; + } + + throw new \Exception('Unsupported schema URI: ' . $schemaUri); + } } diff --git a/tests/Constraints/BaseTestCase.php b/tests/Constraints/BaseTestCase.php index 63b63a42..9b67eedd 100644 --- a/tests/Constraints/BaseTestCase.php +++ b/tests/Constraints/BaseTestCase.php @@ -88,7 +88,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 +152,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/VeryBaseTestCase.php b/tests/Constraints/VeryBaseTestCase.php index f468ac0b..b90dc63f 100644 --- a/tests/Constraints/VeryBaseTestCase.php +++ b/tests/Constraints/VeryBaseTestCase.php @@ -17,7 +17,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') diff --git a/tests/JsonSchemaTestSuiteTest.php b/tests/JsonSchemaTestSuiteTest.php index 0c4931bf..6fc1c440 100644 --- a/tests/JsonSchemaTestSuiteTest.php +++ b/tests/JsonSchemaTestSuiteTest.php @@ -5,6 +5,7 @@ namespace JsonSchema\Tests; use CallbackFilterIterator; +use JsonSchema\Constraints\Constraint; use JsonSchema\Constraints\Factory; use JsonSchema\SchemaStorage; use JsonSchema\SchemaStorageInterface; @@ -18,24 +19,25 @@ 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, 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, Constraint::CHECK_MODE_NORMAL | Constraint::CHECK_MODE_STRICT); } catch (\Exception $e) { if ($optional) { $this->markTestSkipped('Optional test case would during validate() invocation'); @@ -48,7 +50,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,7 +63,7 @@ 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 = ['draft3', 'draft4', 'draft7', 'draft2019-09', 'draft2020-12', 'draft-next', 'latest']; foreach ($drafts as $draft) { if (in_array(basename($draft), $skippedDrafts, true)) { @@ -76,6 +82,9 @@ function ($file) { foreach ($files as $file) { $contents = json_decode(file_get_contents($file->getPathname()), false); foreach ($contents as $testCase) { + if (is_object($testCase->schema)) { + $testCase->schema->{'$schema'} = 'http://json-schema.org/draft-06/schema#'; // Hardcode $schema property in schema + } foreach ($testCase->tests as $test) { $name = sprintf( '[%s/%s%s]: %s: %s is expected to be %s', @@ -100,7 +109,6 @@ function ($file) { 'optional' => str_contains($file->getPathname(), '/optional/') ]; } - } } } @@ -153,5 +161,4 @@ private function is32Bit(): bool { return PHP_INT_SIZE === 4; } - }