From b84af6b78bb03da9f3194e178af95221b7e88959 Mon Sep 17 00:00:00 2001 From: Denis Rosca Date: Mon, 23 Feb 2026 12:41:46 +0200 Subject: [PATCH 1/4] Add grpc-status-details-bin carrier shapes and grpcStatus traits - Add alloy.proto#ProtobufAny and alloy.proto#GoogleRpcStatus Smithy shapes wire-compatible with google.protobuf.Any and google.rpc.Status (field numbers via @alloy.proto#protoIndex) without importing google protos. - Add alloy.proto#grpcStatus trait (integer-valued) for error structures, plus GrpcStatusCode reference enum; implement Java trait provider and a warning-only validator for non-standard codes. - Add alloy.proto#grpcErrorMessage trait to mark error fields that should be used when returning errors - Update protobuf docs to cover grpc status details usage, typeUrl guidance (including # -> .), and behavior for status codes outside 0..16; add tests and register services/validators. --- ...re.amazon.smithy.model.traits.TraitService | 2 + ...e.amazon.smithy.model.validation.Validator | 2 + .../core/resources/META-INF/smithy/manifest | 2 + .../META-INF/smithy/proto/grpc-status.smithy | 36 +++++ .../META-INF/smithy/proto/status.smithy | 26 ++++ .../alloy/proto/GrpcErrorMessageTrait.java | 46 ++++++ .../core/src/alloy/proto/GrpcErrorTrait.java | 135 +++++++++++++++++ .../GrpcErrorMessageTraitValidator.java | 55 +++++++ .../validation/GrpcErrorTraitValidator.java | 53 +++++++ .../resources/META-INF/smithy/traits.smithy | 9 ++ .../GrpcErrorTraitValidatorSuite.scala | 140 ++++++++++++++++++ modules/docs/serialisation/protobuf.md | 21 +++ 12 files changed, 527 insertions(+) create mode 100644 modules/core/resources/META-INF/smithy/proto/grpc-status.smithy create mode 100644 modules/core/resources/META-INF/smithy/proto/status.smithy create mode 100644 modules/core/src/alloy/proto/GrpcErrorMessageTrait.java create mode 100644 modules/core/src/alloy/proto/GrpcErrorTrait.java create mode 100644 modules/core/src/alloy/proto/validation/GrpcErrorMessageTraitValidator.java create mode 100644 modules/core/src/alloy/proto/validation/GrpcErrorTraitValidator.java create mode 100644 modules/core/test/src/alloy/proto/validation/GrpcErrorTraitValidatorSuite.scala diff --git a/modules/core/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService b/modules/core/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService index 298c71c7..2eeb1d3f 100644 --- a/modules/core/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService +++ b/modules/core/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService @@ -32,6 +32,8 @@ alloy.common.LanguageTagFormatTrait$Provider alloy.openapi.OpenApiExtensionsTrait$Provider alloy.openapi.SummaryTrait$Provider alloy.proto.GrpcTrait$Provider +alloy.proto.GrpcErrorTrait$Provider +alloy.proto.GrpcErrorMessageTrait$Provider alloy.proto.ProtoCompactLocalDateTrait$Provider alloy.proto.ProtoCompactLocalTimeTrait$Provider alloy.proto.ProtoCompactMonthDayTrait$Provider diff --git a/modules/core/resources/META-INF/services/software.amazon.smithy.model.validation.Validator b/modules/core/resources/META-INF/services/software.amazon.smithy.model.validation.Validator index 9cff7ad3..40f036a4 100644 --- a/modules/core/resources/META-INF/services/software.amazon.smithy.model.validation.Validator +++ b/modules/core/resources/META-INF/services/software.amazon.smithy.model.validation.Validator @@ -1,4 +1,6 @@ alloy.proto.validation.GrpcTraitValidator +alloy.proto.validation.GrpcErrorTraitValidator +alloy.proto.validation.GrpcErrorMessageTraitValidator alloy.proto.validation.ProtoIndexTraitValidator alloy.proto.validation.ProtoInlinedOneOfValidator alloy.proto.validation.ProtoReservedFieldsTraitValidator diff --git a/modules/core/resources/META-INF/smithy/manifest b/modules/core/resources/META-INF/smithy/manifest index 0d3c751e..86b55704 100644 --- a/modules/core/resources/META-INF/smithy/manifest +++ b/modules/core/resources/META-INF/smithy/manifest @@ -6,6 +6,8 @@ examples.smithy openapi/openapi.smithy presence.smithy proto/proto.smithy +proto/grpc-status.smithy +proto/status.smithy restjson.smithy string.smithy jsonunknown.smithy diff --git a/modules/core/resources/META-INF/smithy/proto/grpc-status.smithy b/modules/core/resources/META-INF/smithy/proto/grpc-status.smithy new file mode 100644 index 00000000..55a10867 --- /dev/null +++ b/modules/core/resources/META-INF/smithy/proto/grpc-status.smithy @@ -0,0 +1,36 @@ +$version: "2" + +namespace alloy.proto + +use alloy#openEnum + +@openEnum +intEnum GrpcStatusCode { + OK = 0 + CANCELLED = 1 + UNKNOWN = 2 + INVALID_ARGUMENT = 3 + DEADLINE_EXCEEDED = 4 + NOT_FOUND = 5 + ALREADY_EXISTS = 6 + PERMISSION_DENIED = 7 + RESOURCE_EXHAUSTED = 8 + FAILED_PRECONDITION = 9 + ABORTED = 10 + OUT_OF_RANGE = 11 + UNIMPLEMENTED = 12 + INTERNAL = 13 + UNAVAILABLE = 14 + DATA_LOSS = 15 + UNAUTHENTICATED = 16 +} + +@trait(selector: "structure[trait|smithy.api#error]") +structure grpcError { + @required + code: Integer + message: String +} + +@trait(selector: "structure[trait|alloy.proto#grpcError] > member :test(> string)") +structure grpcErrorMessage {} diff --git a/modules/core/resources/META-INF/smithy/proto/status.smithy b/modules/core/resources/META-INF/smithy/proto/status.smithy new file mode 100644 index 00000000..66c402ef --- /dev/null +++ b/modules/core/resources/META-INF/smithy/proto/status.smithy @@ -0,0 +1,26 @@ +$version: "2" + +namespace alloy.proto + +structure ProtobufAny { + @protoIndex(1) + typeUrl: String + + @protoIndex(2) + value: Blob +} + +list ProtobufAnyList { + member: ProtobufAny +} + +structure GoogleRpcStatus { + @protoIndex(1) + code: Integer + + @protoIndex(2) + message: String + + @protoIndex(3) + details: ProtobufAnyList +} diff --git a/modules/core/src/alloy/proto/GrpcErrorMessageTrait.java b/modules/core/src/alloy/proto/GrpcErrorMessageTrait.java new file mode 100644 index 00000000..6adb07be --- /dev/null +++ b/modules/core/src/alloy/proto/GrpcErrorMessageTrait.java @@ -0,0 +1,46 @@ +/* Copyright 2026 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 alloy.proto; + +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.AbstractTrait; +import software.amazon.smithy.model.traits.AnnotationTrait; + +public final class GrpcErrorMessageTrait extends AnnotationTrait { + + public static ShapeId ID = ShapeId.from("alloy.proto#grpcErrorMessage"); + + public GrpcErrorMessageTrait(ObjectNode node) { + super(ID, node); + } + + public GrpcErrorMessageTrait() { + super(ID, Node.objectNode()); + } + + public static final class Provider extends AbstractTrait.Provider { + public Provider() { + super(ID); + } + + @Override + public GrpcErrorMessageTrait createTrait(ShapeId target, Node node) { + return new GrpcErrorMessageTrait(node.expectObjectNode()); + } + } +} diff --git a/modules/core/src/alloy/proto/GrpcErrorTrait.java b/modules/core/src/alloy/proto/GrpcErrorTrait.java new file mode 100644 index 00000000..2a52a595 --- /dev/null +++ b/modules/core/src/alloy/proto/GrpcErrorTrait.java @@ -0,0 +1,135 @@ +/* Copyright 2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 alloy.proto; + +import software.amazon.smithy.model.FromSourceLocation; +import software.amazon.smithy.model.SourceException; +import software.amazon.smithy.model.SourceLocation; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.AbstractTrait; +import software.amazon.smithy.model.traits.Trait; + +import java.util.Optional; + +public final class GrpcErrorTrait extends AbstractTrait { + public static final ShapeId ID = ShapeId.from("alloy.proto#grpcError"); + + private final int code; + private final String message; + + public GrpcErrorTrait(int code, String message, FromSourceLocation sourceLocation) { + super(ID, sourceLocation); + this.code = code; + this.message = message; + } + + public GrpcErrorTrait(int code, String message) { + this(code, message, SourceLocation.NONE); + } + + public GrpcErrorTrait(int code) { + this(code, null, SourceLocation.NONE); + } + + public int getCode() { + return code; + } + + public Optional getMessage() { + return Optional.ofNullable(message); + } + + public static final class Provider extends AbstractTrait.Provider { + public Provider() { + super(ID); + } + + @Override + public Trait createTrait(ShapeId target, Node value) { + ObjectNode obj = value.expectObjectNode(); + + Node codeNode = obj.getMember("code").orElseThrow( + () -> new SourceException("grpcError requires a 'code' field", value.getSourceLocation()) + ); + + int code; + if (codeNode.isNumberNode()) { + code = codeNode.expectNumberNode().getValue().intValue(); + } else if (codeNode.isStringNode()) { + code = parseSymbol(codeNode.expectStringNode().getValue(), codeNode.getSourceLocation()); + } else { + throw new SourceException("grpcError 'code' must be a number or enum symbol", codeNode.getSourceLocation()); + } + + String message = obj.getStringMember("message").map(n -> n.getValue()).orElse(null); + + return new GrpcErrorTrait(code, message, value.getSourceLocation()); + } + } + + @Override + protected Node createNode() { + ObjectNode.Builder builder = Node.objectNodeBuilder() + .withMember("code", Node.from(code)); + if (message != null) { + builder.withMember("message", Node.from(message)); + } + return builder.build(); + } + + private static int parseSymbol(String value, SourceLocation sourceLocation) { + switch (value) { + case "OK": + return 0; + case "CANCELLED": + return 1; + case "UNKNOWN": + return 2; + case "INVALID_ARGUMENT": + return 3; + case "DEADLINE_EXCEEDED": + return 4; + case "NOT_FOUND": + return 5; + case "ALREADY_EXISTS": + return 6; + case "PERMISSION_DENIED": + return 7; + case "RESOURCE_EXHAUSTED": + return 8; + case "FAILED_PRECONDITION": + return 9; + case "ABORTED": + return 10; + case "OUT_OF_RANGE": + return 11; + case "UNIMPLEMENTED": + return 12; + case "INTERNAL": + return 13; + case "UNAVAILABLE": + return 14; + case "DATA_LOSS": + return 15; + case "UNAUTHENTICATED": + return 16; + default: + throw new SourceException("Unknown grpcError code: " + value, sourceLocation); + } + } +} diff --git a/modules/core/src/alloy/proto/validation/GrpcErrorMessageTraitValidator.java b/modules/core/src/alloy/proto/validation/GrpcErrorMessageTraitValidator.java new file mode 100644 index 00000000..88979a42 --- /dev/null +++ b/modules/core/src/alloy/proto/validation/GrpcErrorMessageTraitValidator.java @@ -0,0 +1,55 @@ +/* Copyright 2026 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 alloy.proto.validation; + +import alloy.proto.GrpcErrorMessageTrait; +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; +import software.amazon.smithy.model.Model; +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.validation.AbstractValidator; +import software.amazon.smithy.model.validation.Severity; +import software.amazon.smithy.model.validation.ValidationEvent; + +public final class GrpcErrorMessageTraitValidator extends AbstractValidator { + public static final String MULTIPLE_GRPC_ERROR_MESSAGE = "GrpcErrorMessageMultipleMembers"; + + @Override + public List validate(Model model) { + return model.getMemberShapesWithTrait(GrpcErrorMessageTrait.class).stream() + .collect(Collectors.groupingBy(MemberShape::getContainer)) + .entrySet().stream() + .flatMap(entry -> validateContainer(model, entry.getKey(), entry.getValue()).stream()) + .collect(Collectors.toList()); + } + + private List validateContainer(Model model, ShapeId containerId, List members) { + if (members.size() <= 1) { + return Collections.emptyList(); + } + + Shape container = model.getShape(containerId).orElse(null); + return Collections.singletonList(ValidationEvent.builder() + .id(MULTIPLE_GRPC_ERROR_MESSAGE) + .severity(Severity.ERROR) + .shape(container) + .message("Multiple members are annotated with @grpcErrorMessage; only one is allowed") + .build()); + } +} diff --git a/modules/core/src/alloy/proto/validation/GrpcErrorTraitValidator.java b/modules/core/src/alloy/proto/validation/GrpcErrorTraitValidator.java new file mode 100644 index 00000000..162e907c --- /dev/null +++ b/modules/core/src/alloy/proto/validation/GrpcErrorTraitValidator.java @@ -0,0 +1,53 @@ +/* Copyright 2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 alloy.proto.validation; + +import alloy.proto.GrpcErrorTrait; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.validation.AbstractValidator; +import software.amazon.smithy.model.validation.Severity; +import software.amazon.smithy.model.validation.ValidationEvent; + +import java.util.Collections; +import java.util.List; +import java.util.stream.Collectors; + +public final class GrpcErrorTraitValidator extends AbstractValidator { + public static final String GRPC_ERROR_NON_STANDARD = "GrpcErrorNonStandard"; + + @Override + public List validate(Model model) { + return model.getShapesWithTrait(GrpcErrorTrait.class).stream() + .map(shape -> validateTrait(shape, shape.expectTrait(GrpcErrorTrait.class))) + .flatMap(List::stream) + .collect(Collectors.toList()); + } + + private List validateTrait(Shape shape, GrpcErrorTrait trait) { + int code = trait.getCode(); + if (code < 0 || code > 16) { + return Collections.singletonList(ValidationEvent.builder() + .id(GRPC_ERROR_NON_STANDARD) + .severity(Severity.WARNING) + .shape(shape) + .message("grpcError code is outside the standard gRPC range (0..16); many runtimes coerce unknown codes to UNKNOWN") + .build()); + } + + return Collections.emptyList(); + } +} diff --git a/modules/core/test/resources/META-INF/smithy/traits.smithy b/modules/core/test/resources/META-INF/smithy/traits.smithy index 58d833f8..e8dbed45 100644 --- a/modules/core/test/resources/META-INF/smithy/traits.smithy +++ b/modules/core/test/resources/META-INF/smithy/traits.smithy @@ -39,6 +39,8 @@ use alloy.proto#protoCompactMonthDay use alloy.proto#protoOffsetDateTimeFormat use alloy.proto#protoWrapped use alloy.proto#protoReservedFields +use alloy.proto#grpcError +use alloy.proto#grpcErrorMessage use alloy#localTimeFormat use alloy#localDateTimeFormat use alloy#offsetTimeFormat @@ -149,6 +151,13 @@ structure ProtoStructTwo { enum: TestEnum } +@error("client") +@grpcError(code: "UNAUTHENTICATED") +structure GrpcStatusError { + @grpcErrorMessage + message: String +} + enum TestEnum { A } diff --git a/modules/core/test/src/alloy/proto/validation/GrpcErrorTraitValidatorSuite.scala b/modules/core/test/src/alloy/proto/validation/GrpcErrorTraitValidatorSuite.scala new file mode 100644 index 00000000..1f5b07fd --- /dev/null +++ b/modules/core/test/src/alloy/proto/validation/GrpcErrorTraitValidatorSuite.scala @@ -0,0 +1,140 @@ +/* Copyright 2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 alloy.proto.validation + +import alloy.proto.GrpcErrorTrait +import munit.FunSuite +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.shapes.ShapeId +import software.amazon.smithy.model.validation.Severity + +import scala.jdk.CollectionConverters._ + +class GrpcErrorTraitValidatorSuite extends FunSuite { + + test("grpcError parses numeric code") { + val source = + """|$version: "2" + | + |namespace test + | + |use alloy.proto#grpcError + | + |@error("client") + |@grpcError(code: 16) + |structure NumericError {} + |""".stripMargin + + val model = Model.assembler + .discoverModels() + .addUnparsedModel("/test.smithy", source) + .assemble() + .unwrap() + + val code = model + .expectShape(ShapeId.from("test#NumericError")) + .expectTrait(classOf[GrpcErrorTrait]) + .getCode + + assertEquals(code, 16) + } + + test("grpcError parses string symbol code") { + val source = + """|$version: "2" + | + |namespace test + | + |use alloy.proto#grpcError + | + |@error("client") + |@grpcError(code: "UNAUTHENTICATED") + |structure SymbolError {} + |""".stripMargin + + val model = Model.assembler + .discoverModels() + .addUnparsedModel("/test.smithy", source) + .assemble() + .unwrap() + + val code = model + .expectShape(ShapeId.from("test#SymbolError")) + .expectTrait(classOf[GrpcErrorTrait]) + .getCode + + assertEquals(code, 16) + } + + test("grpcError emits warning outside standard range") { + val source = + """|$version: "2" + | + |namespace test + | + |use alloy.proto#grpcError + | + |@error("client") + |@grpcError(code: 42) + |structure NonStandardError {} + |""".stripMargin + + val model = Model.assembler + .discoverModels() + .addUnparsedModel("/test.smithy", source) + .assemble() + .unwrap() + + val events = new GrpcErrorTraitValidator() + .validate(model) + .asScala + .toList + + assertEquals(events.length, 1) + assertEquals( + events.head.getId, + GrpcErrorTraitValidator.GRPC_ERROR_NON_STANDARD + ) + assertEquals(events.head.getSeverity, Severity.WARNING) + } + + test("grpcError parses optional message") { + val source = + """|$version: "2" + | + |namespace test + | + |use alloy.proto#grpcError + | + |@error("client") + |@grpcError(code: 16, message: "auth failed") + |structure MessageError {} + |""".stripMargin + + val model = Model.assembler + .discoverModels() + .addUnparsedModel("/test.smithy", source) + .assemble() + .unwrap() + + val trait_ = model + .expectShape(ShapeId.from("test#MessageError")) + .expectTrait(classOf[GrpcErrorTrait]) + + assertEquals(trait_.getCode, 16) + assertEquals(trait_.getMessage, java.util.Optional.of("auth failed")) + } +} diff --git a/modules/docs/serialisation/protobuf.md b/modules/docs/serialisation/protobuf.md index baaa8dc5..63053865 100644 --- a/modules/docs/serialisation/protobuf.md +++ b/modules/docs/serialisation/protobuf.md @@ -161,6 +161,27 @@ Documents should be serialised using a protobuf message equivalent to the [`goog Timestamps should be serialised using a protobuf message equivalent to the [`google.protobuf.Timestamp`](https://github.com/protocolbuffers/protobuf/blob/5ecfdd76ef25f069cd84fac0b0fb3b95e2d61a34/src/google/protobuf/timestamp.proto#L133) type, which is commonly used in the protobuf ecosystem to represent [Timestamp values](https://protobuf.dev/reference/protobuf/google.protobuf/#timestamp). +### gRPC error + +Error structures can override their gRPC status code via `@alloy.proto#grpcError`. The trait takes a required `code` (a `GrpcStatusCode` value, accepted as either a string symbol or an integer) and an optional `message` string. For example: + +```smithy +@error("client") +@alloy.proto#grpcError(code: "UNAUTHENTICATED") +structure AuthError {} + +@error("client") +@alloy.proto#grpcError(code: 16, message: "authentication required") +structure AuthErrorWithMessage {} +``` + +While gRPC Core defines status codes in the range 0..16, other integer codes can be observed on the wire; runtimes are permitted to either propagate them as-is or map them to `UNKNOWN`. A validation warning is emitted for codes outside that range. + +### gRPC status details + +For `grpc-status-details-bin`, use `alloy.proto#GoogleRpcStatus` with `alloy.proto#ProtobufAny` entries in `details`. These shapes are wire-compatible with `google.rpc.Status` and `google.protobuf.Any` without requiring google protos in models. + + Recommended `typeUrl` convention: `type.googleapis.com/`, where `` is the fully-qualified protobuf message name (package + message, dot-separated) of the payload encoded in `value`. If you derive this from a Smithy shape ID, care must be taken to replace `#` with `.` (for example `com.foo#MyDetail` -> `com.foo.MyDetail`). ### Aggregate Types From d10cb2b2d69130d2027b0f671d3a19eff2481506 Mon Sep 17 00:00:00 2001 From: Denis Rosca Date: Fri, 27 Feb 2026 16:48:51 +0200 Subject: [PATCH 2/4] Update headers to match expectation --- modules/core/src/alloy/proto/GrpcErrorMessageTrait.java | 2 +- .../alloy/proto/validation/GrpcErrorMessageTraitValidator.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/core/src/alloy/proto/GrpcErrorMessageTrait.java b/modules/core/src/alloy/proto/GrpcErrorMessageTrait.java index 6adb07be..aeabebce 100644 --- a/modules/core/src/alloy/proto/GrpcErrorMessageTrait.java +++ b/modules/core/src/alloy/proto/GrpcErrorMessageTrait.java @@ -1,4 +1,4 @@ -/* Copyright 2026 Disney Streaming +/* Copyright 2022 Disney Streaming * * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/modules/core/src/alloy/proto/validation/GrpcErrorMessageTraitValidator.java b/modules/core/src/alloy/proto/validation/GrpcErrorMessageTraitValidator.java index 88979a42..907d4465 100644 --- a/modules/core/src/alloy/proto/validation/GrpcErrorMessageTraitValidator.java +++ b/modules/core/src/alloy/proto/validation/GrpcErrorMessageTraitValidator.java @@ -1,4 +1,4 @@ -/* Copyright 2026 Disney Streaming +/* Copyright 2022 Disney Streaming * * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); * you may not use this file except in compliance with the License. From 9a3c48f7b8ab70a06dece5d0a7b07c4a6719a143 Mon Sep 17 00:00:00 2001 From: Denis Rosca Date: Tue, 3 Mar 2026 14:24:25 +0200 Subject: [PATCH 3/4] Move to structurally exclusive trait --- ...e.amazon.smithy.model.validation.Validator | 1 - .../META-INF/smithy/proto/grpc-status.smithy | 5 +- .../GrpcErrorMessageTraitValidator.java | 55 ------ .../GrpcErrorMessageTraitSuite.scala | 168 ++++++++++++++++++ 4 files changed, 172 insertions(+), 57 deletions(-) delete mode 100644 modules/core/src/alloy/proto/validation/GrpcErrorMessageTraitValidator.java create mode 100644 modules/core/test/src/alloy/proto/validation/GrpcErrorMessageTraitSuite.scala diff --git a/modules/core/resources/META-INF/services/software.amazon.smithy.model.validation.Validator b/modules/core/resources/META-INF/services/software.amazon.smithy.model.validation.Validator index 40f036a4..7eb4d1df 100644 --- a/modules/core/resources/META-INF/services/software.amazon.smithy.model.validation.Validator +++ b/modules/core/resources/META-INF/services/software.amazon.smithy.model.validation.Validator @@ -1,6 +1,5 @@ alloy.proto.validation.GrpcTraitValidator alloy.proto.validation.GrpcErrorTraitValidator -alloy.proto.validation.GrpcErrorMessageTraitValidator alloy.proto.validation.ProtoIndexTraitValidator alloy.proto.validation.ProtoInlinedOneOfValidator alloy.proto.validation.ProtoReservedFieldsTraitValidator diff --git a/modules/core/resources/META-INF/smithy/proto/grpc-status.smithy b/modules/core/resources/META-INF/smithy/proto/grpc-status.smithy index 55a10867..3092fd03 100644 --- a/modules/core/resources/META-INF/smithy/proto/grpc-status.smithy +++ b/modules/core/resources/META-INF/smithy/proto/grpc-status.smithy @@ -32,5 +32,8 @@ structure grpcError { message: String } -@trait(selector: "structure[trait|alloy.proto#grpcError] > member :test(> string)") +@trait( + selector: "structure[trait|alloy.proto#grpcError] > member :test(> string)" + structurallyExclusive: "member" +) structure grpcErrorMessage {} diff --git a/modules/core/src/alloy/proto/validation/GrpcErrorMessageTraitValidator.java b/modules/core/src/alloy/proto/validation/GrpcErrorMessageTraitValidator.java deleted file mode 100644 index 907d4465..00000000 --- a/modules/core/src/alloy/proto/validation/GrpcErrorMessageTraitValidator.java +++ /dev/null @@ -1,55 +0,0 @@ -/* Copyright 2022 Disney Streaming - * - * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://disneystreaming.github.io/TOST-1.0.txt - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License 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 alloy.proto.validation; - -import alloy.proto.GrpcErrorMessageTrait; -import java.util.Collections; -import java.util.List; -import java.util.stream.Collectors; -import software.amazon.smithy.model.Model; -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.validation.AbstractValidator; -import software.amazon.smithy.model.validation.Severity; -import software.amazon.smithy.model.validation.ValidationEvent; - -public final class GrpcErrorMessageTraitValidator extends AbstractValidator { - public static final String MULTIPLE_GRPC_ERROR_MESSAGE = "GrpcErrorMessageMultipleMembers"; - - @Override - public List validate(Model model) { - return model.getMemberShapesWithTrait(GrpcErrorMessageTrait.class).stream() - .collect(Collectors.groupingBy(MemberShape::getContainer)) - .entrySet().stream() - .flatMap(entry -> validateContainer(model, entry.getKey(), entry.getValue()).stream()) - .collect(Collectors.toList()); - } - - private List validateContainer(Model model, ShapeId containerId, List members) { - if (members.size() <= 1) { - return Collections.emptyList(); - } - - Shape container = model.getShape(containerId).orElse(null); - return Collections.singletonList(ValidationEvent.builder() - .id(MULTIPLE_GRPC_ERROR_MESSAGE) - .severity(Severity.ERROR) - .shape(container) - .message("Multiple members are annotated with @grpcErrorMessage; only one is allowed") - .build()); - } -} diff --git a/modules/core/test/src/alloy/proto/validation/GrpcErrorMessageTraitSuite.scala b/modules/core/test/src/alloy/proto/validation/GrpcErrorMessageTraitSuite.scala new file mode 100644 index 00000000..c41174a5 --- /dev/null +++ b/modules/core/test/src/alloy/proto/validation/GrpcErrorMessageTraitSuite.scala @@ -0,0 +1,168 @@ +/* Copyright 2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License 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 alloy.proto.validation + +import munit.FunSuite +import software.amazon.smithy.model.Model +import software.amazon.smithy.model.SourceLocation +import software.amazon.smithy.model.shapes.ShapeId +import software.amazon.smithy.model.validation.Severity +import software.amazon.smithy.model.validation.ValidationEvent + +import scala.jdk.CollectionConverters._ + +class GrpcErrorMessageTraitSuite extends FunSuite { + + test("single @grpcErrorMessage member is valid") { + val source = + """|$version: "2" + | + |namespace test + | + |use alloy.proto#grpcError + |use alloy.proto#grpcErrorMessage + | + |@error("client") + |@grpcError(code: 3) + |structure MyError { + | @grpcErrorMessage + | message: String + | code: Integer + |} + |""".stripMargin + + val result = Model.assembler + .discoverModels() + .addUnparsedModel("/test.smithy", source) + .assemble() + + val errorEvents = result.getValidationEvents.asScala + .filter(_.getSeverity == Severity.ERROR) + .toList + + assertEquals(errorEvents, Nil) + } + + test("multiple @grpcErrorMessage members on same structure is invalid") { + val source = + """|$version: "2" + | + |namespace test + | + |use alloy.proto#grpcError + |use alloy.proto#grpcErrorMessage + | + |@error("client") + |@grpcError(code: 3) + |structure MultipleError { + | @grpcErrorMessage + | message: String + | @grpcErrorMessage + | detail: String + |} + |""".stripMargin + + + def normalize(e: ValidationEvent): ValidationEvent = + e.toBuilder.sourceLocation(SourceLocation.NONE).build() + + val result = Model.assembler + .discoverModels() + .addUnparsedModel("/test.smithy", source) + .assemble() + + val errorEvents = result.getValidationEvents.asScala + .filter(_.getSeverity == Severity.ERROR) + .toList + + val expectedError = + ValidationEvent + .builder() + .id("ExclusiveStructureMemberTrait") + .shapeId(ShapeId.from("test#MultipleError")) + .message( + "The `alloy.proto#grpcErrorMessage` trait can be applied to only a single member " + + "of a shape, but it was found on the following members: `detail`, `message`" + ) + .severity(Severity.ERROR) + .build() + + assertEquals(errorEvents.map(normalize), List(expectedError)) + } + + test("@grpcErrorMessage member must target a string member") { + val source = + """|$version: "2" + | + |namespace test + | + |use alloy.proto#grpcError + |use alloy.proto#grpcErrorMessage + | + |@error("client") + |@grpcError(code: 3) + |structure WrongTargetError { + | @grpcErrorMessage + | code: Integer + |} + |""".stripMargin + + val result = Model.assembler + .discoverModels() + .addUnparsedModel("/test.smithy", source) + .assemble() + + val errorEvents = result.getValidationEvents.asScala + .filter(_.getSeverity == Severity.ERROR) + .toList + + assert( + errorEvents.nonEmpty, + "Expected a validation error when @grpcErrorMessage is applied to a non-string member" + ) + } + + test( + "@grpcErrorMessage cannot be applied to a structure without @grpcError" + ) { + val source = + """|$version: "2" + | + |namespace test + | + |use alloy.proto#grpcErrorMessage + | + |structure NotAnError { + | @grpcErrorMessage + | message: String + |} + |""".stripMargin + + val result = Model.assembler + .discoverModels() + .addUnparsedModel("/test.smithy", source) + .assemble() + + val errorEvents = result.getValidationEvents.asScala + .filter(_.getSeverity == Severity.ERROR) + .toList + + assert( + errorEvents.nonEmpty, + "Expected a validation error when @grpcErrorMessage is applied outside an @grpcError structure" + ) + } +} From 56e01d6b028de7d23e260eaf17eded91a38414bd Mon Sep 17 00:00:00 2001 From: Denis Rosca Date: Tue, 3 Mar 2026 16:15:21 +0200 Subject: [PATCH 4/4] Fix formatting --- .../src/alloy/proto/validation/GrpcErrorMessageTraitSuite.scala | 1 - 1 file changed, 1 deletion(-) diff --git a/modules/core/test/src/alloy/proto/validation/GrpcErrorMessageTraitSuite.scala b/modules/core/test/src/alloy/proto/validation/GrpcErrorMessageTraitSuite.scala index c41174a5..9ec56913 100644 --- a/modules/core/test/src/alloy/proto/validation/GrpcErrorMessageTraitSuite.scala +++ b/modules/core/test/src/alloy/proto/validation/GrpcErrorMessageTraitSuite.scala @@ -75,7 +75,6 @@ class GrpcErrorMessageTraitSuite extends FunSuite { |} |""".stripMargin - def normalize(e: ValidationEvent): ValidationEvent = e.toBuilder.sourceLocation(SourceLocation.NONE).build()