Skip to content

Commit

Permalink
Add option to apply default values from the schema (#349)
Browse files Browse the repository at this point in the history
* Add option to apply default values from the schema

* Clone default objects instead of passing by reference

Objects should always be assigned via clone, to prevent modifications
to the input object from also modifying the underlying schema.

* Run php-cs-fixer

* Remove two duplicate test cases
  • Loading branch information
erayd authored and bighappyface committed Feb 22, 2017
1 parent 42c1043 commit 48817e5
Show file tree
Hide file tree
Showing 8 changed files with 268 additions and 2 deletions.
38 changes: 37 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,40 @@ $validator->coerce($request, $schema);
// equivalent to $validator->validate($data, $schema, Constraint::CHECK_MODE_COERCE_TYPES);
```

### Default values

If your schema contains default values, you can have these automatically applied during validation:

```php
<?php

use JsonSchema\Validator;
use JsonSchema\Constraints\Constraint;

$request = (object)[
'refundAmount'=>17
];

$validator = new Validator();

$validator->validate(
$request,
(object)[
"type"=>"object",
"properties"=>(object)[
"processRefund"=>(object)[
"type"=>"boolean",
"default"=>true
]
]
],
Constraint::CHECK_MODE_APPLY_DEFAULTS
); //validates, and sets defaults for missing properties

is_bool($request->processRefund); // true
$request->processRefund; // true
```

### With inline references

```php
Expand Down Expand Up @@ -152,9 +186,11 @@ 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_APPLY_DEFAULTS` | Apply default values from the schema if not set |
| `Constraint::CHECK_MODE_EXCEPTIONS` | Throw an exception immediately if validation fails |

Please note that using `Constraint::CHECK_MODE_COERCE_TYPES` will modify your original data.
Please note that using `Constraint::CHECK_MODE_COERCE_TYPES` or `Constraint::CHECK_MODE_APPLY_DEFAULTS`
will modify your original data.

## Running the tests

Expand Down
1 change: 1 addition & 0 deletions src/JsonSchema/Constraints/Factory.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace JsonSchema\Constraints;

use JsonSchema\Constraints\Constraint;
use JsonSchema\Exception\InvalidArgumentException;
use JsonSchema\Exception\InvalidConfigException;
use JsonSchema\SchemaStorage;
Expand Down
9 changes: 9 additions & 0 deletions src/JsonSchema/Constraints/TypeCheck/LooseTypeCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@ public static function propertyGet($value, $property)
return $value[$property];
}

public static function propertySet(&$value, $property, $data)
{
if (is_object($value)) {
$value->{$property} = $data;
} else {
$value[$property] = $data;
}
}

public static function propertyExists($value, $property)
{
if (is_object($value)) {
Expand Down
5 changes: 5 additions & 0 deletions src/JsonSchema/Constraints/TypeCheck/StrictTypeCheck.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ public static function propertyGet($value, $property)
return $value->{$property};
}

public static function propertySet(&$value, $property, $data)
{
$value->{$property} = $data;
}

public static function propertyExists($value, $property)
{
return property_exists($value, $property);
Expand Down
2 changes: 2 additions & 0 deletions src/JsonSchema/Constraints/TypeCheck/TypeCheckInterface.php
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ public static function isArray($value);

public static function propertyGet($value, $property);

public static function propertySet(&$value, $property, $data);

public static function propertyExists($value, $property);

public static function propertyCount($value);
Expand Down
48 changes: 47 additions & 1 deletion src/JsonSchema/Constraints/UndefinedConstraint.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace JsonSchema\Constraints;

use JsonSchema\Constraints\TypeCheck\LooseTypeCheck;
use JsonSchema\Entity\JsonPointer;
use JsonSchema\Uri\UriResolver;

Expand Down Expand Up @@ -57,7 +58,9 @@ public function validateTypes(&$value, $schema = null, JsonPointer $path, $i = n
}

// check object
if ($this->getTypeCheck()->isObject($value)) {
if (LooseTypeCheck::isObject($value)) { // object processing should always be run on assoc arrays,
// so use LooseTypeCheck here even if CHECK_MODE_TYPE_CAST
// is not set (i.e. don't use $this->getTypeCheck() here).
$this->checkObject(
$value,
isset($schema->properties) ? $this->factory->getSchemaStorage()->resolveRefSchema($schema->properties) : $schema,
Expand Down Expand Up @@ -107,6 +110,49 @@ 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 $i => $propertyDefinition) {
if (!$this->getTypeCheck()->propertyExists($value, $i) && isset($propertyDefinition->default)) {
if (is_object($propertyDefinition->default)) {
$this->getTypeCheck()->propertySet($value, $i, clone $propertyDefinition->default);
} else {
$this->getTypeCheck()->propertySet($value, $i, $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 $i => $propertyDefinition) {
if (!isset($value[$i]) && isset($propertyDefinition->default)) {
if (is_object($propertyDefinition->default)) {
$value[$i] = clone $propertyDefinition->default;
} else {
$value[$i] = $propertyDefinition->default;
}
}
}
} elseif (isset($schema->items)) {
// $value is an array, and default items are defined - treat as plain array
foreach ($schema->items as $i => $itemDefinition) {
if (!isset($value[$i]) && isset($itemDefinition->default)) {
if (is_object($itemDefinition->default)) {
$value[$i] = clone $itemDefinition->default;
} else {
$value[$i] = $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;
}
}

// Verify required values
if ($this->getTypeCheck()->isObject($value)) {
if (!($value instanceof self) && isset($schema->required) && is_array($schema->required)) {
Expand Down
153 changes: 153 additions & 0 deletions tests/Constraints/DefaultPropertiesTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
<?php

/*
* This file is part of the JsonSchema package.
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/

namespace JsonSchema\Tests\Constraints;

use JsonSchema\Constraints\Constraint;
use JsonSchema\Constraints\Factory;
use JsonSchema\SchemaStorage;
use JsonSchema\Validator;

class DefaultPropertiesTest extends VeryBaseTestCase
{
public function getValidTests()
{
return array(
array(// default value for entire object
'',
'{"default":"valueOne"}',
'"valueOne"'
),
array(// default value in an empty object
'{}',
'{"properties":{"propertyOne":{"default":"valueOne"}}}',
'{"propertyOne":"valueOne"}'
),
array(// default value for top-level property
'{"propertyOne":"valueOne"}',
'{"properties":{"propertyTwo":{"default":"valueTwo"}}}',
'{"propertyOne":"valueOne","propertyTwo":"valueTwo"}'
),
array(// default value for sub-property
'{"propertyOne":{}}',
'{"properties":{"propertyOne":{"properties":{"propertyTwo":{"default":"valueTwo"}}}}}',
'{"propertyOne":{"propertyTwo":"valueTwo"}}'
),
array(// 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
'{"propertyOne":"valueOne"}',
'{"properties":{"propertyTwo":{"default":"valueTwo","type":"string"}}}',
'{"propertyOne":"valueOne","propertyTwo":"valueTwo"}'
),
array(// 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
'{"propertyOne":"valueOne"}',
'{"properties":{"propertyTwo":{"default":"valueTwo"}},"required":["propertyTwo"]}',
'{"propertyOne":"valueOne","propertyTwo":"valueTwo"}'
),
array(//default value for an already set property
'{"propertyOne":"alreadySetValueOne"}',
'{"properties":{"propertyOne":{"default":"valueOne"}}}',
'{"propertyOne":"alreadySetValueOne"}'
),
array(//default item value for an array
'["valueOne"]',
'{"type":"array","items":[{},{"type":"string","default":"valueTwo"}]}',
'["valueOne","valueTwo"]'
),
array(//default item value for an empty array
'[]',
'{"type":"array","items":[{"type":"string","default":"valueOne"}]}',
'["valueOne"]'
),
array(//property without a default available
'{"propertyOne":"alreadySetValueOne"}',
'{"properties":{"propertyOne":{"type":"string"}}}',
'{"propertyOne":"alreadySetValueOne"}'
),
array(// default property value is an object
'{"propertyOne":"valueOne"}',
'{"properties":{"propertyTwo":{"default":{}}}}',
'{"propertyOne":"valueOne","propertyTwo":{}}'
),
array(// default item value is an object
'[]',
'{"type":"array","items":[{"default":{}}]}',
'[{}]'
)
);
}

/**
* @dataProvider getValidTests
*/
public function testValidCases($input, $schema, $expectOutput = null, $validator = null)
{
if (is_string($input)) {
$inputDecoded = json_decode($input);
} else {
$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));

$this->assertTrue($validator->isValid(), print_r($validator->getErrors(), true));

if ($expectOutput !== null) {
$this->assertEquals($expectOutput, json_encode($inputDecoded));
}
}

/**
* @dataProvider getValidTests
*/
public function testValidCasesUsingAssoc($input, $schema, $expectOutput = null)
{
$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));
}

/**
* @dataProvider getValidTests
*/
public function testValidCasesUsingAssocWithoutTypeCast($input, $schema, $expectOutput = null)
{
$input = json_decode($input, true);
$factory = new Factory(null, null, Constraint::CHECK_MODE_APPLY_DEFAULTS);
self::testValidCases($input, $schema, $expectOutput, new Validator($factory));
}

public function testNoModificationViaReferences()
{
$input = json_decode('');
$schema = json_decode('{"default":{"propertyOne":"valueOne"}}');

$validator = new Validator();
$validator->validate($input, $schema, Constraint::CHECK_MODE_TYPE_CAST | Constraint::CHECK_MODE_APPLY_DEFAULTS);

$this->assertEquals('{"propertyOne":"valueOne"}', json_encode($input));

$input->propertyOne = 'valueTwo';
$this->assertEquals('valueOne', $schema->default->propertyOne);
}
}
14 changes: 14 additions & 0 deletions tests/Constraints/TypeTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

namespace JsonSchema\Tests\Constraints;

use JsonSchema\Constraints\TypeCheck\LooseTypeCheck;
use JsonSchema\Constraints\TypeConstraint;

/**
Expand Down Expand Up @@ -51,6 +52,19 @@ public function testIndefiniteArticleForTypeInTypeCheckErrorMessage($type, $word
$this->assertTypeConstraintError(ucwords($label) . " value found, but $wording is required", $constraint);
}

/**
* Test uncovered areas of the loose type checker
*/
public function testLooseTypeChecking()
{
$v = new \StdClass();
$v->property = 'dataOne';
LooseTypeCheck::propertySet($v, 'property', 'dataTwo');
$this->assertEquals('dataTwo', $v->property);
$this->assertEquals('dataTwo', LooseTypeCheck::propertyGet($v, 'property'));
$this->assertEquals(1, LooseTypeCheck::propertyCount($v));
}

/**
* Helper to assert an error message
*
Expand Down

0 comments on commit 48817e5

Please sign in to comment.