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