Skip to content

Commit cabc3fa

Browse files
committed
Fix infinite recursion on some schemas when setting defaults (jsonrainbow#359) (jsonrainbow#365)
* Don't try to fetch files that don't exist Throws an exception when the ref can't be resolved to a useful file URI, rather than waiting for something further down the line to fail after the fact. * Refactor defaults code to use LooseTypeCheck where appropriate * Test for not treating non-containers like arrays * Update comments * Rename variable for clarity * Add CHECK_MODE_ONLY_REQUIRED_DEFAULTS If CHECK_MODE_ONLY_REQUIRED_DEFAULTS is set, then only apply defaults if they are marked as required. * Workaround for $this scope issue on PHP-5.3 * Fix infinite recursion via $ref when applying defaults * Add missing second test for array case * Add test for setting a default value for null * Also fix infinite recursion via $ref for array defaults * Move nested closure into separate method * $parentSchema will always be set when $name is, so don't check it * Handle nulls properly - fixes issue jsonrainbow#377
1 parent 4faa61f commit cabc3fa

File tree

8 files changed

+274
-82
lines changed

8 files changed

+274
-82
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ third argument to `Validator::validate()`, or can be provided as the third argum
187187
| `Constraint::CHECK_MODE_TYPE_CAST` | Enable fuzzy type checking for associative arrays and objects |
188188
| `Constraint::CHECK_MODE_COERCE_TYPES` | Convert data types to match the schema where possible |
189189
| `Constraint::CHECK_MODE_APPLY_DEFAULTS` | Apply default values from the schema if not set |
190+
| `Constraint::CHECK_MODE_ONLY_REQUIRED_DEFAULTS` | When applying defaults, only set values that are required |
190191
| `Constraint::CHECK_MODE_EXCEPTIONS` | Throw an exception immediately if validation fails |
191192
| `Constraint::CHECK_MODE_DISABLE_FORMAT` | Do not validate "format" constraints |
192193

src/JsonSchema/Constraints/Constraint.php

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ abstract class Constraint extends BaseConstraint implements ConstraintInterface
3131
const CHECK_MODE_APPLY_DEFAULTS = 0x00000008;
3232
const CHECK_MODE_EXCEPTIONS = 0x00000010;
3333
const CHECK_MODE_DISABLE_FORMAT = 0x00000020;
34+
const CHECK_MODE_ONLY_REQUIRED_DEFAULTS = 0x00000080;
3435

3536
/**
3637
* Bubble down the path
@@ -78,10 +79,10 @@ protected function checkArray(&$value, $schema = null, JsonPointer $path = null,
7879
* @param mixed $i
7980
* @param mixed $patternProperties
8081
*/
81-
protected function checkObject(&$value, $schema = null, JsonPointer $path = null, $i = null, $patternProperties = null)
82+
protected function checkObject(&$value, $schema = null, JsonPointer $path = null, $i = null, $patternProperties = null, $appliedDefaults = array())
8283
{
8384
$validator = $this->factory->createInstanceFor('object');
84-
$validator->check($value, $schema, $path, $i, $patternProperties);
85+
$validator->check($value, $schema, $path, $i, $patternProperties, $appliedDefaults);
8586

8687
$this->addErrors($validator->getErrors());
8788
}
@@ -110,11 +111,11 @@ protected function checkType(&$value, $schema = null, JsonPointer $path = null,
110111
* @param JsonPointer|null $path
111112
* @param mixed $i
112113
*/
113-
protected function checkUndefined(&$value, $schema = null, JsonPointer $path = null, $i = null)
114+
protected function checkUndefined(&$value, $schema = null, JsonPointer $path = null, $i = null, $fromDefault = false)
114115
{
115116
$validator = $this->factory->createInstanceFor('undefined');
116117

117-
$validator->check($value, $this->factory->getSchemaStorage()->resolveRefSchema($schema), $path, $i);
118+
$validator->check($value, $this->factory->getSchemaStorage()->resolveRefSchema($schema), $path, $i, $fromDefault);
118119

119120
$this->addErrors($validator->getErrors());
120121
}

src/JsonSchema/Constraints/ObjectConstraint.php

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,22 @@
1919
*/
2020
class ObjectConstraint extends Constraint
2121
{
22+
/**
23+
* @var array List of properties to which a default value has been applied
24+
*/
25+
protected $appliedDefaults = array();
26+
2227
/**
2328
* {@inheritdoc}
2429
*/
25-
public function check(&$element, $definition = null, JsonPointer $path = null, $additionalProp = null, $patternProperties = null)
30+
public function check(&$element, $definition = null, JsonPointer $path = null, $additionalProp = null, $patternProperties = null, $appliedDefaults = array())
2631
{
2732
if ($element instanceof UndefinedConstraint) {
2833
return;
2934
}
3035

36+
$this->appliedDefaults = $appliedDefaults;
37+
3138
$matches = array();
3239
if ($patternProperties) {
3340
$matches = $this->validatePatternProperties($element, $path, $patternProperties);
@@ -63,7 +70,7 @@ public function validatePatternProperties($element, JsonPointer $path = null, $p
6370
foreach ($element as $i => $value) {
6471
if (preg_match($delimiter . $pregex . $delimiter . 'u', $i)) {
6572
$matches[] = $i;
66-
$this->checkUndefined($value, $schema ?: new \stdClass(), $path, $i);
73+
$this->checkUndefined($value, $schema ?: new \stdClass(), $path, $i, in_array($i, $this->appliedDefaults));
6774
}
6875
}
6976
}
@@ -95,9 +102,9 @@ public function validateElement($element, $matches, $objectDefinition = null, Js
95102
// additional properties defined
96103
if (!in_array($i, $matches) && $additionalProp && !$definition) {
97104
if ($additionalProp === true) {
98-
$this->checkUndefined($value, null, $path, $i);
105+
$this->checkUndefined($value, null, $path, $i, in_array($i, $this->appliedDefaults));
99106
} else {
100-
$this->checkUndefined($value, $additionalProp, $path, $i);
107+
$this->checkUndefined($value, $additionalProp, $path, $i, in_array($i, $this->appliedDefaults));
101108
}
102109
}
103110

@@ -131,7 +138,7 @@ public function validateDefinition(&$element, $objectDefinition = null, JsonPoin
131138

132139
if (is_object($definition)) {
133140
// Undefined constraint will check for is_object() and quit if is not - so why pass it?
134-
$this->checkUndefined($property, $definition, $path, $i);
141+
$this->checkUndefined($property, $definition, $path, $i, in_array($i, $this->appliedDefaults));
135142
}
136143
}
137144
}

src/JsonSchema/Constraints/UndefinedConstraint.php

Lines changed: 103 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,24 @@
2222
*/
2323
class UndefinedConstraint extends Constraint
2424
{
25+
/**
26+
* @var array List of properties to which a default value has been applied
27+
*/
28+
protected $appliedDefaults = array();
29+
2530
/**
2631
* {@inheritdoc}
2732
*/
28-
public function check(&$value, $schema = null, JsonPointer $path = null, $i = null)
33+
public function check(&$value, $schema = null, JsonPointer $path = null, $i = null, $fromDefault = false)
2934
{
3035
if (is_null($schema) || !is_object($schema)) {
3136
return;
3237
}
3338

3439
$path = $this->incrementPath($path ?: new JsonPointer(''), $i);
40+
if ($fromDefault) {
41+
$path->setFromDefault();
42+
}
3543

3644
// check special properties
3745
$this->validateCommonProperties($value, $schema, $path, $i);
@@ -67,7 +75,8 @@ public function validateTypes(&$value, $schema = null, JsonPointer $path, $i = n
6775
isset($schema->properties) ? $this->factory->getSchemaStorage()->resolveRefSchema($schema->properties) : $schema,
6876
$path,
6977
isset($schema->additionalProperties) ? $schema->additionalProperties : null,
70-
isset($schema->patternProperties) ? $schema->patternProperties : null
78+
isset($schema->patternProperties) ? $schema->patternProperties : null,
79+
$this->appliedDefaults
7180
);
7281
}
7382

@@ -112,46 +121,8 @@ protected function validateCommonProperties(&$value, $schema = null, JsonPointer
112121
}
113122

114123
// Apply default values from schema
115-
if ($this->factory->getConfig(self::CHECK_MODE_APPLY_DEFAULTS)) {
116-
if ($this->getTypeCheck()->isObject($value) && isset($schema->properties)) {
117-
// $value is an object, so apply default properties if defined
118-
foreach ($schema->properties as $currentProperty => $propertyDefinition) {
119-
if (!$this->getTypeCheck()->propertyExists($value, $currentProperty) && isset($propertyDefinition->default)) {
120-
if (is_object($propertyDefinition->default)) {
121-
$this->getTypeCheck()->propertySet($value, $currentProperty, clone $propertyDefinition->default);
122-
} else {
123-
$this->getTypeCheck()->propertySet($value, $currentProperty, $propertyDefinition->default);
124-
}
125-
}
126-
}
127-
} elseif ($this->getTypeCheck()->isArray($value)) {
128-
if (isset($schema->properties)) {
129-
// $value is an array, but default properties are defined, so treat as assoc
130-
foreach ($schema->properties as $currentProperty => $propertyDefinition) {
131-
if (!isset($value[$currentProperty]) && isset($propertyDefinition->default)) {
132-
if (is_object($propertyDefinition->default)) {
133-
$value[$currentProperty] = clone $propertyDefinition->default;
134-
} else {
135-
$value[$currentProperty] = $propertyDefinition->default;
136-
}
137-
}
138-
}
139-
} elseif (isset($schema->items)) {
140-
// $value is an array, and default items are defined - treat as plain array
141-
foreach ($schema->items as $currentProperty => $itemDefinition) {
142-
if (!isset($value[$currentProperty]) && isset($itemDefinition->default)) {
143-
if (is_object($itemDefinition->default)) {
144-
$value[$currentProperty] = clone $itemDefinition->default;
145-
} else {
146-
$value[$currentProperty] = $itemDefinition->default;
147-
}
148-
}
149-
}
150-
}
151-
} elseif (($value instanceof self || $value === null) && isset($schema->default)) {
152-
// $value is a leaf, not a container - apply the default directly
153-
$value = is_object($schema->default) ? clone $schema->default : $schema->default;
154-
}
124+
if (!$path->fromDefault()) {
125+
$this->applyDefaultValues($value, $schema, $path);
155126
}
156127

157128
// Verify required values
@@ -214,6 +185,96 @@ protected function validateCommonProperties(&$value, $schema = null, JsonPointer
214185
}
215186
}
216187

188+
/**
189+
* Check whether a default should be applied for this value
190+
*
191+
* @param mixed $schema
192+
* @param mixed $parentSchema
193+
* @param bool $requiredOnly
194+
*
195+
* @return bool
196+
*/
197+
private function shouldApplyDefaultValue($requiredOnly, $schema, $name = null, $parentSchema = null)
198+
{
199+
// required-only mode is off
200+
if (!$requiredOnly) {
201+
return true;
202+
}
203+
// draft-04 required is set
204+
if (
205+
$name !== null
206+
&& isset($parentSchema->required)
207+
&& is_array($parentSchema->required)
208+
&& in_array($name, $parentSchema->required)
209+
) {
210+
return true;
211+
}
212+
// draft-03 required is set
213+
if (isset($schema->required) && !is_array($schema->required) && $schema->required) {
214+
return true;
215+
}
216+
// default case
217+
return false;
218+
}
219+
220+
/**
221+
* Apply default values
222+
*
223+
* @param mixed $value
224+
* @param mixed $schema
225+
* @param JsonPointer $path
226+
*/
227+
protected function applyDefaultValues(&$value, $schema, $path)
228+
{
229+
// only apply defaults if feature is enabled
230+
if (!$this->factory->getConfig(self::CHECK_MODE_APPLY_DEFAULTS)) {
231+
return;
232+
}
233+
234+
// apply defaults if appropriate
235+
$requiredOnly = $this->factory->getConfig(self::CHECK_MODE_ONLY_REQUIRED_DEFAULTS);
236+
if (isset($schema->properties) && LooseTypeCheck::isObject($value)) {
237+
// $value is an object or assoc array, and properties are defined - treat as an object
238+
foreach ($schema->properties as $currentProperty => $propertyDefinition) {
239+
if (
240+
!LooseTypeCheck::propertyExists($value, $currentProperty)
241+
&& property_exists($propertyDefinition, 'default')
242+
&& $this->shouldApplyDefaultValue($requiredOnly, $propertyDefinition, $currentProperty, $schema)
243+
) {
244+
// assign default value
245+
if (is_object($propertyDefinition->default)) {
246+
LooseTypeCheck::propertySet($value, $currentProperty, clone $propertyDefinition->default);
247+
} else {
248+
LooseTypeCheck::propertySet($value, $currentProperty, $propertyDefinition->default);
249+
}
250+
$this->appliedDefaults[] = $currentProperty;
251+
}
252+
}
253+
} elseif (isset($schema->items) && LooseTypeCheck::isArray($value)) {
254+
// $value is an array, and items are defined - treat as plain array
255+
foreach ($schema->items as $currentItem => $itemDefinition) {
256+
if (
257+
!array_key_exists($currentItem, $value)
258+
&& property_exists($itemDefinition, 'default')
259+
&& $this->shouldApplyDefaultValue($requiredOnly, $itemDefinition)) {
260+
if (is_object($itemDefinition->default)) {
261+
$value[$currentItem] = clone $itemDefinition->default;
262+
} else {
263+
$value[$currentItem] = $itemDefinition->default;
264+
}
265+
}
266+
$path->setFromDefault();
267+
}
268+
} elseif (
269+
$value instanceof self
270+
&& property_exists($schema, 'default')
271+
&& $this->shouldApplyDefaultValue($requiredOnly, $schema)) {
272+
// $value is a leaf, not a container - apply the default directly
273+
$value = is_object($schema->default) ? clone $schema->default : $schema->default;
274+
$path->setFromDefault();
275+
}
276+
}
277+
217278
/**
218279
* Validate allOf, anyOf, and oneOf properties
219280
*

src/JsonSchema/Entity/JsonPointer.php

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,11 @@ class JsonPointer
2424
/** @var string[] */
2525
private $propertyPaths = array();
2626

27+
/**
28+
* @var bool Whether the value at this path was set from a schema default
29+
*/
30+
private $fromDefault = false;
31+
2732
/**
2833
* @param string $value
2934
*
@@ -135,4 +140,22 @@ public function __toString()
135140
{
136141
return $this->getFilename() . $this->getPropertyPathAsString();
137142
}
143+
144+
/**
145+
* Mark the value at this path as being set from a schema default
146+
*/
147+
public function setFromDefault()
148+
{
149+
$this->fromDefault = true;
150+
}
151+
152+
/**
153+
* Check whether the value at this path was set from a schema default
154+
*
155+
* @return bool
156+
*/
157+
public function fromDefault()
158+
{
159+
return $this->fromDefault;
160+
}
138161
}

src/JsonSchema/SchemaStorage.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,8 +79,18 @@ public function getSchema($id)
7979
public function resolveRef($ref)
8080
{
8181
$jsonPointer = new JsonPointer($ref);
82-
$refSchema = $this->getSchema($jsonPointer->getFilename());
8382

83+
// resolve filename for pointer
84+
$fileName = $jsonPointer->getFilename();
85+
if (!strlen($fileName)) {
86+
throw new UnresolvableJsonPointerException(sprintf(
87+
"Could not resolve fragment '%s': no file is defined",
88+
$jsonPointer->getPropertyPathAsString()
89+
));
90+
}
91+
92+
// get & process the schema
93+
$refSchema = $this->getSchema($fileName);
8494
foreach ($jsonPointer->getPropertyPaths() as $path) {
8595
if (is_object($refSchema) && property_exists($refSchema, $path)) {
8696
$refSchema = $this->resolveRefSchema($refSchema->{$path});

0 commit comments

Comments
 (0)