All Downloads are FREE. Search and download functionalities are using the official Maven repository.

software.amazon.smithy.model.validation.NodeValidationVisitor Maven / Gradle / Ivy

/*
 * Copyright 2019 Amazon.com, Inc. or its affiliates. All Rights Reserved.
 *
 * Licensed under the Apache License, Version 2.0 (the "License").
 * You may not use this file except in compliance with the License.
 * A copy of the License is located at
 *
 *  http://aws.amazon.com/apache2.0
 *
 * or in the "license" file accompanying this file. This file 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 software.amazon.smithy.model.validation;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.SourceLocation;
import software.amazon.smithy.model.knowledge.NullableIndex;
import software.amazon.smithy.model.node.Node;
import software.amazon.smithy.model.node.NodeType;
import software.amazon.smithy.model.node.StringNode;
import software.amazon.smithy.model.shapes.BigDecimalShape;
import software.amazon.smithy.model.shapes.BigIntegerShape;
import software.amazon.smithy.model.shapes.BlobShape;
import software.amazon.smithy.model.shapes.BooleanShape;
import software.amazon.smithy.model.shapes.ByteShape;
import software.amazon.smithy.model.shapes.DocumentShape;
import software.amazon.smithy.model.shapes.DoubleShape;
import software.amazon.smithy.model.shapes.FloatShape;
import software.amazon.smithy.model.shapes.IntegerShape;
import software.amazon.smithy.model.shapes.ListShape;
import software.amazon.smithy.model.shapes.LongShape;
import software.amazon.smithy.model.shapes.MapShape;
import software.amazon.smithy.model.shapes.MemberShape;
import software.amazon.smithy.model.shapes.OperationShape;
import software.amazon.smithy.model.shapes.ResourceShape;
import software.amazon.smithy.model.shapes.ServiceShape;
import software.amazon.smithy.model.shapes.Shape;
import software.amazon.smithy.model.shapes.ShapeId;
import software.amazon.smithy.model.shapes.ShapeVisitor;
import software.amazon.smithy.model.shapes.ShortShape;
import software.amazon.smithy.model.shapes.StringShape;
import software.amazon.smithy.model.shapes.StructureShape;
import software.amazon.smithy.model.shapes.TimestampShape;
import software.amazon.smithy.model.shapes.UnionShape;
import software.amazon.smithy.model.validation.node.NodeValidatorPlugin;
import software.amazon.smithy.model.validation.node.TimestampValidationStrategy;
import software.amazon.smithy.utils.ListUtils;
import software.amazon.smithy.utils.SmithyBuilder;

/**
 * Validates {@link Node} values provided for {@link Shape} definitions.
 *
 * 

This visitor validator is used to ensure that values provided for custom * traits and examples are correct for their schema definitions. A map of * shape types to a list of additional validators can be provided to perform * additional, non-standard, validation of these values. For example, this can * be used to provide additional validation needed for custom traits that are * applied to the shape of the data. */ public final class NodeValidationVisitor implements ShapeVisitor> { private static final List BUILTIN = NodeValidatorPlugin.getBuiltins(); private final Model model; private final TimestampValidationStrategy timestampValidationStrategy; private String eventId; private Node value; private ShapeId eventShapeId; private String startingContext; private NodeValidatorPlugin.Context validationContext; private final NullableIndex nullableIndex; private NodeValidationVisitor(Builder builder) { this.model = SmithyBuilder.requiredState("model", builder.model); this.nullableIndex = NullableIndex.of(model); this.validationContext = new NodeValidatorPlugin.Context(model, Feature.enumSet(builder.features)); this.timestampValidationStrategy = builder.timestampValidationStrategy; setValue(SmithyBuilder.requiredState("value", builder.value)); setStartingContext(builder.contextText); setValue(builder.value); setEventShapeId(builder.eventShapeId); setEventId(builder.eventId); } /** * Features to use when validating. */ public enum Feature { /** * Emit a warning when a range trait is incompatible with a default value of 0. * *

This was a common pattern in Smithy 1.0 and earlier. It implies that the value is effectively * required. However, changing the type of the value by un-boxing it or adjusting the range trait would * be a lossy transformation when migrating a model from 1.0 to 2.0. */ RANGE_TRAIT_ZERO_VALUE_WARNING, /** * Lowers severity of constraint trait validations to WARNING. */ ALLOW_CONSTRAINT_ERRORS, /** * Allows null values to be provided for an optional structure member. * *

By default, null values are not allowed for optional types. */ ALLOW_OPTIONAL_NULLS; public static Feature fromNode(Node node) { return Feature.valueOf(node.expectStringNode().getValue()); } public static Node toNode(Feature feature) { return StringNode.from(feature.toString()); } private static EnumSet enumSet(Collection features) { return features.isEmpty() ? EnumSet.noneOf(Feature.class) : EnumSet.copyOf(features); } } public static Builder builder() { return new Builder(); } /** * Changes the Node value the visitor will evaluate. * * @param value Value to set. */ public void setValue(Node value) { this.value = Objects.requireNonNull(value); } /** * Changes the shape ID that emitted events are associated with. * * @param eventShapeId Shape ID to set. */ public void setEventShapeId(ShapeId eventShapeId) { this.eventShapeId = eventShapeId; } /** * Changes the starting context of the messages emitted by events. * * @param startingContext Starting context message to set. */ public void setStartingContext(String startingContext) { this.startingContext = startingContext == null ? "" : startingContext; } /** * Changes the event ID emitted for events created by this validator. * * @param eventId Event ID to set. */ public void setEventId(String eventId) { this.eventId = eventId == null ? Validator.MODEL_ERROR : eventId; } private NodeValidationVisitor traverse(String segment, Node node) { Builder builder = builder(); builder.eventShapeId(eventShapeId); builder.eventId(eventId); builder.value(node); builder.model(model); builder.startingContext(startingContext.isEmpty() ? segment : (startingContext + "." + segment)); builder.timestampValidationStrategy(timestampValidationStrategy); NodeValidationVisitor visitor = new NodeValidationVisitor(builder); // Use the same validation context. visitor.validationContext = this.validationContext; return visitor; } @Override public List blobShape(BlobShape shape) { return value.asStringNode() .map(stringNode -> applyPlugins(shape)) .orElseGet(() -> invalidShape(shape, NodeType.STRING)); } @Override public List booleanShape(BooleanShape shape) { return value.isBooleanNode() ? applyPlugins(shape) : invalidShape(shape, NodeType.BOOLEAN); } @Override public List byteShape(ByteShape shape) { return validateNaturalNumber(shape, Long.valueOf(Byte.MIN_VALUE), Long.valueOf(Byte.MAX_VALUE)); } @Override public List shortShape(ShortShape shape) { return validateNaturalNumber(shape, Long.valueOf(Short.MIN_VALUE), Long.valueOf(Short.MAX_VALUE)); } @Override public List integerShape(IntegerShape shape) { return validateNaturalNumber(shape, Long.valueOf(Integer.MIN_VALUE), Long.valueOf(Integer.MAX_VALUE)); } @Override public List longShape(LongShape shape) { return validateNaturalNumber(shape, Long.MIN_VALUE, Long.MAX_VALUE); } @Override public List bigIntegerShape(BigIntegerShape shape) { return validateNaturalNumber(shape, null, null); } private List validateNaturalNumber(Shape shape, Long min, Long max) { return value.asNumberNode() .map(number -> { if (number.isFloatingPointNumber()) { return ListUtils.of(event(String.format( "%s shapes must not have floating point values, but found `%s` provided for `%s`", shape.getType(), number.getValue(), shape.getId()))); } Long numberValue = number.getValue().longValue(); if (min != null && numberValue < min) { return ListUtils.of(event(String.format( "%s value must be > %d, but found %d", shape.getType(), min, numberValue))); } else if (max != null && numberValue > max) { return ListUtils.of(event(String.format( "%s value must be < %d, but found %d", shape.getType(), max, numberValue))); } else { return applyPlugins(shape); } }) .orElseGet(() -> invalidShape(shape, NodeType.NUMBER)); } @Override public List floatShape(FloatShape shape) { return value.isNumberNode() || value.isStringNode() ? applyPlugins(shape) : invalidShape(shape, NodeType.NUMBER); } @Override public List documentShape(DocumentShape shape) { // Document values are always valid. return Collections.emptyList(); } @Override public List doubleShape(DoubleShape shape) { return value.isNumberNode() || value.isStringNode() ? applyPlugins(shape) : invalidShape(shape, NodeType.NUMBER); } @Override public List bigDecimalShape(BigDecimalShape shape) { return value.isNumberNode() ? applyPlugins(shape) : invalidShape(shape, NodeType.NUMBER); } @Override public List stringShape(StringShape shape) { return value.asStringNode() .map(string -> applyPlugins(shape)) .orElseGet(() -> invalidShape(shape, NodeType.STRING)); } @Override public List timestampShape(TimestampShape shape) { return applyPlugins(shape); } @Override public List listShape(ListShape shape) { return value.asArrayNode() .map(array -> { MemberShape member = shape.getMember(); List events = applyPlugins(shape); // Each element creates a context with a numeric index (e.g., "foo.0.baz", "foo.1.baz", etc.). for (int i = 0; i < array.getElements().size(); i++) { events.addAll(member.accept(traverse(String.valueOf(i), array.getElements().get(i)))); } return events; }) .orElseGet(() -> invalidShape(shape, NodeType.ARRAY)); } @Override public List mapShape(MapShape shape) { return value.asObjectNode() .map(object -> { List events = applyPlugins(shape); for (Map.Entry entry : object.getMembers().entrySet()) { String key = entry.getKey().getValue(); events.addAll(traverse(key + " (map-key)", entry.getKey()).memberShape(shape.getKey())); events.addAll(traverse(key, entry.getValue()).memberShape(shape.getValue())); } return events; }) .orElseGet(() -> invalidShape(shape, NodeType.OBJECT)); } @Override public List structureShape(StructureShape shape) { return value.asObjectNode() .map(object -> { List events = applyPlugins(shape); Map members = shape.getAllMembers(); for (Map.Entry entry : object.getStringMap().entrySet()) { String entryKey = entry.getKey(); Node entryValue = entry.getValue(); if (!members.containsKey(entryKey)) { String message = String.format( "Invalid structure member `%s` found for `%s`", entryKey, shape.getId()); events.add(event(message, Severity.WARNING, shape.getId().toString(), entryKey)); } else { events.addAll(traverse(entryKey, entryValue).memberShape(members.get(entryKey))); } } for (MemberShape member : members.values()) { if (member.isRequired() && !object.getMember(member.getMemberName()).isPresent()) { Severity severity = this.validationContext.hasFeature(Feature.ALLOW_CONSTRAINT_ERRORS) ? Severity.WARNING : Severity.ERROR; events.add(event(String.format( "Missing required structure member `%s` for `%s`", member.getMemberName(), shape.getId()), severity)); } } return events; }) .orElseGet(() -> invalidShape(shape, NodeType.OBJECT)); } @Override public List unionShape(UnionShape shape) { return value.asObjectNode() .map(object -> { List events = applyPlugins(shape); if (object.size() > 1) { events.add(event("union values can contain a value for only a single member")); } else { Map members = shape.getAllMembers(); for (Map.Entry entry : object.getStringMap().entrySet()) { String entryKey = entry.getKey(); Node entryValue = entry.getValue(); if (!members.containsKey(entryKey)) { events.add(event(String.format( "Invalid union member `%s` found for `%s`", entryKey, shape.getId()))); } else { events.addAll(traverse(entryKey, entryValue).memberShape(members.get(entryKey))); } } } return events; }) .orElseGet(() -> invalidShape(shape, NodeType.OBJECT)); } @Override public List memberShape(MemberShape shape) { List events = applyPlugins(shape); model.getShape(shape.getTarget()).ifPresent(target -> { // We only need to keep track of a single referring member, so a stack of members or anything like that // isn't needed here. validationContext.setReferringMember(shape); events.addAll(target.accept(this)); validationContext.setReferringMember(null); }); return events; } @Override public List operationShape(OperationShape shape) { return invalidSchema(shape); } @Override public List resourceShape(ResourceShape shape) { return invalidSchema(shape); } @Override public List serviceShape(ServiceShape shape) { return invalidSchema(shape); } private List invalidShape(Shape shape, NodeType expectedType) { // Nullable shapes allow null values. if (value.isNullNode() && validationContext.hasFeature(Feature.ALLOW_OPTIONAL_NULLS)) { // Non-members are nullable. Members are nullable based on context. if (!shape.isMemberShape() || shape.asMemberShape().filter(nullableIndex::isMemberNullable).isPresent()) { return Collections.emptyList(); } } String message = String.format( "Expected %s value for %s shape, `%s`; found %s value", expectedType, shape.getType(), shape.getId(), value.getType()); if (value.isStringNode()) { message += ", `" + value.expectStringNode().getValue() + "`"; } else if (value.isNumberNode()) { message += ", `" + value.expectNumberNode().getValue() + "`"; } else if (value.isBooleanNode()) { message += ", `" + value.expectBooleanNode().getValue() + "`"; } return ListUtils.of(event(message)); } private List invalidSchema(Shape shape) { return ListUtils.of(event("Encountered invalid shape type: " + shape.getType())); } private ValidationEvent event(String message, String... additionalEventIdParts) { return event(message, Severity.ERROR, additionalEventIdParts); } private ValidationEvent event(String message, Severity severity, String... additionalEventIdParts) { return event(message, severity, value.getSourceLocation(), additionalEventIdParts); } private ValidationEvent event( String message, Severity severity, SourceLocation sourceLocation, String... additionalEventIdParts ) { return ValidationEvent.builder() .id(additionalEventIdParts.length > 0 ? eventId + "." + String.join(".", additionalEventIdParts) : eventId) .severity(severity) .sourceLocation(sourceLocation) .shapeId(eventShapeId) .message(startingContext.isEmpty() ? message : startingContext + ": " + message) .build(); } private List applyPlugins(Shape shape) { List events = new ArrayList<>(); timestampValidationStrategy.apply(shape, value, validationContext, (location, severity, message, additionalEventIdParts) -> events.add(event(message, severity, location.getSourceLocation(), additionalEventIdParts))); for (NodeValidatorPlugin plugin : BUILTIN) { plugin.apply(shape, value, validationContext, (location, severity, message, additionalEventIdParts) -> events.add(event(message, severity, location.getSourceLocation(), additionalEventIdParts))); } return events; } /** * Builds a {@link NodeValidationVisitor}. */ public static final class Builder implements SmithyBuilder { private String eventId; private String contextText; private ShapeId eventShapeId; private Node value; private Model model; private TimestampValidationStrategy timestampValidationStrategy = TimestampValidationStrategy.FORMAT; private final Set features = new HashSet<>(); Builder() {} /** * Sets the required model to use when traversing * walking shapes during validation. * * @param model Model that contains shapes to validate. * @return Returns the builder. */ public Builder model(Model model) { this.model = model; return this; } /** * Sets the required node value to validate. * * @param value Value to validate. * @return Returns the builder. */ public Builder value(Node value) { this.value = Objects.requireNonNull(value); return this; } /** * Sets an optional custom event ID to use for created validation events. * * @param id Custom event ID. * @return Returns the builder. */ public Builder eventId(String id) { this.eventId = Objects.requireNonNull(id); return this; } /** * Sets an optional starting context of the validator that is prepended * to each emitted validation event message. * * @param contextText Starting event message content. * @return Returns the builder. */ public Builder startingContext(String contextText) { this.contextText = Objects.requireNonNull(contextText); return this; } /** * Sets an optional shape ID that is used as the shape ID in each * validation event emitted by the validator. * * @param eventShapeId Shape ID to set on every validation event. * @return Returns the builder. */ public Builder eventShapeId(ShapeId eventShapeId) { this.eventShapeId = eventShapeId; return this; } /** * Sets the strategy used to validate timestamps. * *

By default, timestamps are validated using * {@link TimestampValidationStrategy#FORMAT}. * * @param timestampValidationStrategy Timestamp validation strategy. * @return Returns the builder. */ public Builder timestampValidationStrategy(TimestampValidationStrategy timestampValidationStrategy) { this.timestampValidationStrategy = timestampValidationStrategy; return this; } @Deprecated public Builder allowBoxedNull(boolean allowBoxedNull) { return allowOptionalNull(allowBoxedNull); } @Deprecated public Builder allowOptionalNull(boolean allowOptionalNull) { if (allowOptionalNull) { return addFeature(Feature.ALLOW_OPTIONAL_NULLS); } else { features.remove(Feature.ALLOW_OPTIONAL_NULLS); return this; } } /** * Adds a feature flag to the validator. * * @param feature Feature to set. * @return Returns the builder. */ public Builder addFeature(Feature feature) { this.features.add(feature); return this; } @Override public NodeValidationVisitor build() { return new NodeValidationVisitor(this); } } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy