Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix infinite recursion on some schemas when setting defaults (#359) #365

Merged
merged 14 commits into from
Mar 21, 2017
Merged
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
9 changes: 5 additions & 4 deletions src/JsonSchema/Constraints/Constraint.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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());
}
Expand Down Expand Up @@ -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());
}
Expand Down
17 changes: 12 additions & 5 deletions src/JsonSchema/Constraints/ObjectConstraint.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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));
}
}
}
Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -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));
}
}
}
Expand Down
145 changes: 103 additions & 42 deletions src/JsonSchema/Constraints/UndefinedConstraint.php
Original file line number Diff line number Diff line change
Expand Up @@ -22,16 +22,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);
Expand Down Expand Up @@ -67,7 +75,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
);
}

Expand Down Expand Up @@ -112,46 +121,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
Expand Down Expand Up @@ -215,6 +186,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
*
Expand Down
23 changes: 23 additions & 0 deletions src/JsonSchema/Entity/JsonPointer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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;
}
}
12 changes: 11 additions & 1 deletion src/JsonSchema/SchemaStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -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});
Expand Down
Loading