Skip to content

Commit

Permalink
Refactor format validation (#958)
Browse files Browse the repository at this point in the history
* Refactor format to allow more complex validation

* Refactor

* Refactor

* Partial

* Refactor

* Refactor

* Fix assertions behavior

* Fix

* Refactor date time validator

* Refactor to allow for localized error messages

* Add docs for unknown formats

* Add tests
  • Loading branch information
justin-tay committed Feb 7, 2024
1 parent 32356e9 commit e60f81f
Show file tree
Hide file tree
Showing 32 changed files with 918 additions and 284 deletions.
5 changes: 5 additions & 0 deletions doc/compatibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,12 @@ This behavior can be overridden to generate assertions by setting the `setFormat
| uri-template | 🚫 | 🟢 | 🟢 | 🟢 | 🟢 |
| uuid | 🚫 | 🚫 | 🟢 | 🟢 | 🟢 |

##### Unknown Formats

When the format assertion vocabularies are used in a meta schema, in accordance to the specification, unknown formats will result in assertions. If the format assertion vocabularies are not used, unknown formats will only result in assertions if the assertions are enabled and if `setStrict("format", true)`.

##### Footnotes
1. Note that the validation are only optional for some of the keywords/formats.
2. Refer to the corresponding JSON schema for more information on whether the keyword/format is optional or not.


126 changes: 123 additions & 3 deletions src/main/java/com/networknt/schema/Format.java
Original file line number Diff line number Diff line change
Expand Up @@ -16,23 +16,143 @@

package com.networknt.schema;

import java.util.Collections;
import java.util.Set;
import java.util.function.Supplier;

import com.fasterxml.jackson.databind.JsonNode;

/**
* Used to implement the various formats for the format keyword.
* <p>
* Simple implementations need only override {@link #matches(ExecutionContext, String)}.
*/
public interface Format {
/**
* Gets the format name.
*
* @return the format name as referred to in a json schema format node.
*/
String getName();

/**
* Gets the message key to use for the message.
* <p>
* See jsv-messages.properties.
* <p>
* The following are the arguments.<br>
* {0} The instance location<br>
* {1} The format name<br>
* {2} The error message description<br>
* {3} The input value
*
* @return the message key
*/
default String getMessageKey() {
return "format";
}

/**
* Gets the error message description.
* <p>
* Deprecated. Override getMessageKey() and set the localized message in the
* resource bundle or message source.
*
* @return the error message description.
*/
@Deprecated
default String getErrorMessageDescription() {
return "";
}


/**
* Determines if the value matches the format.
*
* <p>
* This should be implemented for string node types.
*
* @param executionContext the execution context
* @param value to match
* @return true if matches
*/
boolean matches(ExecutionContext executionContext, String value);
default boolean matches(ExecutionContext executionContext, String value) {
return true;
}

/**
* Determines if the value matches the format.
*
* @param executionContext the execution context
* @param validationContext the validation context
* @param value to match
* @return true if matches
*/
default boolean matches(ExecutionContext executionContext, ValidationContext validationContext, String value) {
return matches(executionContext, value);
}

/**
* Determines if the value matches the format.
*
* @param executionContext the execution context
* @param validationContext the validation context
* @param value to match
* @return true if matches
*/
default boolean matches(ExecutionContext executionContext, ValidationContext validationContext, JsonNode value) {
JsonType nodeType = TypeFactory.getValueNodeType(value, validationContext.getConfig());
if (nodeType != JsonType.STRING) {
return true;
}
return matches(executionContext, validationContext, value.textValue());
}

/**
* Determines if the value matches the format.
* <p>
* This can be implemented for non-string node types.
*
* @param executionContext the execution context
* @param validationContext the validation context
* @param node the node
* @param rootNode the root node
* @param instanceLocation the instance location
* @param assertionsEnabled if assertions are enabled
* @param formatValidator the format validator
* @return true if matches
*/
default boolean matches(ExecutionContext executionContext, ValidationContext validationContext, JsonNode node,
JsonNode rootNode, JsonNodePath instanceLocation, boolean assertionsEnabled, FormatValidator formatValidator) {
return matches(executionContext, validationContext, node);
}

String getErrorMessageDescription();
/**
* Validates the format.
* <p>
* This is the most flexible method to implement.
*
* @param executionContext the execution context
* @param validationContext the validation context
* @param node the node
* @param rootNode the root node
* @param instanceLocation the instance locaiton
* @param assertionsEnabled if assertions are enabled
* @param message the message builder
* @param formatValidator the format validator
* @return the messages
*/
default Set<ValidationMessage> validate(ExecutionContext executionContext, ValidationContext validationContext,
JsonNode node, JsonNode rootNode, JsonNodePath instanceLocation, boolean assertionsEnabled,
Supplier<MessageSourceValidationMessage.Builder> message,
FormatValidator formatValidator) {
if (assertionsEnabled) {
if (!matches(executionContext, validationContext, node, rootNode, instanceLocation, assertionsEnabled,
formatValidator)) {
return Collections
.singleton(message.get()
.arguments(this.getName(), this.getErrorMessageDescription(), node.asText()).build());
}
}
return Collections.emptySet();
}
}
42 changes: 18 additions & 24 deletions src/main/java/com/networknt/schema/FormatKeyword.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,23 +17,31 @@
package com.networknt.schema;

import com.fasterxml.jackson.databind.JsonNode;
import com.networknt.schema.format.DateTimeValidator;
import com.networknt.schema.format.DurationFormat;

import java.util.Collection;
import java.util.Collections;
import java.util.Map;

/**
* Format Keyword.
*/
public class FormatKeyword implements Keyword {
private static final String DATE_TIME = "date-time";
private static final String DURATION = "duration";

private final ValidatorTypeCode type;
private final String value;
private final ErrorMessageType errorMessageType;
private final Map<String, Format> formats;

public FormatKeyword(Map<String, Format> formats) {
this(ValidatorTypeCode.FORMAT, formats);
}

public FormatKeyword(ValidatorTypeCode type, Map<String, Format> formats) {
this.type = type;
this(type.getValue(), type, formats);
}

public FormatKeyword(String value, ErrorMessageType errorMessageType, Map<String, Format> formats) {
this.value = value;
this.formats = formats;
this.errorMessageType = errorMessageType;
}

Collection<Format> getFormats() {
Expand All @@ -46,27 +54,13 @@ public JsonValidator newValidator(SchemaLocation schemaLocation, JsonNodePath ev
if (schemaNode != null && schemaNode.isTextual()) {
String formatName = schemaNode.textValue();
format = this.formats.get(formatName);
if (format != null) {
return new FormatValidator(schemaLocation, evaluationPath, schemaNode, parentSchema, validationContext, format, type);
}

switch (formatName) {
case DURATION:
format = new DurationFormat(validationContext.getConfig().isStrict(DURATION));
break;

case DATE_TIME: {
ValidatorTypeCode typeCode = ValidatorTypeCode.DATETIME;
return new DateTimeValidator(schemaLocation, evaluationPath, schemaNode, parentSchema, validationContext, typeCode);
}
}
}

return new FormatValidator(schemaLocation, evaluationPath, schemaNode, parentSchema, validationContext, format, this.type);
return new FormatValidator(schemaLocation, evaluationPath, schemaNode, parentSchema, validationContext, format,
errorMessageType, this);
}

@Override
public String getValue() {
return this.type.getValue();
return this.value;
}
}
137 changes: 95 additions & 42 deletions src/main/java/com/networknt/schema/FormatValidator.java
Original file line number Diff line number Diff line change
Expand Up @@ -23,71 +23,124 @@
import org.slf4j.LoggerFactory;

import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.regex.PatternSyntaxException;

/**
* Validator for Format.
*/
public class FormatValidator extends BaseFormatJsonValidator implements JsonValidator {
private static final Logger logger = LoggerFactory.getLogger(FormatValidator.class);

private final Format format;

public FormatValidator(SchemaLocation schemaLocation, JsonNodePath evaluationPath, JsonNode schemaNode, JsonSchema parentSchema, ValidationContext validationContext, Format format, ValidatorTypeCode type) {
super(schemaLocation, evaluationPath, schemaNode, parentSchema, type, validationContext);

public FormatValidator(SchemaLocation schemaLocation, JsonNodePath evaluationPath, JsonNode schemaNode,
JsonSchema parentSchema, ValidationContext validationContext, Format format,
ErrorMessageType errorMessageType, Keyword keyword) {
super(schemaLocation, evaluationPath, schemaNode, parentSchema, errorMessageType, keyword, validationContext);
this.format = format;
}

public FormatValidator(SchemaLocation schemaLocation, JsonNodePath evaluationPath, JsonNode schemaNode,
JsonSchema parentSchema, ValidationContext validationContext, Format format, ValidatorTypeCode type) {
this(schemaLocation, evaluationPath, schemaNode, parentSchema, validationContext, format, type, type);
}

/**
* Gets the annotation value.
*
* @return the annotation value
*/
protected Object getAnnotationValue() {
if (this.format != null) {
return this.format.getName();
}
return this.schemaNode.isTextual() ? schemaNode.textValue() : null;
}

public Set<ValidationMessage> validate(ExecutionContext executionContext, JsonNode node, JsonNode rootNode, JsonNodePath instanceLocation) {
debug(logger, node, rootNode, instanceLocation);

if (format != null) {
if (collectAnnotations(executionContext)) {
/*
* Annotations must be collected even if the format is unknown according to the specification.
*/
if (collectAnnotations(executionContext)) {
Object annotationValue = getAnnotationValue();
if (annotationValue != null) {
putAnnotation(executionContext,
annotation -> annotation.instanceLocation(instanceLocation).value(this.format.getName()));
annotation -> annotation.instanceLocation(instanceLocation).value(annotationValue));
}
}

JsonType nodeType = TypeFactory.getValueNodeType(node, this.validationContext.getConfig());
if (nodeType != JsonType.STRING) {
return Collections.emptySet();
}

boolean assertionsEnabled = isAssertionsEnabled(executionContext);
Set<ValidationMessage> errors = new LinkedHashSet<>();
if (format != null) {
if(format.getName().equals("ipv6")) {
if(!node.textValue().trim().equals(node.textValue())) {
if (assertionsEnabled) {
// leading and trailing spaces
errors.add(message().instanceNode(node).instanceLocation(instanceLocation)
.locale(executionContext.getExecutionConfig().getLocale())
.failFast(executionContext.isFailFast())
.arguments(format.getName(), format.getErrorMessageDescription()).build());
}
} else if(node.textValue().contains("%")) {
if (assertionsEnabled) {
// zone id is not part of the ipv6
errors.add(message().instanceNode(node).instanceLocation(instanceLocation)
.locale(executionContext.getExecutionConfig().getLocale())
.arguments(format.getName(), format.getErrorMessageDescription()).build());
}
}
}
if (this.format != null) {
try {
if (!format.matches(executionContext, node.textValue())) {
if (assertionsEnabled) {
errors.add(message().instanceNode(node).instanceLocation(instanceLocation)
.locale(executionContext.getExecutionConfig().getLocale())
.arguments(format.getName(), format.getErrorMessageDescription()).build());
}
}
return format.validate(executionContext, validationContext, node, rootNode, instanceLocation,
assertionsEnabled,
() -> this.message().instanceNode(node).instanceLocation(instanceLocation)
.messageKey(format.getMessageKey())
.locale(executionContext.getExecutionConfig().getLocale())
.failFast(executionContext.isFailFast()),
this);
} catch (PatternSyntaxException pse) {
// String is considered valid if pattern is invalid
logger.error("Failed to apply pattern on {}: Invalid RE syntax [{}]", instanceLocation, format.getName(), pse);
logger.error("Failed to apply pattern on {}: Invalid RE syntax [{}]", instanceLocation,
format.getName(), pse);
return Collections.emptySet();
}
} else {
return validateUnknownFormat(executionContext, node, rootNode, instanceLocation);
}
}

return Collections.unmodifiableSet(errors);
/**
* When the Format-Assertion vocabulary is specified, implementations MUST fail upon encountering unknown formats.
*
* @param executionContext the execution context
* @param node the node
* @param rootNode the root node
* @param instanceLocation the instance location
* @return the messages
*/
protected Set<ValidationMessage> validateUnknownFormat(ExecutionContext executionContext,
JsonNode node, JsonNode rootNode, JsonNodePath instanceLocation) {
/*
* Unknown formats should create an assertion if the vocab is specified
* according to the specification.
*/
if (createUnknownFormatAssertions(executionContext) && this.schemaNode.isTextual()) {
return Collections.singleton(message().instanceLocation(instanceLocation).instanceNode(node)
.messageKey("format.unknown").arguments(schemaNode.textValue()).build());
}
return Collections.emptySet();
}

/**
* When the Format-Assertion vocabulary is specified, implementations MUST fail
* upon encountering unknown formats.
* <p>
* Note that this is different from setting the setFormatAssertionsEnabled
* configuration option.
* <p>
* The following logic will return true if the format assertions option is
* turned on and strict is enabled (default false) or the format assertion
* vocabulary is enabled.
*
* @param executionContext the execution context
* @return true if format assertions should be generated
*/
protected boolean createUnknownFormatAssertions(ExecutionContext executionContext) {
return (isAssertionsEnabled(executionContext) && isStrict(executionContext)) || (isFormatAssertionVocabularyEnabled());
}

/**
* Determines if strict handling.
* <p>
* Note that this defaults to false.
*
* @param executionContext the execution context
* @return whether to perform strict handling
*/
protected boolean isStrict(ExecutionContext executionContext) {
return this.validationContext.getConfig().isStrict(getKeyword(), Boolean.FALSE);
}
}
Loading

0 comments on commit e60f81f

Please sign in to comment.