diff --git a/README.md b/README.md index 4de425f5..46adffbe 100644 --- a/README.md +++ b/README.md @@ -187,6 +187,7 @@ third argument to `Validator::validate()`, or can be provided as the third argum | `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_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 | diff --git a/src/JsonSchema/Constraints/Constraint.php b/src/JsonSchema/Constraints/Constraint.php index 2283ac12..28c8d44c 100644 --- a/src/JsonSchema/Constraints/Constraint.php +++ b/src/JsonSchema/Constraints/Constraint.php @@ -31,6 +31,7 @@ abstract class Constraint extends BaseConstraint implements ConstraintInterface const CHECK_MODE_APPLY_DEFAULTS = 0x00000008; const CHECK_MODE_EXCEPTIONS = 0x00000010; const CHECK_MODE_DISABLE_FORMAT = 0x00000020; + const CHECK_MODE_ONLY_REQUIRED_DEFAULTS = 0x00000080; /** * Bubble down the path @@ -78,10 +79,10 @@ protected function checkArray(&$value, $schema = null, JsonPointer $path = null, * @param mixed $i * @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, $i = null, $patternProperties = null, $appliedDefaults = array()) { $validator = $this->factory->createInstanceFor('object'); - $validator->check($value, $schema, $path, $i, $patternProperties); + $validator->check($value, $schema, $path, $i, $patternProperties, $appliedDefaults); $this->addErrors($validator->getErrors()); } @@ -110,11 +111,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/ObjectConstraint.php b/src/JsonSchema/Constraints/ObjectConstraint.php index d360a659..3c345619 100644 --- a/src/JsonSchema/Constraints/ObjectConstraint.php +++ b/src/JsonSchema/Constraints/ObjectConstraint.php @@ -20,15 +20,22 @@ */ 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, $definition = null, JsonPointer $path = null, $additionalProp = null, $patternProperties = null, $appliedDefaults = array()) { if ($element instanceof UndefinedConstraint) { return; } + $this->appliedDefaults = $appliedDefaults; + $matches = array(); if ($patternProperties) { $matches = $this->validatePatternProperties($element, $path, $patternProperties); @@ -64,7 +71,7 @@ public function validatePatternProperties($element, JsonPointer $path = null, $p 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)); } } } @@ -96,9 +103,9 @@ public function validateElement($element, $matches, $objectDefinition = null, Js // 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)); } } @@ -135,7 +142,7 @@ public function validateDefinition(&$element, $objectDefinition = null, JsonPoin 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)); } } } diff --git a/src/JsonSchema/Constraints/UndefinedConstraint.php b/src/JsonSchema/Constraints/UndefinedConstraint.php index a03291da..ed7d113f 100644 --- a/src/JsonSchema/Constraints/UndefinedConstraint.php +++ b/src/JsonSchema/Constraints/UndefinedConstraint.php @@ -23,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); @@ -68,7 +76,8 @@ public function validateTypes(&$value, $schema = null, JsonPointer $path, $i = n isset($schema->properties) ? $this->factory->getSchemaStorage()->resolveRefSchema($schema->properties) : $schema, $path, isset($schema->additionalProperties) ? $schema->additionalProperties : null, - isset($schema->patternProperties) ? $schema->patternProperties : null + isset($schema->patternProperties) ? $schema->patternProperties : null, + $this->appliedDefaults ); } @@ -113,46 +122,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 @@ -216,6 +187,96 @@ 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)) { + // $value is an array, and items are defined - treat as plain array + foreach ($schema->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 * diff --git a/src/JsonSchema/Entity/JsonPointer.php b/src/JsonSchema/Entity/JsonPointer.php index 8bb71ecf..fcaf5b8d 100644 --- a/src/JsonSchema/Entity/JsonPointer.php +++ b/src/JsonSchema/Entity/JsonPointer.php @@ -24,6 +24,11 @@ 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 * @@ -135,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/SchemaStorage.php b/src/JsonSchema/SchemaStorage.php index 55d7985a..3ee081e3 100644 --- a/src/JsonSchema/SchemaStorage.php +++ b/src/JsonSchema/SchemaStorage.php @@ -79,8 +79,18 @@ public function getSchema($id) public function resolveRef($ref) { $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}); diff --git a/tests/Constraints/DefaultPropertiesTest.php b/tests/Constraints/DefaultPropertiesTest.php index 6687e7c2..a3b9c4e2 100644 --- a/tests/Constraints/DefaultPropertiesTest.php +++ b/tests/Constraints/DefaultPropertiesTest.php @@ -19,83 +19,143 @@ 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]' + ), ); } /** * @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 +163,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 +182,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 +211,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/SchemaStorageTest.php b/tests/SchemaStorageTest.php index cf20ff7b..294f0f95 100644 --- a/tests/SchemaStorageTest.php +++ b/tests/SchemaStorageTest.php @@ -119,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 */