Skip to content
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
Expand Up @@ -28,6 +28,11 @@ public class AwsJson1ProtocolTests {

// Like above, but in smithy-java we populate the defaults but don't change the nullability.
"AwsJson10ClientIgnoresNonTopLevelDefaultsOnMembersWithClientOptional",

// Skipped only for the [dynamic] path; codegen still runs. The document-backed builder does not
// populate modeled (nested) default values during error correction
// (SchemaGuidedDocumentBuilder.errorCorrection is a no-op).
"AwsJson10ClientPopulatesNestedDefaultValuesWhenMissing [dynamic]",
},
skipOperations = "aws.protocoltests.json10#OperationWithRequiredMembersWithDefaults")
public void requestTest(DataStream expected, DataStream actual) {
Expand All @@ -48,6 +53,13 @@ public void requestTest(DataStream expected, DataStream actual) {
skipTests = {
"AwsJson10ClientPopulatesDefaultsValuesWhenMissingInResponse",
"AwsJson10ClientIgnoresDefaultValuesIfMemberValuesArePresentInResponse",

// Skipped only for the [dynamic] (document-backed) path; codegen still runs. These rely on the
// dynamic builder populating modeled defaults during error correction, which it does not yet do
// (SchemaGuidedDocumentBuilder.errorCorrection is a no-op).
"AwsJson10ClientPopulatesNestedDefaultsWhenMissingInResponseBody [dynamic]",
"AwsJson10ClientPopulatesNestedDefaultValuesWhenMissing [dynamic]",
"AwsJson10ClientErrorCorrectsWhenServerFailsToSerializeRequiredValues [dynamic]",
},
skipOperations = "aws.protocoltests.json10#OperationWithRequiredMembersWithDefaults")
public void responseTest(Runnable test) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ public void requestTest(DataStream expected, DataStream actual) {
@ProtocolTestFilter(
skipTests = {
"AwsQueryClientPopulatesDefaultsValuesWhenMissingInResponse",
// Skipped only for the [dynamic] path: the document path doesn't tolerate the awsQuery result
// wrapper element for an empty output (it sees ResponseMetadata instead). Codegen still runs.
"QueryNoInputAndNoOutputWithResponseMetadata [dynamic]",
})
public void responseTest(Runnable test) {
test.run();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,22 @@
"DuplexClientErrorOutput",
// Client doesn't validate missing @required initial response members
"MissingRequiredInitialResponseOutput",
"DuplexMissingRequiredInitialResponseOutput"
"DuplexMissingRequiredInitialResponseOutput",

// The following are skipped only for the [dynamic] (DynamicClient/document-backed) path; the codegen
// versions still run. Each is a pre-existing dynamic-client limitation, not a regression in the
// request/response serialization that dual-mode coverage was added to guard.
// Streaming blobs: the document path resolves the @streaming @httpPayload member to a null/non-stream
// value (the @streaming trait is on the target, not the member), and DataStreamDocument doesn't
// support asBlob() for response comparison.
"RestJsonStreamingTraitsWithBlob [dynamic]",
"RestJsonStreamingTraitsWithMediaTypeWithBlob [dynamic]",
"RestJsonStreamingTraitsWithNoBlobBody [dynamic]",
"RestJsonStreamingTraitsRequireLengthWithNoBlobBody [dynamic]",
// Nested default values are not populated by the document path when missing from the wire
// (SchemaGuidedDocumentBuilder.errorCorrection is a no-op).
"RestJsonClientPopulatesNestedDefaultsWhenMissingInResponseBody [dynamic]",
"RestJsonClientPopulatesNestedDefaultValuesWhenMissing [dynamic]"
})
public class RestJson1ProtocolTests {
private static final String EMPTY_BODY = "";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,9 @@ public class RpcV2CborProtocolTests {
"RpcV2CborClientUsesExplicitlyProvidedMemberValues",
"RpcV2CborClientIgnoresNonTopLevelDefaultsOnMembersWithClientOptional",
"RpcV2CborClientUsesExplicitlyProvidedMemberValuesOverDefaults",
// Skipped only for the [dynamic] path: an empty-input operation produces no CBOR body through the
// document path, so there is nothing for the comparator to read. Codegen still runs.
"no_input [dynamic]",
})
public void requestTest(DataStream expected, DataStream actual) {
CborComparator.assertEquals(expected.asByteBuffer(), actual.asByteBuffer());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -836,7 +836,16 @@ static boolean equals(Object left, Object right, int options) {
}
yield true;
}
default -> false; // unexpected type (DOCUMENT, MEMBER, OPERATION, SERVICE).
case DOCUMENT -> {
// An untyped document carries no schema-driven type of its own; compare the underlying values.
// This also lets a typed-but-document-targeted value (e.g. a struct member whose shape is a
// document) compare equal to an equivalent untyped document.
if (r.type() != ShapeType.DOCUMENT) {
yield false;
}
yield Objects.equals(l.asObject(), r.asObject());
}
default -> false; // unexpected type (MEMBER, OPERATION, SERVICE).
};
}
return false;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -335,6 +335,40 @@ public static List<Arguments> inEqualDocumentsTestProvider() {
Document.of(Map.of("a", Document.of("b")))));
}

