diff --git a/.php_cs.dist b/.php_cs.dist index 9b7f040e..8a227e64 100644 --- a/.php_cs.dist +++ b/.php_cs.dist @@ -11,6 +11,7 @@ $config '@PSR2' => true, '@Symfony' => true, // additionally + 'align_multiline_comment' => array('comment_type' => 'phpdocs_like'), 'array_syntax' => array('syntax' => 'long'), 'binary_operator_spaces' => false, 'concat_space' => array('spacing' => 'one'), @@ -24,6 +25,8 @@ $config 'pre_increment' => false, 'trailing_comma_in_multiline_array' => false, 'simplified_null_return' => false, + 'yoda_style' => null, + 'increment_style' => false, )) ->setFinder($finder) ; diff --git a/.travis.yml b/.travis.yml index 0bb23a4a..3462c9f3 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,6 +10,7 @@ matrix: fast_finish: true include: - php: 5.3 + dist: precise - php: 5.4 - php: 5.5 - php: 5.6 @@ -18,6 +19,7 @@ matrix: - php: 7.1 - php: 'nightly' - php: hhvm + dist: trusty allow_failures: - php: 'nightly' diff --git a/README.md b/README.md index 376c2e27..aabb2eb6 100644 --- a/README.md +++ b/README.md @@ -186,11 +186,19 @@ third argument to `Validator::validate()`, or can be provided as the third argum | `Constraint::CHECK_MODE_NORMAL` | Validate in 'normal' mode - this is the default | | `Constraint::CHECK_MODE_TYPE_CAST` | Enable fuzzy type checking for associative arrays and objects | | `Constraint::CHECK_MODE_COERCE_TYPES` | Convert data types to match the schema where possible | +| `Constraint::CHECK_MODE_EARLY_COERCE` | Apply type coercion as soon as possible | | `Constraint::CHECK_MODE_APPLY_DEFAULTS` | Apply default values from the schema if not set | +| `Constraint::CHECK_MODE_ONLY_REQUIRED_DEFAULTS` | When applying defaults, only set values that are required | | `Constraint::CHECK_MODE_EXCEPTIONS` | Throw an exception immediately if validation fails | +| `Constraint::CHECK_MODE_DISABLE_FORMAT` | Do not validate "format" constraints | +| `Constraint::CHECK_MODE_VALIDATE_SCHEMA` | Validate the schema as well as the provided document | -Please note that using `Constraint::CHECK_MODE_COERCE_TYPES` or `Constraint::CHECK_MODE_APPLY_DEFAULTS` -will modify your original data. +Please note that using `CHECK_MODE_COERCE_TYPES` or `CHECK_MODE_APPLY_DEFAULTS` will modify your +original data. + +`CHECK_MODE_EARLY_COERCE` has no effect unless used in combination with `CHECK_MODE_COERCE_TYPES`. If +enabled, the validator will use (and coerce) the first compatible type it encounters, even if the +schema defines another type that matches directly and does not require coercion. ## Running the tests diff --git a/bin/validate-json b/bin/validate-json index e9c18095..5de011ce 100755 --- a/bin/validate-json +++ b/bin/validate-json @@ -6,29 +6,58 @@ * @author Christian Weiske */ -/** - * Dead simple autoloader - * - * @param string $className Name of class to load - * - * @return void - */ -function __autoload($className) -{ - $className = ltrim($className, '\\'); - $fileName = ''; - $namespace = ''; - if ($lastNsPos = strrpos($className, '\\')) { - $namespace = substr($className, 0, $lastNsPos); - $className = substr($className, $lastNsPos + 1); - $fileName = str_replace('\\', DIRECTORY_SEPARATOR, $namespace) . DIRECTORY_SEPARATOR; - } - $fileName .= str_replace('_', DIRECTORY_SEPARATOR, $className) . '.php'; - if (stream_resolve_include_path($fileName)) { - require_once $fileName; +// support running this tool from git checkout +$projectDirectory = dirname(__DIR__); +if (is_dir($projectDirectory . DIRECTORY_SEPARATOR . 'vendor')) { + set_include_path($projectDirectory . PATH_SEPARATOR . get_include_path()); +} + +// autoload composer classes +$composerAutoload = stream_resolve_include_path('vendor/autoload.php'); +if (!$composerAutoload) { + echo("Cannot load json-schema library\n"); + exit(1); +} +require($composerAutoload); + +$arOptions = array(); +$arArgs = array(); +array_shift($argv);//script itself +foreach ($argv as $arg) { + if ($arg{0} == '-') { + $arOptions[$arg] = true; + } else { + $arArgs[] = $arg; } } +if (count($arArgs) == 0 + || isset($arOptions['--help']) || isset($arOptions['-h']) +) { + echo <<getMessage() . "\n"; + output("Error loading JSON schema file\n"); + output($urlSchema . "\n"); + output($e->getMessage() . "\n"); exit(2); } $refResolver = new JsonSchema\SchemaStorage($retriever, $resolver); @@ -221,17 +220,19 @@ try { $validator->check($data, $schema); if ($validator->isValid()) { - echo "OK. The supplied JSON validates against the schema.\n"; + if(isset($arOptions['--verbose'])) { + output("OK. The supplied JSON validates against the schema.\n"); + } } else { - echo "JSON does not validate. Violations:\n"; + output("JSON does not validate. Violations:\n"); foreach ($validator->getErrors() as $error) { - echo sprintf("[%s] %s\n", $error['property'], $error['message']); + output(sprintf("[%s] %s\n", $error['property'], $error['message'])); } exit(23); } } catch (Exception $e) { - echo "JSON does not validate. Error:\n"; - echo $e->getMessage() . "\n"; - echo "Error code: " . $e->getCode() . "\n"; + output("JSON does not validate. Error:\n"); + output($e->getMessage() . "\n"); + output("Error code: " . $e->getCode() . "\n"); exit(24); } diff --git a/composer.json b/composer.json index f1f6faea..9dc00051 100644 --- a/composer.json +++ b/composer.json @@ -36,13 +36,13 @@ } }], "require": { - "php": ">=5.3.3" + "php": ">=5.3.3", + "marc-mabe/php-enum":"2.3.1" }, "require-dev": { "json-schema/JSON-Schema-Test-Suite": "1.2.0", - "phpunit/phpunit": "^4.8.22", "friendsofphp/php-cs-fixer": "^2.1", - "phpdocumentor/phpdocumentor": "~2" + "phpunit/phpunit": "^4.8.22" }, "autoload": { "psr-4": { "JsonSchema\\": "src/JsonSchema/" } diff --git a/dist/schema/json-schema-draft-03.json b/dist/schema/json-schema-draft-03.json new file mode 100644 index 00000000..7a1a2d38 --- /dev/null +++ b/dist/schema/json-schema-draft-03.json @@ -0,0 +1,174 @@ +{ + "$schema": "http://json-schema.org/draft-03/schema#", + "id": "http://json-schema.org/draft-03/schema#", + "type": "object", + + "properties": { + "type": { + "type": [ "string", "array" ], + "items": { + "type": [ "string", { "$ref": "#" } ] + }, + "uniqueItems": true, + "default": "any" + }, + + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + + "patternProperties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + + "additionalProperties": { + "type": [ { "$ref": "#" }, "boolean" ], + "default": {} + }, + + "items": { + "type": [ { "$ref": "#" }, "array" ], + "items": { "$ref": "#" }, + "default": {} + }, + + "additionalItems": { + "type": [ { "$ref": "#" }, "boolean" ], + "default": {} + }, + + "required": { + "type": "boolean", + "default": false + }, + + "dependencies": { + "type": "object", + "additionalProperties": { + "type": [ "string", "array", { "$ref": "#" } ], + "items": { + "type": "string" + } + }, + "default": {} + }, + + "minimum": { + "type": "number" + }, + + "maximum": { + "type": "number" + }, + + "exclusiveMinimum": { + "type": "boolean", + "default": false + }, + + "exclusiveMaximum": { + "type": "boolean", + "default": false + }, + + "minItems": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + + "maxItems": { + "type": "integer", + "minimum": 0 + }, + + "uniqueItems": { + "type": "boolean", + "default": false + }, + + "pattern": { + "type": "string", + "format": "regex" + }, + + "minLength": { + "type": "integer", + "minimum": 0, + "default": 0 + }, + + "maxLength": { + "type": "integer" + }, + + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + + "default": { + "type": "any" + }, + + "title": { + "type": "string" + }, + + "description": { + "type": "string" + }, + + "format": { + "type": "string" + }, + + "divisibleBy": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true, + "default": 1 + }, + + "disallow": { + "type": [ "string", "array" ], + "items": { + "type": [ "string", { "$ref": "#" } ] + }, + "uniqueItems": true + }, + + "extends": { + "type": [ { "$ref": "#" }, "array" ], + "items": { "$ref": "#" }, + "default": {} + }, + + "id": { + "type": "string", + "format": "uri" + }, + + "$ref": { + "type": "string", + "format": "uri" + }, + + "$schema": { + "type": "string", + "format": "uri" + } + }, + + "dependencies": { + "exclusiveMinimum": "minimum", + "exclusiveMaximum": "maximum" + }, + + "default": {} +} diff --git a/dist/schema/json-schema-draft-04.json b/dist/schema/json-schema-draft-04.json new file mode 100644 index 00000000..85eb502a --- /dev/null +++ b/dist/schema/json-schema-draft-04.json @@ -0,0 +1,150 @@ +{ + "id": "http://json-schema.org/draft-04/schema#", + "$schema": "http://json-schema.org/draft-04/schema#", + "description": "Core schema meta-schema", + "definitions": { + "schemaArray": { + "type": "array", + "minItems": 1, + "items": { "$ref": "#" } + }, + "positiveInteger": { + "type": "integer", + "minimum": 0 + }, + "positiveIntegerDefault0": { + "allOf": [ { "$ref": "#/definitions/positiveInteger" }, { "default": 0 } ] + }, + "simpleTypes": { + "enum": [ "array", "boolean", "integer", "null", "number", "object", "string" ] + }, + "stringArray": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1, + "uniqueItems": true + } + }, + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uri" + }, + "$schema": { + "type": "string", + "format": "uri" + }, + "title": { + "type": "string" + }, + "description": { + "type": "string" + }, + "default": {}, + "multipleOf": { + "type": "number", + "minimum": 0, + "exclusiveMinimum": true + }, + "maximum": { + "type": "number" + }, + "exclusiveMaximum": { + "type": "boolean", + "default": false + }, + "minimum": { + "type": "number" + }, + "exclusiveMinimum": { + "type": "boolean", + "default": false + }, + "maxLength": { "$ref": "#/definitions/positiveInteger" }, + "minLength": { "$ref": "#/definitions/positiveIntegerDefault0" }, + "pattern": { + "type": "string", + "format": "regex" + }, + "additionalItems": { + "anyOf": [ + { "type": "boolean" }, + { "$ref": "#" } + ], + "default": {} + }, + "items": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/schemaArray" } + ], + "default": {} + }, + "maxItems": { "$ref": "#/definitions/positiveInteger" }, + "minItems": { "$ref": "#/definitions/positiveIntegerDefault0" }, + "uniqueItems": { + "type": "boolean", + "default": false + }, + "maxProperties": { "$ref": "#/definitions/positiveInteger" }, + "minProperties": { "$ref": "#/definitions/positiveIntegerDefault0" }, + "required": { "$ref": "#/definitions/stringArray" }, + "additionalProperties": { + "anyOf": [ + { "type": "boolean" }, + { "$ref": "#" } + ], + "default": {} + }, + "definitions": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "properties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "patternProperties": { + "type": "object", + "additionalProperties": { "$ref": "#" }, + "default": {} + }, + "dependencies": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { "$ref": "#" }, + { "$ref": "#/definitions/stringArray" } + ] + } + }, + "enum": { + "type": "array", + "minItems": 1, + "uniqueItems": true + }, + "type": { + "anyOf": [ + { "$ref": "#/definitions/simpleTypes" }, + { + "type": "array", + "items": { "$ref": "#/definitions/simpleTypes" }, + "minItems": 1, + "uniqueItems": true + } + ] + }, + "allOf": { "$ref": "#/definitions/schemaArray" }, + "anyOf": { "$ref": "#/definitions/schemaArray" }, + "oneOf": { "$ref": "#/definitions/schemaArray" }, + "not": { "$ref": "#" } + }, + "dependencies": { + "exclusiveMaximum": [ "maximum" ], + "exclusiveMinimum": [ "minimum" ] + }, + "default": {} +} diff --git a/src/JsonSchema/ConstraintError.php b/src/JsonSchema/ConstraintError.php new file mode 100644 index 00000000..45e9539a --- /dev/null +++ b/src/JsonSchema/ConstraintError.php @@ -0,0 +1,110 @@ +getValue(); + static $messages = array( + self::ADDITIONAL_ITEMS => 'The item %s[%s] is not defined and the definition does not allow additional items', + self::ADDITIONAL_PROPERTIES => 'The property %s is not defined and the definition does not allow additional properties', + self::ALL_OF => 'Failed to match all schemas', + self::ANY_OF => 'Failed to match at least one schema', + self::DEPENDENCIES => '%s depends on %s, which is missing', + self::DISALLOW => 'Disallowed value was matched', + self::DIVISIBLE_BY => 'Is not divisible by %d', + self::ENUM => 'Does not have a value in the enumeration %s', + self::EXCLUSIVE_MINIMUM => 'Must have a minimum value greater than %d', + self::EXCLUSIVE_MAXIMUM => 'Must have a maximum value less than %d', + 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', + self::FORMAT_DATE_UTC => 'Invalid time %s, expected integer of milliseconds since Epoch', + self::FORMAT_EMAIL => 'Invalid email', + self::FORMAT_HOSTNAME => 'Invalid hostname', + self::FORMAT_IP => 'Invalid IP address', + 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_URL => 'Invalid URL format', + self::FORMAT_URL_REF => 'Invalid URL reference format', + self::LENGTH_MAX => 'Must be at most %d characters long', + self::INVALID_SCHEMA => 'Schema is not valid', + self::LENGTH_MIN => 'Must be at least %d characters long', + self::MAX_ITEMS => 'There must be a maximum of %d items in the array', + self::MAXIMUM => 'Must have a maximum value less than or equal to %d', + self::MIN_ITEMS => 'There must be a minimum of %d items in the array', + self::MINIMUM => 'Must have a minimum value greater than or equal to %d', + self::MISSING_MAXIMUM => 'Use of exclusiveMaximum requires presence of maximum', + self::MISSING_MINIMUM => 'Use of exclusiveMinimum requires presence of minimum', + /*self::MISSING_ERROR => 'Used for tests; this error is deliberately commented out',*/ + self::MULTIPLE_OF => 'Must be a multiple of %d', + self::NOT => 'Matched a schema which it should not', + self::ONE_OF => 'Failed to match exactly one schema', + self::REQUIRED => 'The property %s is required', + self::REQUIRES => 'The presence of the property %s requires that %s also be present', + self::PATTERN => 'Does not match the regex pattern %s', + 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::TYPE => '%s value found, but %s is required', + self::UNIQUE_ITEMS => 'There are no duplicates allowed in the array' + ); + + if (!isset($messages[$name])) { + throw new InvalidArgumentException('Missing error message for ' . $name); + } + + return $messages[$name]; + } +} diff --git a/src/JsonSchema/Constraints/BaseConstraint.php b/src/JsonSchema/Constraints/BaseConstraint.php index ef1bdc54..13168d71 100644 --- a/src/JsonSchema/Constraints/BaseConstraint.php +++ b/src/JsonSchema/Constraints/BaseConstraint.php @@ -9,8 +9,12 @@ namespace JsonSchema\Constraints; +use JsonSchema\ConstraintError; +use JsonSchema\Constraints\TypeCheck\LooseTypeCheck; use JsonSchema\Entity\JsonPointer; +use JsonSchema\Exception\InvalidArgumentException; use JsonSchema\Exception\ValidationException; +use JsonSchema\Validator; /** * A more basic constraint definition - used for the public @@ -23,6 +27,11 @@ class BaseConstraint */ protected $errors = array(); + /** + * @var int All error types which have occurred + */ + protected $errorMask = Validator::ERROR_NONE; + /** * @var Factory */ @@ -36,36 +45,68 @@ public function __construct(Factory $factory = null) $this->factory = $factory ?: new Factory(); } - public function addError(JsonPointer $path = null, $message, $constraint = '', array $more = null) + public function addError(ConstraintError $constraint, JsonPointer $path = null, array $more = array()) { + $message = $constraint ? $constraint->getMessage() : ''; + $name = $constraint ? $constraint->getValue() : ''; $error = array( 'property' => $this->convertJsonPointerIntoPropertyPath($path ?: new JsonPointer('')), 'pointer' => ltrim(strval($path ?: new JsonPointer('')), '#'), - 'message' => $message, - 'constraint' => $constraint, + 'message' => ucfirst(vsprintf($message, array_map(function ($val) { + if (is_scalar($val)) { + return $val; + } + + return json_encode($val); + }, array_values($more)))), + 'constraint' => array( + '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'])); } - if (is_array($more) && count($more) > 0) { - $error += $more; - } - $this->errors[] = $error; + $this->errorMask |= $error['context']; } public function addErrors(array $errors) { if ($errors) { $this->errors = array_merge($this->errors, $errors); + $errorMask = &$this->errorMask; + array_walk($errors, function ($error) use (&$errorMask) { + if (isset($error['context'])) { + $errorMask |= $error['context']; + } + }); } } - public function getErrors() + public function getErrors($errorContext = Validator::ERROR_ALL) { - return $this->errors; + if ($errorContext === Validator::ERROR_ALL) { + return $this->errors; + } + + return array_filter($this->errors, function ($error) use ($errorContext) { + if ($errorContext & $error['context']) { + return true; + } + }); + } + + public function numErrors($errorContext = Validator::ERROR_ALL) + { + if ($errorContext === Validator::ERROR_ALL) { + return count($this->errors); + } + + return count($this->getErrors($errorContext)); } public function isValid() @@ -80,5 +121,37 @@ public function isValid() public function reset() { $this->errors = array(); + $this->errorMask = Validator::ERROR_NONE; + } + + /** + * Get the error mask + * + * @return int + */ + public function getErrorMask() + { + return $this->errorMask; + } + + /** + * Recursively cast an associative array to an object + * + * @param array $array + * + * @return object + */ + public static function arrayToObjectRecursive($array) + { + $json = json_encode($array); + if (json_last_error() !== \JSON_ERROR_NONE) { + $message = 'Unable to encode schema array as JSON'; + if (function_exists('json_last_error_msg')) { + $message .= ': ' . json_last_error_msg(); + } + throw new InvalidArgumentException($message); + } + + return (object) json_decode($json); } } diff --git a/src/JsonSchema/Constraints/CollectionConstraint.php b/src/JsonSchema/Constraints/CollectionConstraint.php index 3c594b3c..18f0efd9 100644 --- a/src/JsonSchema/Constraints/CollectionConstraint.php +++ b/src/JsonSchema/Constraints/CollectionConstraint.php @@ -9,6 +9,7 @@ namespace JsonSchema\Constraints; +use JsonSchema\ConstraintError; use JsonSchema\Entity\JsonPointer; /** @@ -26,12 +27,12 @@ public function check(&$value, $schema = null, JsonPointer $path = null, $i = nu { // Verify minItems if (isset($schema->minItems) && count($value) < $schema->minItems) { - $this->addError($path, 'There must be a minimum of ' . $schema->minItems . ' items in the array', 'minItems', array('minItems' => $schema->minItems)); + $this->addError(ConstraintError::MIN_ITEMS(), $path, array('minItems' => $schema->minItems)); } // Verify maxItems if (isset($schema->maxItems) && count($value) > $schema->maxItems) { - $this->addError($path, 'There must be a maximum of ' . $schema->maxItems . ' items in the array', 'maxItems', array('maxItems' => $schema->maxItems)); + $this->addError(ConstraintError::MAX_ITEMS(), $path, array('maxItems' => $schema->maxItems)); } // Verify uniqueItems @@ -43,7 +44,7 @@ public function check(&$value, $schema = null, JsonPointer $path = null, $i = nu }, $value); } if (count(array_unique($unique)) != count($value)) { - $this->addError($path, 'There are no duplicates allowed in the array', 'uniqueItems'); + $this->addError(ConstraintError::UNIQUE_ITEMS(), $path); } } @@ -85,8 +86,8 @@ protected function validateItems(&$value, $schema = null, JsonPointer $path = nu $validator->check($v, $schema->items, $k_path, $i); } - unset($v); // remove dangling reference to prevent any future bugs - // caused by accidentally using $v elsewhere + unset($v); /* remove dangling reference to prevent any future bugs + * caused by accidentally using $v elsewhere */ $this->addErrors($typeValidator->getErrors()); $this->addErrors($validator->getErrors()); } else { @@ -109,8 +110,8 @@ protected function validateItems(&$value, $schema = null, JsonPointer $path = nu $this->errors = $initErrors; } } - unset($v); // remove dangling reference to prevent any future bugs - // caused by accidentally using $v elsewhere + unset($v); /* remove dangling reference to prevent any future bugs + * caused by accidentally using $v elsewhere */ } } else { // Defined item type definitions @@ -124,7 +125,14 @@ protected function validateItems(&$value, $schema = null, JsonPointer $path = nu $this->checkUndefined($v, $schema->additionalItems, $path, $k); } else { $this->addError( - $path, 'The item ' . $i . '[' . $k . '] is not defined and the definition does not allow additional items', 'additionalItems', array('additionalItems' => $schema->additionalItems)); + ConstraintError::ADDITIONAL_ITEMS(), + $path, + array( + 'item' => $i, + 'property' => $k, + 'additionalItems' => $schema->additionalItems + ) + ); } } else { // Should be valid against an empty schema @@ -132,8 +140,8 @@ protected function validateItems(&$value, $schema = null, JsonPointer $path = nu } } } - unset($v); // remove dangling reference to prevent any future bugs - // caused by accidentally using $v elsewhere + unset($v); /* remove dangling reference to prevent any future bugs + * caused by accidentally using $v elsewhere */ // Treat when we have more schema definitions than values, not for empty arrays if (count($value) > 0) { diff --git a/src/JsonSchema/Constraints/Constraint.php b/src/JsonSchema/Constraints/Constraint.php index 7fa0a99a..e0e044bc 100644 --- a/src/JsonSchema/Constraints/Constraint.php +++ b/src/JsonSchema/Constraints/Constraint.php @@ -30,6 +30,10 @@ abstract class Constraint extends BaseConstraint implements ConstraintInterface const CHECK_MODE_COERCE_TYPES = 0x00000004; const CHECK_MODE_APPLY_DEFAULTS = 0x00000008; const CHECK_MODE_EXCEPTIONS = 0x00000010; + const CHECK_MODE_DISABLE_FORMAT = 0x00000020; + const CHECK_MODE_EARLY_COERCE = 0x00000040; + const CHECK_MODE_ONLY_REQUIRED_DEFAULTS = 0x00000080; + const CHECK_MODE_VALIDATE_SCHEMA = 0x00000100; /** * Bubble down the path @@ -74,13 +78,15 @@ protected function checkArray(&$value, $schema = null, JsonPointer $path = null, * @param mixed $value * @param mixed $schema * @param JsonPointer|null $path - * @param mixed $i + * @param mixed $properties + * @param mixed $additionalProperties * @param mixed $patternProperties */ - protected function checkObject(&$value, $schema = null, JsonPointer $path = null, $i = null, $patternProperties = null) + protected function checkObject(&$value, $schema = null, JsonPointer $path = null, $properties = null, + $additionalProperties = null, $patternProperties = null, $appliedDefaults = array()) { $validator = $this->factory->createInstanceFor('object'); - $validator->check($value, $schema, $path, $i, $patternProperties); + $validator->check($value, $schema, $path, $properties, $additionalProperties, $patternProperties, $appliedDefaults); $this->addErrors($validator->getErrors()); } @@ -109,11 +115,11 @@ protected function checkType(&$value, $schema = null, JsonPointer $path = null, * @param JsonPointer|null $path * @param mixed $i */ - protected function checkUndefined(&$value, $schema = null, JsonPointer $path = null, $i = null) + protected function checkUndefined(&$value, $schema = null, JsonPointer $path = null, $i = null, $fromDefault = false) { $validator = $this->factory->createInstanceFor('undefined'); - $validator->check($value, $this->factory->getSchemaStorage()->resolveRefSchema($schema), $path, $i); + $validator->check($value, $this->factory->getSchemaStorage()->resolveRefSchema($schema), $path, $i, $fromDefault); $this->addErrors($validator->getErrors()); } diff --git a/src/JsonSchema/Constraints/ConstraintInterface.php b/src/JsonSchema/Constraints/ConstraintInterface.php index 442268e6..6007c099 100644 --- a/src/JsonSchema/Constraints/ConstraintInterface.php +++ b/src/JsonSchema/Constraints/ConstraintInterface.php @@ -9,6 +9,7 @@ namespace JsonSchema\Constraints; +use JsonSchema\ConstraintError; use JsonSchema\Entity\JsonPointer; /** @@ -35,12 +36,11 @@ public function addErrors(array $errors); /** * adds an error * - * @param JsonPointer|null $path - * @param string $message - * @param string $constraint the constraint/rule that is broken, e.g.: 'minLength' - * @param array $more more array elements to add to the error + * @param ConstraintError $constraint the constraint/rule that is broken, e.g.: ConstraintErrors::LENGTH_MIN() + * @param JsonPointer |null $path + * @param array $more more array elements to add to the error */ - public function addError(JsonPointer $path = null, $message, $constraint='', array $more = null); + public function addError(ConstraintError $constraint, JsonPointer $path = null, array $more = array()); /** * checks if the validator has not raised errors diff --git a/src/JsonSchema/Constraints/EnumConstraint.php b/src/JsonSchema/Constraints/EnumConstraint.php index 0fd2b6a0..5e401228 100644 --- a/src/JsonSchema/Constraints/EnumConstraint.php +++ b/src/JsonSchema/Constraints/EnumConstraint.php @@ -9,6 +9,7 @@ namespace JsonSchema\Constraints; +use JsonSchema\ConstraintError; use JsonSchema\Entity\JsonPointer; /** @@ -49,6 +50,6 @@ public function check(&$element, $schema = null, JsonPointer $path = null, $i = } } - $this->addError($path, 'Does not have a value in the enumeration ' . json_encode($schema->enum), 'enum', array('enum' => $schema->enum)); + $this->addError(ConstraintError::ENUM(), $path, array('enum' => $schema->enum)); } } diff --git a/src/JsonSchema/Constraints/Factory.php b/src/JsonSchema/Constraints/Factory.php index 8c24873f..c9296385 100644 --- a/src/JsonSchema/Constraints/Factory.php +++ b/src/JsonSchema/Constraints/Factory.php @@ -16,6 +16,7 @@ use JsonSchema\SchemaStorageInterface; use JsonSchema\Uri\UriRetriever; use JsonSchema\UriRetrieverInterface; +use JsonSchema\Validator; /** * Factory for centralize constraint initialization. @@ -42,6 +43,11 @@ class Factory */ private $typeCheck = array(); + /** + * @var int Validation context + */ + protected $errorContext = Validator::ERROR_DOCUMENT_VALIDATION; + /** * @var array */ @@ -193,4 +199,24 @@ public function createInstanceFor($constraintName) return clone $this->instanceCache[$constraintName]; } + + /** + * Get the error context + * + * @return string + */ + public function getErrorContext() + { + return $this->errorContext; + } + + /** + * Set the error context + * + * @param string $validationContext + */ + public function setErrorContext($errorContext) + { + $this->errorContext = $errorContext; + } } diff --git a/src/JsonSchema/Constraints/FormatConstraint.php b/src/JsonSchema/Constraints/FormatConstraint.php index ad192b5b..7bc059c2 100644 --- a/src/JsonSchema/Constraints/FormatConstraint.php +++ b/src/JsonSchema/Constraints/FormatConstraint.php @@ -9,6 +9,7 @@ namespace JsonSchema\Constraints; +use JsonSchema\ConstraintError; use JsonSchema\Entity\JsonPointer; use JsonSchema\Rfc3339; @@ -26,60 +27,85 @@ class FormatConstraint extends Constraint */ public function check(&$element, $schema = null, JsonPointer $path = null, $i = null) { - if (!isset($schema->format)) { + if (!isset($schema->format) || $this->factory->getConfig(self::CHECK_MODE_DISABLE_FORMAT)) { return; } switch ($schema->format) { case 'date': if (!$date = $this->validateDateTime($element, 'Y-m-d')) { - $this->addError($path, sprintf('Invalid date %s, expected format YYYY-MM-DD', json_encode($element)), 'format', array('format' => $schema->format)); + $this->addError(ConstraintError::FORMAT_DATE(), $path, array( + 'date' => $element, + 'format' => $schema->format + ) + ); } break; case 'time': if (!$this->validateDateTime($element, 'H:i:s')) { - $this->addError($path, sprintf('Invalid time %s, expected format hh:mm:ss', json_encode($element)), 'format', array('format' => $schema->format)); + $this->addError(ConstraintError::FORMAT_TIME(), $path, array( + 'time' => json_encode($element), + 'format' => $schema->format, + ) + ); } break; case 'date-time': if (null === Rfc3339::createFromString($element)) { - $this->addError($path, sprintf('Invalid date-time %s, expected format YYYY-MM-DDThh:mm:ssZ or YYYY-MM-DDThh:mm:ss+hh:mm', json_encode($element)), 'format', array('format' => $schema->format)); + $this->addError(ConstraintError::FORMAT_DATE_TIME(), $path, array( + 'dateTime' => json_encode($element), + 'format' => $schema->format + ) + ); } break; case 'utc-millisec': if (!$this->validateDateTime($element, 'U')) { - $this->addError($path, sprintf('Invalid time %s, expected integer of milliseconds since Epoch', json_encode($element)), 'format', array('format' => $schema->format)); + $this->addError(ConstraintError::FORMAT_DATE_UTC(), $path, array( + 'value' => $element, + 'format' => $schema->format)); } break; case 'regex': if (!$this->validateRegex($element)) { - $this->addError($path, 'Invalid regex format ' . $element, 'format', array('format' => $schema->format)); + $this->addError(ConstraintError::FORMAT_REGEX(), $path, array( + 'value' => $element, + 'format' => $schema->format + ) + ); } break; case 'color': if (!$this->validateColor($element)) { - $this->addError($path, 'Invalid color', 'format', array('format' => $schema->format)); + $this->addError(ConstraintError::FORMAT_COLOR(), $path, array('format' => $schema->format)); } break; case 'style': if (!$this->validateStyle($element)) { - $this->addError($path, 'Invalid style', 'format', array('format' => $schema->format)); + $this->addError(ConstraintError::FORMAT_STYLE(), $path, array('format' => $schema->format)); } break; case 'phone': if (!$this->validatePhone($element)) { - $this->addError($path, 'Invalid phone number', 'format', array('format' => $schema->format)); + $this->addError(ConstraintError::FORMAT_PHONE(), $path, array('format' => $schema->format)); } break; case 'uri': + if (null === filter_var($element, FILTER_VALIDATE_URL, FILTER_NULL_ON_FAILURE)) { + $this->addError(ConstraintError::FORMAT_URL(), $path, array('format' => $schema->format)); + } + break; + + case 'uriref': + case 'uri-reference': if (null === filter_var($element, FILTER_VALIDATE_URL, FILTER_NULL_ON_FAILURE)) { // FILTER_VALIDATE_URL does not conform to RFC-3986, and cannot handle relative URLs, but // the json-schema spec uses RFC-3986, so need a bit of hackery to properly validate them. @@ -99,34 +125,39 @@ public function check(&$element, $schema = null, JsonPointer $path = null, $i = $validURL = null; } if ($validURL === null) { - $this->addError($path, 'Invalid URL format', 'format', array('format' => $schema->format)); + $this->addError(ConstraintError::FORMAT_URL_REF(), $path, array('format' => $schema->format)); } } break; case 'email': - if (null === filter_var($element, FILTER_VALIDATE_EMAIL, FILTER_NULL_ON_FAILURE)) { - $this->addError($path, 'Invalid email', 'format', array('format' => $schema->format)); + $filterFlags = FILTER_NULL_ON_FAILURE; + if (defined('FILTER_FLAG_EMAIL_UNICODE')) { + // Only available from PHP >= 7.1.0, so ignore it for coverage checks + $filterFlags |= constant('FILTER_FLAG_EMAIL_UNICODE'); // @codeCoverageIgnore + } + if (null === filter_var($element, FILTER_VALIDATE_EMAIL, $filterFlags)) { + $this->addError(ConstraintError::FORMAT_EMAIL(), $path, array('format' => $schema->format)); } break; case 'ip-address': case 'ipv4': if (null === filter_var($element, FILTER_VALIDATE_IP, FILTER_NULL_ON_FAILURE | FILTER_FLAG_IPV4)) { - $this->addError($path, 'Invalid IP address', 'format', array('format' => $schema->format)); + $this->addError(ConstraintError::FORMAT_IP(), $path, array('format' => $schema->format)); } break; case 'ipv6': if (null === filter_var($element, FILTER_VALIDATE_IP, FILTER_NULL_ON_FAILURE | FILTER_FLAG_IPV6)) { - $this->addError($path, 'Invalid IP address', 'format', array('format' => $schema->format)); + $this->addError(ConstraintError::FORMAT_IP(), $path, array('format' => $schema->format)); } break; case 'host-name': case 'hostname': if (!$this->validateHostname($element)) { - $this->addError($path, 'Invalid hostname', 'format', array('format' => $schema->format)); + $this->addError(ConstraintError::FORMAT_HOSTNAME(), $path, array('format' => $schema->format)); } break; diff --git a/src/JsonSchema/Constraints/NumberConstraint.php b/src/JsonSchema/Constraints/NumberConstraint.php index 5a809774..aeea855d 100644 --- a/src/JsonSchema/Constraints/NumberConstraint.php +++ b/src/JsonSchema/Constraints/NumberConstraint.php @@ -9,6 +9,7 @@ namespace JsonSchema\Constraints; +use JsonSchema\ConstraintError; use JsonSchema\Entity\JsonPointer; /** @@ -28,40 +29,40 @@ public function check(&$element, $schema = null, JsonPointer $path = null, $i = if (isset($schema->exclusiveMinimum)) { if (isset($schema->minimum)) { if ($schema->exclusiveMinimum && $element <= $schema->minimum) { - $this->addError($path, 'Must have a minimum value of ' . $schema->minimum, 'exclusiveMinimum', array('minimum' => $schema->minimum)); + $this->addError(ConstraintError::EXCLUSIVE_MINIMUM(), $path, array('minimum' => $schema->minimum)); } elseif ($element < $schema->minimum) { - $this->addError($path, 'Must have a minimum value of ' . $schema->minimum, 'minimum', array('minimum' => $schema->minimum)); + $this->addError(ConstraintError::MINIMUM(), $path, array('minimum' => $schema->minimum)); } } else { - $this->addError($path, 'Use of exclusiveMinimum requires presence of minimum', 'missingMinimum'); + $this->addError(ConstraintError::MISSING_MINIMUM(), $path); } } elseif (isset($schema->minimum) && $element < $schema->minimum) { - $this->addError($path, 'Must have a minimum value of ' . $schema->minimum, 'minimum', array('minimum' => $schema->minimum)); + $this->addError(ConstraintError::MINIMUM(), $path, array('minimum' => $schema->minimum)); } // Verify maximum if (isset($schema->exclusiveMaximum)) { if (isset($schema->maximum)) { if ($schema->exclusiveMaximum && $element >= $schema->maximum) { - $this->addError($path, 'Must have a maximum value of ' . $schema->maximum, 'exclusiveMaximum', array('maximum' => $schema->maximum)); + $this->addError(ConstraintError::EXCLUSIVE_MAXIMUM(), $path, array('maximum' => $schema->maximum)); } elseif ($element > $schema->maximum) { - $this->addError($path, 'Must have a maximum value of ' . $schema->maximum, 'maximum', array('maximum' => $schema->maximum)); + $this->addError(ConstraintError::MAXIMUM(), $path, array('maximum' => $schema->maximum)); } } else { - $this->addError($path, 'Use of exclusiveMaximum requires presence of maximum', 'missingMaximum'); + $this->addError(ConstraintError::MISSING_MAXIMUM(), $path); } } elseif (isset($schema->maximum) && $element > $schema->maximum) { - $this->addError($path, 'Must have a maximum value of ' . $schema->maximum, 'maximum', array('maximum' => $schema->maximum)); + $this->addError(ConstraintError::MAXIMUM(), $path, array('maximum' => $schema->maximum)); } // Verify divisibleBy - Draft v3 if (isset($schema->divisibleBy) && $this->fmod($element, $schema->divisibleBy) != 0) { - $this->addError($path, 'Is not divisible by ' . $schema->divisibleBy, 'divisibleBy', array('divisibleBy' => $schema->divisibleBy)); + $this->addError(ConstraintError::DIVISIBLE_BY(), $path, array('divisibleBy' => $schema->divisibleBy)); } // Verify multipleOf - Draft v4 if (isset($schema->multipleOf) && $this->fmod($element, $schema->multipleOf) != 0) { - $this->addError($path, 'Must be a multiple of ' . $schema->multipleOf, 'multipleOf', array('multipleOf' => $schema->multipleOf)); + $this->addError(ConstraintError::MULTIPLE_OF(), $path, array('multipleOf' => $schema->multipleOf)); } $this->checkFormat($element, $schema, $path, $i); diff --git a/src/JsonSchema/Constraints/ObjectConstraint.php b/src/JsonSchema/Constraints/ObjectConstraint.php index 5ea94f7d..dd1c02b9 100644 --- a/src/JsonSchema/Constraints/ObjectConstraint.php +++ b/src/JsonSchema/Constraints/ObjectConstraint.php @@ -9,6 +9,7 @@ namespace JsonSchema\Constraints; +use JsonSchema\ConstraintError; use JsonSchema\Entity\JsonPointer; /** @@ -19,27 +20,36 @@ */ class ObjectConstraint extends Constraint { + /** + * @var array List of properties to which a default value has been applied + */ + protected $appliedDefaults = array(); + /** * {@inheritdoc} */ - public function check(&$element, $definition = null, JsonPointer $path = null, $additionalProp = null, $patternProperties = null) + public function check(&$element, $schema = null, JsonPointer $path = null, $properties = null, + $additionalProp = null, $patternProperties = null, $appliedDefaults = array()) { if ($element instanceof UndefinedConstraint) { return; } + $this->appliedDefaults = $appliedDefaults; + $matches = array(); if ($patternProperties) { + // validate the element pattern properties $matches = $this->validatePatternProperties($element, $path, $patternProperties); } - if ($definition) { - // validate the definition properties - $this->validateDefinition($element, $definition, $path); + if ($properties) { + // validate the element properties + $this->validateProperties($element, $properties, $path); } - // additional the element properties - $this->validateElement($element, $matches, $definition, $path, $additionalProp); + // validate additional element properties & constraints + $this->validateElement($element, $matches, $schema, $path, $properties, $additionalProp); } public function validatePatternProperties($element, JsonPointer $path = null, $patternProperties) @@ -57,13 +67,13 @@ public function validatePatternProperties($element, JsonPointer $path = null, $p // Validate the pattern before using it to test for matches if (@preg_match($delimiter . $pregex . $delimiter . 'u', '') === false) { - $this->addError($path, 'The pattern "' . $pregex . '" is invalid', 'pregex', array('pregex' => $pregex)); + $this->addError(ConstraintError::PREGEX_INVALID(), $path, array('pregex' => $pregex)); continue; } foreach ($element as $i => $value) { if (preg_match($delimiter . $pregex . $delimiter . 'u', $i)) { $matches[] = $i; - $this->checkUndefined($value, $schema ?: new \stdClass(), $path, $i); + $this->checkUndefined($value, $schema ?: new \stdClass(), $path, $i, in_array($i, $this->appliedDefaults)); } } } @@ -74,37 +84,42 @@ public function validatePatternProperties($element, JsonPointer $path = null, $p /** * Validates the element properties * - * @param \stdClass $element Element to validate - * @param array $matches Matches from patternProperties (if any) - * @param \stdClass $objectDefinition ObjectConstraint definition - * @param JsonPointer|null $path Path to test? - * @param mixed $additionalProp Additional properties + * @param \StdClass $element Element to validate + * @param array $matches Matches from patternProperties (if any) + * @param \StdClass $schema ObjectConstraint definition + * @param JsonPointer|null $path Current test path + * @param \StdClass $properties Properties + * @param mixed $additionalProp Additional properties */ - public function validateElement($element, $matches, $objectDefinition = null, JsonPointer $path = null, $additionalProp = null) + public function validateElement($element, $matches, $schema = null, JsonPointer $path = null, + $properties = null, $additionalProp = null) { - $this->validateMinMaxConstraint($element, $objectDefinition, $path); + $this->validateMinMaxConstraint($element, $schema, $path); foreach ($element as $i => $value) { - $definition = $this->getProperty($objectDefinition, $i); + $definition = $this->getProperty($properties, $i); // no additional properties allowed if (!in_array($i, $matches) && $additionalProp === false && $this->inlineSchemaProperty !== $i && !$definition) { - $this->addError($path, 'The property ' . $i . ' is not defined and the definition does not allow additional properties', 'additionalProp'); + $this->addError(ConstraintError::ADDITIONAL_PROPERTIES(), $path, array('property' => $i)); } // additional properties defined if (!in_array($i, $matches) && $additionalProp && !$definition) { if ($additionalProp === true) { - $this->checkUndefined($value, null, $path, $i); + $this->checkUndefined($value, null, $path, $i, in_array($i, $this->appliedDefaults)); } else { - $this->checkUndefined($value, $additionalProp, $path, $i); + $this->checkUndefined($value, $additionalProp, $path, $i, in_array($i, $this->appliedDefaults)); } } // property requires presence of another $require = $this->getProperty($definition, 'requires'); if ($require && !$this->getProperty($element, $require)) { - $this->addError($path, 'The presence of the property ' . $i . ' requires that ' . $require . ' also be present', 'requires'); + $this->addError(ConstraintError::REQUIRES(), $path, array( + 'property' => $i, + 'requiredProperty' => $require + )); } $property = $this->getProperty($element, $i, $this->factory->createInstanceFor('undefined')); @@ -117,21 +132,21 @@ public function validateElement($element, $matches, $objectDefinition = null, Js /** * Validates the definition properties * - * @param \stdClass $element Element to validate - * @param \stdClass $objectDefinition ObjectConstraint definition - * @param JsonPointer|null $path Path? + * @param \stdClass $element Element to validate + * @param \stdClass $properties Property definitions + * @param JsonPointer|null $path Path? */ - public function validateDefinition(&$element, $objectDefinition = null, JsonPointer $path = null) + public function validateProperties(&$element, $properties = null, JsonPointer $path = null) { $undefinedConstraint = $this->factory->createInstanceFor('undefined'); - foreach ($objectDefinition as $i => $value) { + foreach ($properties as $i => $value) { $property = &$this->getProperty($element, $i, $undefinedConstraint); - $definition = $this->getProperty($objectDefinition, $i); + $definition = $this->getProperty($properties, $i); if (is_object($definition)) { // Undefined constraint will check for is_object() and quit if is not - so why pass it? - $this->checkUndefined($property, $definition, $path, $i); + $this->checkUndefined($property, $definition, $path, $i, in_array($i, $this->appliedDefaults)); } } } @@ -168,13 +183,13 @@ protected function validateMinMaxConstraint($element, $objectDefinition, JsonPoi // Verify minimum number of properties if (isset($objectDefinition->minProperties) && !is_object($objectDefinition->minProperties)) { if ($this->getTypeCheck()->propertyCount($element) < $objectDefinition->minProperties) { - $this->addError($path, 'Must contain a minimum of ' . $objectDefinition->minProperties . ' properties', 'minProperties', array('minProperties' => $objectDefinition->minProperties)); + $this->addError(ConstraintError::PROPERTIES_MIN(), $path, array('minProperties' => $objectDefinition->minProperties)); } } // Verify maximum number of properties if (isset($objectDefinition->maxProperties) && !is_object($objectDefinition->maxProperties)) { if ($this->getTypeCheck()->propertyCount($element) > $objectDefinition->maxProperties) { - $this->addError($path, 'Must contain no more than ' . $objectDefinition->maxProperties . ' properties', 'maxProperties', array('maxProperties' => $objectDefinition->maxProperties)); + $this->addError(ConstraintError::PROPERTIES_MAX(), $path, array('maxProperties' => $objectDefinition->maxProperties)); } } } diff --git a/src/JsonSchema/Constraints/SchemaConstraint.php b/src/JsonSchema/Constraints/SchemaConstraint.php index c33fe8ca..8218d256 100644 --- a/src/JsonSchema/Constraints/SchemaConstraint.php +++ b/src/JsonSchema/Constraints/SchemaConstraint.php @@ -9,8 +9,13 @@ namespace JsonSchema\Constraints; +use JsonSchema\ConstraintError; use JsonSchema\Entity\JsonPointer; use JsonSchema\Exception\InvalidArgumentException; +use JsonSchema\Exception\InvalidSchemaException; +use JsonSchema\Exception\RuntimeException; +use JsonSchema\SchemaStorage; +use JsonSchema\Validator; /** * The SchemaConstraint Constraints, validates an element against a given schema @@ -20,6 +25,8 @@ */ class SchemaConstraint extends Constraint { + const DEFAULT_SCHEMA_SPEC = 'http://json-schema.org/draft-04/schema#'; + /** * {@inheritdoc} */ @@ -27,16 +34,63 @@ public function check(&$element, $schema = null, JsonPointer $path = null, $i = { if ($schema !== null) { // passed schema - $this->checkUndefined($element, $schema, $path, $i); + $validationSchema = $schema; } elseif ($this->getTypeCheck()->propertyExists($element, $this->inlineSchemaProperty)) { - $inlineSchema = $this->getTypeCheck()->propertyGet($element, $this->inlineSchemaProperty); - if (is_array($inlineSchema)) { - $inlineSchema = json_decode(json_encode($inlineSchema)); - } // inline schema - $this->checkUndefined($element, $inlineSchema, $path, $i); + $validationSchema = $this->getTypeCheck()->propertyGet($element, $this->inlineSchemaProperty); } else { throw new InvalidArgumentException('no schema found to verify against'); } + + // cast array schemas to object + if (is_array($validationSchema)) { + $validationSchema = BaseConstraint::arrayToObjectRecursive($validationSchema); + } + + // validate schema against whatever is defined in $validationSchema->$schema. If no + // schema is defined, assume self::DEFAULT_SCHEMA_SPEC (currently draft-04). + if ($this->factory->getConfig(self::CHECK_MODE_VALIDATE_SCHEMA)) { + if (!$this->getTypeCheck()->isObject($validationSchema)) { + throw new RuntimeException('Cannot validate the schema of a non-object'); + } + if ($this->getTypeCheck()->propertyExists($validationSchema, '$schema')) { + $schemaSpec = $this->getTypeCheck()->propertyGet($validationSchema, '$schema'); + } else { + $schemaSpec = self::DEFAULT_SCHEMA_SPEC; + } + + // get the spec schema + $schemaStorage = $this->factory->getSchemaStorage(); + if (!$this->getTypeCheck()->isObject($schemaSpec)) { + $schemaSpec = $schemaStorage->getSchema($schemaSpec); + } + + // save error count, config & subtract CHECK_MODE_VALIDATE_SCHEMA + $initialErrorCount = $this->numErrors(); + $initialConfig = $this->factory->getConfig(); + $initialContext = $this->factory->getErrorContext(); + $this->factory->removeConfig(self::CHECK_MODE_VALIDATE_SCHEMA | self::CHECK_MODE_APPLY_DEFAULTS); + $this->factory->addConfig(self::CHECK_MODE_TYPE_CAST); + $this->factory->setErrorContext(Validator::ERROR_SCHEMA_VALIDATION); + + // validate schema + try { + $this->check($validationSchema, $schemaSpec); + } catch (\Exception $e) { + if ($this->factory->getConfig(self::CHECK_MODE_EXCEPTIONS)) { + throw new InvalidSchemaException('Schema did not pass validation', 0, $e); + } + } + if ($this->numErrors() > $initialErrorCount) { + $this->addError(ConstraintError::INVALID_SCHEMA(), $path); + } + + // restore the initial config + $this->factory->setConfig($initialConfig); + $this->factory->setErrorContext($initialContext); + } + + // validate element against $validationSchema + $this->checkUndefined($element, $validationSchema, $path, $i); } } diff --git a/src/JsonSchema/Constraints/StringConstraint.php b/src/JsonSchema/Constraints/StringConstraint.php index 5b15de7a..b3bdfbf7 100644 --- a/src/JsonSchema/Constraints/StringConstraint.php +++ b/src/JsonSchema/Constraints/StringConstraint.php @@ -9,6 +9,7 @@ namespace JsonSchema\Constraints; +use JsonSchema\ConstraintError; use JsonSchema\Entity\JsonPointer; /** @@ -26,21 +27,21 @@ public function check(&$element, $schema = null, JsonPointer $path = null, $i = { // Verify maxLength if (isset($schema->maxLength) && $this->strlen($element) > $schema->maxLength) { - $this->addError($path, 'Must be at most ' . $schema->maxLength . ' characters long', 'maxLength', array( + $this->addError(ConstraintError::LENGTH_MAX(), $path, array( 'maxLength' => $schema->maxLength, )); } //verify minLength if (isset($schema->minLength) && $this->strlen($element) < $schema->minLength) { - $this->addError($path, 'Must be at least ' . $schema->minLength . ' characters long', 'minLength', array( + $this->addError(ConstraintError::LENGTH_MIN(), $path, array( 'minLength' => $schema->minLength, )); } // Verify a regex pattern if (isset($schema->pattern) && !preg_match('#' . str_replace('#', '\\#', $schema->pattern) . '#u', $element)) { - $this->addError($path, 'Does not match the regex pattern ' . $schema->pattern, 'pattern', array( + $this->addError(ConstraintError::PATTERN(), $path, array( 'pattern' => $schema->pattern, )); } @@ -54,6 +55,7 @@ private function strlen($string) return mb_strlen($string, mb_detect_encoding($string)); } - return strlen($string); + // mbstring is present on all test platforms, so strlen() can be ignored for coverage + return strlen($string); // @codeCoverageIgnore } } diff --git a/src/JsonSchema/Constraints/TypeConstraint.php b/src/JsonSchema/Constraints/TypeConstraint.php index 0ef32843..5bfe08a9 100644 --- a/src/JsonSchema/Constraints/TypeConstraint.php +++ b/src/JsonSchema/Constraints/TypeConstraint.php @@ -9,6 +9,7 @@ namespace JsonSchema\Constraints; +use JsonSchema\ConstraintError; use JsonSchema\Entity\JsonPointer; use JsonSchema\Exception\InvalidArgumentException; use UnexpectedValueException as StandardUnexpectedValueException; @@ -43,16 +44,24 @@ public function check(&$value = null, $schema = null, JsonPointer $path = null, { $type = isset($schema->type) ? $schema->type : null; $isValid = false; + $coerce = $this->factory->getConfig(self::CHECK_MODE_COERCE_TYPES); + $earlyCoerce = $this->factory->getConfig(self::CHECK_MODE_EARLY_COERCE); $wording = array(); if (is_array($type)) { - $this->validateTypesArray($value, $type, $wording, $isValid, $path); + $this->validateTypesArray($value, $type, $wording, $isValid, $path, $coerce && $earlyCoerce); + if (!$isValid && $coerce && !$earlyCoerce) { + $this->validateTypesArray($value, $type, $wording, $isValid, $path, true); + } } elseif (is_object($type)) { $this->checkUndefined($value, $type, $path); return; } else { - $isValid = $this->validateType($value, $type); + $isValid = $this->validateType($value, $type, $coerce && $earlyCoerce); + if (!$isValid && $coerce && !$earlyCoerce) { + $isValid = $this->validateType($value, $type, true); + } } if ($isValid === false) { @@ -60,8 +69,10 @@ public function check(&$value = null, $schema = null, JsonPointer $path = null, $this->validateTypeNameWording($type); $wording[] = self::$wording[$type]; } - $this->addError($path, ucwords(gettype($value)) . ' value found, but ' . - $this->implodeWith($wording, ', ', 'or') . ' is required', 'type'); + $this->addError(ConstraintError::TYPE(), $path, array( + 'found' => gettype($value), + 'expected' => $this->implodeWith($wording, ', ', 'or') + )); } } @@ -76,9 +87,14 @@ public function check(&$value = null, $schema = null, JsonPointer $path = null, * @param bool $isValid The current validation value * @param $path */ - protected function validateTypesArray(&$value, array $type, &$validTypesWording, &$isValid, $path) + protected function validateTypesArray(&$value, array $type, &$validTypesWording, &$isValid, $path, $coerce = false) { foreach ($type as $tp) { + // already valid, so no need to waste cycles looping over everything + if ($isValid) { + return; + } + // $tp can be an object, if it's a schema instead of a simple type, validate it // with a new type constraint if (is_object($tp)) { @@ -95,7 +111,7 @@ protected function validateTypesArray(&$value, array $type, &$validTypesWording, $this->validateTypeNameWording($tp); $validTypesWording[] = self::$wording[$tp]; if (!$isValid) { - $isValid = $this->validateType($value, $tp); + $isValid = $this->validateType($value, $tp, $coerce); } } } @@ -154,7 +170,7 @@ protected function validateTypeNameWording($type) * * @return bool */ - protected function validateType(&$value, $type) + protected function validateType(&$value, $type, $coerce = false) { //mostly the case for inline schema if (!$type) { @@ -170,11 +186,13 @@ protected function validateType(&$value, $type) } if ('array' === $type) { + if ($coerce) { + $value = $this->toArray($value); + } + return $this->getTypeCheck()->isArray($value); } - $coerce = $this->factory->getConfig(Constraint::CHECK_MODE_COERCE_TYPES); - if ('integer' === $type) { if ($coerce) { $value = $this->toInteger($value); @@ -200,14 +218,18 @@ protected function validateType(&$value, $type) } if ('string' === $type) { - return is_string($value); - } + if ($coerce) { + $value = $this->toString($value); + } - if ('email' === $type) { return is_string($value); } if ('null' === $type) { + if ($coerce) { + $value = $this->toNull($value); + } + return is_null($value); } @@ -223,19 +245,21 @@ protected function validateType(&$value, $type) */ protected function toBoolean($value) { - if ($value === 'true') { + if ($value === 1 || $value === 'true') { return true; } - - if ($value === 'false') { + if (is_null($value) || $value === 0 || $value === 'false') { return false; } + if ($this->getTypeCheck()->isArray($value) && count($value) === 1) { + return $this->toBoolean(reset($value)); + } return $value; } /** - * Converts a numeric string to a number. For example, "4" becomes 4. + * Converts a value to a number. For example, "4.5" becomes 4.5. * * @param mixed $value the value to convert to a number * @@ -246,14 +270,89 @@ protected function toNumber($value) if (is_numeric($value)) { return $value + 0; // cast to number } + if (is_bool($value) || is_null($value)) { + return (int) $value; + } + if ($this->getTypeCheck()->isArray($value) && count($value) === 1) { + return $this->toNumber(reset($value)); + } return $value; } + /** + * Converts a value to an integer. For example, "4" becomes 4. + * + * @param mixed $value + * + * @return int|mixed + */ protected function toInteger($value) { - if (is_numeric($value) && (int) $value == $value) { - return (int) $value; // cast to number + $numberValue = $this->toNumber($value); + if (is_numeric($numberValue) && (int) $numberValue == $numberValue) { + return (int) $numberValue; // cast to number + } + + return $value; + } + + /** + * Converts a value to an array containing that value. For example, [4] becomes 4. + * + * @param mixed $value + * + * @return array|mixed + */ + protected function toArray($value) + { + if (is_scalar($value) || is_null($value)) { + return array($value); + } + + return $value; + } + + /** + * Convert a value to a string representation of that value. For example, null becomes "". + * + * @param mixed $value + * + * @return string|mixed + */ + protected function toString($value) + { + if (is_numeric($value)) { + return "$value"; + } + if ($value === true) { + return 'true'; + } + if ($value === false) { + return 'false'; + } + if (is_null($value)) { + return ''; + } + if ($this->getTypeCheck()->isArray($value) && count($value) === 1) { + return $this->toString(reset($value)); + } + } + + /** + * Convert a value to a null. For example, 0 becomes null. + * + * @param mixed $value + * + * @return null|mixed + */ + protected function toNull($value) + { + if ($value === 0 || $value === false || $value === '') { + return null; + } + if ($this->getTypeCheck()->isArray($value) && count($value) === 1) { + return $this->toNull(reset($value)); } return $value; diff --git a/src/JsonSchema/Constraints/UndefinedConstraint.php b/src/JsonSchema/Constraints/UndefinedConstraint.php index 147e5bc3..d88c5361 100644 --- a/src/JsonSchema/Constraints/UndefinedConstraint.php +++ b/src/JsonSchema/Constraints/UndefinedConstraint.php @@ -9,8 +9,10 @@ namespace JsonSchema\Constraints; +use JsonSchema\ConstraintError; use JsonSchema\Constraints\TypeCheck\LooseTypeCheck; use JsonSchema\Entity\JsonPointer; +use JsonSchema\Exception\ValidationException; use JsonSchema\Uri\UriResolver; /** @@ -21,16 +23,24 @@ */ class UndefinedConstraint extends Constraint { + /** + * @var array List of properties to which a default value has been applied + */ + protected $appliedDefaults = array(); + /** * {@inheritdoc} */ - public function check(&$value, $schema = null, JsonPointer $path = null, $i = null) + public function check(&$value, $schema = null, JsonPointer $path = null, $i = null, $fromDefault = false) { if (is_null($schema) || !is_object($schema)) { return; } $path = $this->incrementPath($path ?: new JsonPointer(''), $i); + if ($fromDefault) { + $path->setFromDefault(); + } // check special properties $this->validateCommonProperties($value, $schema, $path, $i); @@ -63,10 +73,12 @@ public function validateTypes(&$value, $schema = null, JsonPointer $path, $i = n // is not set (i.e. don't use $this->getTypeCheck() here). $this->checkObject( $value, - isset($schema->properties) ? $this->factory->getSchemaStorage()->resolveRefSchema($schema->properties) : $schema, + $schema, $path, + isset($schema->properties) ? $schema->properties : null, isset($schema->additionalProperties) ? $schema->additionalProperties : null, - isset($schema->patternProperties) ? $schema->patternProperties : null + isset($schema->patternProperties) ? $schema->patternProperties : null, + $this->appliedDefaults ); } @@ -111,46 +123,8 @@ protected function validateCommonProperties(&$value, $schema = null, JsonPointer } // Apply default values from schema - if ($this->factory->getConfig(self::CHECK_MODE_APPLY_DEFAULTS)) { - if ($this->getTypeCheck()->isObject($value) && isset($schema->properties)) { - // $value is an object, so apply default properties if defined - foreach ($schema->properties as $currentProperty => $propertyDefinition) { - if (!$this->getTypeCheck()->propertyExists($value, $currentProperty) && isset($propertyDefinition->default)) { - if (is_object($propertyDefinition->default)) { - $this->getTypeCheck()->propertySet($value, $currentProperty, clone $propertyDefinition->default); - } else { - $this->getTypeCheck()->propertySet($value, $currentProperty, $propertyDefinition->default); - } - } - } - } elseif ($this->getTypeCheck()->isArray($value)) { - if (isset($schema->properties)) { - // $value is an array, but default properties are defined, so treat as assoc - foreach ($schema->properties as $currentProperty => $propertyDefinition) { - if (!isset($value[$currentProperty]) && isset($propertyDefinition->default)) { - if (is_object($propertyDefinition->default)) { - $value[$currentProperty] = clone $propertyDefinition->default; - } else { - $value[$currentProperty] = $propertyDefinition->default; - } - } - } - } elseif (isset($schema->items)) { - // $value is an array, and default items are defined - treat as plain array - foreach ($schema->items as $currentProperty => $itemDefinition) { - if (!isset($value[$currentProperty]) && isset($itemDefinition->default)) { - if (is_object($itemDefinition->default)) { - $value[$currentProperty] = clone $itemDefinition->default; - } else { - $value[$currentProperty] = $itemDefinition->default; - } - } - } - } - } elseif (($value instanceof self || $value === null) && isset($schema->default)) { - // $value is a leaf, not a container - apply the default directly - $value = is_object($schema->default) ? clone $schema->default : $schema->default; - } + if (!$path->fromDefault()) { + $this->applyDefaultValues($value, $schema, $path); } // Verify required values @@ -160,16 +134,19 @@ protected function validateCommonProperties(&$value, $schema = null, JsonPointer foreach ($schema->required as $required) { if (!$this->getTypeCheck()->propertyExists($value, $required)) { $this->addError( - $this->incrementPath($path ?: new JsonPointer(''), $required), - 'The property ' . $required . ' is required', - 'required' + ConstraintError::REQUIRED(), + $this->incrementPath($path ?: new JsonPointer(''), $required), array( + 'property' => $required + ) ); } } } elseif (isset($schema->required) && !is_array($schema->required)) { // Draft 3 - Required attribute - e.g. "foo": {"type": "string", "required": true} if ($schema->required && $value instanceof self) { - $this->addError($path, 'Is missing and it is required', 'required'); + $propertyPaths = $path->getPropertyPaths(); + $propertyName = end($propertyPaths); + $this->addError(ConstraintError::REQUIRED(), $path, array('property' => $propertyName)); } } } @@ -189,7 +166,7 @@ protected function validateCommonProperties(&$value, $schema = null, JsonPointer // if no new errors were raised it must be a disallowed value if (count($this->getErrors()) == count($initErrors)) { - $this->addError($path, 'Disallowed value was matched', 'disallow'); + $this->addError(ConstraintError::DISALLOW(), $path); } else { $this->errors = $initErrors; } @@ -201,7 +178,7 @@ protected function validateCommonProperties(&$value, $schema = null, JsonPointer // if no new errors were raised then the instance validated against the "not" schema if (count($this->getErrors()) == count($initErrors)) { - $this->addError($path, 'Matched a schema which it should not', 'not'); + $this->addError(ConstraintError::NOT(), $path); } else { $this->errors = $initErrors; } @@ -213,6 +190,102 @@ protected function validateCommonProperties(&$value, $schema = null, JsonPointer } } + /** + * Check whether a default should be applied for this value + * + * @param mixed $schema + * @param mixed $parentSchema + * @param bool $requiredOnly + * + * @return bool + */ + private function shouldApplyDefaultValue($requiredOnly, $schema, $name = null, $parentSchema = null) + { + // required-only mode is off + if (!$requiredOnly) { + return true; + } + // draft-04 required is set + if ( + $name !== null + && isset($parentSchema->required) + && is_array($parentSchema->required) + && in_array($name, $parentSchema->required) + ) { + return true; + } + // draft-03 required is set + if (isset($schema->required) && !is_array($schema->required) && $schema->required) { + return true; + } + // default case + return false; + } + + /** + * Apply default values + * + * @param mixed $value + * @param mixed $schema + * @param JsonPointer $path + */ + protected function applyDefaultValues(&$value, $schema, $path) + { + // only apply defaults if feature is enabled + if (!$this->factory->getConfig(self::CHECK_MODE_APPLY_DEFAULTS)) { + return; + } + + // apply defaults if appropriate + $requiredOnly = $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) { + if ( + !LooseTypeCheck::propertyExists($value, $currentProperty) + && property_exists($propertyDefinition, 'default') + && $this->shouldApplyDefaultValue($requiredOnly, $propertyDefinition, $currentProperty, $schema) + ) { + // assign default value + if (is_object($propertyDefinition->default)) { + LooseTypeCheck::propertySet($value, $currentProperty, clone $propertyDefinition->default); + } else { + LooseTypeCheck::propertySet($value, $currentProperty, $propertyDefinition->default); + } + $this->appliedDefaults[] = $currentProperty; + } + } + } elseif (isset($schema->items) && LooseTypeCheck::isArray($value)) { + $items = array(); + if (LooseTypeCheck::isArray($schema->items)) { + $items = $schema->items; + } elseif (isset($schema->minItems) && count($value) < $schema->minItems) { + $items = array_fill(count($value), $schema->minItems - count($value), $schema->items); + } + // $value is an array, and items are defined - treat as plain array + foreach ($items as $currentItem => $itemDefinition) { + if ( + !array_key_exists($currentItem, $value) + && property_exists($itemDefinition, 'default') + && $this->shouldApplyDefaultValue($requiredOnly, $itemDefinition)) { + if (is_object($itemDefinition->default)) { + $value[$currentItem] = clone $itemDefinition->default; + } else { + $value[$currentItem] = $itemDefinition->default; + } + } + $path->setFromDefault(); + } + } elseif ( + $value instanceof self + && property_exists($schema, 'default') + && $this->shouldApplyDefaultValue($requiredOnly, $schema)) { + // $value is a leaf, not a container - apply the default directly + $value = is_object($schema->default) ? clone $schema->default : $schema->default; + $path->setFromDefault(); + } + } + /** * Validate allOf, anyOf, and oneOf properties * @@ -236,22 +309,27 @@ protected function validateOfProperties(&$value, $schema, JsonPointer $path, $i $isValid = $isValid && (count($this->getErrors()) == count($initErrors)); } if (!$isValid) { - $this->addError($path, 'Failed to match all schemas', 'allOf'); + $this->addError(ConstraintError::ALL_OF(), $path); } } if (isset($schema->anyOf)) { $isValid = false; $startErrors = $this->getErrors(); + $caughtException = null; foreach ($schema->anyOf as $anyOf) { $initErrors = $this->getErrors(); - $this->checkUndefined($value, $anyOf, $path, $i); - if ($isValid = (count($this->getErrors()) == count($initErrors))) { - break; + try { + $this->checkUndefined($value, $anyOf, $path, $i); + if ($isValid = (count($this->getErrors()) == count($initErrors))) { + break; + } + } catch (ValidationException $e) { + $isValid = false; } } if (!$isValid) { - $this->addError($path, 'Failed to match at least one schema', 'anyOf'); + $this->addError(ConstraintError::ANY_OF(), $path); } else { $this->errors = $startErrors; } @@ -262,16 +340,21 @@ protected function validateOfProperties(&$value, $schema, JsonPointer $path, $i $matchedSchemas = 0; $startErrors = $this->getErrors(); foreach ($schema->oneOf as $oneOf) { - $this->errors = array(); - $this->checkUndefined($value, $oneOf, $path, $i); - if (count($this->getErrors()) == 0) { - $matchedSchemas++; + try { + $this->errors = array(); + $this->checkUndefined($value, $oneOf, $path, $i); + if (count($this->getErrors()) == 0) { + $matchedSchemas++; + } + $allErrors = array_merge($allErrors, array_values($this->getErrors())); + } catch (ValidationException $e) { + // deliberately do nothing here - validation failed, but we want to check + // other schema options in the OneOf field. } - $allErrors = array_merge($allErrors, array_values($this->getErrors())); } if ($matchedSchemas !== 1) { $this->addErrors(array_merge($allErrors, $startErrors)); - $this->addError($path, 'Failed to match exactly one schema', 'oneOf'); + $this->addError(ConstraintError::ONE_OF(), $path); } else { $this->errors = $startErrors; } @@ -293,13 +376,19 @@ protected function validateDependencies($value, $dependencies, JsonPointer $path if (is_string($dependency)) { // Draft 3 string is allowed - e.g. "dependencies": {"bar": "foo"} if (!$this->getTypeCheck()->propertyExists($value, $dependency)) { - $this->addError($path, "$key depends on $dependency and $dependency is missing", 'dependencies'); + $this->addError(ConstraintError::DEPENDENCIES(), $path, array( + 'key' => $key, + 'dependency' => $dependency + )); } } elseif (is_array($dependency)) { // Draft 4 must be an array - e.g. "dependencies": {"bar": ["foo"]} foreach ($dependency as $d) { if (!$this->getTypeCheck()->propertyExists($value, $d)) { - $this->addError($path, "$key depends on $d and $d is missing", 'dependencies'); + $this->addError(ConstraintError::DEPENDENCIES(), $path, array( + 'key' => $key, + 'dependency' => $dependency + )); } } } elseif (is_object($dependency)) { diff --git a/src/JsonSchema/Entity/JsonPointer.php b/src/JsonSchema/Entity/JsonPointer.php index 31c753ba..fcaf5b8d 100644 --- a/src/JsonSchema/Entity/JsonPointer.php +++ b/src/JsonSchema/Entity/JsonPointer.php @@ -9,6 +9,8 @@ namespace JsonSchema\Entity; +use JsonSchema\Exception\InvalidArgumentException; + /** * @package JsonSchema\Entity * @@ -22,15 +24,20 @@ class JsonPointer /** @var string[] */ private $propertyPaths = array(); + /** + * @var bool Whether the value at this path was set from a schema default + */ + private $fromDefault = false; + /** * @param string $value * - * @throws \InvalidArgumentException when $value is not a string + * @throws InvalidArgumentException when $value is not a string */ public function __construct($value) { if (!is_string($value)) { - throw new \InvalidArgumentException('Ref value must be a string'); + throw new InvalidArgumentException('Ref value must be a string'); } $splitRef = explode('#', $value, 2); @@ -133,4 +140,22 @@ public function __toString() { return $this->getFilename() . $this->getPropertyPathAsString(); } + + /** + * Mark the value at this path as being set from a schema default + */ + public function setFromDefault() + { + $this->fromDefault = true; + } + + /** + * Check whether the value at this path was set from a schema default + * + * @return bool + */ + public function fromDefault() + { + return $this->fromDefault; + } } diff --git a/src/JsonSchema/Enum.php b/src/JsonSchema/Enum.php new file mode 100644 index 00000000..c52f8daa --- /dev/null +++ b/src/JsonSchema/Enum.php @@ -0,0 +1,7 @@ +uriRetriever->retrieve($id); } - $objectIterator = new ObjectIterator($schema); - foreach ($objectIterator as $toResolveSchema) { - if (property_exists($toResolveSchema, '$ref') && is_string($toResolveSchema->{'$ref'})) { - $jsonPointer = new JsonPointer($this->uriResolver->resolve($toResolveSchema->{'$ref'}, $id)); - $toResolveSchema->{'$ref'} = (string) $jsonPointer; + + // cast array schemas to object + if (is_array($schema)) { + $schema = BaseConstraint::arrayToObjectRecursive($schema); + } + + // 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#') { + $schema->properties->id->format = 'uri-reference'; + } elseif ($schema->id == 'http://json-schema.org/draft-03/schema#') { + $schema->properties->id->format = 'uri-reference'; + $schema->properties->{'$ref'}->format = 'uri-reference'; } } + + // resolve references + $this->expandRefs($schema, $id); + $this->schemas[$id] = $schema; } + /** + * Recursively resolve all references against the provided base + * + * @param mixed $schema + * @param string $base + */ + private function expandRefs(&$schema, $base = null) + { + if (!is_object($schema)) { + if (is_array($schema)) { + foreach ($schema as &$member) { + $this->expandRefs($member, $base); + } + } + + return; + } + + if (property_exists($schema, 'id') && is_string($schema->id) && $base != $schema->id) { + $base = $this->uriResolver->resolve($schema->id, $base); + } + + if (property_exists($schema, '$ref') && is_string($schema->{'$ref'})) { + $refPointer = new JsonPointer($this->uriResolver->resolve($schema->{'$ref'}, $base)); + $schema->{'$ref'} = (string) $refPointer; + } + + foreach ($schema as &$member) { + $this->expandRefs($member, $base); + } + } + /** * {@inheritdoc} */ @@ -71,16 +121,26 @@ public function getSchema($id) /** * {@inheritdoc} */ - public function resolveRef($ref) + public function resolveRef($ref, $resolveStack = array()) { $jsonPointer = new JsonPointer($ref); - $refSchema = $this->getSchema($jsonPointer->getFilename()); + // resolve filename for pointer + $fileName = $jsonPointer->getFilename(); + if (!strlen($fileName)) { + throw new UnresolvableJsonPointerException(sprintf( + "Could not resolve fragment '%s': no file is defined", + $jsonPointer->getPropertyPathAsString() + )); + } + + // get & process the schema + $refSchema = $this->getSchema($fileName); foreach ($jsonPointer->getPropertyPaths() as $path) { if (is_object($refSchema) && property_exists($refSchema, $path)) { - $refSchema = $this->resolveRefSchema($refSchema->{$path}); + $refSchema = $this->resolveRefSchema($refSchema->{$path}, $resolveStack); } elseif (is_array($refSchema) && array_key_exists($path, $refSchema)) { - $refSchema = $this->resolveRefSchema($refSchema[$path]); + $refSchema = $this->resolveRefSchema($refSchema[$path], $resolveStack); } else { throw new UnresolvableJsonPointerException(sprintf( 'File: %s is found, but could not resolve fragment: %s', @@ -96,12 +156,18 @@ public function resolveRef($ref) /** * {@inheritdoc} */ - public function resolveRefSchema($refSchema) + public function resolveRefSchema($refSchema, $resolveStack = array()) { if (is_object($refSchema) && property_exists($refSchema, '$ref') && is_string($refSchema->{'$ref'})) { - $newSchema = $this->resolveRef($refSchema->{'$ref'}); - $refSchema = (object) (get_object_vars($refSchema) + get_object_vars($newSchema)); - unset($refSchema->{'$ref'}); + if (in_array($refSchema, $resolveStack, true)) { + throw new UnresolvableJsonPointerException(sprintf( + 'Dereferencing a pointer to %s results in an infinite loop', + $refSchema->{'$ref'} + )); + } + $resolveStack[] = $refSchema; + + return $this->resolveRef($refSchema->{'$ref'}, $resolveStack); } return $refSchema; diff --git a/src/JsonSchema/Uri/Retrievers/Curl.php b/src/JsonSchema/Uri/Retrievers/Curl.php index a4125aa6..81c86037 100644 --- a/src/JsonSchema/Uri/Retrievers/Curl.php +++ b/src/JsonSchema/Uri/Retrievers/Curl.php @@ -9,6 +9,7 @@ namespace JsonSchema\Uri\Retrievers; +use JsonSchema\Exception\RuntimeException; use JsonSchema\Validator; /** @@ -23,7 +24,8 @@ class Curl extends AbstractRetriever public function __construct() { if (!function_exists('curl_init')) { - throw new \RuntimeException('cURL not installed'); + // Cannot test this, because curl_init is present on all test platforms plus mock + throw new RuntimeException('cURL not installed'); // @codeCoverageIgnore } } diff --git a/src/JsonSchema/Uri/Retrievers/FileGetContents.php b/src/JsonSchema/Uri/Retrievers/FileGetContents.php index 7f0c399a..7019814f 100644 --- a/src/JsonSchema/Uri/Retrievers/FileGetContents.php +++ b/src/JsonSchema/Uri/Retrievers/FileGetContents.php @@ -50,8 +50,10 @@ public function retrieve($uri) $this->messageBody = $response; if (!empty($http_response_header)) { - $this->fetchContentType($http_response_header); - } else { + // $http_response_header cannot be tested, because it's defined in the method's local scope + // See http://php.net/manual/en/reserved.variables.httpresponseheader.php for more info. + $this->fetchContentType($http_response_header); // @codeCoverageIgnore + } else { // @codeCoverageIgnore // Could be a "file://" url or something else - fake up the response $this->contentType = null; } diff --git a/src/JsonSchema/Uri/UriResolver.php b/src/JsonSchema/Uri/UriResolver.php index 7d6e793d..8ab6650e 100644 --- a/src/JsonSchema/Uri/UriResolver.php +++ b/src/JsonSchema/Uri/UriResolver.php @@ -61,8 +61,8 @@ public function generate(array $components) . $components['authority'] . $components['path']; - if (array_key_exists('query', $components)) { - $uri .= $components['query']; + if (array_key_exists('query', $components) && strlen($components['query'])) { + $uri .= '?' . $components['query']; } if (array_key_exists('fragment', $components)) { $uri .= '#' . $components['fragment']; @@ -76,6 +76,21 @@ public function generate(array $components) */ public function resolve($uri, $baseUri = null) { + // treat non-uri base as local file path + if ( + !is_null($baseUri) && + !filter_var($baseUri, \FILTER_VALIDATE_URL) && + !preg_match('|^[^/]+://|u', $baseUri) + ) { + if (is_file($baseUri)) { + $baseUri = 'file://' . realpath($baseUri); + } elseif (is_dir($baseUri)) { + $baseUri = 'file://' . realpath($baseUri) . '/'; + } else { + $baseUri = 'file://' . getcwd() . '/' . $baseUri; + } + } + if ($uri == '') { return $baseUri; } diff --git a/src/JsonSchema/Uri/UriRetriever.php b/src/JsonSchema/Uri/UriRetriever.php index ebb7eb33..65452788 100644 --- a/src/JsonSchema/Uri/UriRetriever.php +++ b/src/JsonSchema/Uri/UriRetriever.php @@ -24,6 +24,14 @@ */ class UriRetriever implements BaseUriRetrieverInterface { + /** + * @var array Map of URL translations + */ + protected $translationMap = array( + // use local copies of the spec schemas + '|^https?://json-schema.org/draft-(0[34])/schema#?|' => 'package://dist/schema/json-schema-draft-$1.json' + ); + /** * @var null|UriRetrieverInterface */ @@ -134,7 +142,7 @@ public function resolvePointer($jsonSchema, $uri) /** * {@inheritdoc} */ - public function retrieve($uri, $baseUri = null) + public function retrieve($uri, $baseUri = null, $translate = true) { $resolver = new UriResolver(); $resolvedUri = $fetchUri = $resolver->resolve($uri, $baseUri); @@ -146,6 +154,11 @@ public function retrieve($uri, $baseUri = null) $fetchUri = $resolver->generate($arParts); } + // apply URI translations + if ($translate) { + $fetchUri = $this->translate($fetchUri); + } + $jsonSchema = $this->loadSchema($fetchUri); // Use the JSON pointer if specified @@ -291,4 +304,27 @@ public function isValid($uri) return !empty($components); } + + /** + * Set a URL translation rule + */ + public function setTranslation($from, $to) + { + $this->translationMap[$from] = $to; + } + + /** + * Apply URI translation rules + */ + public function translate($uri) + { + foreach ($this->translationMap as $from => $to) { + $uri = preg_replace($from, $to, $uri); + } + + // translate references to local files within the json-schema package + $uri = preg_replace('|^package://|', sprintf('file://%s/', realpath(__DIR__ . '/../../..')), $uri); + + return $uri; + } } diff --git a/src/JsonSchema/Validator.php b/src/JsonSchema/Validator.php index e2a919bc..5d031881 100644 --- a/src/JsonSchema/Validator.php +++ b/src/JsonSchema/Validator.php @@ -12,6 +12,7 @@ use JsonSchema\Constraints\BaseConstraint; use JsonSchema\Constraints\Constraint; use JsonSchema\Exception\InvalidConfigException; +use JsonSchema\SchemaStorage; /** * A JsonSchema Constraint @@ -25,6 +26,11 @@ class Validator extends BaseConstraint { const SCHEMA_MEDIA_TYPE = 'application/schema+json'; + const ERROR_NONE = 0x00000000; + const ERROR_ALL = 0xFFFFFFFF; + const ERROR_DOCUMENT_VALIDATION = 0x00000001; + const ERROR_SCHEMA_VALIDATION = 0x00000002; + /** * Validates the given data against the schema and returns an object containing the results * Both the php object and the schema are supposed to be a result of a json_decode call. @@ -36,17 +42,34 @@ class Validator extends BaseConstraint */ public function validate(&$value, $schema = null, $checkMode = null) { + // reset errors prior to validation + $this->reset(); + + // set checkMode $initialCheckMode = $this->factory->getConfig(); if ($checkMode !== null) { $this->factory->setConfig($checkMode); } + // add provided schema to SchemaStorage with internal URI to allow internal $ref resolution + if (is_object($schema) && property_exists($schema, 'id')) { + $schemaURI = $schema->id; + } else { + $schemaURI = SchemaStorage::INTERNAL_PROVIDED_SCHEMA_URI; + } + $this->factory->getSchemaStorage()->addSchema($schemaURI, $schema); + $validator = $this->factory->createInstanceFor('schema'); - $validator->check($value, $schema); + $validator->check( + $value, + $this->factory->getSchemaStorage()->getSchema($schemaURI) + ); $this->factory->setConfig($initialCheckMode); $this->addErrors(array_unique($validator->getErrors(), SORT_REGULAR)); + + return $validator->getErrorMask(); } /** diff --git a/tests/ConstraintErrorTest.php b/tests/ConstraintErrorTest.php new file mode 100644 index 00000000..5efb7b66 --- /dev/null +++ b/tests/ConstraintErrorTest.php @@ -0,0 +1,32 @@ +assertEquals('Failed to match all schemas', $e->getMessage()); + } + + public function testGetInvalidMessage() + { + $e = ConstraintError::MISSING_ERROR(); + + $this->setExpectedException( + '\JsonSchema\Exception\InvalidArgumentException', + 'Missing error message for missingError' + ); + $e->getMessage(); + } +} diff --git a/tests/Constraints/AdditionalPropertiesTest.php b/tests/Constraints/AdditionalPropertiesTest.php index 4d68654b..e064312d 100644 --- a/tests/Constraints/AdditionalPropertiesTest.php +++ b/tests/Constraints/AdditionalPropertiesTest.php @@ -9,8 +9,12 @@ namespace JsonSchema\Tests\Constraints; +use JsonSchema\Validator; + class AdditionalPropertiesTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( @@ -36,7 +40,13 @@ public function getInvalidTests() 'property' => '', 'pointer' => '', 'message' => 'The property additionalProp is not defined and the definition does not allow additional properties', - 'constraint' => 'additionalProp', + 'constraint' => array( + 'name' => 'additionalProp', + 'params' => array( + 'property' => 'additionalProp' + ) + ), + 'context' => Validator::ERROR_DOCUMENT_VALIDATION ) ) ), diff --git a/tests/Constraints/ArraysTest.php b/tests/Constraints/ArraysTest.php index 5498e35e..dac14358 100644 --- a/tests/Constraints/ArraysTest.php +++ b/tests/Constraints/ArraysTest.php @@ -11,6 +11,8 @@ class ArraysTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/BaseTestCase.php b/tests/Constraints/BaseTestCase.php index 75010e33..50efbd82 100644 --- a/tests/Constraints/BaseTestCase.php +++ b/tests/Constraints/BaseTestCase.php @@ -20,19 +20,31 @@ */ abstract class BaseTestCase extends VeryBaseTestCase { + protected $schemaSpec = 'http://json-schema.org/draft-04/schema#'; + protected $validateSchema = false; + /** * @dataProvider getInvalidTests */ public function testInvalidCases($input, $schema, $checkMode = Constraint::CHECK_MODE_NORMAL, $errors = array()) { $checkMode = $checkMode === null ? Constraint::CHECK_MODE_NORMAL : $checkMode; + if ($this->validateSchema) { + $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; + } $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + if (is_object($schema) && !isset($schema->{'$schema'})) { + $schema->{'$schema'} = $this->schemaSpec; + } $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); $checkValue = json_decode($input); - $validator->validate($checkValue, $schema); + $errorMask = $validator->validate($checkValue, $schema); + + $this->assertTrue((bool) ($errorMask & Validator::ERROR_DOCUMENT_VALIDATION)); + $this->assertGreaterThan(0, $validator->numErrors()); if (array() !== $errors) { $this->assertEquals($errors, $validator->getErrors(), print_r($validator->getErrors(), true)); @@ -46,16 +58,25 @@ public function testInvalidCases($input, $schema, $checkMode = Constraint::CHECK public function testInvalidCasesUsingAssoc($input, $schema, $checkMode = Constraint::CHECK_MODE_TYPE_CAST, $errors = array()) { $checkMode = $checkMode === null ? Constraint::CHECK_MODE_TYPE_CAST : $checkMode; - if ($checkMode !== Constraint::CHECK_MODE_TYPE_CAST) { + if ($this->validateSchema) { + $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; + } + if (!($checkMode & Constraint::CHECK_MODE_TYPE_CAST)) { $this->markTestSkipped('Test indicates that it is not for "CHECK_MODE_TYPE_CAST"'); } $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + if (is_object($schema) && !isset($schema->{'$schema'})) { + $schema->{'$schema'} = $this->schemaSpec; + } $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); $checkValue = json_decode($input, true); - $validator->validate($checkValue, $schema); + $errorMask = $validator->validate($checkValue, $schema); + + $this->assertTrue((bool) ($errorMask & Validator::ERROR_DOCUMENT_VALIDATION)); + $this->assertGreaterThan(0, $validator->numErrors()); if (array() !== $errors) { $this->assertEquals($errors, $validator->getErrors(), print_r($validator->getErrors(), true)); @@ -68,12 +89,19 @@ public function testInvalidCasesUsingAssoc($input, $schema, $checkMode = Constra */ public function testValidCases($input, $schema, $checkMode = Constraint::CHECK_MODE_NORMAL) { + if ($this->validateSchema) { + $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; + } $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + if (is_object($schema) && !isset($schema->{'$schema'})) { + $schema->{'$schema'} = $this->schemaSpec; + } $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); $checkValue = json_decode($input); - $validator->validate($checkValue, $schema); + $errorMask = $validator->validate($checkValue, $schema); + $this->assertEquals(0, $errorMask); $this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true)); } @@ -83,18 +111,25 @@ public function testValidCases($input, $schema, $checkMode = Constraint::CHECK_M */ public function testValidCasesUsingAssoc($input, $schema, $checkMode = Constraint::CHECK_MODE_TYPE_CAST) { - if ($checkMode !== Constraint::CHECK_MODE_TYPE_CAST) { + if ($this->validateSchema) { + $checkMode |= Constraint::CHECK_MODE_VALIDATE_SCHEMA; + } + if (!($checkMode & Constraint::CHECK_MODE_TYPE_CAST)) { $this->markTestSkipped('Test indicates that it is not for "CHECK_MODE_TYPE_CAST"'); } $schema = json_decode($schema); $schemaStorage = new SchemaStorage($this->getUriRetrieverMock($schema), new UriResolver()); $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + if (is_object($schema) && !isset($schema->{'$schema'})) { + $schema->{'$schema'} = $this->schemaSpec; + } $value = json_decode($input, true); $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); - $validator->validate($value, $schema); + $errorMask = $validator->validate($value, $schema); + $this->assertEquals(0, $errorMask); $this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true)); } diff --git a/tests/Constraints/BasicTypesTest.php b/tests/Constraints/BasicTypesTest.php index 7daa43ad..0e88ef42 100644 --- a/tests/Constraints/BasicTypesTest.php +++ b/tests/Constraints/BasicTypesTest.php @@ -11,6 +11,9 @@ class BasicTypesTest extends BaseTestCase { + protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/CoerciveTest.php b/tests/Constraints/CoerciveTest.php index cb5c5518..9a910c8a 100644 --- a/tests/Constraints/CoerciveTest.php +++ b/tests/Constraints/CoerciveTest.php @@ -11,271 +11,199 @@ use JsonSchema\Constraints\Constraint; use JsonSchema\Constraints\Factory; -use JsonSchema\SchemaStorage; -use JsonSchema\Uri\UriResolver; +use JsonSchema\Constraints\TypeCheck\LooseTypeCheck; use JsonSchema\Validator; -class CoerciveTest extends BasicTypesTest +class CoerciveTest extends VeryBaseTestCase { - /** - * @dataProvider getValidCoerceTests - */ - public function testValidCoerceCasesUsingAssoc($input, $schema) - { - $checkMode = Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_COERCE_TYPES; - - $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); - $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); - - $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); + protected $factory = null; - $value = json_decode($input, true); - - $validator->validate($value, $schema, $checkMode); - $this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true)); + public function setUp() + { + $this->factory = new Factory(); + $this->factory->setConfig(Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_COERCE_TYPES); } - /** - * @dataProvider getValidCoerceTests - */ - public function testValidCoerceCases($input, $schema, $errors = array()) + public function dataCoerceCases() { - $checkMode = Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_COERCE_TYPES; + // check type conversions + $types = array( + // toType + 'string' => array( + // fromType fromValue toValue valid Test Number + array('string', '"ABC"', 'ABC', true), // #0 + array('integer', '45', '45', true), // #1 + array('boolean', 'true', 'true', true), // #2 + array('boolean', 'false', 'false', true), // #3 + array('NULL', 'null', '', true), // #4 + array('array', '[45]', '45', true), // #5 + array('object', '{"a":"b"}', null, false), // #6 + array('array', '[{"a":"b"}]', null, false), // #7 + ), + 'integer' => array( + array('string', '"45"', 45, true), // #8 + array('integer', '45', 45, true), // #9 + array('boolean', 'true', 1, true), // #10 + array('boolean', 'false', 0, true), // #11 + array('NULL', 'null', 0, true), // #12 + array('array', '["-45"]', -45, true), // #13 + array('object', '{"a":"b"}', null, false), // #14 + array('array', '["ABC"]', null, false), // #15 + ), + 'boolean' => array( + array('string', '"true"', true, true), // #16 + array('integer', '1', true, true), // #17 + array('boolean', 'true', true, true), // #18 + array('NULL', 'null', false, true), // #19 + array('array', '["true"]', true, true), // #20 + array('object', '{"a":"b"}', null, false), // #21 + array('string', '""', null, false), // #22 + array('string', '"ABC"', null, false), // #23 + array('integer', '2', null, false), // #24 + ), + 'NULL' => array( + array('string', '""', null, true), // #25 + array('integer', '0', null, true), // #26 + array('boolean', 'false', null, true), // #27 + array('NULL', 'null', null, true), // #28 + array('array', '[0]', null, true), // #29 + array('object', '{"a":"b"}', null, false), // #30 + array('string', '"null"', null, false), // #31 + array('integer', '-1', null, false), // #32 + ), + 'array' => array( + array('string', '"ABC"', array('ABC'), true), // #33 + array('integer', '45', array(45), true), // #34 + array('boolean', 'true', array(true), true), // #35 + array('NULL', 'null', array(null), true), // #36 + array('array', '["ABC"]', array('ABC'), true), // #37 + array('object', '{"a":"b"}', null, false), // #38 + ), + ); - $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); - $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + // #39 check post-coercion validation (to array) + $tests[] = array( + '{"properties":{"propertyOne":{"type":"array","items":[{"type":"number"}]}}}', + '{"propertyOne":"ABC"}', + 'string', null, null, false + ); - $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); - $value = json_decode($input); + // #40 check multiple types (first valid) + $tests[] = array( + '{"properties":{"propertyOne":{"type":["number", "string"]}}}', + '{"propertyOne":42}', + 'integer', 'integer', 42, true + ); - $this->assertTrue(gettype($value->number) == 'string'); - $this->assertTrue(gettype($value->integer) == 'string'); - $this->assertTrue(gettype($value->boolean) == 'string'); + // #41 check multiple types (last valid) + $tests[] = array( + '{"properties":{"propertyOne":{"type":["number", "string"]}}}', + '{"propertyOne":"42"}', + 'string', 'string', '42', true + ); - $validator->validate($value, $schema, $checkMode); + // #42 check the meaning of life + $tests[] = array( + '{"properties":{"propertyOne":{"type":"any"}}}', + '{"propertyOne":"42"}', + 'string', 'string', '42', true + ); - $this->assertTrue(gettype($value->number) == 'double'); - $this->assertTrue(gettype($value->integer) == 'integer'); - $this->assertTrue(gettype($value->negativeInteger) == 'integer'); - $this->assertTrue(gettype($value->boolean) == 'boolean'); + // #43 check turple coercion + $tests[] = array( + '{"properties":{"propertyOne":{"type":"array","items":[{"type":"number"},{"type":"string"}]}}}', + '{"propertyOne":["42", 42]}', + 'array', 'array', array(42, '42'), true + ); - $this->assertTrue($value->number === 1.5); - $this->assertTrue($value->integer === 1); - $this->assertTrue($value->negativeInteger === -2); - $this->assertTrue($value->boolean === true); + // #44 check early coercion + $tests[] = array( + '{"properties":{"propertyOne":{"type":["object", "number", "string"]}}}', + '{"propertyOne":"42"}', + 'string', 'integer', 42, true, Constraint::CHECK_MODE_EARLY_COERCE + ); - $this->assertTrue(gettype($value->multitype1) == 'boolean'); - $this->assertTrue(gettype($value->multitype2) == 'double'); - $this->assertTrue(gettype($value->multitype3) == 'integer'); + // #45 check multiple types (none valid) + $tests[] = array( + '{"properties":{"propertyOne":{"type":["number", "boolean"]}}}', + '{"propertyOne":"42"}', + 'string', 'integer', 42, true + ); - $this->assertTrue($value->number === 1.5); - $this->assertTrue($value->integer === 1); - $this->assertTrue($value->negativeInteger === -2); - $this->assertTrue($value->boolean === true); + $tests = array(); + foreach ($types as $toType => $testCases) { + foreach ($testCases as $testCase) { + $tests[] = array( + sprintf('{"properties":{"propertyOne":{"type":"%s"}}}', strtolower($toType)), + sprintf('{"propertyOne":%s}', $testCase[1]), + $testCase[0], + $toType, + $testCase[2], + $testCase[3] + ); + } + } - $this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true)); + return $tests; } - /** - * @dataProvider getInvalidCoerceTests - */ - public function testInvalidCoerceCases($input, $schema, $errors = array()) + /** @dataProvider dataCoerceCases **/ + public function testCoerceCases($schema, $data, $startType, $endType, $endValue, $valid, $extraFlags = 0, $assoc = false) { - $checkMode = Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_COERCE_TYPES; - - $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); - $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + $validator = new Validator($this->factory); - $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); - $value = json_decode($input); - $validator->validate($value, $schema, $checkMode); + $schema = json_decode($schema); + $data = json_decode($data, $assoc); - if (array() !== $errors) { - $this->assertEquals($errors, $validator->getErrors(), print_r($validator->getErrors(), true)); + // check initial type + $type = gettype(LooseTypeCheck::propertyGet($data, 'propertyOne')); + if ($assoc && $type == 'array' && $startType == 'object') { + $type = 'object'; } - $this->assertFalse($validator->isValid(), print_r($validator->getErrors(), true)); - } - - /** - * @dataProvider getInvalidCoerceTests - */ - public function testInvalidCoerceCasesUsingAssoc($input, $schema, $errors = array()) - { - $checkMode = Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_COERCE_TYPES; - - $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); - $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); - - $validator = new Validator(new Factory($schemaStorage, null, $checkMode)); - $value = json_decode($input, true); - $validator->validate($value, $schema, $checkMode); - - if (array() !== $errors) { - $this->assertEquals($errors, $validator->getErrors(), print_r($validator->getErrors(), true)); + $this->assertEquals($startType, $type, "Incorrect type '$type': expected '$startType'"); + + $validator->validate($data, $schema, $this->factory->getConfig() | $extraFlags); + + // check validity + if ($valid) { + $prettyPrint = defined('\JSON_PRETTY_PRINT') ? constant('\JSON_PRETTY_PRINT') : 0; + $this->assertTrue( + $validator->isValid(), + 'Validation failed: ' . json_encode($validator->getErrors(), $prettyPrint) + ); + + // check end type + $type = gettype(LooseTypeCheck::propertyGet($data, 'propertyOne')); + $this->assertEquals($endType, $type, "Incorrect type '$type': expected '$endType'"); + + // check end value + $value = LooseTypeCheck::propertyGet($data, 'propertyOne'); + $this->assertTrue( + $value === $endValue, + sprintf( + "Incorrect value '%s': expected '%s'", + is_scalar($value) ? $value : gettype($value), + is_scalar($endValue) ? $endValue : gettype($endValue) + ) + ); + } else { + $this->assertFalse($validator->isValid(), 'Validation succeeded, but should have failed'); + $this->assertEquals(1, count($validator->getErrors())); } - $this->assertFalse($validator->isValid(), print_r($validator->getErrors(), true)); } - public function getValidCoerceTests() + /** @dataProvider dataCoerceCases **/ + public function testCoerceCasesUsingAssoc($schema, $data, $startType, $endType, $endValue, $valid, $early = false) { - return array( - array( - '{ - "string":"string test", - "number":"1.5", - "integer":"1", - "negativeInteger":"-2", - "boolean":"true", - "object":{}, - "array":[], - "null":null, - "any": "string", - "allOf": "1", - "multitype1": "false", - "multitype2": "1.2", - "multitype3": "7", - "arrayOfIntegers":["-1","0","1"], - "tupleTyping":["1","2.2","true"], - "any1": 2.6, - "any2": 4, - "any3": false, - "any4": {}, - "any5": [], - "any6": null - }', - '{ - "type":"object", - "properties":{ - "string":{"type":"string"}, - "number":{"type":"number"}, - "integer":{"type":"integer"}, - "negativeInteger":{"type":"integer"}, - "boolean":{"type":"boolean"}, - "object":{"type":"object"}, - "array":{"type":"array"}, - "null":{"type":"null"}, - "any": {"type":"any"}, - "allOf" : {"allOf":[{ - "type" : "string" - },{ - "type" : "integer" - }]}, - "multitype1": {"type":["boolean","integer","number"]}, - "multitype2": {"type":["boolean","integer","number"]}, - "multitype3": {"type":["boolean","integer","number"]}, - "arrayOfIntegers":{ - "items":{ - "type":"integer" - } - }, - "tupleTyping":{ - "type":"array", - "items":[ - {"type":"integer"}, - {"type":"number"} - ], - "additionalItems":{"type":"boolean"} - }, - "any1": {"type":"any"}, - "any2": {"type":"any"}, - "any3": {"type":"any"}, - "any4": {"type":"any"}, - "any5": {"type":"any"}, - "any6": {"type":"any"} - }, - "additionalProperties":false - }', - ), - ); + $this->testCoerceCases($schema, $data, $startType, $endType, $endValue, $valid, $early, true); } - public function getInvalidCoerceTests() + public function testCoerceAPI() { - return array( - array( - '{ - "string":null - }', - '{ - "type":"object", - "properties": { - "string":{"type":"string"} - }, - "additionalProperties":false - }', - ), - array( - '{ - "number":"five" - }', - '{ - "type":"object", - "properties": { - "number":{"type":"number"} - }, - "additionalProperties":false - }', - ), - array( - '{ - "integer":"5.2" - }', - '{ - "type":"object", - "properties": { - "integer":{"type":"integer"} - }, - "additionalProperties":false - }', - ), - array( - '{ - "boolean":"0" - }', - '{ - "type":"object", - "properties": { - "boolean":{"type":"boolean"} - }, - "additionalProperties":false - }', - ), - array( - '{ - "object":null - }', - '{ - "type":"object", - "properties": { - "object":{"type":"object"} - }, - "additionalProperties":false - }', - ), - array( - '{ - "array":null - }', - '{ - "type":"object", - "properties": { - "array":{"type":"array"} - }, - "additionalProperties":false - }', - ), - array( - '{ - "null":1 - }', - '{ - "type":"object", - "properties": { - "null":{"type":"null"} - }, - "additionalProperties":false - }', - ), - ); + $input = json_decode('{"propertyOne": "10"}'); + $schema = json_decode('{"properties":{"propertyOne":{"type":"number"}}}'); + $v = new Validator(); + $v->coerce($input, $schema); + $this->assertEquals('{"propertyOne":10}', json_encode($input)); } } diff --git a/tests/Constraints/DefaultPropertiesTest.php b/tests/Constraints/DefaultPropertiesTest.php index 6687e7c2..bcf90b2c 100644 --- a/tests/Constraints/DefaultPropertiesTest.php +++ b/tests/Constraints/DefaultPropertiesTest.php @@ -19,83 +19,158 @@ class DefaultPropertiesTest extends VeryBaseTestCase public function getValidTests() { return array( - array(// default value for entire object + /* + // This test case was intended to check whether a default value can be applied for the + // entire object, however testing this case is impossible, because there is no way to + // distinguish between a deliberate top-level NULL and a top level that contains nothing. + // As such, the assumption is that a top-level NULL is deliberate, and should not be + // altered by replacing it with a default value. + array(// #0 default value for entire object '', '{"default":"valueOne"}', '"valueOne"' ), - array(// default value in an empty object + */ + array(// #0 default value in an empty object '{}', '{"properties":{"propertyOne":{"default":"valueOne"}}}', '{"propertyOne":"valueOne"}' ), - array(// default value for top-level property + array(// #1 default value for top-level property '{"propertyOne":"valueOne"}', '{"properties":{"propertyTwo":{"default":"valueTwo"}}}', '{"propertyOne":"valueOne","propertyTwo":"valueTwo"}' ), - array(// default value for sub-property + array(// #2 default value for sub-property '{"propertyOne":{}}', '{"properties":{"propertyOne":{"properties":{"propertyTwo":{"default":"valueTwo"}}}}}', '{"propertyOne":{"propertyTwo":"valueTwo"}}' ), - array(// default value for sub-property with sibling + array(// #3 default value for sub-property with sibling '{"propertyOne":{"propertyTwo":"valueTwo"}}', '{"properties":{"propertyOne":{"properties":{"propertyThree":{"default":"valueThree"}}}}}', '{"propertyOne":{"propertyTwo":"valueTwo","propertyThree":"valueThree"}}' ), - array(// default value for top-level property with type check + array(// #4 default value for top-level property with type check '{"propertyOne":"valueOne"}', '{"properties":{"propertyTwo":{"default":"valueTwo","type":"string"}}}', '{"propertyOne":"valueOne","propertyTwo":"valueTwo"}' ), - array(// default value for top-level property with v3 required check + array(// #5 default value for top-level property with v3 required check '{"propertyOne":"valueOne"}', '{"properties":{"propertyTwo":{"default":"valueTwo","required":"true"}}}', '{"propertyOne":"valueOne","propertyTwo":"valueTwo"}' ), - array(// default value for top-level property with v4 required check + array(// #6 default value for top-level property with v4 required check '{"propertyOne":"valueOne"}', '{"properties":{"propertyTwo":{"default":"valueTwo"}},"required":["propertyTwo"]}', '{"propertyOne":"valueOne","propertyTwo":"valueTwo"}' ), - array(//default value for an already set property + array(// #7 default value for an already set property '{"propertyOne":"alreadySetValueOne"}', '{"properties":{"propertyOne":{"default":"valueOne"}}}', '{"propertyOne":"alreadySetValueOne"}' ), - array(//default item value for an array + array(// #8 default item value for an array '["valueOne"]', '{"type":"array","items":[{},{"type":"string","default":"valueTwo"}]}', '["valueOne","valueTwo"]' ), - array(//default item value for an empty array + array(// #9 default item value for an empty array '[]', '{"type":"array","items":[{"type":"string","default":"valueOne"}]}', '["valueOne"]' ), - array(//property without a default available + array(// #10 property without a default available '{"propertyOne":"alreadySetValueOne"}', '{"properties":{"propertyOne":{"type":"string"}}}', '{"propertyOne":"alreadySetValueOne"}' ), - array(// default property value is an object + array(// #11 default property value is an object '{"propertyOne":"valueOne"}', '{"properties":{"propertyTwo":{"default":{}}}}', '{"propertyOne":"valueOne","propertyTwo":{}}' ), - array(// default item value is an object + array(// #12 default item value is an object '[]', '{"type":"array","items":[{"default":{}}]}', '[{}]' - ) + ), + array(// #13 only set required values (draft-04) + '{}', + '{ + "properties": { + "propertyOne": {"default": "valueOne"}, + "propertyTwo": {"default": "valueTwo"} + }, + "required": ["propertyTwo"] + }', + '{"propertyTwo":"valueTwo"}', + Constraint::CHECK_MODE_ONLY_REQUIRED_DEFAULTS + ), + array(// #14 only set required values (draft-03) + '{}', + '{ + "properties": { + "propertyOne": {"default": "valueOne"}, + "propertyTwo": {"default": "valueTwo", "required": true} + } + }', + '{"propertyTwo":"valueTwo"}', + Constraint::CHECK_MODE_ONLY_REQUIRED_DEFAULTS + ), + array(// #15 infinite recursion via $ref (object) + '{}', + '{"properties":{"propertyOne": {"$ref": "#","default": {}}}}', + '{"propertyOne":{}}' + ), + array(// #16 infinite recursion via $ref (array) + '[]', + '{"items":[{"$ref":"#","default":[]}]}', + '[[]]' + ), + array(// #17 default top value does not overwrite defined null + 'null', + '{"default":"valueOne"}', + 'null' + ), + array(// #18 default property value does not overwrite defined null + '{"propertyOne":null}', + '{"properties":{"propertyOne":{"default":"valueOne"}}}', + '{"propertyOne":null}' + ), + array(// #19 default value in an object is null + '{}', + '{"properties":{"propertyOne":{"default":null}}}', + '{"propertyOne":null}' + ), + array(// #20 default value in an array is null + '[]', + '{"items":[{"default":null}]}', + '[null]' + ), + array(// #21 items might be a schema (instead of an array of schema) + '[{}]', + '{"items":{"properties":{"propertyOne":{"default":"valueOne"}}}}', + '[{"propertyOne":"valueOne"}]' + ), + array(// #22 if items is not an array, it does not create a new item + '[]', + '{"items":{"properties":{"propertyOne":{"default":"valueOne"}}}}', + '[]' + ), + array(// #23 if items is a schema with a default value and minItems is present, fill the array + '["a"]', + '{"items":{"default":"b"}, "minItems": 3}', + '["a","b","b"]' + ), ); } /** * @dataProvider getValidTests */ - public function testValidCases($input, $schema, $expectOutput = null, $validator = null) + public function testValidCases($input, $schema, $expectOutput = null, $checkMode = 0) { if (is_string($input)) { $inputDecoded = json_decode($input); @@ -103,11 +178,14 @@ public function testValidCases($input, $schema, $expectOutput = null, $validator $inputDecoded = $input; } - if ($validator === null) { - $factory = new Factory(null, null, Constraint::CHECK_MODE_APPLY_DEFAULTS); - $validator = new Validator($factory); - } - $validator->validate($inputDecoded, json_decode($schema)); + $checkMode |= Constraint::CHECK_MODE_APPLY_DEFAULTS; + + $schemaStorage = new SchemaStorage(); + $schemaStorage->addSchema('local://testSchema', json_decode($schema)); + $factory = new Factory($schemaStorage); + $validator = new Validator($factory); + + $validator->validate($inputDecoded, json_decode('{"$ref": "local://testSchema"}'), $checkMode); $this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true)); @@ -119,28 +197,28 @@ public function testValidCases($input, $schema, $expectOutput = null, $validator /** * @dataProvider getValidTests */ - public function testValidCasesUsingAssoc($input, $schema, $expectOutput = null) + public function testValidCasesUsingAssoc($input, $schema, $expectOutput = null, $checkMode = 0) { $input = json_decode($input, true); - $factory = new Factory(null, null, Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_APPLY_DEFAULTS); - self::testValidCases($input, $schema, $expectOutput, new Validator($factory)); + $checkMode |= Constraint::CHECK_MODE_TYPE_CAST; + self::testValidCases($input, $schema, $expectOutput, $checkMode); } /** * @dataProvider getValidTests */ - public function testValidCasesUsingAssocWithoutTypeCast($input, $schema, $expectOutput = null) + public function testValidCasesUsingAssocWithoutTypeCast($input, $schema, $expectOutput = null, $checkMode = 0) { $input = json_decode($input, true); - $factory = new Factory(null, null, Constraint::CHECK_MODE_APPLY_DEFAULTS); - self::testValidCases($input, $schema, $expectOutput, new Validator($factory)); + + self::testValidCases($input, $schema, $expectOutput, $checkMode); } public function testNoModificationViaReferences() { - $input = json_decode(''); - $schema = json_decode('{"default":{"propertyOne":"valueOne"}}'); + $input = json_decode('{}'); + $schema = json_decode('{"properties":{"propertyOne":{"default":"valueOne"}}}'); $validator = new Validator(); $validator->validate($input, $schema, Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_APPLY_DEFAULTS); @@ -148,6 +226,21 @@ public function testNoModificationViaReferences() $this->assertEquals('{"propertyOne":"valueOne"}', json_encode($input)); $input->propertyOne = 'valueTwo'; - $this->assertEquals('valueOne', $schema->default->propertyOne); + $this->assertEquals('valueOne', $schema->properties->propertyOne->default); + } + + public function testLeaveBasicTypesAlone() + { + $input = json_decode('"ThisIsAString"'); + $schema = json_decode('{"properties": {"propertyOne": {"default": "valueOne"}}}'); + + $validator = new Validator(); + $validator->validate($input, $schema, Constraint::CHECK_MODE_APPLY_DEFAULTS); + + $this->assertEquals('"ThisIsAString"', json_encode($input)); + + $schema = json_decode('{"items":[{"type":"string","default":"valueOne"}]}'); + $validator->validate($input, $schema, Constraint::CHECK_MODE_APPLY_DEFAULTS); + $this->assertEquals('"ThisIsAString"', json_encode($input)); } } diff --git a/tests/Constraints/DependenciesTest.php b/tests/Constraints/DependenciesTest.php index 2e508218..f7f9d532 100644 --- a/tests/Constraints/DependenciesTest.php +++ b/tests/Constraints/DependenciesTest.php @@ -11,6 +11,9 @@ class DependenciesTest extends BaseTestCase { + protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/DisallowTest.php b/tests/Constraints/DisallowTest.php index 07446d9d..f00d2fab 100644 --- a/tests/Constraints/DisallowTest.php +++ b/tests/Constraints/DisallowTest.php @@ -11,6 +11,14 @@ class DisallowTest extends BaseTestCase { + // 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 + // 'type' option, and cannot take arbitrary patterns. The implementation in this library is + // probably deliberate, but noting that it's invalid, schema validation has been disabled + // for these tests. The 'disallow' option was removed permanently in draft-04. + protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; + protected $validateSchema = false; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/DivisibleByTest.php b/tests/Constraints/DivisibleByTest.php index 8a010965..b88a87a1 100644 --- a/tests/Constraints/DivisibleByTest.php +++ b/tests/Constraints/DivisibleByTest.php @@ -11,6 +11,8 @@ class DivisibleByTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/EnumTest.php b/tests/Constraints/EnumTest.php index 0ca5b9e2..723321d0 100644 --- a/tests/Constraints/EnumTest.php +++ b/tests/Constraints/EnumTest.php @@ -11,6 +11,9 @@ class EnumTest extends BaseTestCase { + protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/ExtendsTest.php b/tests/Constraints/ExtendsTest.php index 289484f3..5df1fa27 100644 --- a/tests/Constraints/ExtendsTest.php +++ b/tests/Constraints/ExtendsTest.php @@ -11,6 +11,9 @@ class ExtendsTest extends BaseTestCase { + protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/FormatTest.php b/tests/Constraints/FormatTest.php index 73de5784..b035aafe 100644 --- a/tests/Constraints/FormatTest.php +++ b/tests/Constraints/FormatTest.php @@ -9,10 +9,14 @@ namespace JsonSchema\Tests\Constraints; +use JsonSchema\Constraints\Constraint; +use JsonSchema\Constraints\Factory; use JsonSchema\Constraints\FormatConstraint; class FormatTest extends BaseTestCase { + protected $validateSchema = true; + public function setUp() { date_default_timezone_set('UTC'); @@ -76,6 +80,21 @@ public function testInvalidFormat($string, $format) $this->assertEquals(1, count($validator->getErrors()), 'Expected 1 error'); } + /** + * @dataProvider getInvalidFormats + */ + public function testDisabledFormat($string, $format) + { + $factory = new Factory(); + $validator = new FormatConstraint($factory); + $schema = new \stdClass(); + $schema->format = $format; + $factory->addConfig(Constraint::CHECK_MODE_DISABLE_FORMAT); + + $validator->check($string, $schema); + $this->assertEmpty($validator->getErrors()); + } + public function getValidFormats() { return array( @@ -124,12 +143,12 @@ public function getValidFormats() array('555 320 1212', 'phone'), array('http://bluebox.org', 'uri'), - array('//bluebox.org', 'uri'), - array('/absolutePathReference/', 'uri'), - array('./relativePathReference/', 'uri'), - array('./relative:PathReference/', 'uri'), - array('relativePathReference/', 'uri'), - array('relative/Path:Reference/', 'uri'), + array('//bluebox.org', 'uri-reference'), + array('/absolutePathReference/', 'uri-reference'), + array('./relativePathReference/', 'uri-reference'), + array('./relative:PathReference/', 'uri-reference'), + array('relativePathReference/', 'uri-reference'), + array('relative/Path:Reference/', 'uri-reference'), array('info@something.edu', 'email'), @@ -181,6 +200,12 @@ public function getInvalidFormats() array('htt:/bluebox.org', 'uri'), array('.relative:path/reference/', 'uri'), array('', 'uri'), + array('//bluebox.org', 'uri'), + array('/absolutePathReference/', 'uri'), + array('./relativePathReference/', 'uri'), + array('./relative:PathReference/', 'uri'), + array('relativePathReference/', 'uri'), + array('relative/Path:Reference/', 'uri'), array('info@somewhere', 'email'), diff --git a/tests/Constraints/LongArraysTest.php b/tests/Constraints/LongArraysTest.php index 849c0371..2757b964 100644 --- a/tests/Constraints/LongArraysTest.php +++ b/tests/Constraints/LongArraysTest.php @@ -15,6 +15,8 @@ class LongArraysTest extends VeryBaseTestCase { + protected $validateSchema = true; + public function testLongStringArray() { $schema = diff --git a/tests/Constraints/MinItemsMaxItemsTest.php b/tests/Constraints/MinItemsMaxItemsTest.php index 1b477845..62fbaa9a 100644 --- a/tests/Constraints/MinItemsMaxItemsTest.php +++ b/tests/Constraints/MinItemsMaxItemsTest.php @@ -11,6 +11,8 @@ class MinItemsMaxItemsTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/MinLengthMaxLengthMultiByteTest.php b/tests/Constraints/MinLengthMaxLengthMultiByteTest.php index ab110a40..b19ec4f7 100644 --- a/tests/Constraints/MinLengthMaxLengthMultiByteTest.php +++ b/tests/Constraints/MinLengthMaxLengthMultiByteTest.php @@ -11,6 +11,8 @@ class MinLengthMaxLengthMultiByteTest extends BaseTestCase { + protected $validateSchema = true; + protected function setUp() { if (!extension_loaded('mbstring')) { diff --git a/tests/Constraints/MinLengthMaxLengthTest.php b/tests/Constraints/MinLengthMaxLengthTest.php index 0e09a7a3..8dfa7158 100644 --- a/tests/Constraints/MinLengthMaxLengthTest.php +++ b/tests/Constraints/MinLengthMaxLengthTest.php @@ -11,6 +11,8 @@ class MinLengthMaxLengthTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/MinMaxPropertiesTest.php b/tests/Constraints/MinMaxPropertiesTest.php index 8c3a641d..2063122c 100644 --- a/tests/Constraints/MinMaxPropertiesTest.php +++ b/tests/Constraints/MinMaxPropertiesTest.php @@ -11,6 +11,8 @@ class MinMaxPropertiesTest extends BaseTestCase { + protected $validateSchema = true; + /** * {@inheritdoc} */ @@ -72,7 +74,7 @@ public function getInvalidTests() return array( array( '{ - "value": 1 + "value": {} }', '{ "type": "object", @@ -82,8 +84,26 @@ public function getInvalidTests() }' ), array( + '{}', '{ - "value": 1 + "type": "object", + "properties": { + "propertyOne": { + "type": "string" + }, + "propertyTwo": { + "type": "string" + } + }, + "minProperties": 1 + }' + ), + array( + '{ + "value": { + "propertyOne": "valueOne", + "propertyTwo": "valueTwo" + } }', '{ "type": "object", diff --git a/tests/Constraints/MinimumMaximumTest.php b/tests/Constraints/MinimumMaximumTest.php index c25a7c29..508c0253 100644 --- a/tests/Constraints/MinimumMaximumTest.php +++ b/tests/Constraints/MinimumMaximumTest.php @@ -11,6 +11,8 @@ class MinimumMaximumTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/NotTest.php b/tests/Constraints/NotTest.php index 27f02225..3a950f57 100644 --- a/tests/Constraints/NotTest.php +++ b/tests/Constraints/NotTest.php @@ -11,6 +11,8 @@ class NotTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/NumberAndIntegerTypesTest.php b/tests/Constraints/NumberAndIntegerTypesTest.php index 91e1c7cb..6c7277b9 100644 --- a/tests/Constraints/NumberAndIntegerTypesTest.php +++ b/tests/Constraints/NumberAndIntegerTypesTest.php @@ -11,6 +11,8 @@ class NumberAndIntegerTypesTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/OfPropertiesTest.php b/tests/Constraints/OfPropertiesTest.php index c36ba29e..ff8bded3 100644 --- a/tests/Constraints/OfPropertiesTest.php +++ b/tests/Constraints/OfPropertiesTest.php @@ -8,11 +8,16 @@ namespace JsonSchema\Tests\Constraints; +use JsonSchema\Constraints\Constraint; +use JsonSchema\Validator; + /** * Class OfPropertiesTest */ class OfPropertiesTest extends BaseTestCase { + protected $validateSchema = true; + public function getValidTests() { return array( @@ -75,19 +80,37 @@ public function getInvalidTests() 'property' => 'prop2', 'pointer' => '/prop2', 'message' => 'Array value found, but a string is required', - 'constraint' => 'type', + 'constraint' => array( + 'name' => 'type', + 'params' => array( + 'expected' => 'a string', + 'found' => 'array' + ) + ), + 'context' => Validator::ERROR_DOCUMENT_VALIDATION ), array( 'property' => 'prop2', 'pointer' => '/prop2', 'message' => 'Array value found, but a number is required', - 'constraint' => 'type', + 'constraint' => array( + 'name' => 'type', + 'params' => array( + 'expected' => 'a number', + 'found' => 'array' + ) + ), + 'context' => Validator::ERROR_DOCUMENT_VALIDATION ), array( 'property' => 'prop2', 'pointer' => '/prop2', 'message' => 'Failed to match exactly one schema', - 'constraint' => 'oneOf', + 'constraint' => array( + 'name' => 'oneOf', + 'params' => array() + ), + 'context' => Validator::ERROR_DOCUMENT_VALIDATION ), ), ), @@ -208,4 +231,44 @@ public function getInvalidTests() ) ); } + + public function testNoPrematureAnyOfException() + { + $schema = json_decode('{ + "type": "object", + "properties": { + "propertyOne": { + "anyOf": [ + {"type": "number"}, + {"type": "string"} + ] + } + } + }'); + $data = json_decode('{"propertyOne":"ABC"}'); + + $v = new Validator(); + $v->validate($data, $schema, Constraint::CHECK_MODE_EXCEPTIONS); + $this->assertTrue($v->isValid()); + } + + public function testNoPrematureOneOfException() + { + $schema = json_decode('{ + "type": "object", + "properties": { + "propertyOne": { + "oneOf": [ + {"type": "number"}, + {"type": "string"} + ] + } + } + }'); + $data = json_decode('{"propertyOne":"ABC"}'); + + $v = new Validator(); + $v->validate($data, $schema, Constraint::CHECK_MODE_EXCEPTIONS); + $this->assertTrue($v->isValid()); + } } diff --git a/tests/Constraints/PatternPropertiesTest.php b/tests/Constraints/PatternPropertiesTest.php index a04e45b9..8dede058 100644 --- a/tests/Constraints/PatternPropertiesTest.php +++ b/tests/Constraints/PatternPropertiesTest.php @@ -11,6 +11,8 @@ class PatternPropertiesTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/PatternTest.php b/tests/Constraints/PatternTest.php index 0f69b9ad..c017600c 100644 --- a/tests/Constraints/PatternTest.php +++ b/tests/Constraints/PatternTest.php @@ -11,6 +11,8 @@ class PatternTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/PointerTest.php b/tests/Constraints/PointerTest.php index ca378e3d..87ab0136 100644 --- a/tests/Constraints/PointerTest.php +++ b/tests/Constraints/PointerTest.php @@ -13,6 +13,8 @@ class PointerTest extends \PHPUnit_Framework_TestCase { + protected $validateSchema = true; + public function testVariousPointers() { $schema = array( @@ -88,25 +90,49 @@ public function testVariousPointers() 'property' => 'prop1', 'pointer' => '/prop1', 'message' => 'The property prop1 is required', - 'constraint' => 'required' + 'constraint' => array( + 'name' => 'required', + 'params' => array( + 'property' => 'prop1' + ) + ), + 'context' => Validator::ERROR_DOCUMENT_VALIDATION ), array( 'property' => 'prop2.prop2.1', 'pointer' => '/prop2/prop2.1', 'message' => 'The property prop2.1 is required', - 'constraint' => 'required' + 'constraint' => array( + 'name' => 'required', + 'params' => array( + 'property' => 'prop2.1' + ) + ), + 'context' => Validator::ERROR_DOCUMENT_VALIDATION ), array( 'property' => 'prop3.prop3/1.prop3/1.1', 'pointer' => '/prop3/prop3~11/prop3~11.1', 'message' => 'The property prop3/1.1 is required', - 'constraint' => 'required' + 'constraint' => array( + 'name' => 'required', + 'params' => array( + 'property' => 'prop3/1.1' + ) + ), + 'context' => Validator::ERROR_DOCUMENT_VALIDATION ), array( 'property' => 'prop4[0].prop4-child', 'pointer' => '/prop4/0/prop4-child', 'message' => 'The property prop4-child is required', - 'constraint' => 'required' + 'constraint' => array( + 'name' => 'required', + 'params' => array( + 'property' => 'prop4-child' + ) + ), + 'context' => Validator::ERROR_DOCUMENT_VALIDATION ) ), $validator->getErrors() diff --git a/tests/Constraints/ReadOnlyTest.php b/tests/Constraints/ReadOnlyTest.php index 7a3e8678..23434406 100644 --- a/tests/Constraints/ReadOnlyTest.php +++ b/tests/Constraints/ReadOnlyTest.php @@ -11,6 +11,8 @@ class ReadOnlyTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { //is readonly really required? diff --git a/tests/Constraints/RequireTest.php b/tests/Constraints/RequireTest.php index c10f8a7b..efb6f63e 100644 --- a/tests/Constraints/RequireTest.php +++ b/tests/Constraints/RequireTest.php @@ -11,6 +11,8 @@ class RequireTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/RequiredPropertyTest.php b/tests/Constraints/RequiredPropertyTest.php index 10d42f57..31545a64 100644 --- a/tests/Constraints/RequiredPropertyTest.php +++ b/tests/Constraints/RequiredPropertyTest.php @@ -14,6 +14,12 @@ class RequiredPropertyTest extends BaseTestCase { + // Most tests are draft-03 compliant, but some tests are draft-04, or mix draft-03 and + // draft-04 syntax within the same schema. Unfortunately, draft-03 and draft-04 required + // definitions are incompatible, so disabling schema validation for these tests. + protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; + protected $validateSchema = false; + public function testErrorPropertyIsPopulatedForRequiredIfMissingInInput() { $validator = new UndefinedConstraint(); diff --git a/tests/Constraints/SchemaValidationTest.php b/tests/Constraints/SchemaValidationTest.php new file mode 100644 index 00000000..356637ae --- /dev/null +++ b/tests/Constraints/SchemaValidationTest.php @@ -0,0 +1,124 @@ +validate($input, $schema, Constraint::CHECK_MODE_VALIDATE_SCHEMA); + + $this->assertTrue((bool) (Validator::ERROR_SCHEMA_VALIDATION & $errorMask)); + $this->assertGreaterThan(0, $v->numErrors(Validator::ERROR_SCHEMA_VALIDATION)); + $this->assertEquals(0, $v->numErrors(Validator::ERROR_DOCUMENT_VALIDATION)); + + $this->assertFalse($v->isValid(), 'Validation succeeded for an invalid test case'); + foreach ($v->getErrors() as $error) { + $this->assertEquals(Validator::ERROR_SCHEMA_VALIDATION, $error['context']); + } + } + + /** + * @dataProvider getValidTests + */ + public function testValidCases($schema) + { + $input = json_decode('{"propertyOne":"valueOne"}'); + $schema = json_decode($schema); + + $v = new Validator(); + $errorMask = $v->validate($input, $schema, Constraint::CHECK_MODE_VALIDATE_SCHEMA); + $this->assertEquals(0, $errorMask); + + if (!$v->isValid()) { + var_dump($v->getErrors(Validator::ERROR_SCHEMA_VALIDATION)); + } + $this->assertTrue($v->isValid(), 'Validation failed on a valid test case'); + } + + public function testNonObjectSchema() + { + $this->setExpectedException( + '\JsonSchema\Exception\RuntimeException', + 'Cannot validate the schema of a non-object' + ); + $this->testValidCases('"notAnObject"'); + } + + public function testInvalidSchemaException() + { + $this->setExpectedException( + '\JsonSchema\Exception\InvalidSchemaException', + 'Schema did not pass validation' + ); + + $input = json_decode('{}'); + $schema = json_decode('{"properties":{"propertyOne":{"type":"string","required":true}}}'); + + $v = new Validator(); + $v->validate($input, $schema, Constraint::CHECK_MODE_VALIDATE_SCHEMA | Constraint::CHECK_MODE_EXCEPTIONS); + } +} diff --git a/tests/Constraints/SelfDefinedSchemaTest.php b/tests/Constraints/SelfDefinedSchemaTest.php index d2cce50e..e7d3d70b 100644 --- a/tests/Constraints/SelfDefinedSchemaTest.php +++ b/tests/Constraints/SelfDefinedSchemaTest.php @@ -13,12 +13,15 @@ class SelfDefinedSchemaTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( array( '{ "$schema": { + "$schema": "http://json-schema.org/draft-04/schema#", "properties": { "name": { "type": "string" @@ -44,6 +47,7 @@ public function getValidTests() array( '{ "$schema": { + "$schema": "http://json-schema.org/draft-04/schema#", "properties": { "name": { "type": "string" diff --git a/tests/Constraints/TupleTypingTest.php b/tests/Constraints/TupleTypingTest.php index ceab8ec6..08fedc0a 100644 --- a/tests/Constraints/TupleTypingTest.php +++ b/tests/Constraints/TupleTypingTest.php @@ -11,6 +11,8 @@ class TupleTypingTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/TypeTest.php b/tests/Constraints/TypeTest.php index df8d6dd1..24138478 100644 --- a/tests/Constraints/TypeTest.php +++ b/tests/Constraints/TypeTest.php @@ -92,4 +92,31 @@ private function assertTypeConstraintError($expected, TypeConstraint $actual) $this->assertEquals($expected, $actualMessage); // first equal for the diff $this->assertSame($expected, $actualMessage); // the same for the strictness } + + public function testValidateTypeNameWording() + { + $t = new TypeConstraint(); + $r = new \ReflectionObject($t); + $m = $r->getMethod('validateTypeNameWording'); + $m->setAccessible(true); + + $this->setExpectedException( + '\UnexpectedValueException', + "No wording for 'notAValidTypeName' available, expected wordings are: [an integer, a number, a boolean, an object, an array, a string, a null]" + ); + $m->invoke($t, 'notAValidTypeName'); + } + + public function testValidateTypeException() + { + $t = new TypeConstraint(); + $data = new \StdClass(); + $schema = json_decode('{"type": "notAValidTypeName"}'); + + $this->setExpectedException( + 'JsonSchema\Exception\InvalidArgumentException', + 'object is an invalid type for notAValidTypeName' + ); + $t->check($data, $schema); + } } diff --git a/tests/Constraints/UnionTypesTest.php b/tests/Constraints/UnionTypesTest.php index 01e49c4e..42676308 100644 --- a/tests/Constraints/UnionTypesTest.php +++ b/tests/Constraints/UnionTypesTest.php @@ -11,6 +11,8 @@ class UnionTypesTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/UnionWithNullValueTest.php b/tests/Constraints/UnionWithNullValueTest.php index a077cfdf..60301f2e 100644 --- a/tests/Constraints/UnionWithNullValueTest.php +++ b/tests/Constraints/UnionWithNullValueTest.php @@ -11,6 +11,8 @@ class UnionWithNullValueTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/UniqueItemsTest.php b/tests/Constraints/UniqueItemsTest.php index 4abac569..099b407c 100644 --- a/tests/Constraints/UniqueItemsTest.php +++ b/tests/Constraints/UniqueItemsTest.php @@ -11,6 +11,8 @@ class UniqueItemsTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Constraints/VeryBaseTestCase.php b/tests/Constraints/VeryBaseTestCase.php index 7d8eb267..7cc0d1c6 100644 --- a/tests/Constraints/VeryBaseTestCase.php +++ b/tests/Constraints/VeryBaseTestCase.php @@ -66,7 +66,7 @@ private function getJsonSchemaDraft03() { if (!$this->jsonSchemaDraft03) { $this->jsonSchemaDraft03 = json_decode( - file_get_contents(__DIR__ . '/../fixtures/json-schema-draft-03.json') + file_get_contents(__DIR__ . '/../../dist/schema/json-schema-draft-03.json') ); } @@ -80,7 +80,7 @@ private function getJsonSchemaDraft04() { if (!$this->jsonSchemaDraft04) { $this->jsonSchemaDraft04 = json_decode( - file_get_contents(__DIR__ . '/../fixtures/json-schema-draft-04.json') + file_get_contents(__DIR__ . '/../../dist/schema/json-schema-draft-04.json') ); } diff --git a/tests/Constraints/WrongMessagesFailingTestCaseTest.php b/tests/Constraints/WrongMessagesFailingTestCaseTest.php index ca620420..80a14421 100644 --- a/tests/Constraints/WrongMessagesFailingTestCaseTest.php +++ b/tests/Constraints/WrongMessagesFailingTestCaseTest.php @@ -11,6 +11,8 @@ class WrongMessagesFailingTestCaseTest extends BaseTestCase { + protected $validateSchema = true; + public function getInvalidTests() { return array( diff --git a/tests/Drafts/Draft3Test.php b/tests/Drafts/Draft3Test.php index 4a744441..1942a3b1 100644 --- a/tests/Drafts/Draft3Test.php +++ b/tests/Drafts/Draft3Test.php @@ -16,6 +16,9 @@ */ class Draft3Test extends BaseDraftTestCase { + protected $schemaSpec = 'http://json-schema.org/draft-03/schema#'; + protected $validateSchema = true; + /** * {@inheritdoc} */ diff --git a/tests/Drafts/Draft4Test.php b/tests/Drafts/Draft4Test.php index a4508b0f..54eee4c4 100644 --- a/tests/Drafts/Draft4Test.php +++ b/tests/Drafts/Draft4Test.php @@ -14,6 +14,9 @@ */ class Draft4Test extends BaseDraftTestCase { + protected $schemaSpec = 'http://json-schema.org/draft-04/schema#'; + protected $validateSchema = true; + /** * {@inheritdoc} */ diff --git a/tests/Entity/JsonPointerTest.php b/tests/Entity/JsonPointerTest.php index 6a5ff4bf..65859895 100644 --- a/tests/Entity/JsonPointerTest.php +++ b/tests/Entity/JsonPointerTest.php @@ -109,4 +109,13 @@ public function testJsonPointerWithPropertyPaths() $this->assertEquals(array('~definitions/general', '%custom%'), $modified->getPropertyPaths()); $this->assertEquals('#/~0definitions~1general/%25custom%25', $modified->getPropertyPathAsString()); } + + public function testCreateWithInvalidValue() + { + $this->setExpectedException( + '\JsonSchema\Exception\InvalidArgumentException', + 'Ref value must be a string' + ); + new JsonPointer(null); + } } diff --git a/tests/Iterators/ObjectIteratorTest.php b/tests/Iterators/ObjectIteratorTest.php new file mode 100644 index 00000000..703df833 --- /dev/null +++ b/tests/Iterators/ObjectIteratorTest.php @@ -0,0 +1,89 @@ +testObject = (object) array( + 'subOne' => (object) array( + 'propertyOne' => 'valueOne', + 'propertyTwo' => 'valueTwo', + 'propertyThree' => 'valueThree' + ), + 'subTwo' => (object) array( + 'propertyFour' => 'valueFour', + 'subThree' => (object) array( + 'propertyFive' => 'valueFive', + 'propertySix' => 'valueSix' + ) + ), + 'propertySeven' => 'valueSeven' + ); + } + + public function testCreate() + { + $i = new ObjectIterator($this->testObject); + + $this->assertInstanceOf('\JsonSchema\Iterator\ObjectIterator', $i); + } + + public function testInitialState() + { + $i = new ObjectIterator($this->testObject); + + $this->assertEquals($this->testObject, $i->current()); + } + + public function testCount() + { + $i = new ObjectIterator($this->testObject); + + $this->assertEquals(4, $i->count()); + } + + public function testKey() + { + $i = new ObjectIterator($this->testObject); + + while ($i->key() != 2) { + $i->next(); + } + + $this->assertEquals($this->testObject->subTwo->subThree, $i->current()); + } + + public function testAlwaysObjects() + { + $i= new ObjectIterator($this->testObject); + + foreach ($i as $item) { + $this->assertInstanceOf('\StdClass', $item); + } + } + + public function testReachesAllProperties() + { + $i = new ObjectIterator($this->testObject); + + $count = 0; + foreach ($i as $item) { + $count += count(get_object_vars($item)); + } + + $this->assertEquals(10, $count); + } +} diff --git a/tests/RefTest.php b/tests/RefTest.php new file mode 100644 index 00000000..b67852c1 --- /dev/null +++ b/tests/RefTest.php @@ -0,0 +1,78 @@ +setExpectedException($exception); + } + + $v->validate($document, $schema); + + $this->assertEquals($isValid, $v->isValid()); + } +} diff --git a/tests/Rfc3339Test.php b/tests/Rfc3339Test.php index d01da520..13294d0a 100644 --- a/tests/Rfc3339Test.php +++ b/tests/Rfc3339Test.php @@ -35,8 +35,14 @@ public function provideValidFormats() '2000-05-01T12:12:12Z', \DateTime::createFromFormat('Y-m-d\TH:i:s', '2000-05-01T12:12:12', new \DateTimeZone('UTC')) ), - array('2000-05-01T12:12:12+0100', \DateTime::createFromFormat('Y-m-d\TH:i:sP', '2000-05-01T12:12:12+01:00')), - array('2000-05-01T12:12:12+01:00', \DateTime::createFromFormat('Y-m-d\TH:i:sP', '2000-05-01T12:12:12+01:00')), + array( + '2000-05-01T12:12:12+0100', + \DateTime::createFromFormat('Y-m-d\TH:i:sP', '2000-05-01T12:12:12+01:00') + ), + array( + '2000-05-01T12:12:12+01:00', + \DateTime::createFromFormat('Y-m-d\TH:i:sP', '2000-05-01T12:12:12+01:00') + ), array( '2000-05-01T12:12:12.123456Z', \DateTime::createFromFormat('Y-m-d\TH:i:s.u', '2000-05-01T12:12:12.123456', new \DateTimeZone('UTC')) @@ -45,6 +51,14 @@ public function provideValidFormats() '2000-05-01T12:12:12.123Z', \DateTime::createFromFormat('Y-m-d\TH:i:s.u', '2000-05-01T12:12:12.123000', new \DateTimeZone('UTC')) ), + array( + '2000-05-01 12:12:12.123Z', + \DateTime::createFromFormat('Y-m-d H:i:s.u', '2000-05-01 12:12:12.123000', new \DateTimeZone('UTC')) + ), + array( + '2000-05-01 12:12:12.123456Z', + \DateTime::createFromFormat('Y-m-d H:i:s.u', '2000-05-01 12:12:12.123456', new \DateTimeZone('UTC')) + ) ); } @@ -54,6 +68,8 @@ public function provideInvalidFormats() array('1999-1-11T00:00:00Z'), array('1999-01-11T00:00:00+100'), array('1999-01-11T00:00:00+1:00'), + array('1999-01-01 00:00:00Z'), + array('1999-1-11 00:00:00Z') ); } } diff --git a/tests/SchemaStorageTest.php b/tests/SchemaStorageTest.php index c3388bf4..0aa219ac 100644 --- a/tests/SchemaStorageTest.php +++ b/tests/SchemaStorageTest.php @@ -11,6 +11,7 @@ use JsonSchema\SchemaStorage; use JsonSchema\Uri\UriRetriever; +use JsonSchema\Validator; use Prophecy\Argument; class SchemaStorageTest extends \PHPUnit_Framework_TestCase @@ -31,6 +32,15 @@ public function testResolveRef() ); } + public function testResolveTopRef() + { + $input = json_decode('{"propertyOne":"notANumber"}'); + $schema = json_decode('{"$ref":"#/definition","definition":{"properties":{"propertyOne":{"type":"number"}}}}'); + $v = new Validator(); + $v->validate($input, $schema); + $this->assertFalse($v->isValid()); + } + /** * @depends testResolveRef */ @@ -69,7 +79,7 @@ public function testSchemaWithLocalAndExternalReferencesWithCircularReference() ); // local ref with overriding - $this->assertNotEquals( + $this->assertEquals( $schemaStorage->resolveRef("$mainSchemaPath#/definitions/house/additionalProperties"), $schemaStorage->resolveRef("$mainSchemaPath#/properties/house/additionalProperties") ); @@ -109,6 +119,17 @@ public function testUnresolvableJsonPointExceptionShouldBeThrown() $schemaStorage->resolveRef("$mainSchemaPath#/definitions/car"); } + public function testResolveRefWithNoAssociatedFileName() + { + $this->setExpectedException( + 'JsonSchema\Exception\UnresolvableJsonPointerException', + "Could not resolve fragment '#': no file is defined" + ); + + $schemaStorage = new SchemaStorage(); + $schemaStorage->resolveRef('#'); + } + /** * @return object */ @@ -254,4 +275,52 @@ private function getInvalidSchema() ) ); } + + public function testGetUriRetriever() + { + $s = new SchemaStorage(); + $s->addSchema('http://json-schema.org/draft-04/schema#'); + $this->assertInstanceOf('\JsonSchema\Uri\UriRetriever', $s->getUriRetriever()); + } + + public function testGetUriResolver() + { + $s = new SchemaStorage(); + $s->addSchema('http://json-schema.org/draft-04/schema#'); + $this->assertInstanceOf('\JsonSchema\Uri\UriResolver', $s->getUriResolver()); + } + + public function testMetaSchemaFixes() + { + $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#'); + + $this->assertEquals('uri-reference', $draft_03->properties->id->format); + $this->assertEquals('uri-reference', $draft_03->properties->{'$ref'}->format); + $this->assertEquals('uri-reference', $draft_04->properties->id->format); + } + + public function testNoDoubleResolve() + { + $schemaOne = json_decode('{"id": "test/schema", "$ref": "../test2/schema2"}'); + + $uriRetriever = $this->prophesize('JsonSchema\UriRetrieverInterface'); + $uriRetriever->retrieve('test/schema')->willReturn($schemaOne)->shouldBeCalled(); + + $s = new SchemaStorage($uriRetriever->reveal()); + $schema = $s->addSchema('test/schema'); + + $r = new \ReflectionObject($s); + $p = $r->getProperty('schemas'); + $p->setAccessible(true); + $schemas = $p->getValue($s); + + $this->assertEquals( + 'file://' . getcwd() . '/test2/schema2#', + $schemas['test/schema']->{'$ref'} + ); + } } diff --git a/tests/Uri/Retrievers/CurlTest.php b/tests/Uri/Retrievers/CurlTest.php new file mode 100644 index 00000000..f833b590 --- /dev/null +++ b/tests/Uri/Retrievers/CurlTest.php @@ -0,0 +1,57 @@ +retrieve(realpath(__DIR__ . '/../../fixtures/foobar.json')); + } + + public function testRetrieveNonexistantFile() + { + $c = new Curl(); + + $this->setExpectedException( + '\JsonSchema\Exception\ResourceNotFoundException', + 'JSON schema not found' + ); + $c->retrieve(__DIR__ . '/notARealFile'); + } + + public function testNoContentType() + { + $c = new Curl(); + $c->retrieve(realpath(__DIR__ . '/../../fixtures') . '/foobar-noheader.json'); + } + } +} + +namespace JsonSchema\Uri\Retrievers +{ + function curl_exec($curl) + { + $uri = curl_getinfo($curl, \CURLINFO_EFFECTIVE_URL); + + if ($uri === realpath(__DIR__ . '/../../fixtures/foobar.json')) { + // return file with headers + $headers = implode("\n", array( + 'Content-Type: application/json' + )); + + return sprintf("%s\r\n\r\n%s", $headers, file_get_contents($uri)); + } elseif ($uri === realpath(__DIR__ . '/../../fixtures') . '/foobar-noheader.json') { + // return file without headers + $uri = realpath(__DIR__ . '/../../fixtures/foobar.json'); + + return "\r\n\r\n" . file_get_contents($uri); + } + + // fallback to real curl_exec + return \curl_exec($curl); + } +} diff --git a/tests/Uri/Retrievers/FileGetContentsTest.php b/tests/Uri/Retrievers/FileGetContentsTest.php index 7b67facb..d9b06263 100644 --- a/tests/Uri/Retrievers/FileGetContentsTest.php +++ b/tests/Uri/Retrievers/FileGetContentsTest.php @@ -1,27 +1,74 @@ retrieve(__DIR__ . '/Fixture/missing.json'); + /** + * @expectedException \JsonSchema\Exception\ResourceNotFoundException + */ + public function testFetchMissingFile() + { + $res = new FileGetContents(); + $res->retrieve(__DIR__ . '/Fixture/missing.json'); + } + + public function testFetchFile() + { + $res = new FileGetContents(); + $result = $res->retrieve(__DIR__ . '/../Fixture/child.json'); + $this->assertNotEmpty($result); + } + + public function testFalseReturn() + { + $res = new FileGetContents(); + + $this->setExpectedException( + '\JsonSchema\Exception\ResourceNotFoundException', + 'JSON schema not found at http://example.com/false' + ); + $res->retrieve('http://example.com/false'); + } + + public function testFetchDirectory() + { + $res = new FileGetContents(); + + $this->setExpectedException( + '\JsonSchema\Exception\ResourceNotFoundException', + 'JSON schema not found at file:///this/is/a/directory/' + ); + $res->retrieve('file:///this/is/a/directory/'); + } + + public function testContentType() + { + $res = new FileGetContents(); + + $reflector = new \ReflectionObject($res); + $fetchContentType = $reflector->getMethod('fetchContentType'); + $fetchContentType->setAccessible(true); + + $this->assertTrue($fetchContentType->invoke($res, array('Content-Type: application/json'))); + $this->assertFalse($fetchContentType->invoke($res, array('X-Some-Header: whateverValue'))); + } } +} - public function testFetchFile() +namespace JsonSchema\Uri\Retrievers +{ + function file_get_contents($uri) { - $res = new FileGetContents(); - $result = $res->retrieve(__DIR__ . '/../Fixture/child.json'); - $this->assertNotEmpty($result); + switch ($uri) { + case 'http://example.com/false': return false; + case 'file:///this/is/a/directory/': return ''; + default: return \file_get_contents($uri); + } } } diff --git a/tests/Uri/UriResolverTest.php b/tests/Uri/UriResolverTest.php index ea564f65..051c2a58 100644 --- a/tests/Uri/UriResolverTest.php +++ b/tests/Uri/UriResolverTest.php @@ -172,4 +172,55 @@ public function testResolveEmpty() ) ); } + + public function testReversable() + { + $uri = 'scheme://user:password@authority/path?query#fragment'; + $split = $this->resolver->parse($uri); + + // check that the URI was split as expected + $this->assertEquals(array( + 'scheme' => 'scheme', + 'authority' => 'user:password@authority', + 'path' => '/path', + 'query' => 'query', + 'fragment' => 'fragment' + ), $split); + + // check that the recombined URI matches the original input + $this->assertEquals($uri, $this->resolver->generate($split)); + } + + public function testRelativeFileAsRoot() + { + $this->assertEquals( + 'file://' . getcwd() . '/src/JsonSchema/Validator.php', + $this->resolver->resolve( + 'Validator.php', + 'src/JsonSchema/SchemaStorage.php' + ) + ); + } + + public function testRelativeDirectoryAsRoot() + { + $this->assertEquals( + 'file://' . getcwd() . '/src/JsonSchema/Validator.php', + $this->resolver->resolve( + 'Validator.php', + 'src/JsonSchema' + ) + ); + } + + public function testRelativeNonExistentFileAsRoot() + { + $this->assertEquals( + 'file://' . getcwd() . '/resolved.file', + $this->resolver->resolve( + 'resolved.file', + 'test.file' + ) + ); + } } diff --git a/tests/Uri/UriRetrieverTest.php b/tests/Uri/UriRetrieverTest.php index 5d0d0e95..f5db5ca1 100644 --- a/tests/Uri/UriRetrieverTest.php +++ b/tests/Uri/UriRetrieverTest.php @@ -10,6 +10,7 @@ namespace JsonSchema\Tests\Uri; use JsonSchema\Exception\JsonDecodingException; +use JsonSchema\Uri\UriRetriever; use JsonSchema\Validator; /** @@ -279,4 +280,136 @@ private function mockRetriever($schema) $retriever->setAccessible(true); $retriever->setValue($factory, $retrieverMock); } + + public function testTranslations() + { + $retriever = new UriRetriever(); + + $uri = 'http://example.com/foo/bar'; + $translated = 'file://another/bar'; + + $retriever->setTranslation('|^https?://example.com/foo/bar#?|', 'file://another/bar'); + $this->assertEquals($translated, $retriever->translate($uri)); + } + + public function testPackageURITranslation() + { + $retriever = new UriRetriever(); + $root = sprintf('file://%s/', realpath(__DIR__ . '/../..')); + + $uri = $retriever->translate('package://foo/bar.json'); + $this->assertEquals("${root}foo/bar.json", $uri); + } + + public function testDefaultDistTranslations() + { + $retriever = new UriRetriever(); + $root = sprintf('file://%s/dist/schema/', realpath(__DIR__ . '/../..')); + + $this->assertEquals( + $root . 'json-schema-draft-03.json', + $retriever->translate('http://json-schema.org/draft-03/schema#') + ); + + $this->assertEquals( + $root . 'json-schema-draft-04.json', + $retriever->translate('http://json-schema.org/draft-04/schema#') + ); + } + + public function testRetrieveSchemaFromPackage() + { + $retriever = new UriRetriever(); + + // load schema from package + $schema = $retriever->retrieve('package://tests/fixtures/foobar.json'); + $this->assertNotFalse($schema); + + // check that the schema was loaded & processed correctly + $this->assertEquals('454f423bd7edddf0bc77af4130ed9161', md5(json_encode($schema))); + } + + public function testJsonSchemaOrgMediaTypeHack() + { + $mock = $this->getMock('JsonSchema\Uri\UriRetriever', array('getContentType')); + $mock->method('getContentType')->willReturn('Application/X-Fake-Type'); + $retriever = new UriRetriever(); + + $this->assertTrue($retriever->confirmMediaType($mock, 'http://json-schema.org/')); + } + + public function testSchemaCache() + { + $retriever = new UriRetriever(); + $reflector = new \ReflectionObject($retriever); + + // inject a schema cache value + $schemaCache = $reflector->getProperty('schemaCache'); + $schemaCache->setAccessible(true); + $schemaCache->setValue($retriever, array('local://test/uri' => 'testSchemaValue')); + + // retrieve from schema cache + $loadSchema = $reflector->getMethod('loadSchema'); + $loadSchema->setAccessible(true); + $this->assertEquals( + 'testSchemaValue', + $loadSchema->invoke($retriever, 'local://test/uri') + ); + } + + public function testLoadSchemaJSONDecodingException() + { + $retriever = new UriRetriever(); + + $this->setExpectedException( + 'JsonSchema\Exception\JsonDecodingException', + 'JSON syntax is malformed' + ); + $schema = $retriever->retrieve('package://tests/fixtures/bad-syntax.json'); + } + + public function testGenerateURI() + { + $retriever = new UriRetriever(); + $components = array( + 'scheme' => 'scheme', + 'authority' => 'authority', + 'path' => '/path', + 'query' => '?query', + 'fragment' => '#fragment' + ); + $this->assertEquals('scheme://authority/path?query#fragment', $retriever->generate($components)); + } + + public function testResolveHTTP() + { + $retriever = new UriRetriever(); + $this->assertEquals( + 'http://example.com/schema', + $retriever->resolve('http://example.com/schema') + ); + } + + public function combinedURITests() + { + return array( + array('blue', 'http://example.com/red', 'http://example.com/blue'), + array('blue', 'http://example.com/', 'http://example.com/blue'), + ); + } + + /** + * @dataProvider combinedURITests + */ + public function testResolveCombinedURI($uri, $baseURI, $combinedURI) + { + $retriever = new UriRetriever(); + $this->assertEquals($combinedURI, $retriever->resolve($uri, $baseURI)); + } + + public function testIsValidURI() + { + $retriever = new UriRetriever(); + $this->assertTrue($retriever->isValid('http://example.com/schema')); + } } diff --git a/tests/ValidatorTest.php b/tests/ValidatorTest.php new file mode 100644 index 00000000..73688537 --- /dev/null +++ b/tests/ValidatorTest.php @@ -0,0 +1,59 @@ +validate($data, $schema); + + $this->assertFalse($validator->isValid(), 'Validation succeeded, but should have failed.'); + } + + public function testBadAssocSchemaInput() + { + if (version_compare(phpversion(), '5.5.0', '<')) { + $this->markTestSkipped('PHP versions < 5.5.0 trigger an error on json_encode issues'); + } + if (defined('HHVM_VERSION')) { + $this->markTestSkipped('HHVM has no problem with encoding resources'); + } + $schema = array('propertyOne' => fopen('php://stdout', 'w')); + $data = json_decode('{"propertyOne":[42]}', true); + + $validator = new Validator(); + + $this->setExpectedException('\JsonSchema\Exception\InvalidArgumentException'); + $validator->validate($data, $schema); + } + + public function testCheck() + { + $schema = json_decode('{"type":"string"}'); + $data = json_decode('42'); + + $validator = new Validator(); + $validator->check($data, $schema); + + $this->assertFalse($validator->isValid(), 'Validation succeeded, but should have failed.'); + } + + public function testCoerce() + { + $schema = json_decode('{"type":"integer"}'); + $data = json_decode('"42"'); + + $validator = new Validator(); + $validator->coerce($data, $schema); + + $this->assertTrue($validator->isValid(), 'Validation failed, but should have succeeded.'); + } +} diff --git a/tests/fixtures/bad-syntax.json b/tests/fixtures/bad-syntax.json new file mode 100644 index 00000000..98232c64 --- /dev/null +++ b/tests/fixtures/bad-syntax.json @@ -0,0 +1 @@ +{ diff --git a/tests/fixtures/foobar.json b/tests/fixtures/foobar.json new file mode 100644 index 00000000..b27b6861 --- /dev/null +++ b/tests/fixtures/foobar.json @@ -0,0 +1,12 @@ +{ + "$id": "http://example.com/foo/bar#", + "type": "object", + "properties": { + "foo": { + "type": "string", + "default": "bar" + } + }, + "required": ["foo"], + "additionalProperties": false +} diff --git a/tests/fixtures/json-schema-draft-03.json b/tests/fixtures/json-schema-draft-03.json deleted file mode 100644 index dcf07342..00000000 --- a/tests/fixtures/json-schema-draft-03.json +++ /dev/null @@ -1,193 +0,0 @@ -{ - "$schema": "http://json-schema.org/draft-03/schema#", - "id": "http://json-schema.org/draft-03/schema#", - "type": "object", - "properties": { - "type": { - "type": [ - "string", - "array" - ], - "items": { - "type": [ - "string", - { - "$ref": "#" - } - ] - }, - "uniqueItems": true, - "default": "any" - }, - "properties": { - "type": "object", - "additionalProperties": { - "$ref": "#" - }, - "default": {} - }, - "patternProperties": { - "type": "object", - "additionalProperties": { - "$ref": "#" - }, - "default": {} - }, - "additionalProperties": { - "type": [ - { - "$ref": "#" - }, - "boolean" - ], - "default": {} - }, - "items": { - "type": [ - { - "$ref": "#" - }, - "array" - ], - "items": { - "$ref": "#" - }, - "default": {} - }, - "additionalItems": { - "type": [ - { - "$ref": "#" - }, - "boolean" - ], - "default": {} - }, - "required": { - "type": "boolean", - "default": false - }, - "dependencies": { - "type": "object", - "additionalProperties": { - "type": [ - "string", - "array", - { - "$ref": "#" - } - ], - "items": { - "type": "string" - } - }, - "default": {} - }, - "minimum": { - "type": "number" - }, - "maximum": { - "type": "number" - }, - "exclusiveMinimum": { - "type": "boolean", - "default": false - }, - "exclusiveMaximum": { - "type": "boolean", - "default": false - }, - "minItems": { - "type": "integer", - "minimum": 0, - "default": 0 - }, - "maxItems": { - "type": "integer", - "minimum": 0 - }, - "uniqueItems": { - "type": "boolean", - "default": false - }, - "pattern": { - "type": "string", - "format": "regex" - }, - "minLength": { - "type": "integer", - "minimum": 0, - "default": 0 - }, - "maxLength": { - "type": "integer" - }, - "enum": { - "type": "array", - "minItems": 1, - "uniqueItems": true - }, - "default": { - "type": "any" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "format": { - "type": "string" - }, - "divisibleBy": { - "type": "number", - "minimum": 0, - "exclusiveMinimum": true, - "default": 1 - }, - "disallow": { - "type": [ - "string", - "array" - ], - "items": { - "type": [ - "string", - { - "$ref": "#" - } - ] - }, - "uniqueItems": true - }, - "extends": { - "type": [ - { - "$ref": "#" - }, - "array" - ], - "items": { - "$ref": "#" - }, - "default": {} - }, - "id": { - "type": "string", - "format": "uri" - }, - "$ref": { - "type": "string", - "format": "uri" - }, - "$schema": { - "type": "string", - "format": "uri" - } - }, - "dependencies": { - "exclusiveMinimum": "minimum", - "exclusiveMaximum": "maximum" - }, - "default": {} -} \ No newline at end of file diff --git a/tests/fixtures/json-schema-draft-04.json b/tests/fixtures/json-schema-draft-04.json deleted file mode 100644 index 96e7f16a..00000000 --- a/tests/fixtures/json-schema-draft-04.json +++ /dev/null @@ -1,221 +0,0 @@ -{ - "id": "http://json-schema.org/draft-04/schema#", - "$schema": "http://json-schema.org/draft-04/schema#", - "description": "Core schema meta-schema", - "definitions": { - "schemaArray": { - "type": "array", - "minItems": 1, - "items": { - "$ref": "#" - } - }, - "positiveInteger": { - "type": "integer", - "minimum": 0 - }, - "positiveIntegerDefault0": { - "allOf": [ - { - "$ref": "#/definitions/positiveInteger" - }, - { - "default": 0 - } - ] - }, - "simpleTypes": { - "enum": [ - "array", - "boolean", - "integer", - "null", - "number", - "object", - "string" - ] - }, - "stringArray": { - "type": "array", - "items": { - "type": "string" - }, - "minItems": 1, - "uniqueItems": true - } - }, - "type": "object", - "properties": { - "id": { - "type": "string", - "format": "uri" - }, - "$schema": { - "type": "string", - "format": "uri" - }, - "title": { - "type": "string" - }, - "description": { - "type": "string" - }, - "default": {}, - "multipleOf": { - "type": "number", - "minimum": 0, - "exclusiveMinimum": true - }, - "maximum": { - "type": "number" - }, - "exclusiveMaximum": { - "type": "boolean", - "default": false - }, - "minimum": { - "type": "number" - }, - "exclusiveMinimum": { - "type": "boolean", - "default": false - }, - "maxLength": { - "$ref": "#/definitions/positiveInteger" - }, - "minLength": { - "$ref": "#/definitions/positiveIntegerDefault0" - }, - "pattern": { - "type": "string", - "format": "regex" - }, - "additionalItems": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#" - } - ], - "default": {} - }, - "items": { - "anyOf": [ - { - "$ref": "#" - }, - { - "$ref": "#/definitions/schemaArray" - } - ], - "default": {} - }, - "maxItems": { - "$ref": "#/definitions/positiveInteger" - }, - "minItems": { - "$ref": "#/definitions/positiveIntegerDefault0" - }, - "uniqueItems": { - "type": "boolean", - "default": false - }, - "maxProperties": { - "$ref": "#/definitions/positiveInteger" - }, - "minProperties": { - "$ref": "#/definitions/positiveIntegerDefault0" - }, - "required": { - "$ref": "#/definitions/stringArray" - }, - "additionalProperties": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#" - } - ], - "default": {} - }, - "definitions": { - "type": "object", - "additionalProperties": { - "$ref": "#" - }, - "default": {} - }, - "properties": { - "type": "object", - "additionalProperties": { - "$ref": "#" - }, - "default": {} - }, - "patternProperties": { - "type": "object", - "additionalProperties": { - "$ref": "#" - }, - "default": {} - }, - "dependencies": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "$ref": "#" - }, - { - "$ref": "#/definitions/stringArray" - } - ] - } - }, - "enum": { - "type": "array", - "minItems": 1, - "uniqueItems": true - }, - "type": { - "anyOf": [ - { - "$ref": "#/definitions/simpleTypes" - }, - { - "type": "array", - "items": { - "$ref": "#/definitions/simpleTypes" - }, - "minItems": 1, - "uniqueItems": true - } - ] - }, - "allOf": { - "$ref": "#/definitions/schemaArray" - }, - "anyOf": { - "$ref": "#/definitions/schemaArray" - }, - "oneOf": { - "$ref": "#/definitions/schemaArray" - }, - "not": { - "$ref": "#" - } - }, - "dependencies": { - "exclusiveMaximum": [ - "maximum" - ], - "exclusiveMinimum": [ - "minimum" - ] - }, - "default": {} -} \ No newline at end of file