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..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,4 +1,5 @@ alloy.proto.validation.GrpcTraitValidator +alloy.proto.validation.GrpcErrorTraitValidator 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..3092fd03 --- /dev/null +++ b/modules/core/resources/META-INF/smithy/proto/grpc-status.smithy @@ -0,0 +1,39 @@ +$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)" + structurallyExclusive: "member" +) +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..aeabebce --- /dev/null +++ b/modules/core/src/alloy/proto/GrpcErrorMessageTrait.java @@ -0,0 +1,46 @@ +/* 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.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/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/GrpcErrorMessageTraitSuite.scala b/modules/core/test/src/alloy/proto/validation/GrpcErrorMessageTraitSuite.scala new file mode 100644 index 00000000..9ec56913 --- /dev/null +++ b/modules/core/test/src/alloy/proto/validation/GrpcErrorMessageTraitSuite.scala @@ -0,0 +1,167 @@ +/* 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" + ) + } +} 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