From 7ea3b4ba86cb7dc749591627549a6010f404d5e0 Mon Sep 17 00:00:00 2001 From: Kevin Stich Date: Mon, 16 Mar 2026 13:31:06 -0700 Subject: [PATCH] Add support for Smithy RPC v2 JSON Smithy RPC v2 JSON is a JSON-payload based protocol in the Smithy RPC v2 family. Support is added for both clients and servers. The JSON codec has been updated to allow for transmitting arbitrary precision numbers in JSON strings. A bug in the protocol test implementation was fixed to allow for testing arbitrary precision numbers have been fixed. --- .gitignore | 2 + client/client-rpcv2-cbor/build.gradle.kts | 3 +- .../java/client/rpcv2/RpcV2CborProtocol.java | 146 +--------- client/client-rpcv2-json/build.gradle.kts | 23 ++ .../rpcv2json/RpcV2JsonProtocolTests.java | 56 ++++ .../client/rpcv2json/RpcV2JsonProtocol.java | 54 ++++ ...thy.java.client.core.ClientProtocolFactory | 1 + client/client-rpcv2/build.gradle.kts | 13 + .../rpcv2/AbstractRpcV2ClientProtocol.java | 209 ++++++++++++++ .../amazon/smithy/java/json/JsonCodec.java | 14 + .../amazon/smithy/java/json/JsonSettings.java | 30 ++ .../json/jackson/JacksonJsonDeserializer.java | 12 + .../json/jackson/JacksonJsonSerializer.java | 12 +- .../java/json/JsonDeserializerTest.java | 32 +++ .../harness/ProtocolTestDocument.java | 4 +- server/server-rpcv2-cbor/build.gradle.kts | 5 +- .../java/server/rpcv2/RpcV2CborProtocol.java | 207 +------------- server/server-rpcv2-json/build.gradle.kts | 26 ++ .../rpcv2json/RpcV2JsonProtocolTests.java | 68 +++++ .../server/rpcv2json/RpcV2JsonProtocol.java | 40 +++ .../rpcv2json/RpcV2JsonProtocolProvider.java | 30 ++ ...hy.java.server.core.ServerProtocolProvider | 1 + server/server-rpcv2/build.gradle.kts | 13 + .../rpcv2/AbstractRpcV2ServerProtocol.java | 257 ++++++++++++++++++ settings.gradle.kts | 4 + 25 files changed, 915 insertions(+), 347 deletions(-) create mode 100644 client/client-rpcv2-json/build.gradle.kts create mode 100644 client/client-rpcv2-json/src/it/java/software/amazon/smithy/java/client/rpcv2json/RpcV2JsonProtocolTests.java create mode 100644 client/client-rpcv2-json/src/main/java/software/amazon/smithy/java/client/rpcv2json/RpcV2JsonProtocol.java create mode 100644 client/client-rpcv2-json/src/main/resources/META-INF/services/software.amazon.smithy.java.client.core.ClientProtocolFactory create mode 100644 client/client-rpcv2/build.gradle.kts create mode 100644 client/client-rpcv2/src/main/java/software/amazon/smithy/java/client/rpcv2/AbstractRpcV2ClientProtocol.java create mode 100644 server/server-rpcv2-json/build.gradle.kts create mode 100644 server/server-rpcv2-json/src/it/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocolTests.java create mode 100644 server/server-rpcv2-json/src/main/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocol.java create mode 100644 server/server-rpcv2-json/src/main/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocolProvider.java create mode 100644 server/server-rpcv2-json/src/main/resources/META-INF/services/software.amazon.smithy.java.server.core.ServerProtocolProvider create mode 100644 server/server-rpcv2/build.gradle.kts create mode 100644 server/server-rpcv2/src/main/java/software/amazon/smithy/java/server/rpcv2/AbstractRpcV2ServerProtocol.java diff --git a/.gitignore b/.gitignore index 428cf79cd..9c2545a43 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,5 @@ crash-* mise.toml .claude/settings.local.json + +**/bin diff --git a/client/client-rpcv2-cbor/build.gradle.kts b/client/client-rpcv2-cbor/build.gradle.kts index 115b5d777..1e6f20480 100644 --- a/client/client-rpcv2-cbor/build.gradle.kts +++ b/client/client-rpcv2-cbor/build.gradle.kts @@ -9,9 +9,8 @@ extra["displayName"] = "Smithy :: Java :: Client :: RPCv2 CBOR" extra["moduleName"] = "software.amazon.smithy.java.client.rpcv2cbor" dependencies { - api(project(":client:client-http")) + api(project(":client:client-rpcv2")) api(project(":codecs:cbor-codec")) - api(project(":aws:aws-event-streams")) api(libs.smithy.aws.traits) implementation(libs.smithy.protocol.traits) diff --git a/client/client-rpcv2-cbor/src/main/java/software/amazon/smithy/java/client/rpcv2/RpcV2CborProtocol.java b/client/client-rpcv2-cbor/src/main/java/software/amazon/smithy/java/client/rpcv2/RpcV2CborProtocol.java index 46a9366ce..0192ce8d4 100644 --- a/client/client-rpcv2-cbor/src/main/java/software/amazon/smithy/java/client/rpcv2/RpcV2CborProtocol.java +++ b/client/client-rpcv2-cbor/src/main/java/software/amazon/smithy/java/client/rpcv2/RpcV2CborProtocol.java @@ -5,170 +5,36 @@ package software.amazon.smithy.java.client.rpcv2; -import java.net.URI; -import java.util.List; -import java.util.Map; import java.util.Objects; -import software.amazon.smithy.java.aws.events.AwsEventDecoderFactory; -import software.amazon.smithy.java.aws.events.AwsEventEncoderFactory; -import software.amazon.smithy.java.aws.events.AwsEventFrame; -import software.amazon.smithy.java.aws.events.RpcEventStreamsUtil; import software.amazon.smithy.java.cbor.Rpcv2CborCodec; import software.amazon.smithy.java.client.core.ClientProtocol; import software.amazon.smithy.java.client.core.ClientProtocolFactory; import software.amazon.smithy.java.client.core.ProtocolSettings; -import software.amazon.smithy.java.client.http.ErrorTypeUtils; -import software.amazon.smithy.java.client.http.HttpClientProtocol; -import software.amazon.smithy.java.client.http.HttpErrorDeserializer; -import software.amazon.smithy.java.context.Context; -import software.amazon.smithy.java.core.schema.ApiOperation; -import software.amazon.smithy.java.core.schema.SerializableStruct; -import software.amazon.smithy.java.core.schema.TraitKey; import software.amazon.smithy.java.core.serde.Codec; -import software.amazon.smithy.java.core.serde.TypeRegistry; -import software.amazon.smithy.java.core.serde.document.Document; -import software.amazon.smithy.java.core.serde.document.DocumentDeserializer; -import software.amazon.smithy.java.core.serde.event.EventDecoderFactory; -import software.amazon.smithy.java.core.serde.event.EventEncoderFactory; -import software.amazon.smithy.java.core.serde.event.EventStreamingException; -import software.amazon.smithy.java.http.api.HttpHeaders; import software.amazon.smithy.java.http.api.HttpRequest; -import software.amazon.smithy.java.http.api.HttpResponse; import software.amazon.smithy.java.http.api.HttpVersion; -import software.amazon.smithy.java.io.ByteBufferOutputStream; -import software.amazon.smithy.java.io.datastream.DataStream; import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.protocol.traits.Rpcv2CborTrait; /** - * Implements smithy.protocols#rpcv2Cbor. + * Client protocol implementation for {@code smithy.protocols#rpcv2Cbor}. */ -public final class RpcV2CborProtocol extends HttpClientProtocol { - private static final Codec CBOR_CODEC = Rpcv2CborCodec.builder().build(); +public final class RpcV2CborProtocol extends AbstractRpcV2ClientProtocol { private static final String PAYLOAD_MEDIA_TYPE = "application/cbor"; - private static final List CONTENT_TYPE = List.of(PAYLOAD_MEDIA_TYPE); - private static final List SMITHY_PROTOCOL = List.of("rpc-v2-cbor"); - - private final ShapeId service; - private final HttpErrorDeserializer errorDeserializer; + private static final Codec CBOR_CODEC = Rpcv2CborCodec.builder().build(); public RpcV2CborProtocol(ShapeId service) { - super(Rpcv2CborTrait.ID); - this.service = service; - this.errorDeserializer = HttpErrorDeserializer.builder() - .codec(CBOR_CODEC) - .serviceId(service) - .errorPayloadParser(RpcV2CborProtocol::extractErrorType) - .build(); + super(Rpcv2CborTrait.ID, service, PAYLOAD_MEDIA_TYPE); } @Override - public Codec payloadCodec() { + protected Codec codec() { return CBOR_CODEC; } @Override - public HttpRequest createRequest( - ApiOperation operation, - I input, - Context context, - URI endpoint - ) { - var target = "/service/" + service.getName() + "/operation/" + operation.schema().id().getName(); - var builder = HttpRequest.builder().method("POST").uri(endpoint.resolve(target)); - + protected void customizeRequestBuilder(HttpRequest.Builder builder) { builder.httpVersion(HttpVersion.HTTP_2); - if (operation.inputSchema().hasTrait(TraitKey.UNIT_TYPE_TRAIT)) { - // Top-level Unit types do not get serialized - builder.headers(HttpHeaders.of(headersForEmptyBody())) - .body(DataStream.ofEmpty()); - } else if (operation.inputEventBuilderSupplier() != null) { - // Event streaming - var encoderFactory = getEventEncoderFactory(operation); - var body = RpcEventStreamsUtil.bodyForEventStreaming(encoderFactory, input); - builder.headers(HttpHeaders.of(headersForEventStreaming())) - .body(body); - } else { - // Regular request - builder.headers(HttpHeaders.of(headers())) - .body(getBody(input)); - } - return builder.build(); - } - - @Override - public O deserializeResponse( - ApiOperation operation, - Context context, - TypeRegistry typeRegistry, - HttpRequest request, - HttpResponse response - ) { - if (response.statusCode() != 200) { - throw errorDeserializer.createError(context, operation, typeRegistry, response); - } - - if (operation.outputEventBuilderSupplier() != null) { - var eventDecoderFactory = getEventDecoderFactory(operation); - return RpcEventStreamsUtil.deserializeResponse(eventDecoderFactory, bodyDataStream(response)); - } - - var builder = operation.outputBuilder(); - var content = response.body(); - if (content.contentLength() == 0) { - return builder.build(); - } - - var bytes = content.asByteBuffer(); - return CBOR_CODEC.deserializeShape(bytes, builder); - } - - private static DataStream bodyDataStream(HttpResponse response) { - var contentType = response.headers().contentType(); - var contentLength = response.headers().contentLength(); - return DataStream.withMetadata(response.body(), contentType, contentLength, null); - } - - private DataStream getBody(SerializableStruct input) { - var sink = new ByteBufferOutputStream(); - try (var serializer = CBOR_CODEC.createSerializer(sink)) { - input.serialize(serializer); - } - return DataStream.ofByteBuffer(sink.toByteBuffer(), PAYLOAD_MEDIA_TYPE); - } - - private Map> headers() { - return Map.of("smithy-protocol", SMITHY_PROTOCOL, "Content-Type", CONTENT_TYPE, "Accept", CONTENT_TYPE); - } - - private Map> headersForEmptyBody() { - return Map.of("smithy-protocol", SMITHY_PROTOCOL, "Accept", CONTENT_TYPE); - } - - private Map> headersForEventStreaming() { - return Map.of("smithy-protocol", - SMITHY_PROTOCOL, - "Content-Type", - List.of("application/vnd.amazon.eventstream"), - "Accept", - CONTENT_TYPE); - } - - private EventEncoderFactory getEventEncoderFactory(ApiOperation operation) { - return AwsEventEncoderFactory.forInputStream(operation, - payloadCodec(), - PAYLOAD_MEDIA_TYPE, - (e) -> new EventStreamingException("InternalServerException", "Internal Server Error")); - } - - private EventDecoderFactory getEventDecoderFactory(ApiOperation operation) { - return AwsEventDecoderFactory.forOutputStream(operation, payloadCodec(), f -> f); - } - - private static ShapeId extractErrorType(Document document, String namespace) { - return DocumentDeserializer.parseDiscriminator( - ErrorTypeUtils.removeUri(ErrorTypeUtils.readType(document)), - namespace); } public static final class Factory implements ClientProtocolFactory { diff --git a/client/client-rpcv2-json/build.gradle.kts b/client/client-rpcv2-json/build.gradle.kts new file mode 100644 index 000000000..cdaa4e1c7 --- /dev/null +++ b/client/client-rpcv2-json/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + id("smithy-java.module-conventions") + id("smithy-java.protocol-testing-conventions") +} + +description = "This module provides the implementation of the client RpcV2 JSON protocol" + +extra["displayName"] = "Smithy :: Java :: Client :: RPCv2 JSON" +extra["moduleName"] = "software.amazon.smithy.java.client.rpcv2json" + +dependencies { + api(project(":client:client-rpcv2")) + api(project(":codecs:json-codec", configuration = "shadow")) + api(libs.smithy.aws.traits) + + implementation(libs.smithy.protocol.traits) + + // Protocol test dependencies + testImplementation(libs.smithy.protocol.tests) +} + +val generator = "software.amazon.smithy.java.protocoltests.generators.ProtocolTestGenerator" +addGenerateSrcsTask(generator, "rpcv2Json", "smithy.protocoltests.rpcv2Json#RpcV2JsonProtocol") diff --git a/client/client-rpcv2-json/src/it/java/software/amazon/smithy/java/client/rpcv2json/RpcV2JsonProtocolTests.java b/client/client-rpcv2-json/src/it/java/software/amazon/smithy/java/client/rpcv2json/RpcV2JsonProtocolTests.java new file mode 100644 index 000000000..36a6e9bdc --- /dev/null +++ b/client/client-rpcv2-json/src/it/java/software/amazon/smithy/java/client/rpcv2json/RpcV2JsonProtocolTests.java @@ -0,0 +1,56 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rpcv2json; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import software.amazon.smithy.java.io.ByteBufferUtils; +import software.amazon.smithy.java.io.datastream.DataStream; +import software.amazon.smithy.java.protocoltests.harness.HttpClientRequestTests; +import software.amazon.smithy.java.protocoltests.harness.HttpClientResponseTests; +import software.amazon.smithy.java.protocoltests.harness.ProtocolTest; +import software.amazon.smithy.java.protocoltests.harness.ProtocolTestFilter; +import software.amazon.smithy.java.protocoltests.harness.StringBuildingSubscriber; +import software.amazon.smithy.java.protocoltests.harness.TestType; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; + +@ProtocolTest( + service = "smithy.protocoltests.rpcv2Json#RpcV2JsonProtocol", + testType = TestType.CLIENT) +public class RpcV2JsonProtocolTests { + @HttpClientRequestTests + @ProtocolTestFilter( + skipTests = { + // clientOptional is not respected for client-generated shapes yet + "RpcV2JsonRequestClientSkipsTopLevelDefaultValuesInInput", + "RpcV2JsonRequestClientPopulatesDefaultValuesInInput", + "RpcV2JsonRequestClientUsesExplicitlyProvidedMemberValuesOverDefaults", + "RpcV2JsonRequestClientIgnoresNonTopLevelDefaultsOnMembersWithClientOptional", + }) + public void requestTest(DataStream expected, DataStream actual) { + Node expectedNode = ObjectNode.objectNode(); + if (expected.contentLength() != 0) { + expectedNode = Node.parse(new String(ByteBufferUtils.getBytes(expected.asByteBuffer()), + StandardCharsets.UTF_8)); + } + Node actualNode = ObjectNode.objectNode(); + if (actual.contentLength() != 0) { + actualNode = Node.parse(new StringBuildingSubscriber(actual).getResult()); + } + assertEquals(expectedNode, actualNode); + } + + @HttpClientResponseTests + @ProtocolTestFilter( + skipTests = { + "RpcV2JsonResponseClientPopulatesDefaultsValuesWhenMissingInResponse", + }) + public void responseTest(Runnable test) { + test.run(); + } +} diff --git a/client/client-rpcv2-json/src/main/java/software/amazon/smithy/java/client/rpcv2json/RpcV2JsonProtocol.java b/client/client-rpcv2-json/src/main/java/software/amazon/smithy/java/client/rpcv2json/RpcV2JsonProtocol.java new file mode 100644 index 000000000..00bc315c9 --- /dev/null +++ b/client/client-rpcv2-json/src/main/java/software/amazon/smithy/java/client/rpcv2json/RpcV2JsonProtocol.java @@ -0,0 +1,54 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rpcv2json; + +import java.util.Objects; +import software.amazon.smithy.java.client.core.ClientProtocol; +import software.amazon.smithy.java.client.core.ClientProtocolFactory; +import software.amazon.smithy.java.client.core.ProtocolSettings; +import software.amazon.smithy.java.client.rpcv2.AbstractRpcV2ClientProtocol; +import software.amazon.smithy.java.core.serde.Codec; +import software.amazon.smithy.java.json.JsonCodec; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.protocol.traits.Rpcv2JsonTrait; + +/** + * Client protocol implementation for {@code smithy.protocols#rpcv2Json}. + * + *

BigDecimal and BigInteger values are serialized as JSON strings to preserve + * arbitrary precision. + */ +public final class RpcV2JsonProtocol extends AbstractRpcV2ClientProtocol { + private static final String PAYLOAD_MEDIA_TYPE = "application/json"; + + private final JsonCodec codec; + + public RpcV2JsonProtocol(ShapeId service) { + super(Rpcv2JsonTrait.ID, service, PAYLOAD_MEDIA_TYPE); + this.codec = JsonCodec.builder() + .defaultNamespace(service.getNamespace()) + .useStringForArbitraryPrecision(true) + .build(); + } + + @Override + protected Codec codec() { + return codec; + } + + public static final class Factory implements ClientProtocolFactory { + @Override + public ShapeId id() { + return Rpcv2JsonTrait.ID; + } + + @Override + public ClientProtocol createProtocol(ProtocolSettings settings, Rpcv2JsonTrait trait) { + return new RpcV2JsonProtocol( + Objects.requireNonNull(settings.service(), "service is a required protocol setting")); + } + } +} diff --git a/client/client-rpcv2-json/src/main/resources/META-INF/services/software.amazon.smithy.java.client.core.ClientProtocolFactory b/client/client-rpcv2-json/src/main/resources/META-INF/services/software.amazon.smithy.java.client.core.ClientProtocolFactory new file mode 100644 index 000000000..b92ca47ea --- /dev/null +++ b/client/client-rpcv2-json/src/main/resources/META-INF/services/software.amazon.smithy.java.client.core.ClientProtocolFactory @@ -0,0 +1 @@ +software.amazon.smithy.java.client.rpcv2json.RpcV2JsonProtocol$Factory diff --git a/client/client-rpcv2/build.gradle.kts b/client/client-rpcv2/build.gradle.kts new file mode 100644 index 000000000..fcf0b85bb --- /dev/null +++ b/client/client-rpcv2/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("smithy-java.module-conventions") +} + +description = "This module provides the shared base implementation for client RpcV2 protocols" + +extra["displayName"] = "Smithy :: Java :: Client :: RPCv2" +extra["moduleName"] = "software.amazon.smithy.java.client.rpcv2" + +dependencies { + api(project(":client:client-http")) + api(project(":aws:aws-event-streams")) +} diff --git a/client/client-rpcv2/src/main/java/software/amazon/smithy/java/client/rpcv2/AbstractRpcV2ClientProtocol.java b/client/client-rpcv2/src/main/java/software/amazon/smithy/java/client/rpcv2/AbstractRpcV2ClientProtocol.java new file mode 100644 index 000000000..f815e35d5 --- /dev/null +++ b/client/client-rpcv2/src/main/java/software/amazon/smithy/java/client/rpcv2/AbstractRpcV2ClientProtocol.java @@ -0,0 +1,209 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.client.rpcv2; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import software.amazon.smithy.java.aws.events.AwsEventDecoderFactory; +import software.amazon.smithy.java.aws.events.AwsEventEncoderFactory; +import software.amazon.smithy.java.aws.events.AwsEventFrame; +import software.amazon.smithy.java.aws.events.RpcEventStreamsUtil; +import software.amazon.smithy.java.client.http.ErrorTypeUtils; +import software.amazon.smithy.java.client.http.HttpClientProtocol; +import software.amazon.smithy.java.client.http.HttpErrorDeserializer; +import software.amazon.smithy.java.context.Context; +import software.amazon.smithy.java.core.schema.ApiOperation; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.schema.TraitKey; +import software.amazon.smithy.java.core.serde.Codec; +import software.amazon.smithy.java.core.serde.TypeRegistry; +import software.amazon.smithy.java.core.serde.document.Document; +import software.amazon.smithy.java.core.serde.document.DocumentDeserializer; +import software.amazon.smithy.java.core.serde.event.EventDecoderFactory; +import software.amazon.smithy.java.core.serde.event.EventEncoderFactory; +import software.amazon.smithy.java.core.serde.event.EventStreamingException; +import software.amazon.smithy.java.http.api.HttpHeaders; +import software.amazon.smithy.java.http.api.HttpRequest; +import software.amazon.smithy.java.http.api.HttpResponse; +import software.amazon.smithy.java.io.ByteBufferOutputStream; +import software.amazon.smithy.java.io.datastream.DataStream; +import software.amazon.smithy.model.shapes.ShapeId; + +/** + * Shared base for RPC v2 client protocol implementations. + * + *

Subclasses provide the codec, media type, and smithy-protocol header value + * that distinguish the concrete wire format (CBOR vs JSON). All request construction, + * response deserialization, error extraction, and event-streaming plumbing is handled here. + */ +public abstract class AbstractRpcV2ClientProtocol extends HttpClientProtocol { + + private final ShapeId service; + private final String payloadMediaType; + private final List contentType; + private final List smithyProtocolHeader; + private volatile HttpErrorDeserializer errorDeserializer; + + private static final String SMITHY_PROTOCOL_PREFIX = "rpc-v2-"; + // length of "application/" + private static final int MEDIA_TYPE_PREFIX_LENGTH = 12; + + /** + * @param protocolId the Smithy protocol trait shape ID + * @param service the service shape ID + * @param payloadMediaType the media type for request/response payloads (e.g. "application/cbor") + */ + protected AbstractRpcV2ClientProtocol( + ShapeId protocolId, + ShapeId service, + String payloadMediaType + ) { + super(protocolId); + this.service = service; + this.payloadMediaType = payloadMediaType; + this.contentType = List.of(payloadMediaType); + this.smithyProtocolHeader = List.of( + SMITHY_PROTOCOL_PREFIX + payloadMediaType.substring(MEDIA_TYPE_PREFIX_LENGTH)); + } + + /** Returns the codec used for serialization and deserialization. */ + protected abstract Codec codec(); + + protected ShapeId service() { + return service; + } + + @Override + public Codec payloadCodec() { + return codec(); + } + + private HttpErrorDeserializer errorDeserializer() { + if (errorDeserializer == null) { + errorDeserializer = HttpErrorDeserializer.builder() + .codec(codec()) + .serviceId(service) + .errorPayloadParser(AbstractRpcV2ClientProtocol::extractErrorType) + .build(); + } + return errorDeserializer; + } + + /** + * Hook for subclasses to customize the request builder before headers and body are set. + * For example, the CBOR protocol uses this to force HTTP/2. + */ + protected void customizeRequestBuilder(HttpRequest.Builder builder) { + // default: no customization + } + + @Override + public HttpRequest createRequest( + ApiOperation operation, + I input, + Context context, + URI endpoint + ) { + var target = "/service/" + service.getName() + "/operation/" + operation.schema().id().getName(); + var builder = HttpRequest.builder().method("POST").uri(endpoint.resolve(target)); + + customizeRequestBuilder(builder); + + if (operation.inputSchema().hasTrait(TraitKey.UNIT_TYPE_TRAIT)) { + // Top-level Unit types do not get serialized + builder.headers(HttpHeaders.of(headersForEmptyBody())) + .body(DataStream.ofEmpty()); + } else if (operation.inputEventBuilderSupplier() != null) { + // Event streaming + var encoderFactory = getEventEncoderFactory(operation); + var body = RpcEventStreamsUtil.bodyForEventStreaming(encoderFactory, input); + builder.headers(HttpHeaders.of(headersForEventStreaming())) + .body(body); + } else { + // Regular request + builder.headers(HttpHeaders.of(headers())) + .body(getBody(input)); + } + return builder.build(); + } + + @Override + public O deserializeResponse( + ApiOperation operation, + Context context, + TypeRegistry typeRegistry, + HttpRequest request, + HttpResponse response + ) { + if (response.statusCode() != 200) { + throw errorDeserializer().createError(context, operation, typeRegistry, response); + } + + if (operation.outputEventBuilderSupplier() != null) { + var eventDecoderFactory = getEventDecoderFactory(operation); + return RpcEventStreamsUtil.deserializeResponse(eventDecoderFactory, bodyDataStream(response)); + } + + var builder = operation.outputBuilder(); + var content = response.body(); + if (content.contentLength() == 0) { + return builder.build(); + } + + var bytes = content.asByteBuffer(); + return codec().deserializeShape(bytes, builder); + } + + private static DataStream bodyDataStream(HttpResponse response) { + var contentType = response.headers().contentType(); + var contentLength = response.headers().contentLength(); + return DataStream.withMetadata(response.body(), contentType, contentLength, null); + } + + private DataStream getBody(SerializableStruct input) { + var sink = new ByteBufferOutputStream(); + try (var serializer = codec().createSerializer(sink)) { + input.serialize(serializer); + } + return DataStream.ofByteBuffer(sink.toByteBuffer(), payloadMediaType); + } + + private Map> headers() { + return Map.of("smithy-protocol", smithyProtocolHeader, "Content-Type", contentType, "Accept", contentType); + } + + private Map> headersForEmptyBody() { + return Map.of("smithy-protocol", smithyProtocolHeader, "Accept", contentType); + } + + private Map> headersForEventStreaming() { + return Map.of( + "smithy-protocol", + smithyProtocolHeader, + "Content-Type", + List.of("application/vnd.amazon.eventstream"), + "Accept", + contentType); + } + + private EventEncoderFactory getEventEncoderFactory(ApiOperation operation) { + return AwsEventEncoderFactory.forInputStream(operation, + codec(), + payloadMediaType, + (e) -> new EventStreamingException("InternalServerException", "Internal Server Error")); + } + + private EventDecoderFactory getEventDecoderFactory(ApiOperation operation) { + return AwsEventDecoderFactory.forOutputStream(operation, codec(), f -> f); + } + + private static ShapeId extractErrorType(Document document, String namespace) { + return DocumentDeserializer.parseDiscriminator( + ErrorTypeUtils.removeUri(ErrorTypeUtils.readType(document)), + namespace); + } +} diff --git a/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/JsonCodec.java b/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/JsonCodec.java index 3c61450bd..844493da6 100644 --- a/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/JsonCodec.java +++ b/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/JsonCodec.java @@ -164,6 +164,20 @@ public Builder prettyPrint(boolean prettyPrint) { return this; } + /** + * Whether to serialize BigDecimal and BigInteger values as JSON strings to preserve arbitrary precision. + * + *

When enabled, these values are written as quoted strings and deserialization accepts both string + * and number tokens. Disabled by default. + * + * @param useStringForArbitraryPrecision true to use string encoding for arbitrary precision numbers + * @return the builder + */ + public Builder useStringForArbitraryPrecision(boolean useStringForArbitraryPrecision) { + settingsBuilder.useStringForArbitraryPrecision(useStringForArbitraryPrecision); + return this; + } + /** * Uses a custom JSON serde provider. * diff --git a/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/JsonSettings.java b/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/JsonSettings.java index 437dbd1ea..88878bae3 100644 --- a/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/JsonSettings.java +++ b/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/JsonSettings.java @@ -45,6 +45,7 @@ public final class JsonSettings { private final JsonSerdeProvider provider; private final boolean serializeTypeInDocuments; private final boolean prettyPrint; + private final boolean useStringForArbitraryPrecision; private JsonSettings(Builder builder) { this.timestampResolver = builder.useTimestampFormat @@ -58,6 +59,7 @@ private JsonSettings(Builder builder) { this.provider = builder.provider; this.serializeTypeInDocuments = builder.serializeTypeInDocuments; this.prettyPrint = builder.prettyPrint; + this.useStringForArbitraryPrecision = builder.useStringForArbitraryPrecision; } /** @@ -124,6 +126,18 @@ public boolean prettyPrint() { return prettyPrint; } + /** + * Whether BigDecimal and BigInteger values are serialized as JSON strings to preserve arbitrary precision. + * + *

When enabled, serialization writes these values as quoted strings (e.g., {@code "1.5"} instead of + * {@code 1.5}), and deserialization accepts both string and number tokens. + * + * @return true if arbitrary precision numbers use string encoding + */ + public boolean useStringForArbitraryPrecision() { + return useStringForArbitraryPrecision; + } + JsonSerdeProvider provider() { return provider; } @@ -140,6 +154,7 @@ void updateBuilder(Builder builder) { } builder.serializeTypeInDocuments(serializeTypeInDocuments); builder.prettyPrint(prettyPrint); + builder.useStringForArbitraryPrecision(useStringForArbitraryPrecision); } /** @@ -165,6 +180,7 @@ public static final class Builder { private JsonSerdeProvider provider = PROVIDER; private boolean serializeTypeInDocuments = true; private boolean prettyPrint = false; + private boolean useStringForArbitraryPrecision = false; private Builder() {} @@ -265,6 +281,20 @@ public Builder prettyPrint(boolean prettyPrint) { return this; } + /** + * Whether to serialize BigDecimal and BigInteger values as JSON strings to preserve arbitrary precision. + * + *

When enabled, these values are written as quoted strings and deserialization accepts both string + * and number tokens. Disabled by default. + * + * @param useStringForArbitraryPrecision true to use string encoding for arbitrary precision numbers + * @return the builder + */ + public Builder useStringForArbitraryPrecision(boolean useStringForArbitraryPrecision) { + this.useStringForArbitraryPrecision = useStringForArbitraryPrecision; + return this; + } + /** * Uses a custom JSON serde provider. * diff --git a/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/jackson/JacksonJsonDeserializer.java b/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/jackson/JacksonJsonDeserializer.java index 252ff9fb0..689d911aa 100644 --- a/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/jackson/JacksonJsonDeserializer.java +++ b/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/jackson/JacksonJsonDeserializer.java @@ -154,6 +154,12 @@ public double readDouble(Schema schema) { @Override public BigInteger readBigInteger(Schema schema) { try { + if (settings.useStringForArbitraryPrecision()) { + if (parser.currentToken() != JsonToken.VALUE_STRING) { + throw new SerializationException("Expected string, found: " + describeToken()); + } + return new BigInteger(parser.getText()); + } return parser.getBigIntegerValue(); } catch (Exception e) { throw new SerializationException(e); @@ -163,6 +169,12 @@ public BigInteger readBigInteger(Schema schema) { @Override public BigDecimal readBigDecimal(Schema schema) { try { + if (settings.useStringForArbitraryPrecision()) { + if (parser.currentToken() != JsonToken.VALUE_STRING) { + throw new SerializationException("Expected string, found: " + describeToken()); + } + return new BigDecimal(parser.getText()); + } return parser.getDecimalValue(); } catch (Exception e) { throw new SerializationException(e); diff --git a/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/jackson/JacksonJsonSerializer.java b/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/jackson/JacksonJsonSerializer.java index 88380b96b..f98b25066 100644 --- a/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/jackson/JacksonJsonSerializer.java +++ b/codecs/json-codec/src/main/java/software/amazon/smithy/java/json/jackson/JacksonJsonSerializer.java @@ -170,7 +170,11 @@ public void writeDouble(Schema schema, double value) { @Override public void writeBigInteger(Schema schema, BigInteger value) { try { - generator.writeNumber(value); + if (settings.useStringForArbitraryPrecision()) { + generator.writeString(value.toString()); + } else { + generator.writeNumber(value); + } } catch (Exception e) { throw new SerializationException(e); } @@ -179,7 +183,11 @@ public void writeBigInteger(Schema schema, BigInteger value) { @Override public void writeBigDecimal(Schema schema, BigDecimal value) { try { - generator.writeNumber(value); + if (settings.useStringForArbitraryPrecision()) { + generator.writeString(value.toPlainString()); + } else { + generator.writeNumber(value); + } } catch (Exception e) { throw new SerializationException(e); } diff --git a/codecs/json-codec/src/test/java/software/amazon/smithy/java/json/JsonDeserializerTest.java b/codecs/json-codec/src/test/java/software/amazon/smithy/java/json/JsonDeserializerTest.java index 1f207c243..6d8b32b33 100644 --- a/codecs/json-codec/src/test/java/software/amazon/smithy/java/json/JsonDeserializerTest.java +++ b/codecs/json-codec/src/test/java/software/amazon/smithy/java/json/JsonDeserializerTest.java @@ -154,6 +154,22 @@ public void deserializesBigIntegerOnlyFromRawNumbersByDefault() { } } + @Test + public void deserializesBigIntegerFromStringWhenConfigured() { + try (var codec = JsonCodec.builder().useStringForArbitraryPrecision(true).build()) { + var de = codec.createDeserializer("\"1\"".getBytes(StandardCharsets.UTF_8)); + assertThat(de.readBigInteger(PreludeSchemas.BIG_INTEGER), is(BigInteger.ONE)); + } + } + + @Test + public void deserializesBigIntegerOnlyFromStringWhenConfigured() { + try (var codec = JsonCodec.builder().useStringForArbitraryPrecision(true).build()) { + var de = codec.createDeserializer("1".getBytes(StandardCharsets.UTF_8)); + Assertions.assertThrows(SerializationException.class, () -> de.readBigInteger(PreludeSchemas.BIG_INTEGER)); + } + } + @Test public void deserializesBigDecimal() { try (var codec = JsonCodec.builder().build()) { @@ -170,6 +186,22 @@ public void deserializesBigDecimalOnlyFromRawNumbersByDefault() { } } + @Test + public void deserializesBigDecimalFromStringWhenConfigured() { + try (var codec = JsonCodec.builder().useStringForArbitraryPrecision(true).build()) { + var de = codec.createDeserializer("\"1\"".getBytes(StandardCharsets.UTF_8)); + assertThat(de.readBigDecimal(PreludeSchemas.BIG_DECIMAL), is(BigDecimal.ONE)); + } + } + + @Test + public void deserializesBigDecimalOnlyFromStringWhenConfigured() { + try (var codec = JsonCodec.builder().useStringForArbitraryPrecision(true).build()) { + var de = codec.createDeserializer("1".getBytes(StandardCharsets.UTF_8)); + Assertions.assertThrows(SerializationException.class, () -> de.readBigDecimal(PreludeSchemas.BIG_DECIMAL)); + } + } + @Test public void deserializesTimestamp() { try (var codec = JsonCodec.builder().build()) { diff --git a/protocol-test-harness/src/main/java/software/amazon/smithy/java/protocoltests/harness/ProtocolTestDocument.java b/protocol-test-harness/src/main/java/software/amazon/smithy/java/protocoltests/harness/ProtocolTestDocument.java index 0dbae0d24..14f4572d2 100644 --- a/protocol-test-harness/src/main/java/software/amazon/smithy/java/protocoltests/harness/ProtocolTestDocument.java +++ b/protocol-test-harness/src/main/java/software/amazon/smithy/java/protocoltests/harness/ProtocolTestDocument.java @@ -147,8 +147,8 @@ public BigInteger asBigInteger() { return null; } return node.asNumberNode() - .map(NumberNode::getValue) - .map(n -> BigInteger.valueOf(n.longValue())) + .flatMap(NumberNode::asBigDecimal) + .map(BigDecimal::toBigIntegerExact) .orElseGet(Document.super::asBigInteger); } diff --git a/server/server-rpcv2-cbor/build.gradle.kts b/server/server-rpcv2-cbor/build.gradle.kts index d0dbbddd5..c65bba2f8 100644 --- a/server/server-rpcv2-cbor/build.gradle.kts +++ b/server/server-rpcv2-cbor/build.gradle.kts @@ -9,11 +9,8 @@ extra["displayName"] = "Smithy :: Java :: Server :: RPCv2 CBOR" extra["moduleName"] = "software.amazon.smithy.java.server.rpcv2cbor" dependencies { - api(project(":server:server-api")) + api(project(":server:server-rpcv2")) api(libs.smithy.protocol.traits) - implementation(project(":server:server-core")) - implementation(project(":context")) - implementation(project(":core")) implementation(project(":codecs:cbor-codec")) itImplementation(project(":server:server-api")) diff --git a/server/server-rpcv2-cbor/src/main/java/software/amazon/smithy/java/server/rpcv2/RpcV2CborProtocol.java b/server/server-rpcv2-cbor/src/main/java/software/amazon/smithy/java/server/rpcv2/RpcV2CborProtocol.java index 6e4ee04ad..beede804e 100644 --- a/server/server-rpcv2-cbor/src/main/java/software/amazon/smithy/java/server/rpcv2/RpcV2CborProtocol.java +++ b/server/server-rpcv2-cbor/src/main/java/software/amazon/smithy/java/server/rpcv2/RpcV2CborProtocol.java @@ -6,29 +6,21 @@ package software.amazon.smithy.java.server.rpcv2; import java.util.List; -import java.util.concurrent.CompletableFuture; import software.amazon.smithy.java.cbor.Rpcv2CborCodec; -import software.amazon.smithy.java.core.error.ModeledException; -import software.amazon.smithy.java.core.schema.SerializableStruct; -import software.amazon.smithy.java.framework.model.MalformedRequestException; -import software.amazon.smithy.java.framework.model.UnknownOperationException; -import software.amazon.smithy.java.io.ByteBufferOutputStream; -import software.amazon.smithy.java.io.datastream.DataStream; +import software.amazon.smithy.java.core.serde.Codec; import software.amazon.smithy.java.server.Service; -import software.amazon.smithy.java.server.core.Job; -import software.amazon.smithy.java.server.core.ServerProtocol; -import software.amazon.smithy.java.server.core.ServiceProtocolResolutionRequest; -import software.amazon.smithy.java.server.core.ServiceProtocolResolutionResult; import software.amazon.smithy.model.shapes.ShapeId; import software.amazon.smithy.protocol.traits.Rpcv2CborTrait; -final class RpcV2CborProtocol extends ServerProtocol { - - private final Rpcv2CborCodec codec; +/** + * Server protocol implementation for {@code smithy.protocols#rpcv2Cbor}. + */ +final class RpcV2CborProtocol extends AbstractRpcV2ServerProtocol { + private static final String PAYLOAD_MEDIA_TYPE = "application/cbor"; + private static final Rpcv2CborCodec CODEC = Rpcv2CborCodec.builder().build(); RpcV2CborProtocol(List services) { - super(services); - this.codec = Rpcv2CborCodec.builder().build(); + super(services, PAYLOAD_MEDIA_TYPE, true); } @Override @@ -37,186 +29,7 @@ public ShapeId getProtocolId() { } @Override - public ServiceProtocolResolutionResult resolveOperation( - ServiceProtocolResolutionRequest request, - List candidates - ) { - if (!isRpcV2Request(request)) { - // This doesn't appear to be an RpcV2 request, let other protocols try. - return null; - } - String path = request.uri().getPath(); - var serviceAndOperation = parseRpcV2StylePath(path); - Service selectedService = null; - if (candidates.size() == 1) { - Service service = candidates.get(0); - if (matchService(service, serviceAndOperation)) { - selectedService = service; - } - } else { - for (Service service : candidates) { - if (matchService(service, serviceAndOperation)) { - selectedService = service; - break; - } - } - } - if (selectedService == null) { - throw UnknownOperationException.builder().build(); - } - return new ServiceProtocolResolutionResult( - selectedService, - selectedService.getOperation(serviceAndOperation.operation), - this); - } - - @Override - public CompletableFuture deserializeInput(Job job) { - var dataStream = job.request().getDataStream(); - if (dataStream.contentLength() > 0 && !"application/cbor".equals(dataStream.contentType())) { - throw MalformedRequestException.builder().message("Invalid content type").build(); - } - - var input = codec.deserializeShape(dataStream.asByteBuffer(), job.operation().getApiOperation().inputBuilder()); - job.request().setDeserializedValue(input); - return CompletableFuture.completedFuture(null); - } - - @Override - public CompletableFuture serializeOutput(Job job, SerializableStruct output, boolean isError) { - var sink = new ByteBufferOutputStream(); - try (var serializer = codec.createSerializer(sink)) { - output.serialize(serializer); - } - job.response().setSerializedValue(DataStream.ofByteBuffer(sink.toByteBuffer(), "application/cbor")); - var httpJob = job.asHttpJob(); - final int statusCode; - if (isError) { - statusCode = ModeledException.getHttpStatusCode(output.schema()); - } else { - statusCode = 200; - } - httpJob.response().headers().setHeader("smithy-protocol", "rpc-v2-cbor"); - httpJob.response().setStatusCode(statusCode); - return CompletableFuture.completedFuture(null); - } - - private boolean matchService(Service service, ServiceAndOperation serviceAndOperation) { - var schema = service.schema(); - if (serviceAndOperation.isFullyQualifiedService()) { - return schema.id().toString().equals(serviceAndOperation.service()); - } else - return service.schema().id().getName().equals(serviceAndOperation.service()); - } - - private static ServiceAndOperation parseRpcV2StylePath(String path) { - // serviceNameStart must be non-negative for any of these offsets - // to be considered valid - int pos = path.length() - 1; - int serviceNameStart = -1, serviceNameEnd; - int operationNameStart = 0, operationNameEnd; - int namespaceIdx = -1; - int term = pos + 1; - operationNameEnd = term; - - for (; pos >= 0; pos--) { - if (path.charAt(pos) == '/') { - operationNameStart = pos + 1; - break; - } - } - - // we could do the same check above the first for loop if we wanted to - // fail if we went all the way to the start of the path or if the first - // character encountered is a "/" (e.g. in "/service/foo/operation/") - if (operationNameStart == 0 || operationNameStart == term || !isValidOperationPrefix(path, pos)) { - throw UnknownOperationException.builder().message("Invalid RpcV2 URI").build(); - } - - // seek pos to the character before "/operation", pos is currently on the "n" - serviceNameEnd = (pos -= 11) + 1; - for (; pos >= 0; pos--) { - int c = path.charAt(pos); - if (c == '/') { - serviceNameStart = pos + 1; - break; - } else if (c == '.' && namespaceIdx < 0) { - namespaceIdx = pos; - } - } - - // still need "/service" - // serviceNameStart < 0 means we never found a "/" - // serviceNameStart == serviceNameEnd means we had a zero-width name, "/service//" - if (serviceNameStart < 0 || serviceNameStart == serviceNameEnd || !isValidServicePrefix(path, pos)) { - throw UnknownOperationException.builder().message("Invalid RpcV2 URI").build(); - } - - String serviceName; - boolean isFullyQualifiedService; - if (namespaceIdx > 0) { - isFullyQualifiedService = true; - serviceName = path.substring(namespaceIdx + 1, serviceNameEnd); - } else { - isFullyQualifiedService = false; - serviceName = path.substring(serviceNameStart, serviceNameEnd); - } - - return new ServiceAndOperation( - serviceName, - path.substring(operationNameStart, operationNameEnd), - isFullyQualifiedService); + protected Codec codec() { + return CODEC; } - - private static boolean isValidOperationPrefix(String uri, int pos) { - // need 10 chars: "/operation/", pos points to "/" - // then need another 9 chars for "/service/" - return pos >= 19 && - ((uri.charAt(pos - 10) == '/') && - (uri.charAt(pos - 9) == 'o') - && - (uri.charAt(pos - 8) == 'p') - && - (uri.charAt(pos - 7) == 'e') - && - (uri.charAt(pos - 6) == 'r') - && - (uri.charAt(pos - 5) == 'a') - && - (uri.charAt(pos - 4) == 't') - && - (uri.charAt(pos - 3) == 'i') - && - (uri.charAt(pos - 2) == 'o') - && - (uri.charAt(pos - 1) == 'n')); - } - - private static boolean isValidServicePrefix(String uri, int pos) { - // need 8 chars: "/service/", pos points to "/" - return pos >= 8 && - ((uri.charAt(pos - 8) == '/') && - (uri.charAt(pos - 7) == 's') - && - (uri.charAt(pos - 6) == 'e') - && - (uri.charAt(pos - 5) == 'r') - && - (uri.charAt(pos - 4) == 'v') - && - (uri.charAt(pos - 3) == 'i') - && - (uri.charAt(pos - 2) == 'c') - && - (uri.charAt(pos - 1) == 'e')); - } - - private static boolean isRpcV2Request(ServiceProtocolResolutionRequest request) { - if (!"POST".equals(request.method())) { - return false; - } - return "rpc-v2-cbor".equals(request.headers().firstValue("smithy-protocol")); - } - - private record ServiceAndOperation(String service, String operation, boolean isFullyQualifiedService) {} } diff --git a/server/server-rpcv2-json/build.gradle.kts b/server/server-rpcv2-json/build.gradle.kts new file mode 100644 index 000000000..0f2c02a54 --- /dev/null +++ b/server/server-rpcv2-json/build.gradle.kts @@ -0,0 +1,26 @@ +plugins { + id("smithy-java.module-conventions") + id("smithy-java.protocol-testing-conventions") +} + +description = "This module provides the RpcV2 JSON support for servers." + +extra["displayName"] = "Smithy :: Java :: Server :: RPCv2 JSON" +extra["moduleName"] = "software.amazon.smithy.java.server.rpcv2json" + +dependencies { + api(project(":server:server-rpcv2")) + api(libs.smithy.protocol.traits) + implementation(project(":codecs:json-codec", configuration = "shadow")) + + itImplementation(project(":server:server-api")) + itImplementation(project(":server:server-netty")) + itImplementation(project(":client:client-rpcv2-json")) + + // Protocol test dependencies + testImplementation(libs.smithy.aws.protocol.tests) + testImplementation(libs.smithy.protocol.tests) +} + +val generator = "software.amazon.smithy.java.protocoltests.generators.ProtocolTestGenerator" +addGenerateSrcsTask(generator, "rpcv2Json", "smithy.protocoltests.rpcv2Json#RpcV2JsonProtocol", "server") diff --git a/server/server-rpcv2-json/src/it/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocolTests.java b/server/server-rpcv2-json/src/it/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocolTests.java new file mode 100644 index 000000000..f88b8b573 --- /dev/null +++ b/server/server-rpcv2-json/src/it/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocolTests.java @@ -0,0 +1,68 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.rpcv2json; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.nio.charset.StandardCharsets; +import software.amazon.smithy.java.io.ByteBufferUtils; +import software.amazon.smithy.java.io.datastream.DataStream; +import software.amazon.smithy.java.protocoltests.harness.HttpServerRequestTests; +import software.amazon.smithy.java.protocoltests.harness.HttpServerResponseTests; +import software.amazon.smithy.java.protocoltests.harness.ProtocolTest; +import software.amazon.smithy.java.protocoltests.harness.ProtocolTestFilter; +import software.amazon.smithy.java.protocoltests.harness.TestType; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; + +@ProtocolTest( + service = "smithy.protocoltests.rpcv2Json#RpcV2JsonProtocol", + testType = TestType.SERVER) +public class RpcV2JsonProtocolTests { + + @HttpServerRequestTests + @ProtocolTestFilter( + skipTests = { + // TODO fix empty body handling in the deserializer + "RpcV2JsonRequestNoInput", + "RpcV2JsonRequestNoInputServerAllowsEmptyBody", + "RpcV2JsonRequestEmptyInputNoBody", + // TODO fix the protocol test + "RpcV2JsonRequestServerPopulatesDefaultsWhenMissingInRequestBody" + }) + public void requestTest(Runnable test) { + test.run(); + } + + @HttpServerResponseTests + @ProtocolTestFilter( + skipTests = { + "RpcV2JsonResponseNoOutput", // TODO genuine bug, fix + // TODO fix the protocol test + "RpcV2JsonResponseServerPopulatesDefaultsInResponseWhenMissingInParams", + // Error serialization doesn't include __type so the below fail + "RpcV2JsonResponseInvalidGreetingError", + "RpcV2JsonResponseComplexError", + "RpcV2JsonResponseEmptyComplexError" + }) + public void responseTest(DataStream expected, DataStream actual) { + assertThat(expected.hasKnownLength()) + .isTrue() + .isSameAs(actual.hasKnownLength()); + Node expectedNode = ObjectNode.objectNode(); + if (expected.contentLength() != 0) { + expectedNode = Node.parse(new String(ByteBufferUtils.getBytes(expected.asByteBuffer()), + StandardCharsets.UTF_8)); + } + Node actualNode = ObjectNode.objectNode(); + if (actual.contentLength() != 0) { + actualNode = Node.parse(new String(ByteBufferUtils.getBytes(actual.asByteBuffer()), + StandardCharsets.UTF_8)); + } + assertEquals(expectedNode, actualNode); + } +} diff --git a/server/server-rpcv2-json/src/main/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocol.java b/server/server-rpcv2-json/src/main/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocol.java new file mode 100644 index 000000000..23ffad155 --- /dev/null +++ b/server/server-rpcv2-json/src/main/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocol.java @@ -0,0 +1,40 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.rpcv2json; + +import java.util.List; +import software.amazon.smithy.java.core.serde.Codec; +import software.amazon.smithy.java.json.JsonCodec; +import software.amazon.smithy.java.server.Service; +import software.amazon.smithy.java.server.rpcv2.AbstractRpcV2ServerProtocol; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.protocol.traits.Rpcv2JsonTrait; + +/** + * Server protocol implementation for {@code smithy.protocols#rpcv2Json}. + * + *

Uses JSON with string-encoded arbitrary precision numbers. + */ +final class RpcV2JsonProtocol extends AbstractRpcV2ServerProtocol { + private static final String PAYLOAD_MEDIA_TYPE = "application/json"; + private static final JsonCodec CODEC = JsonCodec.builder() + .useStringForArbitraryPrecision(true) + .build(); + + RpcV2JsonProtocol(List services) { + super(services, PAYLOAD_MEDIA_TYPE); + } + + @Override + public ShapeId getProtocolId() { + return Rpcv2JsonTrait.ID; + } + + @Override + protected Codec codec() { + return CODEC; + } +} diff --git a/server/server-rpcv2-json/src/main/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocolProvider.java b/server/server-rpcv2-json/src/main/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocolProvider.java new file mode 100644 index 000000000..5e0960766 --- /dev/null +++ b/server/server-rpcv2-json/src/main/java/software/amazon/smithy/java/server/rpcv2json/RpcV2JsonProtocolProvider.java @@ -0,0 +1,30 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.rpcv2json; + +import java.util.List; +import software.amazon.smithy.java.server.Service; +import software.amazon.smithy.java.server.core.ServerProtocol; +import software.amazon.smithy.java.server.core.ServerProtocolProvider; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.protocol.traits.Rpcv2JsonTrait; + +public final class RpcV2JsonProtocolProvider implements ServerProtocolProvider { + @Override + public ServerProtocol provideProtocolHandler(List candidateServices) { + return new RpcV2JsonProtocol(candidateServices); + } + + @Override + public ShapeId getProtocolId() { + return Rpcv2JsonTrait.ID; + } + + @Override + public int precision() { + return 0; + } +} diff --git a/server/server-rpcv2-json/src/main/resources/META-INF/services/software.amazon.smithy.java.server.core.ServerProtocolProvider b/server/server-rpcv2-json/src/main/resources/META-INF/services/software.amazon.smithy.java.server.core.ServerProtocolProvider new file mode 100644 index 000000000..ba8d32093 --- /dev/null +++ b/server/server-rpcv2-json/src/main/resources/META-INF/services/software.amazon.smithy.java.server.core.ServerProtocolProvider @@ -0,0 +1 @@ +software.amazon.smithy.java.server.rpcv2json.RpcV2JsonProtocolProvider diff --git a/server/server-rpcv2/build.gradle.kts b/server/server-rpcv2/build.gradle.kts new file mode 100644 index 000000000..18d1410fe --- /dev/null +++ b/server/server-rpcv2/build.gradle.kts @@ -0,0 +1,13 @@ +plugins { + id("smithy-java.module-conventions") +} + +description = "This module provides the shared base implementation for server RpcV2 protocols" + +extra["displayName"] = "Smithy :: Java :: Server :: RPCv2" +extra["moduleName"] = "software.amazon.smithy.java.server.rpcv2" + +dependencies { + api(project(":server:server-api")) + api(project(":server:server-core")) +} diff --git a/server/server-rpcv2/src/main/java/software/amazon/smithy/java/server/rpcv2/AbstractRpcV2ServerProtocol.java b/server/server-rpcv2/src/main/java/software/amazon/smithy/java/server/rpcv2/AbstractRpcV2ServerProtocol.java new file mode 100644 index 000000000..12d8ab4e6 --- /dev/null +++ b/server/server-rpcv2/src/main/java/software/amazon/smithy/java/server/rpcv2/AbstractRpcV2ServerProtocol.java @@ -0,0 +1,257 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ + +package software.amazon.smithy.java.server.rpcv2; + +import java.util.List; +import java.util.concurrent.CompletableFuture; +import software.amazon.smithy.java.core.error.ModeledException; +import software.amazon.smithy.java.core.schema.SerializableStruct; +import software.amazon.smithy.java.core.serde.Codec; +import software.amazon.smithy.java.framework.model.MalformedRequestException; +import software.amazon.smithy.java.framework.model.UnknownOperationException; +import software.amazon.smithy.java.io.ByteBufferOutputStream; +import software.amazon.smithy.java.io.datastream.DataStream; +import software.amazon.smithy.java.server.Service; +import software.amazon.smithy.java.server.core.Job; +import software.amazon.smithy.java.server.core.ServerProtocol; +import software.amazon.smithy.java.server.core.ServiceProtocolResolutionRequest; +import software.amazon.smithy.java.server.core.ServiceProtocolResolutionResult; + +/** + * Shared base for RPC v2 server protocol implementations. + * + *

Subclasses provide the codec, media type, smithy-protocol header value, and + * protocol ID that distinguish the concrete wire format (CBOR vs JSON). URL path + * parsing, operation resolution, input deserialization, and output serialization + * are handled here. + */ +public abstract class AbstractRpcV2ServerProtocol extends ServerProtocol { + + private static final String SMITHY_PROTOCOL_PREFIX = "rpc-v2-"; + // length of "application/" + private static final int MEDIA_TYPE_PREFIX_LENGTH = 12; + + private final String payloadMediaType; + private final String smithyProtocolValue; + private final boolean allowFullyQualifiedService; + + /** + * @param services the list of services this protocol handles + * @param payloadMediaType the media type for request/response payloads (e.g. "application/cbor") + */ + protected AbstractRpcV2ServerProtocol(List services, String payloadMediaType) { + this(services, payloadMediaType, false); + } + + /** + * @param services the list of services this protocol handles + * @param payloadMediaType the media type for request/response payloads (e.g. "application/cbor") + * @param allowFullyQualifiedService whether to accept fully qualified service names in the URI + */ + protected AbstractRpcV2ServerProtocol( + List services, + String payloadMediaType, + boolean allowFullyQualifiedService + ) { + super(services); + this.payloadMediaType = payloadMediaType; + this.smithyProtocolValue = SMITHY_PROTOCOL_PREFIX + + payloadMediaType.substring(MEDIA_TYPE_PREFIX_LENGTH); + this.allowFullyQualifiedService = allowFullyQualifiedService; + } + + /** Returns the codec used for serialization and deserialization. */ + protected abstract Codec codec(); + + @Override + public ServiceProtocolResolutionResult resolveOperation( + ServiceProtocolResolutionRequest request, + List candidates + ) { + if (!isRpcV2Request(request)) { + return null; + } + String path = request.uri().getPath(); + var serviceAndOperation = parseRpcV2StylePath(path); + if (!allowFullyQualifiedService && serviceAndOperation.isFullyQualifiedService()) { + throw UnknownOperationException.builder().message("Invalid RpcV2 URI").build(); + } + Service selectedService = null; + if (candidates.size() == 1) { + Service service = candidates.get(0); + if (matchService(service, serviceAndOperation)) { + selectedService = service; + } + } else { + for (Service service : candidates) { + if (matchService(service, serviceAndOperation)) { + selectedService = service; + break; + } + } + } + if (selectedService == null) { + throw UnknownOperationException.builder().build(); + } + return new ServiceProtocolResolutionResult( + selectedService, + selectedService.getOperation(serviceAndOperation.operation), + this); + } + + @Override + public CompletableFuture deserializeInput(Job job) { + var dataStream = job.request().getDataStream(); + if (dataStream.contentLength() > 0 && !payloadMediaType.equals(dataStream.contentType())) { + throw MalformedRequestException.builder().message("Invalid content type").build(); + } + + var input = codec().deserializeShape(dataStream.asByteBuffer(), + job.operation().getApiOperation().inputBuilder()); + job.request().setDeserializedValue(input); + return CompletableFuture.completedFuture(null); + } + + @Override + protected CompletableFuture serializeOutput(Job job, SerializableStruct output, boolean isError) { + var sink = new ByteBufferOutputStream(); + try (var serializer = codec().createSerializer(sink)) { + output.serialize(serializer); + } + job.response().setSerializedValue(DataStream.ofByteBuffer(sink.toByteBuffer(), payloadMediaType)); + var httpJob = job.asHttpJob(); + final int statusCode; + if (isError) { + statusCode = ModeledException.getHttpStatusCode(output.schema()); + } else { + statusCode = 200; + } + httpJob.response().headers().setHeader("smithy-protocol", smithyProtocolValue); + httpJob.response().setStatusCode(statusCode); + return CompletableFuture.completedFuture(null); + } + + private boolean matchService(Service service, ServiceAndOperation serviceAndOperation) { + var schema = service.schema(); + if (serviceAndOperation.isFullyQualifiedService()) { + return schema.id().toString().equals(serviceAndOperation.service()); + } else { + return service.schema().id().getName().equals(serviceAndOperation.service()); + } + } + + // URL path parsing for /service/{ServiceName}/operation/{OperationName} + // serviceNameStart must be non-negative for any of these offsets to be considered valid + private static ServiceAndOperation parseRpcV2StylePath(String path) { + int pos = path.length() - 1; + int serviceNameStart = -1, serviceNameEnd; + int operationNameStart = 0, operationNameEnd; + int namespaceIdx = -1; + int term = pos + 1; + operationNameEnd = term; + + for (; pos >= 0; pos--) { + if (path.charAt(pos) == '/') { + operationNameStart = pos + 1; + break; + } + } + + // Fail if we went all the way to the start of the path or if the first + // character encountered is a "/" (e.g. in "/service/foo/operation/") + if (operationNameStart == 0 || operationNameStart == term || !isValidOperationPrefix(path, pos)) { + throw UnknownOperationException.builder().message("Invalid RpcV2 URI").build(); + } + + // Seek pos to the character before "/operation", pos is currently on the "n" + serviceNameEnd = (pos -= 11) + 1; + for (; pos >= 0; pos--) { + int c = path.charAt(pos); + if (c == '/') { + serviceNameStart = pos + 1; + break; + } else if (c == '.' && namespaceIdx < 0) { + namespaceIdx = pos; + } + } + + // Still need "/service" prefix. + // serviceNameStart < 0 means we never found a "/" + // serviceNameStart == serviceNameEnd means we had a zero-width name, "/service//" + if (serviceNameStart < 0 || serviceNameStart == serviceNameEnd || !isValidServicePrefix(path, pos)) { + throw UnknownOperationException.builder().message("Invalid RpcV2 URI").build(); + } + + String serviceName; + boolean isFullyQualifiedService; + if (namespaceIdx > 0) { + isFullyQualifiedService = true; + serviceName = path.substring(namespaceIdx + 1, serviceNameEnd); + } else { + isFullyQualifiedService = false; + serviceName = path.substring(serviceNameStart, serviceNameEnd); + } + + return new ServiceAndOperation( + serviceName, + path.substring(operationNameStart, operationNameEnd), + isFullyQualifiedService); + } + + // Need 10 chars: "/operation/", pos points to "/" + // then need another 9 chars for "/service/" + private static boolean isValidOperationPrefix(String uri, int pos) { + return pos >= 19 && + uri.charAt(pos - 10) == '/' + && + uri.charAt(pos - 9) == 'o' + && + uri.charAt(pos - 8) == 'p' + && + uri.charAt(pos - 7) == 'e' + && + uri.charAt(pos - 6) == 'r' + && + uri.charAt(pos - 5) == 'a' + && + uri.charAt(pos - 4) == 't' + && + uri.charAt(pos - 3) == 'i' + && + uri.charAt(pos - 2) == 'o' + && + uri.charAt(pos - 1) == 'n'; + } + + // Need 8 chars: "/service/", pos points to "/" + private static boolean isValidServicePrefix(String uri, int pos) { + return pos >= 8 && + uri.charAt(pos - 8) == '/' + && + uri.charAt(pos - 7) == 's' + && + uri.charAt(pos - 6) == 'e' + && + uri.charAt(pos - 5) == 'r' + && + uri.charAt(pos - 4) == 'v' + && + uri.charAt(pos - 3) == 'i' + && + uri.charAt(pos - 2) == 'c' + && + uri.charAt(pos - 1) == 'e'; + } + + private boolean isRpcV2Request(ServiceProtocolResolutionRequest request) { + if (!"POST".equals(request.method())) { + return false; + } + return smithyProtocolValue.equals(request.headers().firstValue("smithy-protocol")); + } + + private record ServiceAndOperation(String service, String operation, boolean isFullyQualifiedService) {} +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 5af549a38..d97e1ff1e 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -39,7 +39,9 @@ include(":client:client-core") include(":client:client-auth-api") include(":client:client-http") include(":client:client-http-binding") +include(":client:client-rpcv2") include(":client:client-rpcv2-cbor") +include(":client:client-rpcv2-json") include(":client:dynamic-client") include(":client:client-mock-plugin") include(":client:client-waiters") @@ -50,7 +52,9 @@ include(":client:client-metrics-otel") include(":server:server-api") include(":server:server-core") include(":server:server-netty") +include(":server:server-rpcv2") include(":server:server-rpcv2-cbor") +include(":server:server-rpcv2-json") include(":server:server-proxy") // Codegen