Skip to content

Commit

Permalink
Fixes for discriminator (#971)
Browse files Browse the repository at this point in the history
* Fix discriminator

* Fix oneOf

* Filter messages for oneOf discriminator

* Support discriminator in oneOf sibling

* Fix

* Fix

* Fix

* Support mappings

* Refactor

* Update doc
  • Loading branch information
justin-tay authored Feb 16, 2024
1 parent 2879ca3 commit bd085ed
Show file tree
Hide file tree
Showing 7 changed files with 808 additions and 28 deletions.
27 changes: 14 additions & 13 deletions doc/openapi-discriminators.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,28 +2,32 @@

## OpenAPI 3.x discriminator support

Starting with `1.0.51`, `json-schema-validator` partly supports the use of discriminators as described under
https://github.com/OAI/OpenAPI-Specification/blame/master/versions/3.0.3.md#L2693 and following.
Starting with `1.0.51`, `json-schema-validator` partly supports the use of the [`discriminator`](https://github.com/OAI/OpenAPI-Specification/blob/7cc8f4c4e742a20687fa65ace54ed32fcb8c6df0/versions/3.1.0.md#discriminator-object) keyword.

Note that the use of the `discriminator` keyword does not affect the validation of `anyOf` or `oneOf`. The use of `discriminator` is not equivalent to having a `if`/`then` with the `discriminator` propertyName.

When a `discriminator` is used, the assertions generated by `anyOf` or `oneOf` will only be the assertions generated from the schema that the discriminator applies to. An assertion will be generated if a `discriminator` is used but there is no matching schema that maps to the value in the `propertyName`.

## How to use

1. Configure `SchemaValidatorsConfig` accordingly:
```java
class Demo{
void demo() {
void demo() {
SchemaValidatorsConfig config = new SchemaValidatorsConfig();
config.setOpenAPI3StyleDiscriminators(true); // defaults to false
}
}
}
```
2. Use the configured `SchemaValidatorsConfig` with the `JSONSchemaFactory` when creating the `JSONSchema`
2. Use the configured `SchemaValidatorsConfig` with the `JsonSchemaFactory` when creating the `JsonSchema`
```java
class Demo{
void demo() {
void demo() {
SchemaValidatorsConfig config = new SchemaValidatorsConfig();
config.setOpenAPI3StyleDiscriminators(true); // defaults to false
JsonSchema schema = validatorFactory.getSchema(schemaURI, schemaJacksonJsonNode, config);
}
JsonSchemaFactory factory = JsonSchemaFactory.getInstance(VersionFlag.V202012);
JsonSchema schema = factory.getSchema(schemaURI, schemaJacksonJsonNode, config);
}
}
```
3. Ensure that the type field that you want to use as discriminator `propertyName` is required in your schema
Expand All @@ -43,9 +47,6 @@ those parts that are indisputable are considered at this moment.

* `propertyName` redefinition is prohibited on additive discriminators
* `mapping` key redefinition is also prohibited on additive discriminators
* `oneOf` ignores discriminators as today it is not clear from the spec whether `oneOf` + `discriminator` should be equal to
`anyOf` + `discriminator` or not. Especially if `oneOf` should respect the discriminator and skip the other schemas, it's
functionally not JSON Schema `oneOf` anymore as multiple matches would not make the validation fail anymore.
* the specification indicates that inline properties should be ignored.
So, this example would respect `foo`
```yaml
Expand All @@ -71,11 +72,11 @@ those parts that are indisputable are considered at this moment.

## Schema Examples

more examples in https://github.com/networknt/json-schema-validator/blob/master/src/test/resources/openapi3/discriminator.json
More examples in https://github.com/networknt/json-schema-validator/blob/master/src/test/resources/openapi3/discriminator.json

### Base type and extended type (the `anyOf` forward references are required)

#### the simplest example:
#### Example:

```json
{
Expand Down
14 changes: 4 additions & 10 deletions src/main/java/com/networknt/schema/AnyOfValidator.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,6 @@ public class AnyOfValidator extends BaseJsonValidator {
private static final String DISCRIMINATOR_REMARK = "and the discriminator-selected candidate schema didn't pass validation";

private final List<JsonSchema> schemas = new ArrayList<>();
private final DiscriminatorContext discriminatorContext;

private Boolean canShortCircuit = null;

Expand All @@ -43,12 +42,6 @@ public AnyOfValidator(SchemaLocation schemaLocation, JsonNodePath evaluationPath
this.schemas.add(validationContext.newSchema(schemaLocation.append(i), evaluationPath.append(i),
schemaNode.get(i), parentSchema));
}

if (this.validationContext.getConfig().isOpenAPI3StyleDiscriminators()) {
this.discriminatorContext = new DiscriminatorContext();
} else {
this.discriminatorContext = null;
}
}

@Override
Expand All @@ -59,7 +52,7 @@ public Set<ValidationMessage> validate(ExecutionContext executionContext, JsonNo
ValidatorState state = executionContext.getValidatorState();

if (this.validationContext.getConfig().isOpenAPI3StyleDiscriminators()) {
executionContext.enterDiscriminatorContext(this.discriminatorContext, instanceLocation);
executionContext.enterDiscriminatorContext(new DiscriminatorContext(), instanceLocation);
}

boolean initialHasMatchedNode = state.hasMatchedNode();
Expand Down Expand Up @@ -113,7 +106,7 @@ && canShortCircuit() && canShortCircuit(executionContext)) {
// return empty errors.
return errors;
} else if (this.validationContext.getConfig().isOpenAPI3StyleDiscriminators()) {
if (this.discriminatorContext.isDiscriminatorMatchFound()) {
if (executionContext.getCurrentDiscriminatorContext().isDiscriminatorMatchFound()) {
if (!errors.isEmpty()) {
// The following is to match the previous logic adding to all errors
// which is generally discarded as it returns errors but the allErrors
Expand Down Expand Up @@ -143,7 +136,8 @@ && canShortCircuit() && canShortCircuit(executionContext)) {
executionContext.setFailFast(failFast);
}

if (this.validationContext.getConfig().isOpenAPI3StyleDiscriminators() && this.discriminatorContext.isActive()) {
if (this.validationContext.getConfig().isOpenAPI3StyleDiscriminators()
&& executionContext.getCurrentDiscriminatorContext().isActive()) {
return Collections.singleton(message().instanceNode(node).instanceLocation(instanceLocation)
.locale(executionContext.getExecutionConfig().getLocale())
.arguments(
Expand Down
84 changes: 84 additions & 0 deletions src/main/java/com/networknt/schema/DiscriminatorValidator.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/*
* Copyright (c) 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package com.networknt.schema;

import java.util.Collections;
import java.util.HashMap;
import java.util.Iterator;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Set;

import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.node.ObjectNode;

/**
* {@link JsonValidator} that resolves discriminator.
*/
public class DiscriminatorValidator extends BaseJsonValidator {
private final String propertyName;
private final Map<String, String> mapping;

public DiscriminatorValidator(SchemaLocation schemaLocation, JsonNodePath evaluationPath, JsonNode schemaNode,
JsonSchema parentSchema, ValidationContext validationContext) {
super(schemaLocation, evaluationPath, schemaNode, parentSchema, ValidatorTypeCode.DISCRIMINATOR,
validationContext);
ObjectNode discriminator = schemaNode.isObject() ? (ObjectNode) schemaNode : null;
if (discriminator != null) {
JsonNode propertyName = discriminator.get("propertyName");
this.propertyName = propertyName != null ? propertyName.asText() : "";
JsonNode mappingNode = discriminator.get("mapping");
ObjectNode mapping = mappingNode != null && mappingNode.isObject() ? (ObjectNode) mappingNode : null;
if (mapping != null) {
this.mapping = new HashMap<>();
for (Iterator<Entry<String, JsonNode>> iter = mapping.fields(); iter.hasNext();) {
Entry<String, JsonNode> entry = iter.next();
this.mapping.put(entry.getKey(), entry.getValue().asText());
}
} else {
this.mapping = Collections.emptyMap();
}
} else {
this.propertyName = "";
this.mapping = Collections.emptyMap();
}
}

@Override
public Set<ValidationMessage> validate(ExecutionContext executionContext, JsonNode node, JsonNode rootNode,
JsonNodePath instanceLocation) {
return Collections.emptySet();
}

/**
* Gets the property name of the discriminator.
*
* @return the property name
*/
public String getPropertyName() {
return propertyName;
}

/**
* Gets the mapping to map the property name value to the schema name.
*
* @return the discriminator mappings
*/
public Map<String, String> getMapping() {
return mapping;
}
}
8 changes: 8 additions & 0 deletions src/main/java/com/networknt/schema/JsonMetaSchema.java
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,14 @@ public JsonValidator newValidator(ValidationContext validationContext, SchemaLoc
try {
Keyword kw = this.keywords.get(keyword);
if (kw == null) {
if ("message".equals(keyword) && validationContext.getConfig().isCustomMessageSupported()) {
return null;
}
if (ValidatorTypeCode.DISCRIMINATOR.getValue().equals(keyword)
&& validationContext.getConfig().isOpenAPI3StyleDiscriminators()) {
return ValidatorTypeCode.DISCRIMINATOR.newValidator(schemaLocation, evaluationPath, schemaNode,
parentSchema, validationContext);
}
if (UNKNOWN_KEYWORDS.put(keyword, keyword) == null) {
logger.warn("Unknown keyword {} - you should define your own Meta Schema. If the keyword is irrelevant for validation, just use a NonValidationKeyword", keyword);
}
Expand Down
54 changes: 51 additions & 3 deletions src/main/java/com/networknt/schema/OneOfValidator.java
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,15 @@ public Set<ValidationMessage> validate(ExecutionContext executionContext, JsonNo
// Save flag as nested schema evaluation shouldn't trigger fail fast
boolean failFast = executionContext.isFailFast();
try {
DiscriminatorValidator discriminator = null;
if (this.validationContext.getConfig().isOpenAPI3StyleDiscriminators()) {
DiscriminatorContext discriminatorContext = new DiscriminatorContext();
executionContext.enterDiscriminatorContext(discriminatorContext, instanceLocation);

// check if discriminator present
discriminator = (DiscriminatorValidator) this.getParentSchema().getValidators().stream()
.filter(v -> "discriminator".equals(v.getKeyword())).findFirst().orElse(null);
}
executionContext.setFailFast(false);
for (JsonSchema schema : this.schemas) {
Set<ValidationMessage> schemaErrors = Collections.emptySet();
Expand Down Expand Up @@ -95,23 +104,62 @@ public Set<ValidationMessage> validate(ExecutionContext executionContext, JsonNo
// note that the short circuit means that only 2 valid schemas are reported even if could be more
break;
}

if (!schemaErrors.isEmpty() && reportChildErrors(executionContext)) {

if (this.validationContext.getConfig().isOpenAPI3StyleDiscriminators()) {
// The discriminator will cause all messages other than the one with the
// matching discriminator to be discarded. Note that the discriminator cannot
// affect the actual validation result.
if (discriminator != null && !discriminator.getPropertyName().isEmpty()) {
String discriminatorPropertyValue = node.get(discriminator.getPropertyName()).asText();
discriminatorPropertyValue = discriminator.getMapping().getOrDefault(discriminatorPropertyValue,
discriminatorPropertyValue);
JsonNode refNode = schema.getSchemaNode().get("$ref");
if (refNode != null) {
String ref = refNode.asText();
if (ref.equals(discriminatorPropertyValue) || ref.endsWith("/" + discriminatorPropertyValue)) {
executionContext.getCurrentDiscriminatorContext().markMatch();
}
}
}
boolean discriminatorMatchFound = executionContext.getCurrentDiscriminatorContext().isDiscriminatorMatchFound();
if (discriminatorMatchFound && childErrors == null) {
// Note that the match is set if found and not reset so checking if childErrors
// found is null triggers on the correct schema
childErrors = new SetView<>();
childErrors.union(schemaErrors);
}
} else if (!schemaErrors.isEmpty() && reportChildErrors(executionContext)) {
// This is the normal handling when discriminators aren't enabled
if (childErrors == null) {
childErrors = new SetView<>();
}
childErrors.union(schemaErrors);
}
index++;
}

if (this.validationContext.getConfig().isOpenAPI3StyleDiscriminators()
&& (discriminator != null || executionContext.getCurrentDiscriminatorContext().isActive())
&& !executionContext.getCurrentDiscriminatorContext().isDiscriminatorMatchFound()) {
errors = Collections.singleton(message().instanceNode(node).instanceLocation(instanceLocation)
.locale(executionContext.getExecutionConfig().getLocale())
.arguments(
"based on the provided discriminator. No alternative could be chosen based on the discriminator property")
.build());
}
} finally {
// Restore flag
executionContext.setFailFast(failFast);

if (this.validationContext.getConfig().isOpenAPI3StyleDiscriminators()) {
executionContext.leaveDiscriminatorContextImmediately(instanceLocation);
}
}

// ensure there is always an "OneOf" error reported if number of valid schemas
// is not equal to 1.
if (numberOfValidSchema != 1) {
// errors will only not be null in the discriminator case where no match is found
if (numberOfValidSchema != 1 && errors == null) {
ValidationMessage message = message().instanceNode(node).instanceLocation(instanceLocation)
.messageKey(numberOfValidSchema > 1 ? "oneOf.indexes" : "oneOf")
.locale(executionContext.getExecutionConfig().getLocale())
Expand Down
6 changes: 4 additions & 2 deletions src/main/java/com/networknt/schema/ValidatorTypeCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ JsonValidator newInstance(SchemaLocation schemaLocation, JsonNodePath evaluation
}

enum VersionCode {
None(new SpecVersion.VersionFlag[] { }),
AllVersions(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V4, SpecVersion.VersionFlag.V6, SpecVersion.VersionFlag.V7, SpecVersion.VersionFlag.V201909, SpecVersion.VersionFlag.V202012 }),
MinV6(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V6, SpecVersion.VersionFlag.V7, SpecVersion.VersionFlag.V201909, SpecVersion.VersionFlag.V202012 }),
MinV7(new SpecVersion.VersionFlag[] { SpecVersion.VersionFlag.V7, SpecVersion.VersionFlag.V201909, SpecVersion.VersionFlag.V202012 }),
Expand All @@ -51,8 +52,8 @@ enum VersionCode {
}

EnumSet<VersionFlag> getVersions() {
return this.versions;
}
return this.versions;
}
}

public enum ValidatorTypeCode implements Keyword, ErrorMessageType {
Expand All @@ -66,6 +67,7 @@ public enum ValidatorTypeCode implements Keyword, ErrorMessageType {
DEPENDENCIES("dependencies", "1007", DependenciesValidator::new, VersionCode.AllVersions),
DEPENDENT_REQUIRED("dependentRequired", "1045", DependentRequired::new, VersionCode.MinV201909),
DEPENDENT_SCHEMAS("dependentSchemas", "1046", DependentSchemas::new, VersionCode.MinV201909),
DISCRIMINATOR("discriminator", "2001", DiscriminatorValidator::new, VersionCode.None),
DYNAMIC_REF("$dynamicRef", "1051", DynamicRefValidator::new, VersionCode.MinV202012),
ENUM("enum", "1008", EnumValidator::new, VersionCode.AllVersions),
EXCLUSIVE_MAXIMUM("exclusiveMaximum", "1038", ExclusiveMaximumValidator::new, VersionCode.MinV6),
Expand Down
Loading

0 comments on commit bd085ed

Please sign in to comment.