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 {}