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
8 changes: 8 additions & 0 deletions .changelog/2e107c2caba94fc7a7ea87a9e259466d.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"id": "2e107c2c-aba9-4fc7-a7ea-87a9e259466d",
"type": "bugfix",
"description": "Don't serialize unset JSON documents as `nil` in structure members.",
"modules": [
"."
]
}
8 changes: 8 additions & 0 deletions .changelog/95eb090821094264bc1c5a718b3dc64a.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"id": "95eb0908-2109-4264-bc1c-5a718b3dc64a",
"type": "bugfix",
"description": "Fix a deserialization panic around collection members in recursive shape configs.",
"modules": [
"."
]
}

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

import java.util.ArrayList;
import java.util.Set;
import software.amazon.smithy.aws.traits.protocols.AwsJson1_0Trait;
import software.amazon.smithy.aws.traits.protocols.AwsJson1_1Trait;
import software.amazon.smithy.aws.traits.protocols.RestJson1Trait;
import software.amazon.smithy.codegen.core.SymbolProvider;
import software.amazon.smithy.go.codegen.ChainWritable;
import software.amazon.smithy.go.codegen.GoDelegator;
Expand Down Expand Up @@ -32,32 +35,37 @@ public void writeAdditionalFiles(
goDelegator.useFileWriter("request_snapshot_test.go", settings.getModuleName(), writer -> {
writer.addBuildTag("request_snapshot");
writer.write(commonSource());
writer.write(bodyEqual(isCbor(settings)));
writer.write(bodyEqual(settings));

var service = settings.getService(model);
var generator = new SnapshotInputGenerator(model, symbolProvider);
var generator = new SnapshotInputGenerator(model, symbolProvider, settings, false);
writer.write(snapshotTests(model, service, symbolProvider, generator));
writer.write(snapshotUpdaters(model, service, symbolProvider, generator));
});
}

