diff --git a/README.md b/README.md index c0c9feb2..3eab3e35 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,32 @@ if ($validator->isValid()) { } } ``` +###Type Coercion +If you're validating data passed to your application via HTTP, you can cast strings and booleans to the expected types defined by your schema: +``` +$request = (object)[ + 'processRefund'=>"true", + 'refundAmount'=>"17" +]; + +$validator = new \JsonSchema\Validator(\JsonSchema\Constraints\Constraint::CHECK_MODE_TYPE_CAST | \JsonSchema\Constraints\Constraint::CHECK_MODE_COERCE); +$validator->check($request, (object) [ + "type"=>"object", + "properties"=>[ + "processRefund"=>[ + "type"=>"boolean" + ], + "refundAmount"=>[ + "type"=>"number" + ] + ] +]); // validates! + +is_bool($request->processRefund); // true +is_int($request->refundAmount); // true +``` + +Note that the ```CHECK_MODE_COERCE``` flag will only take effect when an object is passed into the ```check``` method. ## Running the tests diff --git a/src/JsonSchema/Constraints/Constraint.php b/src/JsonSchema/Constraints/Constraint.php index 521a0e06..c5683a64 100644 --- a/src/JsonSchema/Constraints/Constraint.php +++ b/src/JsonSchema/Constraints/Constraint.php @@ -28,8 +28,9 @@ abstract class Constraint implements ConstraintInterface protected $errors = array(); protected $inlineSchemaProperty = '$schema'; - const CHECK_MODE_NORMAL = 1; - const CHECK_MODE_TYPE_CAST = 2; + const CHECK_MODE_NORMAL = 0x00000001; + const CHECK_MODE_TYPE_CAST = 0x00000002; + const CHECK_MODE_COERCE = 0x00000004; /** * @var null|Factory diff --git a/src/JsonSchema/Constraints/EnumConstraint.php b/src/JsonSchema/Constraints/EnumConstraint.php index 69fd0308..88b78cd7 100644 --- a/src/JsonSchema/Constraints/EnumConstraint.php +++ b/src/JsonSchema/Constraints/EnumConstraint.php @@ -32,7 +32,7 @@ public function check($element, $schema = null, JsonPointer $path = null, $i = n foreach ($schema->enum as $enum) { $enumType = gettype($enum); - if ($this->checkMode === self::CHECK_MODE_TYPE_CAST && $type == "array" && $enumType == "object") { + if (($this->checkMode & self::CHECK_MODE_TYPE_CAST) && $type == "array" && $enumType == "object") { if ((object)$element == $enum) { return; } diff --git a/src/JsonSchema/Constraints/Factory.php b/src/JsonSchema/Constraints/Factory.php index 83355c5e..8fd72a83 100644 --- a/src/JsonSchema/Constraints/Factory.php +++ b/src/JsonSchema/Constraints/Factory.php @@ -54,6 +54,7 @@ class Factory 'format' => 'JsonSchema\Constraints\FormatConstraint', 'schema' => 'JsonSchema\Constraints\SchemaConstraint', 'validator' => 'JsonSchema\Validator', + 'coercer' => 'JsonSchema\Coerce' ); /** @@ -92,7 +93,7 @@ public function getSchemaStorage() public function getTypeCheck() { if (!isset($this->typeCheck[$this->checkMode])) { - $this->typeCheck[$this->checkMode] = $this->checkMode === Constraint::CHECK_MODE_TYPE_CAST + $this->typeCheck[$this->checkMode] = ($this->checkMode & Constraint::CHECK_MODE_TYPE_CAST) ? new TypeCheck\LooseTypeCheck : new TypeCheck\StrictTypeCheck; } diff --git a/src/JsonSchema/Constraints/ObjectConstraint.php b/src/JsonSchema/Constraints/ObjectConstraint.php index df8d8088..8431cd9f 100644 --- a/src/JsonSchema/Constraints/ObjectConstraint.php +++ b/src/JsonSchema/Constraints/ObjectConstraint.php @@ -121,10 +121,26 @@ public function validateElement($element, $matches, $objectDefinition = null, Js */ public function validateDefinition($element, $objectDefinition = null, JsonPointer $path = null) { + $default = $this->getFactory()->createInstanceFor('undefined'); + foreach ($objectDefinition as $i => $value) { - $property = $this->getProperty($element, $i, $this->getFactory()->createInstanceFor('undefined')); + $property = $this->getProperty($element, $i, $default); $definition = $this->getProperty($objectDefinition, $i); + if($this->checkMode & Constraint::CHECK_MODE_TYPE_CAST){ + if(!($property instanceof Constraint)) { + $property = $this->coerce($property, $definition); + + if($this->checkMode & Constraint::CHECK_MODE_COERCE) { + if (is_object($element)) { + $element->{$i} = $property; + } else { + $element[$i] = $property; + } + } + } + } + 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); @@ -132,6 +148,75 @@ public function validateDefinition($element, $objectDefinition = null, JsonPoint } } + /** + * Converts a value to boolean. For example, "true" becomes true. + * @param $value The value to convert to boolean + * @return bool|mixed + */ + protected function toBoolean($value) + { + if($value === "true"){ + return true; + } + + if($value === "false"){ + return false; + } + + return $value; + } + + /** + * Converts a numeric string to a number. For example, "4" becomes 4. + * + * @param mixed $value The value to convert to a number. + * @return int|float|mixed + */ + protected function toNumber($value) + { + if(is_numeric($value)) { + return $value + 0; // cast to number + } + + return $value; + } + + protected function toInteger($value) + { + if(ctype_digit ($value)) { + return (int)$value; // cast to number + } + + return $value; + } + + /** + * Given a value and a definition, attempts to coerce the value into the + * type specified by the definition's 'type' property. + * + * @param mixed $value Value to coerce. + * @param \stdClass $definition A definition with information about the expected type. + * @return bool|int|string + */ + protected function coerce($value, $definition) + { + $type = isset($definition->type)?$definition->type:null; + if($type){ + switch($type){ + case "boolean": + $value = $this->toBoolean($value); + break; + case "integer": + $value = $this->toInteger($value); + break; + case "number": + $value = $this->toNumber($value); + break; + } + } + return $value; + } + /** * retrieves a property from an object or array * diff --git a/src/JsonSchema/Validator.php b/src/JsonSchema/Validator.php index b627d785..cf147d76 100644 --- a/src/JsonSchema/Validator.php +++ b/src/JsonSchema/Validator.php @@ -9,7 +9,6 @@ namespace JsonSchema; -use JsonSchema\Constraints\SchemaConstraint; use JsonSchema\Constraints\Constraint; use JsonSchema\Entity\JsonPointer; diff --git a/tests/Constraints/BaseTestCase.php b/tests/Constraints/BaseTestCase.php index fe6c6951..0aa34476 100644 --- a/tests/Constraints/BaseTestCase.php +++ b/tests/Constraints/BaseTestCase.php @@ -131,7 +131,7 @@ public function getInvalidForAssocTests() * @param object $schema * @return object */ - private function getUriRetrieverMock($schema) + protected function getUriRetrieverMock($schema) { $relativeTestsRoot = realpath(__DIR__ . '/../../vendor/json-schema/JSON-Schema-Test-Suite/remotes'); diff --git a/tests/Constraints/CoerciveTest.php b/tests/Constraints/CoerciveTest.php new file mode 100644 index 00000000..7765a78f --- /dev/null +++ b/tests/Constraints/CoerciveTest.php @@ -0,0 +1,235 @@ +getUriRetrieverMock($schema), new UriResolver); + $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + + $value = json_decode($input, true); + $validator = new Validator($checkMode, $schemaStorage); + + $validator->check($value, $schema); + $this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true)); + } + + /** + * @dataProvider getValidCoerceTests + */ + public function testValidCoerceCases($input, $schema, $errors = array()) + { + $checkMode = Constraint::CHECK_MODE_COERCE | Constraint::CHECK_MODE_TYPE_CAST; + + $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); + $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + + $validator = new Validator($checkMode, $schemaStorage); + $value = json_decode($input); + + $this->assertTrue(gettype($value->number) == "string"); + $this->assertTrue(gettype($value->integer) == "string"); + $this->assertTrue(gettype($value->boolean) == "string"); + + $validator->check($value, $schema); + + $this->assertTrue(gettype($value->number) == "double"); + $this->assertTrue(gettype($value->integer) == "integer"); + $this->assertTrue(gettype($value->boolean) == "boolean"); + + $this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true)); + } + + /** + * @dataProvider getInvalidCoerceTests + */ + public function testInvalidCoerceCases($input, $schema, $errors = array()) + { + $checkMode = Constraint::CHECK_MODE_COERCE | Constraint::CHECK_MODE_TYPE_CAST; + + $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); + $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + + $validator = new Validator($checkMode, $schemaStorage); + $validator->check(json_decode($input), $schema); + + if (array() !== $errors) { + $this->assertEquals($errors, $validator->getErrors(), print_r($validator->getErrors(),true)); + } + $this->assertFalse($validator->isValid(), print_r($validator->getErrors(), true)); + } + + /** + * @dataProvider getInvalidCoerceTests + */ + public function testInvalidCoerceCasesUsingAssoc($input, $schema, $errors = array()) + { + $checkMode = Constraint::CHECK_MODE_COERCE | Constraint::CHECK_MODE_TYPE_CAST; + + $schemaStorage = new SchemaStorage($this->getUriRetrieverMock(json_decode($schema))); + $schema = $schemaStorage->getSchema('http://www.my-domain.com/schema.json'); + + $validator = new Validator($checkMode, $schemaStorage); + $validator->check(json_decode($input, true), $schema); + + if (array() !== $errors) { + $this->assertEquals($errors, $validator->getErrors(), print_r($validator->getErrors(), true)); + } + $this->assertFalse($validator->isValid(), print_r($validator->getErrors(), true)); + } + + public function getValidCoerceTests() + { + return array( + array( + '{ + "string":"string test", + "number":"1.5", + "integer":"1", + "boolean":"true", + "object":{}, + "array":[], + "null":null, + "any": "string", + "any1": 2.6, + "any2": 4, + "any3": false, + "any4": {}, + "any5": [], + "any6": null + }', + '{ + "type":"object", + "properties":{ + "string":{"type":"string"}, + "number":{"type":"number"}, + "integer":{"type":"integer"}, + "boolean":{"type":"boolean"}, + "object":{"type":"object"}, + "array":{"type":"array"}, + "null":{"type":"null"}, + "any": {"type":"any"}, + "any1": {"type":"any"}, + "any2": {"type":"any"}, + "any3": {"type":"any"}, + "any4": {"type":"any"}, + "any5": {"type":"any"}, + "any6": {"type":"any"} + }, + "additionalProperties":false + }' + ) + ); + } + + + + public function getInvalidCoerceTests() + { + 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 + }' + ) + ); + } +} diff --git a/tests/Constraints/NumberAndIntegerTypesTest.php b/tests/Constraints/NumberAndIntegerTypesTest.php index 932c2f56..c0db510d 100644 --- a/tests/Constraints/NumberAndIntegerTypesTest.php +++ b/tests/Constraints/NumberAndIntegerTypesTest.php @@ -25,24 +25,6 @@ public function getInvalidTests() } }' ), - array( - '{"number": "1.5"}', - '{ - "type": "object", - "properties": { - "number": {"type": "number"} - } - }' - ), - array( - '{"integer": "1"}', - '{ - "type": "object", - "properties": { - "integer": {"type": "integer"} - } - }' - ), array( '{"integer": 1.001}', '{