@Test
public void documentTypedValuesCompareByContent() {
// A document whose own type() is DOCUMENT (an untyped wrapper) must compare by its underlying contents,
// not be treated as never-equal. This is what happens for a struct member modeled as a `document`.
var a = new DocumentTypedWrapper(Document.of(Map.of("foo", Document.of("bar"))));
var b = new DocumentTypedWrapper(Document.of(Map.of("foo", Document.of("bar"))));
var c = new DocumentTypedWrapper(Document.of(Map.of("foo", Document.of("baz"))));

assertThat(a.type(), is(ShapeType.DOCUMENT));
assertThat(Document.equals(a, b), is(true));
assertThat(Document.equals(a, c), is(false));
// A document-typed value is not equal to a non-document of the same content.
assertThat(Document.equals(a, Document.of(Map.of("foo", Document.of("bar")))), is(false));
}

// A document that reports its type as DOCUMENT but delegates content access, mimicking an untyped/document-target
// value such as a struct member modeled as `document`.
record DocumentTypedWrapper(Document delegate) implements Document {
@Override
public ShapeType type() {
return ShapeType.DOCUMENT;
}

@Override
public Object asObject() {
return delegate.asObject();
}

@Override
public void serializeContents(ShapeSerializer serializer) {
delegate.serializeContents(serializer);
}
}

