Skip to content

Commit 46d10ac

Browse files
eraydbighappyface
authored andcommitted
Fix infinite recursion on some schemas when setting defaults (#359) (#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 #377
1 parent af14372 commit 46d10ac

File tree

8 files changed

+274
-82
lines changed

8 files changed

+274
-82
lines changed

README.md

+1
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

+5-4
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

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

37+
$this->appliedDefaults = $appliedDefaults;
38+
3239
$matches = array();
3340
if ($patternProperties) {
3441
$matches = $this->validatePatternProperties($element, $path, $patternProperties);
@@ -64,7 +71,7 @@ public function validatePatternProperties($element, JsonPointer $path = null, $p
6471
foreach ($element as $i => $value) {
6572
if (preg_match($delimiter . $pregex . $delimiter . 'u', $i)) {
6673
$matches[] = $i;
67-
$this->checkUndefined($value, $schema ?: new \stdClass(), $path, $i);
74+
$this->checkUndefined($value, $schema ?: new \stdClass(), $path, $i, in_array($i, $this->appliedDefaults));
6875
}
6976
}
7077
}
@@ -96,9 +103,9 @@ public function validateElement($element, $matches, $objectDefinition = null, Js
96103
// additional properties defined
97104
if (!in_array($i, $matches) && $additionalProp && !$definition) {
98105
if ($additionalProp === true) {
99-
$this->checkUndefined($value, null, $path, $i);
106+
$this->checkUndefined($value, null, $path, $i, in_array($i, $this->appliedDefaults));
100107
} else {
101-
$this->checkUndefined($value, $additionalProp, $path, $i);
108+
$this->checkUndefined($value, $additionalProp, $path, $i, in_array($i, $this->appliedDefaults));
102109
}
103110
}
104111

@@ -135,7 +142,7 @@ public function validateDefinition(&$element, $objectDefinition = null, JsonPoin
135142

136143
if (is_object($definition)) {
137144
// Undefined constraint will check for is_object() and quit if is not - so why pass it?
138-
$this->checkUndefined($property, $definition, $path, $i);
145+
$this->checkUndefined($property, $definition, $path, $i, in_array($i, $this->appliedDefaults));
139146
}
140147
}
141148
}

src/JsonSchema/Constraints/UndefinedConstraint.php

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

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

3745
// check special properties
3846
$this->validateCommonProperties($value, $schema, $path, $i);
@@ -68,7 +76,8 @@ public function validateTypes(&$value, $schema = null, JsonPointer $path, $i = n
6876
isset($schema->properties) ? $this->factory->getSchemaStorage()->resolveRefSchema($schema->properties) : $schema,
6977
$path,
7078
isset($schema->additionalProperties) ? $schema->additionalProperties : null,
71-
isset($schema->patternProperties) ? $schema->patternProperties : null
79+
isset($schema->patternProperties) ? $schema->patternProperties : null,
80+
$this->appliedDefaults
7281
);
7382
}
7483

@@ -113,46 +122,8 @@ protected function validateCommonProperties(&$value, $schema = null, JsonPointer
113122
}
114123

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

158129
// Verify required values
@@ -216,6 +187,96 @@ protected function validateCommonProperties(&$value, $schema = null, JsonPointer
216187
}
217188
}
218189

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

src/JsonSchema/Entity/JsonPointer.php

+23
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

+11-1
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)