private static boolean isCbor(GoSettings settings) {
return Rpcv2CborTrait.ID.equals(settings.getProtocol());
// Emits serdeBodyEqual, the request-body comparator, per protocol. Every variant falls back to a byte compare
// when either side won't decode, so a malformed golden fails loudly rather than silently passing.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what's a malformed golden?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

golden = snapshot file

private Writable bodyEqual(GoSettings settings) {
var protocol = settings.getProtocol();
if (Rpcv2CborTrait.ID.equals(protocol)) {
return cborBodyEqual();
} else if (AwsJson1_0Trait.ID.equals(protocol) || AwsJson1_1Trait.ID.equals(protocol)
|| RestJson1Trait.ID.equals(protocol)) {
return jsonBodyEqual();
}
// restXml has the same member-ordering divergence in XML element form, and awsQuery/ec2Query bodies are
// form-urlencoded. Those are out of scope for now (JSON snapshots only), so they stay on a byte compare.
return goTemplate("""
func serdeBodyEqual(got, expected []byte) bool {
return bytes.Equal(got, expected)
}
""");
}

// Emits serdeBodyEqual, the request-body comparator. Most protocols serialize
// deterministically, so a raw byte compare is correct. rpcv2Cbor encodes struct
// fields as a CBOR map and the encoder emits map entries in Go map iteration
// order, so the same input produces different byte orderings across runs. For
// that protocol we compare decoded CBOR values, which is order-independent.
private Writable bodyEqual(boolean isCbor) {
if (!isCbor) {
return goTemplate("""
func serdeBodyEqual(got, expected []byte) bool {
return bytes.Equal(got, expected)
}
""");
}
// rpcv2Cbor encodes struct fields as a CBOR map and the encoder emits map entries in Go map iteration order, so
// the same input produces different byte orderings across runs. Comparing decoded values is order-independent.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't have to resolve it here, but this has also been a point of friction on JSON encoders. Since we are now taking more control of the encoders, would it be worth it to sort map entries?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by default, a million percent no, it's bad for performance,
as an option, such that we could turn it on for snapshots - 100% and i intend to do so. we have an open issue for this over in the sdk.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, as an option it sounds good.

by default, a million percent no, it's bad for performance

Can't argue with this, but we've had multiple issues with serialization not being deterministic being an issue even if it's not a guarantee for JSON, so it's definitely a tredoff.

Interestingly, JSON v2 switched this behavior and now map keys are not deterministic by default and you have to pass a deterministic flag https://pkg.go.dev/encoding/json/v2#Deterministic

private Writable cborBodyEqual() {
return writer -> {
writer.addUseImports(SmithyGoDependency.REFLECT);
writer.addUseImports(SmithyGoDependency.SMITHY_CBOR);
Expand All @@ -77,6 +85,43 @@ func serdeBodyEqual(got, expected []byte) bool {
};
}

// JSON object member order isn't semantically meaningful, and legacy serde orders members by ShapeId
// (case-insensitive, via TreeSet) while schema-serde orders them by member name (byte order). A byte compare
// therefore reports thousands of semantically identical bodies as mismatches for the whole legacy ->
// schema-serde transition.
private Writable jsonBodyEqual() {
return writer -> {
writer.addUseImports(SmithyGoDependency.REFLECT);
writer.addUseImports(SmithyGoDependency.JSON);
writer.write("""
func serdeBodyEqual(got, expected []byte) bool {
if len(got) == 0 || len(expected) == 0 {
return bytes.Equal(got, expected)
}
gv, gok := serdeDecodeJSON(got)
ev, eok := serdeDecodeJSON(expected)
if !gok || !eok {
return bytes.Equal(got, expected)
}
return reflect.DeepEqual(gv, ev)
}

// serdeDecodeJSON decodes a body for structural comparison. Numbers are kept as
// json.Number rather than float64 so a large int64 doesn't lose precision (which would
// mask a real difference) and so numeric formatting differences still show up.
func serdeDecodeJSON(b []byte) (any, bool) {
d := json.NewDecoder(bytes.NewReader(b))
d.UseNumber()
var v any
if err := d.Decode(&v); err != nil {
return nil, false
}
return v, true
}
""");
};
}

private Writable commonSource() {
return writer -> {
writer.addUseImports(SmithyGoDependency.OS);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,17 @@

package software.amazon.smithy.go.codegen.integration;

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import software.amazon.smithy.aws.traits.protocols.AwsJson1_0Trait;
import software.amazon.smithy.aws.traits.protocols.AwsJson1_1Trait;
import software.amazon.smithy.aws.traits.protocols.RestJson1Trait;
import software.amazon.smithy.codegen.core.SymbolProvider;
import software.amazon.smithy.go.codegen.GoSettings;
import software.amazon.smithy.go.codegen.GoStdlibTypes;
import software.amazon.smithy.go.codegen.GoWriter;
import software.amazon.smithy.go.codegen.ProtocolDocumentGenerator;
import software.amazon.smithy.go.codegen.SmithyGoDependency;
import software.amazon.smithy.go.codegen.Writable;
import software.amazon.smithy.go.codegen.knowledge.GoPointableIndex;
Expand All @@ -31,9 +36,15 @@
import software.amazon.smithy.model.shapes.OperationShape;
import software.amazon.smithy.model.shapes.Shape;
import software.amazon.smithy.model.shapes.ShapeId;
import software.amazon.smithy.model.shapes.ShapeType;
import software.amazon.smithy.model.shapes.StructureShape;
import software.amazon.smithy.model.shapes.UnionShape;
import software.amazon.smithy.model.traits.EnumTrait;
import software.amazon.smithy.model.traits.ErrorTrait;
import software.amazon.smithy.model.traits.HostLabelTrait;
import software.amazon.smithy.model.traits.HttpHeaderTrait;
import software.amazon.smithy.model.traits.HttpResponseCodeTrait;
import software.amazon.smithy.model.traits.JsonNameTrait;
import software.amazon.smithy.model.traits.StreamingTrait;

/**
Expand All @@ -52,11 +63,22 @@ public final class SnapshotInputGenerator {
private final Model model;
private final SymbolProvider symbolProvider;
private final GoPointableIndex pointableIndex;
private final GoSettings settings;
private final boolean responseMode;

public SnapshotInputGenerator(Model model, SymbolProvider symbolProvider) {
/**
* @param responseMode When true, values are generated for a shape deserialized from a RESPONSE:
* {@code @httpResponseCode} takes the fixture's status, and a member occupying the protocol's
* error-code slot takes the error's shape name.
*/
public SnapshotInputGenerator(
Model model, SymbolProvider symbolProvider, GoSettings settings, boolean responseMode
) {
this.model = model;
this.symbolProvider = symbolProvider;
this.pointableIndex = GoPointableIndex.of(model);
this.settings = settings;
this.responseMode = responseMode;
}

/**
Expand Down Expand Up @@ -86,6 +108,67 @@ public List<TestCase> generateCasesForShape(StructureShape shape) {

private static final int MAX_DEPTH = 10;

private static final String ERROR_TYPE_HEADER = "x-amzn-errortype";
private static final String ERROR_CODE_MEMBER = "code";

// legacy json resolves errortype header -> code -> __type
// schema-serde resolves errortype header -> __type -> code
//
// the latter is objectively more correct because some services e.g. chime actually have a separate modeled code
Comment thread
Madrigal marked this conversation as resolved.
// field which would _not_ have the error, meaning if errortype header was not set there we'd actually resolve the
// wrong thing
//
// to make the old deserializer figure out the "correct" code, we just set any modeled code member to the error
// shape name as well
private String errorCodeValue(MemberShape member) {
if (!responseMode || !isJsonProtocol()) {
return null;
}
var container = model.expectShape(member.getContainer());
if (!container.hasTrait(ErrorTrait.class)) {
return null;
}
if (member.hasTrait(HttpHeaderTrait.class)
&& member.expectTrait(HttpHeaderTrait.class).getValue().equalsIgnoreCase(ERROR_TYPE_HEADER)) {
return container.getId().getName();
}
if (wireName(member).equalsIgnoreCase(ERROR_CODE_MEMBER)) {
return container.getId().getName();
}
return null;
}

private String wireName(MemberShape member) {
return member.hasTrait(JsonNameTrait.class)
? member.expectTrait(JsonNameTrait.class).getValue()
: member.getMemberName();
}

private boolean isJsonProtocol() {
var protocol = settings.getProtocol();
return AwsJson1_0Trait.ID.equals(protocol)
|| AwsJson1_1Trait.ID.equals(protocol)
|| RestJson1Trait.ID.equals(protocol);
}

// Success fixtures are written with HTTP 200, so an @httpResponseCode member deserializes to exactly that.
Comment thread
Madrigal marked this conversation as resolved.
private boolean isSuccessResponseCode(MemberShape member) {
return responseMode && member.hasTrait(HttpResponseCodeTrait.class);
}

// A streaming UNION is an event stream, whose framing the plain serializer doesn't produce. Those operations are
// skipped wholesale by the snapshot integrations; this is belt-and-braces for a shape reached some other way.
private boolean isEventStream(Shape target) {
return target.hasTrait(StreamingTrait.class) && target.getType() != ShapeType.BLOB;
}

// A streaming blob gets a deterministic reader: the request serializer routes it into the body, the response
// deserializer hands the body back as the stream, and CompareValues short-circuits on io.Reader and compares
// contents. So it's an assertion about the body rather than a skipped member.
private boolean isStreamingBlob(Shape target) {
return target.getType() == ShapeType.BLOB && target.hasTrait(StreamingTrait.class);
}

private void writeStructure(GoWriter writer, StructureShape shape, Set<ShapeId> visited, UnionChoice choice,
boolean pointable) {
if (!visited.add(shape.getId()) || visited.size() > MAX_DEPTH) {
Expand All @@ -103,7 +186,7 @@ private void writeStructure(GoWriter writer, StructureShape shape, Set<ShapeId>
writer.indent();
for (var member : shape.getAllMembers().values()) {
var target = model.expectShape(member.getTarget());
if (target.hasTrait(StreamingTrait.class)) {
if (isEventStream(target)) {
continue;
}
var memberName = symbolProvider.toMemberName(member);
Expand All @@ -121,6 +204,18 @@ private void writeMemberValue(
GoWriter writer, MemberShape member, Shape target, Set<ShapeId> visited, UnionChoice choice
) {
boolean needsPointer = pointableIndex.isPointable(member);
if (isSuccessResponseCode(member)) {
writeScalar(writer, needsPointer, "200", SmithyGoDependency.SMITHY_PTR, "Int32");
return;
}
if (isStreamingBlob(target)) {
var memberName = symbolProvider.toMemberName(member);
writer.writeInline("$T($T([]byte($S)))",
SmithyGoDependency.IO.valueSymbol("NopCloser"),
GoStdlibTypes.Bytes.NewReader,
"__" + memberName + "__");
return;
}
switch (target.getType()) {
case BOOLEAN -> writeScalar(writer, needsPointer, "true", SmithyGoDependency.SMITHY_PTR, "Bool");
case BYTE -> writeScalar(writer, needsPointer, "1", SmithyGoDependency.SMITHY_PTR, "Int8");
Expand All @@ -144,7 +239,10 @@ private void writeMemberValue(
writer.writeInline("$T", memberSymbol);
} else {
var memberName = symbolProvider.toMemberName(member);
if (member.hasTrait(software.amazon.smithy.model.traits.HostLabelTrait.class)) {
var errorCode = errorCodeValue(member);
if (errorCode != null) {
writeStringValue(writer, needsPointer, errorCode);
} else if (member.hasTrait(HostLabelTrait.class)) {
writeStringValue(writer, needsPointer, memberName + "-value");
} else {
writeStringValue(writer, needsPointer, "__" + memberName + "__");
Expand All @@ -154,7 +252,11 @@ private void writeMemberValue(
case ENUM -> {
var enumSymbol = symbolProvider.toSymbol(target);
var members = target.getAllMembers().values();
if (members.isEmpty()) {
var errorCode = errorCodeValue(member);
if (errorCode != null) {
// The slot holds the code, which is a shape name rather than one of the enum's own values.
writer.writeInline("$T($S)", enumSymbol, errorCode);
} else if (members.isEmpty()) {
writer.writeInline("$T(\"\")", enumSymbol);
} else {
var firstMember = members.iterator().next();
Expand Down Expand Up @@ -192,7 +294,11 @@ private void writeMemberValue(
case STRUCTURE -> writeStructure(writer, target.asStructureShape().get(), visited, choice,
needsPointer);
case UNION -> writeUnion(writer, target.asUnionShape().get(), visited, choice);
case DOCUMENT -> writer.writeInline("nil");
case DOCUMENT -> {
var newLazyDocument = ProtocolDocumentGenerator.Utilities.getDocumentSymbolBuilder(
settings, ProtocolDocumentGenerator.NEW_LAZY_DOCUMENT).build();
writer.writeInline("$T($S)", newLazyDocument, "__Document__");
}
default -> writer.writeInline("nil");
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@

import java.util.List;
import software.amazon.smithy.codegen.core.SymbolProvider;
import software.amazon.smithy.go.codegen.GoSettings;
import software.amazon.smithy.model.Model;
import software.amazon.smithy.model.shapes.OperationShape;
import software.amazon.smithy.model.shapes.StructureShape;
Expand All @@ -30,9 +31,9 @@ public final class SnapshotOutputGenerator {
private final Model model;
private final SnapshotInputGenerator values;

public SnapshotOutputGenerator(Model model, SymbolProvider symbolProvider) {
public SnapshotOutputGenerator(Model model, SymbolProvider symbolProvider, GoSettings settings) {
this.model = model;
this.values = new SnapshotInputGenerator(model, symbolProvider);
this.values = new SnapshotInputGenerator(model, symbolProvider, settings, true);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,8 @@ private void generateSerializeMember(GoWriter writer, MemberShape member, Shape
writer.write("if $2L != nil { s.WriteStruct($1L)\n$2L.SerializeMembers(s)\ns.CloseStruct() }", schemaName, ident);
case DOCUMENT -> {
writer.addUseImports(SmithyGoDependency.SMITHY_DOCUMENT);
writer.write("s.WriteDocument($L, &smithydocument.Opaque{Value: $L})", schemaName, ident);
writer.write("if $2L != nil { s.WriteDocument($1L, &smithydocument.Opaque{Value: $2L}) }",
schemaName, ident);
}

// FUTURE(602)
Expand Down
Loading
Loading