static final class DifferentDocument implements Document {

private final Document delegate;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
import software.amazon.smithy.model.shapes.ShapeId;
import software.amazon.smithy.model.shapes.ShapeType;
import software.amazon.smithy.model.traits.Trait;
import software.amazon.smithy.model.traits.UnitTypeTrait;
import software.amazon.smithy.model.traits.synthetic.OriginalShapeIdTrait;

/**
* Converts {@link Shape}s to {@link Schema}s.
Expand Down Expand Up @@ -98,30 +100,31 @@ private Schema createSchema(Shape shape, Set<Shape> building) {
}

private Schema createNonRecursiveSchema(Shape shape) {
var id = schemaId(shape);
return switch (shape.getType()) {
case BLOB -> Schema.createBlob(shape.getId(), convertTraits(shape));
case BOOLEAN -> Schema.createBoolean(shape.getId(), convertTraits(shape));
case STRING -> Schema.createString(shape.getId(), convertTraits(shape));
case TIMESTAMP -> Schema.createTimestamp(shape.getId(), convertTraits(shape));
case BYTE -> Schema.createByte(shape.getId(), convertTraits(shape));
case SHORT -> Schema.createShort(shape.getId(), convertTraits(shape));
case INTEGER -> Schema.createInteger(shape.getId(), convertTraits(shape));
case LONG -> Schema.createLong(shape.getId(), convertTraits(shape));
case FLOAT -> Schema.createFloat(shape.getId(), convertTraits(shape));
case DOCUMENT -> Schema.createDocument(shape.getId(), convertTraits(shape));
case DOUBLE -> Schema.createDouble(shape.getId(), convertTraits(shape));
case BIG_DECIMAL -> Schema.createBigDecimal(shape.getId(), convertTraits(shape));
case BIG_INTEGER -> Schema.createBigInteger(shape.getId(), convertTraits(shape));
case BLOB -> Schema.createBlob(id, convertTraits(shape));
case BOOLEAN -> Schema.createBoolean(id, convertTraits(shape));
case STRING -> Schema.createString(id, convertTraits(shape));
case TIMESTAMP -> Schema.createTimestamp(id, convertTraits(shape));
case BYTE -> Schema.createByte(id, convertTraits(shape));
case SHORT -> Schema.createShort(id, convertTraits(shape));
case INTEGER -> Schema.createInteger(id, convertTraits(shape));
case LONG -> Schema.createLong(id, convertTraits(shape));
case FLOAT -> Schema.createFloat(id, convertTraits(shape));
case DOCUMENT -> Schema.createDocument(id, convertTraits(shape));
case DOUBLE -> Schema.createDouble(id, convertTraits(shape));
case BIG_DECIMAL -> Schema.createBigDecimal(id, convertTraits(shape));
case BIG_INTEGER -> Schema.createBigInteger(id, convertTraits(shape));
case ENUM -> Schema.createEnum(
shape.getId(),
id,
new HashSet<>(shape.asEnumShape().orElseThrow().getEnumValues().values()),
convertTraits(shape));
case INT_ENUM -> Schema.createIntEnum(
shape.getId(),
id,
new HashSet<>(shape.asIntEnumShape().orElseThrow().getEnumValues().values()),
convertTraits(shape));
case OPERATION -> Schema.createOperation(shape.getId(), convertTraits(shape));
case SERVICE -> Schema.createService(shape.getId(), convertTraits(shape));
case OPERATION -> Schema.createOperation(id, convertTraits(shape));
case SERVICE -> Schema.createService(id, convertTraits(shape));
default -> throw new UnsupportedOperationException("Unexpected shape: " + shape);
};
}
Expand All @@ -132,16 +135,33 @@ private static Trait[] convertTraits(Shape shape) {
return traits;
}

/**
* Resolve the shape ID to use for a schema, honoring the {@link OriginalShapeIdTrait} left behind when model
* transforms (e.g. {@code createDedicatedInputAndOutput}) rename a shape. Wire-facing behavior — such as the
* restXml root element name, which derives from the schema's shape ID — must use the original modeled name, not
* the transformed one. Mirrors the codegen schema generator. The {@code Unit} original ID is the exception:
* synthesized dedicated inputs keep their generated ID rather than collapsing onto {@code smithy.api#Unit}.
*/
private static ShapeId schemaId(Shape shape) {
var original = shape.getTrait(OriginalShapeIdTrait.class)
.map(OriginalShapeIdTrait::getOriginalId)
.orElse(null);
if (original == null || UnitTypeTrait.UNIT.equals(original)) {
return shape.getId();
}
return original;
}

private SchemaBuilder getOrCreateRecursiveSchemaBuilder(Shape shape, Set<Shape> building) {
SchemaBuilder builder;
builder = recursiveBuilders.get(shape);

if (builder == null) {
builder = switch (shape.getType()) {
case LIST, SET -> Schema.listBuilder(shape.getId(), convertTraits(shape));
case MAP -> Schema.mapBuilder(shape.getId(), convertTraits(shape));
case STRUCTURE -> Schema.structureBuilder(shape.getId(), convertTraits(shape));
case UNION -> Schema.unionBuilder(shape.getId(), convertTraits(shape));
case LIST, SET -> Schema.listBuilder(schemaId(shape), convertTraits(shape));
case MAP -> Schema.mapBuilder(schemaId(shape), convertTraits(shape));
case STRUCTURE -> Schema.structureBuilder(schemaId(shape), convertTraits(shape));
case UNION -> Schema.unionBuilder(schemaId(shape), convertTraits(shape));
default -> throw new UnsupportedOperationException("Expected aggregate shape: " + shape);
};
SchemaBuilder previous = recursiveBuilders.putIfAbsent(shape, builder);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,28 @@ private Document deserialize(ShapeDeserializer decoder, Schema schema) {
case BIG_DECIMAL -> new ContentDocument(Document.ofNumber(decoder.readBigDecimal(schema)), schema);
case BIG_INTEGER -> new ContentDocument(Document.ofNumber(decoder.readBigInteger(schema)), schema);
case LIST -> {
var sparse = schema.hasTrait(TraitKey.SPARSE_TRAIT);
var items = new SchemaList(schema.listMember());
decoder.readList(schema, items, (it, memberDeserializer) -> {
it.add(deserialize(memberDeserializer, it.schema));
// A null element (only valid in @sparse lists) is retained as null; otherwise read the value.
if (sparse && memberDeserializer.isNull()) {
it.add(memberDeserializer.readNull());
} else {
it.add(deserialize(memberDeserializer, it.schema));
}
});
yield new ContentDocument(Document.of(items), schema);
}
case MAP -> {
var sparse = schema.hasTrait(TraitKey.SPARSE_TRAIT);
var map = new SchemaMap(schema);
decoder.readStringMap(schema, map, (state, mapKey, memberDeserializer) -> {
state.put(mapKey, deserialize(memberDeserializer, state.schema.mapValueMember()));
// A null value (only valid in @sparse maps) is retained as null; otherwise read the value.
if (sparse && memberDeserializer.isNull()) {
state.put(mapKey, memberDeserializer.readNull());
} else {
state.put(mapKey, deserialize(memberDeserializer, state.schema.mapValueMember()));
}
});
yield new ContentDocument(Document.of(map), schema);
}
Expand Down
3 changes: 3 additions & 0 deletions protocol-test-harness/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ dependencies {
implementation(project(":codegen:codegen-plugin"))
implementation(libs.smithy.codegen)
implementation(project(":client:client-core"))
// Document-backed client models so protocol tests also exercise the DynamicClient path.
implementation(project(":client:dynamic-client"))
implementation(project(":dynamic-schemas"))
implementation(libs.smithy.protocol.test.traits)
implementation(project(":http:http-api"))
implementation(project(":server:server-api"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import java.util.Comparator;
import org.assertj.core.api.recursive.comparison.RecursiveComparisonConfiguration;
import software.amazon.smithy.java.core.serde.document.Document;
import software.amazon.smithy.java.core.serde.document.DocumentEqualsFlags;
import software.amazon.smithy.java.io.ByteBufferUtils;
import software.amazon.smithy.java.io.datastream.DataStream;
import software.amazon.smithy.java.retries.api.RetrySafety;
Expand All @@ -30,7 +31,11 @@ static RecursiveComparisonConfiguration getComparisonConfig() {
// Compare doubles and floats as longs so NaN's will be equatable
.withComparatorForType(nanPermittingDoubleComparator(), Double.class)
.withComparatorForType(nanPermittingFloatComparator(), Float.class)
.withComparatorForType((a, b) -> Document.equals(a, b) ? 0 : 1, Document.class)
// Dynamic-path shapes are compared as Documents. Use NUMBER_PROMOTION so float/double values compare
// with NaN/Infinity tolerance (mirroring the typed nanPermitting* comparators used for codegen shapes).
.withComparatorForType(
(a, b) -> Document.equals(a, b, DocumentEqualsFlags.NUMBER_PROMOTION) ? 0 : 1,
Document.class)
.withIgnoredFieldsOfTypes(RetrySafety.class)
.build();
}
Expand Down
Loading