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

Add BoxIndex #234

Merged
merged 1 commit into from
Dec 17, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
/*
* 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.knowledge;

import java.util.HashMap;
import java.util.Map;
import java.util.stream.Collectors;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.shapes.CollectionShape;
import software.amazon.smithy.model.shapes.MemberShape;
import software.amazon.smithy.model.shapes.Shape;
import software.amazon.smithy.model.shapes.ShapeId;
import software.amazon.smithy.model.shapes.ToShapeId;
import software.amazon.smithy.model.traits.BoxTrait;

/**
* An index that checks if a shape is boxed or not.
*
* <p>A service, resource, and operation are never considered boxed. A member
* is considered boxed if the member is targeted by the {@code box} trait or
* if the shape the member targets is considered boxed. A shape is considered
* boxed if it is targeted by the {@code box} trait or if the shape is a
* string, blob, timestamp, bigInteger, bigDecimal, list, set, map, structure,
* or union.
*/
public final class BoxIndex implements KnowledgeIndex {

private final Map<ShapeId, Boolean> boxMap;

public BoxIndex(Model model) {
// Create a HashMap with the same initial capacity as the number of shapes in the model.
boxMap = model.shapes().collect(Collectors.toMap(
Shape::getId,
s -> isBoxed(model, s),
(a, b) -> b,
() -> new HashMap<>(model.toSet().size())));
}

private static boolean isBoxed(Model model, Shape shape) {
if (shape.hasTrait(BoxTrait.class)) {
return true;
}

if (shape.isStringShape()
|| shape.isBlobShape()
|| shape.isTimestampShape()
|| shape.isBigDecimalShape()
|| shape.isBigIntegerShape()
|| shape instanceof CollectionShape
|| shape.isMapShape()
|| shape.isStructureShape()
|| shape.isUnionShape()) {
return true;
}

// Check if the member targets a boxed shape.
if (shape.isMemberShape()) {
return shape.asMemberShape()
.map(MemberShape::getTarget)
.flatMap(model::getShape)
.filter(target -> isBoxed(model, target))
.isPresent();
}

return false;
}

/**
* Checks if the given shape should be considered boxed, meaning it
* accepts a null value.
*
* <p>Note that "accepts a null value" means that the type that
* represents the shape, <em>in code</em>, accepts a null value or can
* be optionally set, but does not necessarily mean that sending a null
* value over the wire in a given protocol has any special meaning
* that's materially different than if a value is completely omitted.
*
* @param shape Shape to check.
* @return Returns true if the shape is effectively boxed.
*/
public boolean isBoxed(ToShapeId shape) {
return boxMap.get(shape.toShapeId());
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
import java.util.Objects;
import java.util.stream.Collectors;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.knowledge.BoxIndex;
import software.amazon.smithy.model.node.Node;
import software.amazon.smithy.model.node.NodeType;
import software.amazon.smithy.model.node.StringNode;
Expand Down Expand Up @@ -52,7 +53,6 @@
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.traits.BoxTrait;
import software.amazon.smithy.model.validation.node.BlobLengthPlugin;
import software.amazon.smithy.model.validation.node.CollectionLengthPlugin;
import software.amazon.smithy.model.validation.node.IdRefPlugin;
Expand Down Expand Up @@ -80,19 +80,21 @@ public final class NodeValidationVisitor implements ShapeVisitor<List<Validation

private final String eventId;
private final Node value;
private final ShapeIndex index;
private final Model model;
private final String context;
private final ShapeId eventShapeId;
private final List<NodeValidatorPlugin> plugins;
private final TimestampValidationStrategy timestampValidationStrategy;
private final boolean allowBoxedNull;

private NodeValidationVisitor(Builder builder) {
this.value = SmithyBuilder.requiredState("value", builder.value);
this.index = SmithyBuilder.requiredState("index", builder.index);
this.model = SmithyBuilder.requiredState("model", builder.model);
this.context = builder.context;
this.eventId = builder.eventId;
this.eventShapeId = builder.eventShapeId;
this.timestampValidationStrategy = builder.timestampValidationStrategy;
this.allowBoxedNull = builder.allowBoxedNull;

plugins = Arrays.asList(
new BlobLengthPlugin(),
Expand All @@ -116,9 +118,10 @@ private NodeValidationVisitor withNode(String segment, Node node) {
builder.eventShapeId(eventShapeId);
builder.eventId(eventId);
builder.value(node);
builder.index(index);
builder.model(model);
builder.startingContext(context.isEmpty() ? segment : (context + "." + segment));
builder.timestampValidationStrategy(timestampValidationStrategy);
builder.allowBoxedNull(allowBoxedNull);
return new NodeValidationVisitor(builder);
}

Expand Down Expand Up @@ -291,20 +294,7 @@ public List<ValidationEvent> structureShape(StructureShape shape) {
}

private boolean isMemberPrimitive(MemberShape member) {
Shape target = index.getShape(member.getTarget()).orElse(null);
if (member.getTrait(BoxTrait.class).isPresent()
|| target == null
|| target.getTrait(BoxTrait.class).isPresent()) {
return false;
}

return target.isBooleanShape()
|| target.isByteShape()
|| target.isShortShape()
|| target.isIntegerShape()
|| target.isLongShape()
|| target.isFloatShape()
|| target.isDoubleShape();
return !model.getKnowledge(BoxIndex.class).isBoxed(member);
}

@Override
Expand Down Expand Up @@ -334,7 +324,7 @@ public List<ValidationEvent> unionShape(UnionShape shape) {
@Override
public List<ValidationEvent> memberShape(MemberShape shape) {
List<ValidationEvent> events = applyPlugins(shape);
events.addAll(index.getShape(shape.getTarget())
events.addAll(model.getShape(shape.getTarget())
.map(member -> member.accept(this))
.orElse(ListUtils.of()));
return events;
Expand All @@ -356,6 +346,11 @@ public List<ValidationEvent> serviceShape(ServiceShape shape) {
}

private List<ValidationEvent> invalidShape(Shape shape, NodeType expectedType) {
// Boxed shapes allow null values.
if (allowBoxedNull && value.isNullNode() && model.getKnowledge(BoxIndex.class).isBoxed(shape)) {
return Collections.emptyList();
}

String message = String.format(
"Expected %s value for %s shape, `%s`; found %s value",
expectedType, shape.getType(), shape.getId(), value.getType());
Expand Down Expand Up @@ -385,7 +380,7 @@ private ValidationEvent event(String message) {

private List<ValidationEvent> applyPlugins(Shape shape) {
return plugins.stream()
.flatMap(plugin -> plugin.apply(shape, value, index).stream())
.flatMap(plugin -> plugin.apply(shape, value, model).stream())
.map(this::event)
.collect(Collectors.toList());
}
Expand All @@ -398,14 +393,15 @@ public static final class Builder implements SmithyBuilder<NodeValidationVisitor
private String context = "";
private ShapeId eventShapeId;
private Node value;
private ShapeIndex index;
private Model model;
private TimestampValidationStrategy timestampValidationStrategy = TimestampValidationStrategy.FORMAT;
private boolean allowBoxedNull;

Builder() {}

@Deprecated
public Builder index(ShapeIndex index) {
this.index = Objects.requireNonNull(index);
this.model = Model.builder().shapeIndex(index).build();
return this;
}

Expand All @@ -417,7 +413,8 @@ public Builder index(ShapeIndex index) {
* @return Returns the builder.
*/
public Builder model(Model model) {
return index(model.getShapeIndex());
this.model = model;
return this;
}

/**
Expand Down Expand Up @@ -480,6 +477,20 @@ public Builder timestampValidationStrategy(TimestampValidationStrategy timestamp
return this;
}

/**
* Configure how null values are handled when they are provided for
* boxed types.
*
* <p>By default, null values are not allowed for boxed types.
*
* @param allowBoxedNull Set to true to allow null values for boxed shapes.
* @return Returns the builder.
*/
public Builder allowBoxedNull(boolean allowBoxedNull) {
this.allowBoxedNull = allowBoxedNull;
return this;
}

@Override
public NodeValidationVisitor build() {
return new NodeValidationVisitor(this);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
package software.amazon.smithy.model.knowledge;

import java.util.Arrays;
import java.util.Collection;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.MethodSource;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.shapes.ListShape;
import software.amazon.smithy.model.shapes.MemberShape;
import software.amazon.smithy.model.shapes.ShapeId;
import software.amazon.smithy.model.traits.BoxTrait;

public class BoxIndexTest {

@ParameterizedTest
@MethodSource("data")
public void checksIfBoxed(Model model, String shapeId, boolean isBoxed) {
BoxIndex index = model.getKnowledge(BoxIndex.class);
ShapeId targetId = ShapeId.from(shapeId);

if (isBoxed && !index.isBoxed(targetId)) {
Assertions.fail("Did not expect shape to be determined as boxed: " + targetId);
} else if (!isBoxed && index.isBoxed(targetId)) {
Assertions.fail("Expected shape to be determined as boxed: " + targetId);
}
}

public static Collection<Object[]> data() {
MemberShape boxedListMember = MemberShape.builder()
.id("smithy.test#BoxedList$member")
.target("smithy.api#PrimitiveBoolean")
.addTrait(new BoxTrait())
.build();
ListShape boxedList = ListShape.builder()
.id("smithy.test#BoxedList")
.member(boxedListMember)
.build();

MemberShape primitiveMember = MemberShape.builder()
.id("smithy.test#PrimitiveList$member")
.target("smithy.api#PrimitiveBoolean")
.build();
ListShape primitiveList = ListShape.builder()
.id("smithy.test#PrimitiveList")
.member(primitiveMember)
.build();

Model model = Model.assembler()
.addShape(primitiveList)
.addShape(boxedList)
.assemble()
.unwrap();

return Arrays.asList(new Object[][]{
{model, "smithy.api#String", true},
{model, "smithy.api#Blob", true},
{model, "smithy.api#Boolean", true},
{model, "smithy.api#Timestamp", true},
{model, "smithy.api#Byte", true},
{model, "smithy.api#Short", true},
{model, "smithy.api#Integer", true},
{model, "smithy.api#Long", true},
{model, "smithy.api#Float", true},
{model, "smithy.api#Double", true},
{model, "smithy.api#BigInteger", true},
{model, "smithy.api#BigDecimal", true},

{model, "smithy.api#PrimitiveByte", false},
{model, "smithy.api#PrimitiveShort", false},
{model, "smithy.api#PrimitiveInteger", false},
{model, "smithy.api#PrimitiveLong", false},
{model, "smithy.api#PrimitiveFloat", false},
{model, "smithy.api#PrimitiveDouble", false},
{model, "smithy.api#PrimitiveBoolean", false},

{model, primitiveList.getId().toString(), true},
{model, primitiveList.getMember().getId().toString(), false},
{model, boxedList.getId().toString(), true},
{model, boxedList.getMember().getId().toString(), true},
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -302,4 +302,31 @@ public void canUnsuccessfullyValidateTimestampsAsUnixTimestamps() {

assertThat(events, not(empty()));
}

@Test
public void doesNotAllowNullByDefault() {
NodeValidationVisitor cases = NodeValidationVisitor.builder()
.value(Node.nullNode())
.model(MODEL)
.build();
List<ValidationEvent> events = MODEL
.expectShape(ShapeId.from("smithy.api#String"))
.accept(cases);

assertThat(events, not(empty()));
}

@Test
public void canConfigureToSupportNull() {
NodeValidationVisitor cases = NodeValidationVisitor.builder()
.value(Node.nullNode())
.model(MODEL)
.allowBoxedNull(true)
.build();
List<ValidationEvent> events = MODEL
.expectShape(ShapeId.from("smithy.api#String"))
.accept(cases);

assertThat(events, empty());
}
}