From d82da725e6411934db8192f43ca44c22c5e16e8e Mon Sep 17 00:00:00 2001 From: goelakash Date: Tue, 21 Jul 2026 18:12:39 -0700 Subject: [PATCH 1/2] fix(codegen): support event streams in server SDK serde context Server SDK generation stubbed the event stream serde context type as `& any` and never supplied an event stream marshaller, so generated SSDK code for operations with event streams did not type-check. Type the response serializer / request deserializer context with __EventStreamSerdeContext, and wire the Node eventStreamSerdeProvider into the handler's serde context base (it only needs utf8 encode/decode, which the base already provides). Adds server event stream codegen tests, which were previously absent. --- .../codegen/DirectedTypeScriptCodegen.java | 12 +- .../typescript/codegen/ServerGenerator.java | 26 ++- .../integration/AddEventStreamDependency.java | 2 +- .../HttpBindingProtocolGenerator.java | 22 ++- .../HttpBindingEventStreamServerTest.java | 164 ++++++++++++++++++ .../codegen/ServerEventStreamTest.java | 66 +++++++ .../codegen/ServerGeneratorMetricsTest.java | 4 +- .../codegen/server-event-stream-http.smithy | 41 +++++ .../codegen/server-event-stream.smithy | 36 ++++ 9 files changed, 360 insertions(+), 13 deletions(-) create mode 100644 smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/HttpBindingEventStreamServerTest.java create mode 100644 smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/ServerEventStreamTest.java create mode 100644 smithy-typescript-codegen/src/test/resources/software/amazon/smithy/typescript/codegen/server-event-stream-http.smithy create mode 100644 smithy-typescript-codegen/src/test/resources/software/amazon/smithy/typescript/codegen/server-event-stream.smithy diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/DirectedTypeScriptCodegen.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/DirectedTypeScriptCodegen.java index 0f120072694..55285f8adb3 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/DirectedTypeScriptCodegen.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/DirectedTypeScriptCodegen.java @@ -44,6 +44,7 @@ import software.amazon.smithy.model.validation.ValidationEvent; import software.amazon.smithy.typescript.codegen.auth.http.HttpAuthSchemeProviderGenerator; import software.amazon.smithy.typescript.codegen.endpointsV2.EndpointsV2Generator; +import software.amazon.smithy.typescript.codegen.integration.AddEventStreamDependency; import software.amazon.smithy.typescript.codegen.integration.ProtocolGenerator; import software.amazon.smithy.typescript.codegen.integration.RuntimeClientPlugin; import software.amazon.smithy.typescript.codegen.integration.TypeScriptIntegration; @@ -258,9 +259,10 @@ public void generateService(GenerateServiceDirective { - ServerGenerator.generateOperationHandler(symbolProvider, service, operation, w); + ServerGenerator.generateOperationHandler(symbolProvider, service, operation, w, hasEventStream); }); } } @@ -736,7 +738,13 @@ private void generateServiceInterface( .useShapeWriter(service, writer -> { ServerGenerator.generateOperationsType(symbolProvider, service, operations, writer); ServerGenerator.generateServerInterfaces(symbolProvider, service, operations, writer); - ServerGenerator.generateServiceHandler(symbolProvider, service, operations, writer); + ServerGenerator.generateServiceHandler( + symbolProvider, + service, + operations, + writer, + AddEventStreamDependency.hasEventStream(directive.model(), service) + ); }); } diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/ServerGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/ServerGenerator.java index dd7904ee454..3223a6beb5b 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/ServerGenerator.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/ServerGenerator.java @@ -39,7 +39,8 @@ static void generateServiceHandler( SymbolProvider symbolProvider, Shape serviceShape, Set operations, - TypeScriptWriter writer + TypeScriptWriter writer, + boolean hasEventStream ) { addCommonHandlerImports(writer); @@ -47,7 +48,7 @@ static void generateServiceHandler( Symbol handlerSymbol = serviceSymbol.expectProperty("handler", Symbol.class); Symbol operationsType = serviceSymbol.expectProperty("operations", Symbol.class); - writeSerdeContextBase(writer); + writeSerdeContextBase(writer, hasEventStream); writer.openBlock( "const $LValidators: { [K in $T]: (input: any) => __ValidationFailure[] } = {", @@ -211,12 +212,13 @@ static void generateOperationHandler( SymbolProvider symbolProvider, Shape serviceShape, OperationShape operation, - TypeScriptWriter writer + TypeScriptWriter writer, + boolean hasEventStream ) { addCommonHandlerImports(writer); writer.addImport("Operation", "__Operation", TypeScriptDependency.SERVER_COMMON); - writeSerdeContextBase(writer); + writeSerdeContextBase(writer, hasEventStream); Symbol serviceSymbol = symbolProvider.toSymbol(serviceShape); Symbol operationSymbol = symbolProvider.toSymbol(operation); @@ -670,7 +672,7 @@ private static void addCommonHandlerImports(TypeScriptWriter writer) { writer.addImport("MetricsRecorderFactory", "__MetricsRecorderFactory", TypeScriptDependency.SMITHY_TYPES); } - private static void writeSerdeContextBase(TypeScriptWriter writer) { + private static void writeSerdeContextBase(TypeScriptWriter writer, boolean hasEventStream) { writer.addImport("ServerSerdeContext", "__ServerSerdeContext", TypeScriptDependency.SERVER_COMMON); writer.addImport("NodeHttpHandler", null, TypeScriptDependency.AWS_SDK_NODE_HTTP_HANDLER); writer.addImport("streamCollector", null, TypeScriptDependency.AWS_SDK_NODE_HTTP_HANDLER); @@ -678,6 +680,14 @@ private static void writeSerdeContextBase(TypeScriptWriter writer) { writer.addImportSubmodule("toBase64", null, TypeScriptDependency.SMITHY_CORE, SmithyCoreSubmodules.SERDE); writer.addImportSubmodule("fromUtf8", null, TypeScriptDependency.SMITHY_CORE, SmithyCoreSubmodules.SERDE); writer.addImportSubmodule("toUtf8", null, TypeScriptDependency.SMITHY_CORE, SmithyCoreSubmodules.SERDE); + if (hasEventStream) { + writer.addImportSubmodule( + "eventStreamSerdeProvider", + null, + TypeScriptDependency.SMITHY_CORE, + SmithyCoreSubmodules.EVENT_STREAMS + ); + } writer.openBlock("const serdeContextBase = {", "};", () -> { writer.write("base64Encoder: toBase64,"); @@ -685,6 +695,12 @@ private static void writeSerdeContextBase(TypeScriptWriter writer) { writer.write("utf8Encoder: toUtf8,"); writer.write("utf8Decoder: fromUtf8,"); writer.write("streamCollector: streamCollector,"); + if (hasEventStream) { + writer.write( + "eventStreamMarshaller: eventStreamSerdeProvider(" + + "{ utf8Encoder: toUtf8, utf8Decoder: fromUtf8 })," + ); + } writer.write("requestHandler: new NodeHttpHandler(),"); writer.write("disableHostPrefix: true"); }); diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/AddEventStreamDependency.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/AddEventStreamDependency.java index 45309ecffb3..2a5cc82558b 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/AddEventStreamDependency.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/AddEventStreamDependency.java @@ -142,7 +142,7 @@ public List> ); } - private static boolean hasEventStream(Model model, ServiceShape service) { + public static boolean hasEventStream(Model model, ServiceShape service) { TopDownIndex topDownIndex = TopDownIndex.of(model); Set operations = topDownIndex.getContainedOperations(service); EventStreamIndex eventStreamIndex = EventStreamIndex.of(model); diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpBindingProtocolGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpBindingProtocolGenerator.java index 013547694e7..f891fcae175 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpBindingProtocolGenerator.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/HttpBindingProtocolGenerator.java @@ -698,12 +698,23 @@ private void generateOperationResponseSerializer( Symbol outputType = symbol.expectProperty("outputType", Symbol.class); writer.addImport("ServerSerdeContext", null, TypeScriptDependency.SERVER_COMMON); + String ctxType = "ServerSerdeContext"; + if (EventStreamGenerator.hasEventStreamOutput(context, operation)) { + writer.addTypeImport( + "EventStreamSerdeContext", + "__EventStreamSerdeContext", + TypeScriptDependency.SMITHY_TYPES + ); + ctxType += " & __EventStreamSerdeContext"; + } + writer.openBlock( - "export const $L = async (\n" + " input: $T,\n" + " ctx: ServerSerdeContext\n" + "export const $L = async (\n" + " input: $T,\n" + " ctx: $L\n" + "): Promise<$T> => {", "};", methodName, outputType, + ctxType, responseType, () -> { writeEmptyEndpoint(context, operation); @@ -834,8 +845,13 @@ private void writeEmptyEndpoint(GenerationContext context, OperationShape operat String contextType = "__SerdeContext"; boolean hasEventStreamResponse = EventStreamGenerator.hasEventStreamOutput(context, operation); if (hasEventStreamResponse) { - // todo: unsupported SSDK feature. - contextType += "& any /*event stream context unsupported in ssdk*/"; + context.getWriter() + .addTypeImport( + "EventStreamSerdeContext", + "__EventStreamSerdeContext", + TypeScriptDependency.SMITHY_TYPES + ); + contextType += " & __EventStreamSerdeContext"; } context .getWriter() diff --git a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/HttpBindingEventStreamServerTest.java b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/HttpBindingEventStreamServerTest.java new file mode 100644 index 00000000000..19515401f80 --- /dev/null +++ b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/HttpBindingEventStreamServerTest.java @@ -0,0 +1,164 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.typescript.codegen; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.knowledge.HttpBinding; +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.ServiceShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.traits.TimestampFormatTrait.Format; +import software.amazon.smithy.typescript.codegen.integration.HttpBindingProtocolGenerator; +import software.amazon.smithy.typescript.codegen.integration.ProtocolGenerator.GenerationContext; + +public class HttpBindingEventStreamServerTest { + + /** + * A minimal concrete {@link HttpBindingProtocolGenerator} for exercising the shared + * server-side generation paths. Document-body serde is stubbed because event stream + * payloads are handled through the payload/event-stream path, not document bodies. + */ + private static final class TestHttpBindingProtocolGenerator extends HttpBindingProtocolGenerator { + TestHttpBindingProtocolGenerator() { + super(false); + } + + @Override + public ShapeId getProtocol() { + return ShapeId.from("smithy.example#fakeProtocol"); + } + + @Override + public void generateProtocolTests(GenerationContext context) {} + + @Override + protected Format getDocumentTimestampFormat() { + return Format.EPOCH_SECONDS; + } + + @Override + protected String getDocumentContentType() { + return "application/json"; + } + + @Override + protected void generateDocumentBodyShapeSerializers(GenerationContext context, Set shapes) {} + + @Override + protected void generateDocumentBodyShapeDeserializers(GenerationContext context, Set shapes) {} + + @Override + protected void serializeInputDocumentBody( + GenerationContext context, + OperationShape operation, + List documentBindings + ) {} + + @Override + protected void serializeInputEventDocumentPayload(GenerationContext context) {} + + @Override + protected void serializeOutputDocumentBody( + GenerationContext context, + OperationShape operation, + List documentBindings + ) {} + + @Override + protected void serializeErrorDocumentBody( + GenerationContext context, + StructureShape error, + List documentBindings + ) {} + + @Override + protected void writeErrorCodeParser(GenerationContext context) {} + + @Override + protected void deserializeInputDocumentBody( + GenerationContext context, + OperationShape operation, + List documentBindings + ) {} + + @Override + protected void deserializeOutputDocumentBody( + GenerationContext context, + OperationShape operation, + List documentBindings + ) {} + + @Override + protected void deserializeErrorDocumentBody( + GenerationContext context, + StructureShape error, + List documentBindings + ) {} + + @Override + protected boolean requiresNumericEpochSecondsInPayload() { + return false; + } + } + + private GenerationContext serverContext() { + Model model = Model.assembler(getClass().getClassLoader()) + .discoverModels(getClass().getClassLoader()) + .addImport(getClass().getResource("server-event-stream-http.smithy")) + .assemble() + .unwrap(); + ServiceShape service = model.expectShape(ShapeId.from("smithy.example#Example"), ServiceShape.class); + TypeScriptSettings settings = TypeScriptSettings.from( + model, + Node.objectNodeBuilder() + .withMember("service", Node.from("smithy.example#Example")) + .withMember("package", Node.from("example-ssdk")) + .withMember("packageVersion", Node.from("1.0.0")) + .withMember("disableDefaultValidation", Node.from(true)) + .build() + ); + GenerationContext context = new GenerationContext(); + context.setModel(model); + context.setService(service); + context.setSettings(settings); + context.setSymbolProvider(new SymbolVisitor(model, settings)); + context.setProtocolName("fakeProtocol"); + context.setWriter(new TypeScriptWriter("./Publish")); + return context; + } + + @Test + public void responseSerializerUsesTypedEventStreamContext() { + GenerationContext context = serverContext(); + new TestHttpBindingProtocolGenerator().generateResponseSerializers(context); + String generated = context.getWriter().toString(); + // The response serializer's serde context must be typed with __EventStreamSerdeContext + // rather than the previous `& any` escape hatch. + assertThat(generated, containsString("ctx: ServerSerdeContext & __EventStreamSerdeContext")); + assertThat(generated, not(containsString("unsupported in ssdk"))); + assertThat(generated, not(containsString("& any"))); + } + + @Test + public void requestDeserializerUsesTypedEventStreamContext() { + GenerationContext context = serverContext(); + new TestHttpBindingProtocolGenerator().generateRequestDeserializers(context); + String generated = context.getWriter().toString(); + // The request deserializer's serde context must include __EventStreamSerdeContext + // for operations with an event stream input. + assertThat(generated, containsString("__EventStreamSerdeContext")); + assertThat(generated, not(containsString("& any"))); + } +} diff --git a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/ServerEventStreamTest.java b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/ServerEventStreamTest.java new file mode 100644 index 00000000000..99a78351602 --- /dev/null +++ b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/ServerEventStreamTest.java @@ -0,0 +1,66 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.typescript.codegen; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.not; + +import org.junit.jupiter.api.Test; +import software.amazon.smithy.build.MockManifest; +import software.amazon.smithy.build.PluginContext; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.node.Node; + +public class ServerEventStreamTest { + + private MockManifest generateServer() { + Model model = Model.assembler(getClass().getClassLoader()) + .discoverModels(getClass().getClassLoader()) + .addImport(getClass().getResource("server-event-stream.smithy")) + .assemble() + .unwrap(); + MockManifest manifest = new MockManifest(); + PluginContext context = PluginContext.builder() + .model(model) + .fileManifest(manifest) + .pluginClassLoader(getClass().getClassLoader()) + .settings( + Node.objectNodeBuilder() + .withMember("service", Node.from("smithy.example#Example")) + .withMember("package", Node.from("example-ssdk")) + .withMember("packageVersion", Node.from("1.0.0")) + .withMember("disableDefaultValidation", Node.from(true)) + .build() + ) + .build(); + new TypeScriptServerCodegenPlugin().execute(context); + return manifest; + } + + @Test + public void wiresEventStreamMarshallerIntoServerSerdeContext() { + MockManifest manifest = generateServer(); + String handler = manifest.getFileString( + CodegenUtils.SOURCE_FOLDER + "/server/operations/Publish.ts" + ).get(); + // The server handler's serde context base must supply an event stream marshaller, + // reusing the Node event stream serde provider (which only needs utf8 encode/decode). + assertThat(handler, containsString("eventStreamSerdeProvider")); + assertThat(handler, containsString("eventStreamMarshaller: eventStreamSerdeProvider(")); + } + + @Test + public void generatesEventStreamSerdeWithTypedContext() { + MockManifest manifest = generateServer(); + String protocol = manifest.getFileString( + CodegenUtils.SOURCE_FOLDER + "/protocols/Rpcv2cbor.ts" + ).get(); + // Event stream serde functions are generated and use the strongly typed + // __EventStreamSerdeContext rather than an untyped `any` escape hatch. + assertThat(protocol, containsString("__EventStreamSerdeContext")); + assertThat(protocol, not(containsString("unsupported in ssdk"))); + } +} diff --git a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/ServerGeneratorMetricsTest.java b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/ServerGeneratorMetricsTest.java index a626fee6867..b373f3b3f8b 100644 --- a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/ServerGeneratorMetricsTest.java +++ b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/ServerGeneratorMetricsTest.java @@ -43,13 +43,13 @@ public void setup() { private String generateOperationHandler() { OperationShape operation = model.expectShape(ShapeId.from("smithy.example#GetFoo"), OperationShape.class); TypeScriptWriter writer = new TypeScriptWriter("./GetFoo"); - ServerGenerator.generateOperationHandler(symbolProvider, service, operation, writer); + ServerGenerator.generateOperationHandler(symbolProvider, service, operation, writer, false); return writer.toString(); } private String generateServiceHandler() { TypeScriptWriter writer = new TypeScriptWriter("./Example"); - ServerGenerator.generateServiceHandler(symbolProvider, service, model.getOperationShapes(), writer); + ServerGenerator.generateServiceHandler(symbolProvider, service, model.getOperationShapes(), writer, false); return writer.toString(); } diff --git a/smithy-typescript-codegen/src/test/resources/software/amazon/smithy/typescript/codegen/server-event-stream-http.smithy b/smithy-typescript-codegen/src/test/resources/software/amazon/smithy/typescript/codegen/server-event-stream-http.smithy new file mode 100644 index 00000000000..617d8c11dac --- /dev/null +++ b/smithy-typescript-codegen/src/test/resources/software/amazon/smithy/typescript/codegen/server-event-stream-http.smithy @@ -0,0 +1,41 @@ +$version: "2.0" + +namespace smithy.example + +@trait(selector: "service") +@protocolDefinition +structure fakeProtocol {} + +@fakeProtocol +service Example { + version: "1.0.0" + operations: [Publish] +} + +@http(method: "POST", uri: "/publish") +operation Publish { + input: PublishInput + output: PublishOutput +} + +structure PublishInput { + @httpPayload + events: PublishEvents +} + +structure PublishOutput { + @httpPayload + events: PublishEvents +} + +@streaming +union PublishEvents { + message: MessageEvent + leave: LeaveEvent +} + +structure MessageEvent { + message: String +} + +structure LeaveEvent {} diff --git a/smithy-typescript-codegen/src/test/resources/software/amazon/smithy/typescript/codegen/server-event-stream.smithy b/smithy-typescript-codegen/src/test/resources/software/amazon/smithy/typescript/codegen/server-event-stream.smithy new file mode 100644 index 00000000000..6e651dda632 --- /dev/null +++ b/smithy-typescript-codegen/src/test/resources/software/amazon/smithy/typescript/codegen/server-event-stream.smithy @@ -0,0 +1,36 @@ +$version: "2.0" + +namespace smithy.example + +use smithy.protocols#rpcv2Cbor + +@rpcv2Cbor +service Example { + version: "1.0.0" + operations: [Publish] +} + +operation Publish { + input: PublishInput + output: PublishOutput +} + +structure PublishInput { + events: PublishEvents +} + +structure PublishOutput { + events: PublishEvents +} + +@streaming +union PublishEvents { + message: MessageEvent + leave: LeaveEvent +} + +structure MessageEvent { + message: String +} + +structure LeaveEvent {} From 4a697becb115edc7f671734c387df450cd0519f8 Mon Sep 17 00:00:00 2001 From: goelakash Date: Tue, 21 Jul 2026 21:15:48 -0700 Subject: [PATCH 2/2] feat(codegen): add SSE (text/event-stream) event stream protocol Adds an experimental sseJson protocol whose event streams are framed as Server-Sent Events instead of the binary vnd.amazon.eventstream encoding, so a Smithy model can generate a server that streams over text/event-stream. - SseEventStreamMarshaller in @smithy/core/event-streams: an EventStreamMarshaller that frames messages as SSE, with the same options and callback contract as the binary marshaller. - ProtocolGenerator.getEventStreamSerdeProviderName lets a protocol choose which provider the generated handler wires into its serde context; defaults to the binary provider. - SseJsonProtocolGenerator + sseJson trait: an HTTP protocol that overrides the provider to emit the SSE marshaller. Document body serde for non-streaming members is not yet implemented. Gated behind the experimentalSseProtocol smithy-build setting; when unset, the protocol is not registered and codegen output is unchanged. --- .changeset/sse-event-stream-marshaller.md | 5 + CONTRIBUTING.md | 2 +- .../SseEventStreamMarshaller.spec.ts | 152 +++++++++++++++ .../SseEventStreamMarshaller.ts | 179 ++++++++++++++++++ .../submodules/event-streams/index.browser.ts | 4 + .../src/submodules/event-streams/index.ts | 4 + .../codegen/DirectedTypeScriptCodegen.java | 26 ++- .../typescript/codegen/ServerGenerator.java | 22 ++- .../codegen/TypeScriptSettings.java | 16 +- .../integration/ProtocolGenerator.java | 10 + .../codegen/protocols/AddProtocols.java | 3 +- .../sse/SseJsonProtocolGenerator.java | 160 ++++++++++++++++ .../codegen/protocols/sse/SseJsonTrait.java | 41 ++++ ...re.amazon.smithy.model.traits.TraitService | 1 + .../main/resources/META-INF/smithy/manifest | 1 + .../smithy.typescript.protocols.sse.smithy | 41 ++++ .../codegen/ServerGeneratorMetricsTest.java | 12 +- .../codegen/SseJsonProtocolTest.java | 74 ++++++++ .../codegen/sse-json-event-stream.smithy | 38 ++++ 19 files changed, 776 insertions(+), 15 deletions(-) create mode 100644 .changeset/sse-event-stream-marshaller.md create mode 100644 packages/core/src/submodules/event-streams/eventstream-serde-sse/SseEventStreamMarshaller.spec.ts create mode 100644 packages/core/src/submodules/event-streams/eventstream-serde-sse/SseEventStreamMarshaller.ts create mode 100644 smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/protocols/sse/SseJsonProtocolGenerator.java create mode 100644 smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/protocols/sse/SseJsonTrait.java create mode 100644 smithy-typescript-codegen/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService create mode 100644 smithy-typescript-codegen/src/main/resources/META-INF/smithy/manifest create mode 100644 smithy-typescript-codegen/src/main/resources/META-INF/smithy/smithy.typescript.protocols.sse.smithy create mode 100644 smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/SseJsonProtocolTest.java create mode 100644 smithy-typescript-codegen/src/test/resources/software/amazon/smithy/typescript/codegen/sse-json-event-stream.smithy diff --git a/.changeset/sse-event-stream-marshaller.md b/.changeset/sse-event-stream-marshaller.md new file mode 100644 index 00000000000..346cf9d96f7 --- /dev/null +++ b/.changeset/sse-event-stream-marshaller.md @@ -0,0 +1,5 @@ +--- +"@smithy/core": minor +--- + +add SSE (text/event-stream) event stream marshaller diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 61acef3184e..ebd34c4a39a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -31,7 +31,7 @@ under development: | Experimental Feature | Flag | Description | | -------------------- | ---- | ----------- | -| N/A | N/A | N/A | +| SSE protocol | `experimentalSseProtocol` | Enables the `sseJson` protocol, which frames event streams as Server-Sent Events (`text/event-stream`). | ## Reporting Bugs/Feature Requests diff --git a/packages/core/src/submodules/event-streams/eventstream-serde-sse/SseEventStreamMarshaller.spec.ts b/packages/core/src/submodules/event-streams/eventstream-serde-sse/SseEventStreamMarshaller.spec.ts new file mode 100644 index 00000000000..ffbd880fc5b --- /dev/null +++ b/packages/core/src/submodules/event-streams/eventstream-serde-sse/SseEventStreamMarshaller.spec.ts @@ -0,0 +1,152 @@ +import type { Message } from "@smithy/types"; +import { describe, expect, test as it } from "vitest"; + +import { SseEventStreamMarshaller } from "./SseEventStreamMarshaller"; + +const utf8Encoder = (bytes: Uint8Array) => Buffer.from(bytes).toString("utf-8"); +const utf8Decoder = (str: string) => new Uint8Array(Buffer.from(str, "utf-8")); + +const marshaller = new SseEventStreamMarshaller({ utf8Encoder, utf8Decoder }); + +async function* iterate(items: T[]): AsyncIterable { + for (const item of items) { + yield item; + } +} + +async function collect(stream: AsyncIterable): Promise { + const out: T[] = []; + for await (const item of stream) { + out.push(item); + } + return out; +} + +type ChatEvent = { message: { text: string } } | { done: { reason: string } }; + +const serializer = (event: ChatEvent): Message => { + const type = Object.keys(event)[0] as keyof ChatEvent; + return { + headers: { + ":message-type": { type: "string", value: "event" }, + ":event-type": { type: "string", value: type }, + ":content-type": { type: "string", value: "application/json" }, + }, + body: utf8Decoder(JSON.stringify((event as any)[type])), + }; +}; + +const deserializer = async (input: Record): Promise => { + const type = Object.keys(input)[0]; + if (type === "message" || type === "done") { + return { [type]: JSON.parse(utf8Encoder(input[type].body)) }; + } + return { $unknown: [type, input[type]] }; +}; + +describe("SseEventStreamMarshaller", () => { + it("serializes events into text/event-stream frames", async () => { + const frames = await collect( + marshaller.serialize(iterate([{ message: { text: "hi" } }, { done: { reason: "stop" } }]), serializer) + ); + const wire = frames.map(utf8Encoder).join(""); + expect(wire).toBe( + `event: message\ndata: {"text":"hi"}\n\n` + `event: done\ndata: {"reason":"stop"}\n\n` + ); + }); + + it("round-trips events through serialize then deserialize", async () => { + const events: ChatEvent[] = [{ message: { text: "one" } }, { message: { text: "two" } }, { done: { reason: "eof" } }]; + const wire = marshaller.serialize(iterate(events), serializer); + const back = await collect(marshaller.deserialize(wire, deserializer)); + expect(back).toEqual(events); + }); + + it("handles payloads split across chunk boundaries", async () => { + const wire = marshaller.serialize(iterate([{ message: { text: "chunked" } }]), serializer); + const whole = utf8Encoder((await collect(wire))[0]); + async function* byChar(): AsyncIterable { + for (const ch of whole) { + yield utf8Decoder(ch); + } + } + const back = await collect(marshaller.deserialize(byChar(), deserializer)); + expect(back).toEqual([{ message: { text: "chunked" } }]); + }); + + it("splits raw-newline payloads across multiple data: lines and rejoins them", async () => { + const rawBody = "line1\nline2"; + const rawSerializer = (): Message => ({ + headers: { + ":message-type": { type: "string", value: "event" }, + ":event-type": { type: "string", value: "raw" }, + ":content-type": { type: "string", value: "text/plain" }, + }, + body: utf8Decoder(rawBody), + }); + const rawDeserializer = async (input: Record): Promise => { + const type = Object.keys(input)[0]; + return type === "raw" ? { raw: utf8Encoder(input[type].body) } : { $unknown: [type, input[type]] }; + }; + const frame = utf8Encoder((await collect(marshaller.serialize(iterate([{} as any]), rawSerializer)))[0]); + expect(frame.match(/data: /g)?.length).toBe(2); + const back = await collect( + marshaller.deserialize(marshaller.serialize(iterate([{} as any]), rawSerializer), rawDeserializer) + ); + expect(back).toEqual([{ raw: rawBody }]); + }); + + it("round-trips CR and CRLF line terminators as LF", async () => { + const rawSerializer = (input: any): Message => ({ + headers: { + ":message-type": { type: "string", value: "event" }, + ":event-type": { type: "string", value: "raw" }, + ":content-type": { type: "string", value: "text/plain" }, + }, + body: utf8Decoder(input.raw), + }); + const rawDeserializer = async (input: Record): Promise => { + const type = Object.keys(input)[0]; + return type === "raw" ? { raw: utf8Encoder(input[type].body) } : { $unknown: [type, input[type]] }; + }; + // SSE data: fields cannot preserve which terminator was used; all rejoin as LF. + for (const [body, expected] of [ + ["a\rb", "a\nb"], + ["a\r\nb", "a\nb"], + ["a\nb", "a\nb"], + ]) { + const back = await collect( + marshaller.deserialize(marshaller.serialize(iterate([{ raw: body }]), rawSerializer), rawDeserializer) + ); + expect(back).toEqual([{ raw: expected }]); + } + }); + + it("maps modeled exceptions to exception: events and throws on deserialize", async () => { + const errSerializer = (): Message => ({ + headers: { + ":message-type": { type: "string", value: "exception" }, + ":exception-type": { type: "string", value: "ThrottlingError" }, + ":content-type": { type: "string", value: "application/json" }, + }, + body: utf8Decoder(JSON.stringify({ message: "slow down" })), + }); + const errDeserializer = async (input: Record): Promise => { + const type = Object.keys(input)[0]; + if (type === "ThrottlingError") { + const parsed = JSON.parse(utf8Encoder(input[type].body)); + const e = new Error(parsed.message); + e.name = "ThrottlingError"; + return { ThrottlingError: e }; + } + return { $unknown: [type, input[type]] }; + }; + const wire = marshaller.serialize(iterate([{} as any]), errSerializer); + const frame = utf8Encoder((await collect(wire))[0]); + expect(frame.startsWith("event: exception:ThrottlingError\n")).toBe(true); + + await expect( + collect(marshaller.deserialize(marshaller.serialize(iterate([{} as any]), errSerializer), errDeserializer)) + ).rejects.toThrow("slow down"); + }); +}); diff --git a/packages/core/src/submodules/event-streams/eventstream-serde-sse/SseEventStreamMarshaller.ts b/packages/core/src/submodules/event-streams/eventstream-serde-sse/SseEventStreamMarshaller.ts new file mode 100644 index 00000000000..f545c25394b --- /dev/null +++ b/packages/core/src/submodules/event-streams/eventstream-serde-sse/SseEventStreamMarshaller.ts @@ -0,0 +1,179 @@ +import type { + Decoder, + Encoder, + EventStreamMarshaller as IEventStreamMarshaller, + EventStreamSerdeProvider, + Message, +} from "@smithy/types"; + +/** + * Options for {@link SseEventStreamMarshaller}. Mirrors the options of the + * binary event stream marshaller so the two are interchangeable at the + * codegen serde-context seam. + * + * @internal + */ +export interface SseEventStreamMarshallerOptions { + utf8Encoder: Encoder; + utf8Decoder: Decoder; +} + +const EXCEPTION_PREFIX = "exception:"; + +/** + * An {@link IEventStreamMarshaller} that frames Smithy event stream messages + * as Server-Sent Events (`text/event-stream`) instead of the binary + * `application/vnd.amazon.eventstream` encoding. + * + * Wire mapping: + * - `:event-type` header -> SSE `event:` field + * - message body -> SSE `data:` field(s) + * - modeled exceptions -> SSE `event: exception:` + * + * The serializer/deserializer callbacks use the same contract as the binary + * marshaller: a `Record` keyed by event type on the way in, + * a `Message` with `:event-type` / `:message-type` headers on the way out. + * + * @internal + */ +export class SseEventStreamMarshaller implements IEventStreamMarshaller> { + private readonly utf8Encoder: Encoder; + private readonly utf8Decoder: Decoder; + + constructor({ utf8Encoder, utf8Decoder }: SseEventStreamMarshallerOptions) { + this.utf8Encoder = utf8Encoder; + this.utf8Decoder = utf8Decoder; + } + + public serialize(input: AsyncIterable, serializer: (event: T) => Message): AsyncIterable { + const { utf8Decoder, utf8Encoder } = this; + return { + [Symbol.asyncIterator]: async function* () { + for await (const event of input) { + const message = serializer(event); + const messageType = String(message.headers[":message-type"]?.value ?? "event"); + let eventName: string; + if (messageType === "exception") { + eventName = EXCEPTION_PREFIX + String(message.headers[":exception-type"]?.value ?? "UnknownError"); + } else { + eventName = String(message.headers[":event-type"]?.value ?? "message"); + } + const body = message.body.length ? utf8Encoder(message.body) : ""; + // Per the SSE spec, payloads containing line terminators (CRLF, CR, or LF) + // are sent as repeated data: fields and rejoined with LF when parsed. + const dataLines = body + .split(/\r\n|\r|\n/) + .map((line) => `data: ${line}`) + .join("\n"); + yield utf8Decoder(`event: ${eventName}\n${dataLines}\n\n`); + } + }, + }; + } + + public deserialize( + body: AsyncIterable, + deserializer: (input: Record) => Promise + ): AsyncIterable { + const { utf8Encoder, utf8Decoder } = this; + return { + [Symbol.asyncIterator]: async function* () { + for await (const sse of parseSseStream(body, utf8Encoder)) { + const isException = sse.event.startsWith(EXCEPTION_PREFIX); + const type = isException ? sse.event.slice(EXCEPTION_PREFIX.length) : sse.event; + const message: Message = { + headers: { + ":message-type": { type: "string", value: isException ? "exception" : "event" }, + [isException ? ":exception-type" : ":event-type"]: { type: "string", value: type }, + ":content-type": { type: "string", value: "application/json" }, + }, + body: utf8Decoder(sse.data), + }; + const deserialized: any = await deserializer({ [type]: message }); + if (isException) { + if (deserialized.$unknown) { + const error = new Error(sse.data || "UnknownError"); + error.name = type; + throw error; + } + throw deserialized[type]; + } + if (deserialized.$unknown) { + continue; + } + yield deserialized as T; + } + }, + }; + } +} + +/** + * @internal + */ +export const sseEventStreamSerdeProvider: EventStreamSerdeProvider = (options: SseEventStreamMarshallerOptions) => + new SseEventStreamMarshaller(options); + +interface SseEvent { + event: string; + data: string; +} + +/** + * Incrementally parses a byte stream into SSE events per the WHATWG spec's + * field rules: events are delimited by blank lines, `data:` fields accumulate + * joined by newlines, comment lines (`:`) and unknown fields are ignored. + */ +async function* parseSseStream(source: AsyncIterable, toUtf8: Encoder): AsyncIterable { + let buffer = ""; + for await (const chunk of source) { + buffer += toUtf8(chunk); + let boundary: number; + while ((boundary = findEventBoundary(buffer)) !== -1) { + const rawEvent = buffer.slice(0, boundary); + buffer = buffer.slice(boundary).replace(/^(\r\n|\n|\r){2}/, ""); + const parsed = parseSseEvent(rawEvent); + if (parsed !== undefined) { + yield parsed; + } + } + } + const trailing = parseSseEvent(buffer); + if (trailing !== undefined) { + yield trailing; + } +} + +function findEventBoundary(buffer: string): number { + const match = buffer.match(/(\r\n\r\n|\n\n|\r\r)/); + return match?.index ?? -1; +} + +function parseSseEvent(raw: string): SseEvent | undefined { + let event = "message"; + const data: string[] = []; + let sawField = false; + for (const line of raw.split(/\r\n|\n|\r/)) { + if (line === "" || line.startsWith(":")) { + continue; + } + const colon = line.indexOf(":"); + const field = colon === -1 ? line : line.slice(0, colon); + let value = colon === -1 ? "" : line.slice(colon + 1); + if (value.startsWith(" ")) { + value = value.slice(1); + } + if (field === "event") { + event = value; + sawField = true; + } else if (field === "data") { + data.push(value); + sawField = true; + } + // id: and retry: fields are valid SSE but carry no Smithy meaning; ignored. + } + if (!sawField) { + return undefined; + } + return { event, data: data.join("\n") }; +} diff --git a/packages/core/src/submodules/event-streams/index.browser.ts b/packages/core/src/submodules/event-streams/index.browser.ts index efe29765b93..3a4c1ee82b4 100644 --- a/packages/core/src/submodules/event-streams/index.browser.ts +++ b/packages/core/src/submodules/event-streams/index.browser.ts @@ -30,6 +30,10 @@ export { EventStreamMarshaller, eventStreamSerdeProvider } from "./eventstream-s export type { EventStreamMarshallerOptions } from "./eventstream-serde/EventStreamMarshaller.browser"; export { readableStreamToIterable, iterableToReadableStream } from "./eventstream-serde/utils"; +// SSE (text/event-stream) event stream serde +export { SseEventStreamMarshaller, sseEventStreamSerdeProvider } from "./eventstream-serde-sse/SseEventStreamMarshaller"; +export type { SseEventStreamMarshallerOptions } from "./eventstream-serde-sse/SseEventStreamMarshaller"; + // @smithy/eventstream-serde-universal export { EventStreamMarshaller as UniversalEventStreamMarshaller, diff --git a/packages/core/src/submodules/event-streams/index.ts b/packages/core/src/submodules/event-streams/index.ts index 858b7cd73f7..6a7aa7a089a 100644 --- a/packages/core/src/submodules/event-streams/index.ts +++ b/packages/core/src/submodules/event-streams/index.ts @@ -47,5 +47,9 @@ export type { EventStreamSerdeResolvedConfig, } from "./eventstream-serde-config-resolver/EventStreamSerdeConfig"; +// SSE (text/event-stream) event stream serde +export { SseEventStreamMarshaller, sseEventStreamSerdeProvider } from "./eventstream-serde-sse/SseEventStreamMarshaller"; +export type { SseEventStreamMarshallerOptions } from "./eventstream-serde-sse/SseEventStreamMarshaller"; + // EventStreamSerde export { EventStreamSerde } from "./EventStreamSerde"; diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/DirectedTypeScriptCodegen.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/DirectedTypeScriptCodegen.java index 55285f8adb3..74459289863 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/DirectedTypeScriptCodegen.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/DirectedTypeScriptCodegen.java @@ -48,6 +48,7 @@ import software.amazon.smithy.typescript.codegen.integration.ProtocolGenerator; import software.amazon.smithy.typescript.codegen.integration.RuntimeClientPlugin; import software.amazon.smithy.typescript.codegen.integration.TypeScriptIntegration; +import software.amazon.smithy.typescript.codegen.protocols.sse.SseJsonTrait; import software.amazon.smithy.typescript.codegen.schema.SchemaGenerationAllowlist; import software.amazon.smithy.typescript.codegen.schema.SchemaGenerator; import software.amazon.smithy.typescript.codegen.validation.LongValidator; @@ -170,6 +171,9 @@ private ProtocolGenerator resolveProtocolGenerator( for (TypeScriptIntegration integration : integrations) { for (ProtocolGenerator generator : integration.getProtocolGenerators()) { + if (generator.getProtocol().equals(SseJsonTrait.ID) && !settings.experimentalSseProtocol()) { + continue; + } // allow overrides of the same protocol ShapeId to change the order. generators.remove(generator.getProtocol()); generators.put(generator.getProtocol(), generator); @@ -260,9 +264,17 @@ public void generateService(GenerateServiceDirective { - ServerGenerator.generateOperationHandler(symbolProvider, service, operation, w, hasEventStream); + ServerGenerator.generateOperationHandler( + symbolProvider, + service, + operation, + w, + hasEventStream, + eventStreamSerdeProviderName + ); }); } } @@ -743,11 +755,21 @@ private void generateServiceInterface( service, operations, writer, - AddEventStreamDependency.hasEventStream(directive.model(), service) + AddEventStreamDependency.hasEventStream(directive.model(), service), + eventStreamSerdeProviderName(directive) ); }); } + private static String eventStreamSerdeProviderName( + GenerateServiceDirective directive + ) { + ProtocolGenerator protocolGenerator = directive.context().protocolGenerator(); + return protocolGenerator == null + ? "eventStreamSerdeProvider" + : protocolGenerator.getEventStreamSerdeProviderName(); + } + private static String generateTsconfigTypes(TypeScriptSettings settings) { InputStream resource = DirectedTypeScriptCodegen.class.getResourceAsStream("tsconfig.types.json"); ObjectNode node = Node.parse(IoUtils.toUtf8String(resource)).expectObjectNode(); diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/ServerGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/ServerGenerator.java index 3223a6beb5b..a2660af0fcc 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/ServerGenerator.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/ServerGenerator.java @@ -40,7 +40,8 @@ static void generateServiceHandler( Shape serviceShape, Set operations, TypeScriptWriter writer, - boolean hasEventStream + boolean hasEventStream, + String eventStreamSerdeProviderName ) { addCommonHandlerImports(writer); @@ -48,7 +49,7 @@ static void generateServiceHandler( Symbol handlerSymbol = serviceSymbol.expectProperty("handler", Symbol.class); Symbol operationsType = serviceSymbol.expectProperty("operations", Symbol.class); - writeSerdeContextBase(writer, hasEventStream); + writeSerdeContextBase(writer, hasEventStream, eventStreamSerdeProviderName); writer.openBlock( "const $LValidators: { [K in $T]: (input: any) => __ValidationFailure[] } = {", @@ -213,12 +214,13 @@ static void generateOperationHandler( Shape serviceShape, OperationShape operation, TypeScriptWriter writer, - boolean hasEventStream + boolean hasEventStream, + String eventStreamSerdeProviderName ) { addCommonHandlerImports(writer); writer.addImport("Operation", "__Operation", TypeScriptDependency.SERVER_COMMON); - writeSerdeContextBase(writer, hasEventStream); + writeSerdeContextBase(writer, hasEventStream, eventStreamSerdeProviderName); Symbol serviceSymbol = symbolProvider.toSymbol(serviceShape); Symbol operationSymbol = symbolProvider.toSymbol(operation); @@ -672,7 +674,11 @@ private static void addCommonHandlerImports(TypeScriptWriter writer) { writer.addImport("MetricsRecorderFactory", "__MetricsRecorderFactory", TypeScriptDependency.SMITHY_TYPES); } - private static void writeSerdeContextBase(TypeScriptWriter writer, boolean hasEventStream) { + private static void writeSerdeContextBase( + TypeScriptWriter writer, + boolean hasEventStream, + String eventStreamSerdeProviderName + ) { writer.addImport("ServerSerdeContext", "__ServerSerdeContext", TypeScriptDependency.SERVER_COMMON); writer.addImport("NodeHttpHandler", null, TypeScriptDependency.AWS_SDK_NODE_HTTP_HANDLER); writer.addImport("streamCollector", null, TypeScriptDependency.AWS_SDK_NODE_HTTP_HANDLER); @@ -682,7 +688,7 @@ private static void writeSerdeContextBase(TypeScriptWriter writer, boolean hasEv writer.addImportSubmodule("toUtf8", null, TypeScriptDependency.SMITHY_CORE, SmithyCoreSubmodules.SERDE); if (hasEventStream) { writer.addImportSubmodule( - "eventStreamSerdeProvider", + eventStreamSerdeProviderName, null, TypeScriptDependency.SMITHY_CORE, SmithyCoreSubmodules.EVENT_STREAMS @@ -697,8 +703,8 @@ private static void writeSerdeContextBase(TypeScriptWriter writer, boolean hasEv writer.write("streamCollector: streamCollector,"); if (hasEventStream) { writer.write( - "eventStreamMarshaller: eventStreamSerdeProvider(" - + "{ utf8Encoder: toUtf8, utf8Decoder: fromUtf8 })," + "eventStreamMarshaller: $L({ utf8Encoder: toUtf8, utf8Decoder: fromUtf8 }),", + eventStreamSerdeProviderName ); } writer.write("requestHandler: new NodeHttpHandler(),"); diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/TypeScriptSettings.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/TypeScriptSettings.java index 03eeb0f90e8..80e0cd7584c 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/TypeScriptSettings.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/TypeScriptSettings.java @@ -65,6 +65,7 @@ public final class TypeScriptSettings { private static final String GENERATE_ENDPOINT_BDD = "generateEndpointBdd"; private static final String VERSIONING_SCHEME = "versioningScheme"; private static final String TSCONFIG = "tsconfig"; + private static final String EXPERIMENTAL_SSE_PROTOCOL = "experimentalSseProtocol"; private String packageName; private String packageDescription = ""; @@ -90,6 +91,7 @@ public final class TypeScriptSettings { private boolean generateSnapshotTests = false; private String versioningScheme = ""; private boolean isolatedModules = false; + private boolean experimentalSseProtocol = false; @Deprecated public static TypeScriptSettings from(Model model, ObjectNode config) { @@ -162,6 +164,7 @@ public static TypeScriptSettings from(Model model, ObjectNode config, ArtifactTy settings.setGenerateIndexTests(config.getBooleanMemberOrDefault(GENERATE_INDEX_TESTS, false)); settings.setGenerateSnapshotTests(config.getBooleanMemberOrDefault(GENERATE_SNAPSHOT_TESTS, false)); settings.setVersioningScheme(config.getStringMemberOrDefault(VERSIONING_SCHEME, "")); + settings.setExperimentalSseProtocol(config.getBooleanMemberOrDefault(EXPERIMENTAL_SSE_PROTOCOL, false)); settings.setIsolatedModules( config.getObjectMember("tsconfig") .flatMap(tsconfig -> tsconfig.getObjectMember("types")) @@ -287,6 +290,16 @@ public boolean generateSchemas() { return generateSchemas; } + @SmithyInternalApi + public void setExperimentalSseProtocol(boolean experimentalSseProtocol) { + this.experimentalSseProtocol = experimentalSseProtocol; + } + + @SmithyInternalApi + public boolean experimentalSseProtocol() { + return experimentalSseProtocol; + } + @SmithyInternalApi public void setGenerateEndpointBdd(boolean generateEndpointBdd) { this.generateEndpointBdd = generateEndpointBdd; @@ -663,7 +676,8 @@ public enum ArtifactType { GENERATE_SCHEMAS, GENERATE_ENDPOINT_BDD, VERSIONING_SCHEME, - TSCONFIG + TSCONFIG, + EXPERIMENTAL_SSE_PROTOCOL ) ), SSDK( diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/ProtocolGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/ProtocolGenerator.java index b7764eb9424..66bbc28492d 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/ProtocolGenerator.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/integration/ProtocolGenerator.java @@ -122,6 +122,16 @@ default ApplicationProtocol resolveApplicationProtocol( return other; } + /** + * Returns the {@code @smithy/core/event-streams} provider that generated servers + * use to frame event streams. Defaults to the binary provider. + * + * @return The event stream serde provider export name. + */ + default String getEventStreamSerdeProviderName() { + return "eventStreamSerdeProvider"; + } + /** * Generates any standard code for service request/response serde. * diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/protocols/AddProtocols.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/protocols/AddProtocols.java index a41e607d34c..049a693d950 100644 --- a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/protocols/AddProtocols.java +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/protocols/AddProtocols.java @@ -8,6 +8,7 @@ import software.amazon.smithy.typescript.codegen.integration.ProtocolGenerator; import software.amazon.smithy.typescript.codegen.integration.TypeScriptIntegration; import software.amazon.smithy.typescript.codegen.protocols.cbor.SmithyRpcV2Cbor; +import software.amazon.smithy.typescript.codegen.protocols.sse.SseJsonProtocolGenerator; import software.amazon.smithy.utils.ListUtils; import software.amazon.smithy.utils.SmithyInternalApi; @@ -19,6 +20,6 @@ public class AddProtocols implements TypeScriptIntegration { @Override public List getProtocolGenerators() { - return ListUtils.of(new SmithyRpcV2Cbor()); + return ListUtils.of(new SmithyRpcV2Cbor(), new SseJsonProtocolGenerator()); } } diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/protocols/sse/SseJsonProtocolGenerator.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/protocols/sse/SseJsonProtocolGenerator.java new file mode 100644 index 00000000000..e9f18e62f7e --- /dev/null +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/protocols/sse/SseJsonProtocolGenerator.java @@ -0,0 +1,160 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.typescript.codegen.protocols.sse; + +import java.util.List; +import java.util.Set; +import software.amazon.smithy.model.knowledge.HttpBinding; +import software.amazon.smithy.model.shapes.OperationShape; +import software.amazon.smithy.model.shapes.Shape; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.shapes.StructureShape; +import software.amazon.smithy.model.traits.TimestampFormatTrait.Format; +import software.amazon.smithy.typescript.codegen.TypeScriptDependency; +import software.amazon.smithy.typescript.codegen.TypeScriptWriter; +import software.amazon.smithy.typescript.codegen.integration.HttpBindingProtocolGenerator; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * Generates an HTTP protocol whose event streams are framed as Server-Sent + * Events (text/event-stream) rather than the binary vnd.amazon.eventstream + * encoding. Document body serde for non-streaming members is not yet + * implemented; only HTTP bindings and event stream payloads are supported. + */ +@SmithyUnstableApi +public final class SseJsonProtocolGenerator extends HttpBindingProtocolGenerator { + + public SseJsonProtocolGenerator() { + super(true); + } + + @Override + public ShapeId getProtocol() { + return SseJsonTrait.ID; + } + + @Override + public String getEventStreamSerdeProviderName() { + return "sseEventStreamSerdeProvider"; + } + + @Override + protected String getDocumentContentType() { + return "application/json"; + } + + @Override + protected Format getDocumentTimestampFormat() { + return Format.EPOCH_SECONDS; + } + + @Override + protected boolean requiresNumericEpochSecondsInPayload() { + return true; + } + + @Override + protected void serializeInputDocumentBody( + GenerationContext context, + OperationShape operation, + List documentBindings + ) { + context.getWriter().write("body = \"{}\";"); + } + + @Override + protected void serializeOutputDocumentBody( + GenerationContext context, + OperationShape operation, + List documentBindings + ) { + context.getWriter().write("body = \"{}\";"); + } + + @Override + protected void serializeErrorDocumentBody( + GenerationContext context, + StructureShape error, + List documentBindings + ) { + context.getWriter().write("body = \"{}\";"); + } + + @Override + protected void serializeInputEventDocumentPayload(GenerationContext context) {} + + @Override + protected void deserializeInputDocumentBody( + GenerationContext context, + OperationShape operation, + List documentBindings + ) {} + + @Override + protected void deserializeOutputDocumentBody( + GenerationContext context, + OperationShape operation, + List documentBindings + ) {} + + @Override + protected void deserializeErrorDocumentBody( + GenerationContext context, + StructureShape error, + List documentBindings + ) {} + + @Override + protected void generateDocumentBodyShapeSerializers(GenerationContext context, Set shapes) {} + + @Override + protected void generateDocumentBodyShapeDeserializers(GenerationContext context, Set shapes) {} + + @Override + protected void writeErrorCodeParser(GenerationContext context) { + context.getWriter().write("const errorCode = parseErrorCode(output, parsedOutput.body);"); + } + + @Override + public void generateProtocolTests(GenerationContext context) {} + + @Override + public void generateSharedComponents(GenerationContext context) { + super.generateSharedComponents(context); + + TypeScriptWriter writer = context.getWriter(); + writer.addImport("SerdeContext", "__SerdeContext", TypeScriptDependency.SMITHY_TYPES); + writer.openBlock( + "const parseBody = (streamBody: any, context: __SerdeContext): " + + "any => collectBodyString(streamBody, context).then(encoded => {", + "});", + () -> { + writer.openBlock("if (encoded.length) {", "}", () -> { + writer.write("return JSON.parse(encoded);"); + }); + writer.write("return {};"); + } + ); + writer.write(""); + writer.openBlock("const parseErrorBody = async (errorBody: any, context: __SerdeContext) => {", "}", () -> { + writer.write("const value = await parseBody(errorBody, context);"); + writer.write("value.message = value.message ?? value.Message;"); + writer.write("return value;"); + }); + writer.write(""); + writer.openBlock( + "const parseErrorCode = (output: __HttpResponse, data: any): string | undefined => {", + "}", + () -> { + writer.openBlock("if (output.headers[\"x-error\"]) {", "}", () -> { + writer.write("return output.headers[\"x-error\"];"); + }); + writer.openBlock("if (data.code !== undefined) {", "}", () -> { + writer.write("return data.code;"); + }); + } + ); + } +} diff --git a/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/protocols/sse/SseJsonTrait.java b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/protocols/sse/SseJsonTrait.java new file mode 100644 index 00000000000..b5ae7a6d2ae --- /dev/null +++ b/smithy-typescript-codegen/src/main/java/software/amazon/smithy/typescript/codegen/protocols/sse/SseJsonTrait.java @@ -0,0 +1,41 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.typescript.codegen.protocols.sse; + +import software.amazon.smithy.model.node.Node; +import software.amazon.smithy.model.node.ObjectNode; +import software.amazon.smithy.model.shapes.ShapeId; +import software.amazon.smithy.model.traits.AbstractTrait; +import software.amazon.smithy.model.traits.AnnotationTrait; +import software.amazon.smithy.utils.SmithyUnstableApi; + +/** + * An HTTP protocol that serializes structures as JSON and frames event streams + * as Server-Sent Events (text/event-stream). + */ +@SmithyUnstableApi +public final class SseJsonTrait extends AnnotationTrait { + + public static final ShapeId ID = ShapeId.from("smithy.typescript.protocols#sseJson"); + + public SseJsonTrait(ObjectNode node) { + super(ID, node); + } + + public SseJsonTrait() { + this(Node.objectNode()); + } + + public static final class Provider extends AbstractTrait.Provider { + public Provider() { + super(ID); + } + + @Override + public SseJsonTrait createTrait(ShapeId target, Node node) { + return new SseJsonTrait(node.expectObjectNode()); + } + } +} diff --git a/smithy-typescript-codegen/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService b/smithy-typescript-codegen/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService new file mode 100644 index 00000000000..02392418c72 --- /dev/null +++ b/smithy-typescript-codegen/src/main/resources/META-INF/services/software.amazon.smithy.model.traits.TraitService @@ -0,0 +1 @@ +software.amazon.smithy.typescript.codegen.protocols.sse.SseJsonTrait$Provider diff --git a/smithy-typescript-codegen/src/main/resources/META-INF/smithy/manifest b/smithy-typescript-codegen/src/main/resources/META-INF/smithy/manifest new file mode 100644 index 00000000000..a87f3505f93 --- /dev/null +++ b/smithy-typescript-codegen/src/main/resources/META-INF/smithy/manifest @@ -0,0 +1 @@ +smithy.typescript.protocols.sse.smithy diff --git a/smithy-typescript-codegen/src/main/resources/META-INF/smithy/smithy.typescript.protocols.sse.smithy b/smithy-typescript-codegen/src/main/resources/META-INF/smithy/smithy.typescript.protocols.sse.smithy new file mode 100644 index 00000000000..8a05b2bae80 --- /dev/null +++ b/smithy-typescript-codegen/src/main/resources/META-INF/smithy/smithy.typescript.protocols.sse.smithy @@ -0,0 +1,41 @@ +$version: "2.0" + +namespace smithy.typescript.protocols + +use smithy.api#cors +use smithy.api#endpoint +use smithy.api#hostLabel +use smithy.api#http +use smithy.api#httpError +use smithy.api#httpHeader +use smithy.api#httpLabel +use smithy.api#httpPayload +use smithy.api#httpPrefixHeaders +use smithy.api#httpQuery +use smithy.api#httpQueryParams +use smithy.api#httpResponseCode +use smithy.api#jsonName +use smithy.api#timestampFormat + +/// An HTTP protocol that serializes structures as JSON and frames event +/// streams as Server-Sent Events (text/event-stream). +@trait(selector: "service") +@protocolDefinition( + traits: [ + cors + endpoint + hostLabel + http + httpError + httpHeader + httpLabel + httpPayload + httpPrefixHeaders + httpQuery + httpQueryParams + httpResponseCode + jsonName + timestampFormat + ] +) +structure sseJson {} diff --git a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/ServerGeneratorMetricsTest.java b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/ServerGeneratorMetricsTest.java index b373f3b3f8b..4fe4189b096 100644 --- a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/ServerGeneratorMetricsTest.java +++ b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/ServerGeneratorMetricsTest.java @@ -43,13 +43,21 @@ public void setup() { private String generateOperationHandler() { OperationShape operation = model.expectShape(ShapeId.from("smithy.example#GetFoo"), OperationShape.class); TypeScriptWriter writer = new TypeScriptWriter("./GetFoo"); - ServerGenerator.generateOperationHandler(symbolProvider, service, operation, writer, false); + ServerGenerator + .generateOperationHandler(symbolProvider, service, operation, writer, false, "eventStreamSerdeProvider"); return writer.toString(); } private String generateServiceHandler() { TypeScriptWriter writer = new TypeScriptWriter("./Example"); - ServerGenerator.generateServiceHandler(symbolProvider, service, model.getOperationShapes(), writer, false); + ServerGenerator.generateServiceHandler( + symbolProvider, + service, + model.getOperationShapes(), + writer, + false, + "eventStreamSerdeProvider" + ); return writer.toString(); } diff --git a/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/SseJsonProtocolTest.java b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/SseJsonProtocolTest.java new file mode 100644 index 00000000000..5674648bdc7 --- /dev/null +++ b/smithy-typescript-codegen/src/test/java/software/amazon/smithy/typescript/codegen/SseJsonProtocolTest.java @@ -0,0 +1,74 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * SPDX-License-Identifier: Apache-2.0 + */ +package software.amazon.smithy.typescript.codegen; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.is; + +import org.junit.jupiter.api.Test; +import software.amazon.smithy.build.MockManifest; +import software.amazon.smithy.build.PluginContext; +import software.amazon.smithy.model.Model; +import software.amazon.smithy.model.node.Node; + +public class SseJsonProtocolTest { + + private MockManifest generateServer(boolean experimentalSseProtocol) { + Model model = Model.assembler(getClass().getClassLoader()) + .discoverModels(getClass().getClassLoader()) + .addImport(getClass().getResource("sse-json-event-stream.smithy")) + .assemble() + .unwrap(); + MockManifest manifest = new MockManifest(); + PluginContext context = PluginContext.builder() + .model(model) + .fileManifest(manifest) + .pluginClassLoader(getClass().getClassLoader()) + .settings( + Node.objectNodeBuilder() + .withMember("service", Node.from("smithy.example#Example")) + .withMember("package", Node.from("example-ssdk")) + .withMember("packageVersion", Node.from("1.0.0")) + .withMember("disableDefaultValidation", Node.from(true)) + .withMember("experimentalSseProtocol", Node.from(experimentalSseProtocol)) + .build() + ) + .build(); + new TypeScriptServerCodegenPlugin().execute(context); + return manifest; + } + + private MockManifest generateServer() { + return generateServer(true); + } + + @Test + public void wiresSseEventStreamProviderIntoHandler() { + String handler = generateServer() + .getFileString(CodegenUtils.SOURCE_FOLDER + "/server/operations/Publish.ts") + .get(); + assertThat(handler, containsString("sseEventStreamSerdeProvider")); + assertThat(handler, containsString("eventStreamMarshaller: sseEventStreamSerdeProvider(")); + } + + @Test + public void generatesEventStreamSerdeForTheStreamUnion() { + String protocol = generateServer() + .getFileString(CodegenUtils.SOURCE_FOLDER + "/protocols/Ssejson.ts") + .get(); + assertThat(protocol, containsString("se_PublishEvents")); + assertThat(protocol, containsString("de_PublishEvents")); + assertThat(protocol, containsString("eventStreamMarshaller")); + } + + @Test + public void protocolIsInertWithoutTheExperimentalFlag() { + assertThat( + generateServer(false).hasFile(CodegenUtils.SOURCE_FOLDER + "/protocols/Ssejson.ts"), + is(false) + ); + } +} diff --git a/smithy-typescript-codegen/src/test/resources/software/amazon/smithy/typescript/codegen/sse-json-event-stream.smithy b/smithy-typescript-codegen/src/test/resources/software/amazon/smithy/typescript/codegen/sse-json-event-stream.smithy new file mode 100644 index 00000000000..59a093bd43f --- /dev/null +++ b/smithy-typescript-codegen/src/test/resources/software/amazon/smithy/typescript/codegen/sse-json-event-stream.smithy @@ -0,0 +1,38 @@ +$version: "2.0" + +namespace smithy.example + +use smithy.typescript.protocols#sseJson + +@sseJson +service Example { + version: "1.0.0" + operations: [Publish] +} + +@http(method: "POST", uri: "/publish") +operation Publish { + input: PublishInput + output: PublishOutput +} + +structure PublishInput { + room: String +} + +structure PublishOutput { + @httpPayload + events: PublishEvents +} + +@streaming +union PublishEvents { + message: MessageEvent + leave: LeaveEvent +} + +structure MessageEvent { + text: String +} + +structure LeaveEvent {}