diff --git a/smithy-model/src/main/java/software/amazon/smithy/model/knowledge/HttpBindingIndex.java b/smithy-model/src/main/java/software/amazon/smithy/model/knowledge/HttpBindingIndex.java index 06a8c3dab0e..1073b9b8b62 100644 --- a/smithy-model/src/main/java/software/amazon/smithy/model/knowledge/HttpBindingIndex.java +++ b/smithy-model/src/main/java/software/amazon/smithy/model/knowledge/HttpBindingIndex.java @@ -32,6 +32,7 @@ import software.amazon.smithy.model.shapes.StructureShape; import software.amazon.smithy.model.shapes.ToShapeId; import software.amazon.smithy.model.traits.ErrorTrait; +import software.amazon.smithy.model.traits.EventStreamTrait; import software.amazon.smithy.model.traits.HttpErrorTrait; import software.amazon.smithy.model.traits.HttpHeaderTrait; import software.amazon.smithy.model.traits.HttpLabelTrait; @@ -252,6 +253,20 @@ public TimestampFormatTrait.Format determineTimestampFormat( }); } + /** + * Returns the expected request Content-Type of the given operation. + * + *

See {@link #determineRequestContentType(ToShapeId, String, String)} + * for documentation on how the content-type is resolved. + * + * @param operation Operation to determine the content-type of. + * @param documentContentType Content-Type to use for protocol documents. + * @return Returns the optionally resolved content-type of the request. + */ + public Optional determineRequestContentType(ToShapeId operation, String documentContentType) { + return determineRequestContentType(operation, documentContentType, null); + } + /** * Returns the expected request Content-Type of the given operation. * @@ -260,14 +275,16 @@ public TimestampFormatTrait.Format determineTimestampFormat( * to the payload, then the following checks are made: * *

* *

If no members are sent in the payload, an empty Optional is @@ -275,11 +292,31 @@ public TimestampFormatTrait.Format determineTimestampFormat( * * @param operation Operation to determine the content-type of. * @param documentContentType Content-Type to use for protocol documents. + * @param eventStreamContentType Content-Type to use for event streams. * @return Returns the optionally resolved content-type of the request. */ - public Optional determineRequestContentType(ToShapeId operation, String documentContentType) { - String contentType = determineContentType(getRequestBindings(operation).values(), documentContentType); - return Optional.ofNullable(contentType); + public Optional determineRequestContentType( + ToShapeId operation, + String documentContentType, + String eventStreamContentType + ) { + Collection bindings = getRequestBindings(operation).values(); + return Optional.ofNullable(determineContentType(bindings, documentContentType, eventStreamContentType)); + } + + /** + * Returns the expected response Content-Type of the given operation + * or error. + * + *

See {@link #determineResponseContentType(ToShapeId, String, String)} + * for documentation on how the content-type is resolved. + * + * @param operationOrError Operation or error to determine the content-type of. + * @param documentContentType Content-Type to use for protocol documents. + * @return Returns the optionally resolved content-type of the response. + */ + public Optional determineResponseContentType(ToShapeId operationOrError, String documentContentType) { + return determineResponseContentType(operationOrError, documentContentType, null); } /** @@ -291,14 +328,16 @@ public Optional determineRequestContentType(ToShapeId operation, String * to the payload, then the following checks are made: * *

* *

If no members are sent in the payload, an empty Optional is @@ -306,35 +345,48 @@ public Optional determineRequestContentType(ToShapeId operation, String * * @param operationOrError Operation or error to determine the content-type of. * @param documentContentType Content-Type to use for protocol documents. + * @param eventStreamContentType Content-Type used for event streams. * @return Returns the optionally resolved content-type of the response. */ - public Optional determineResponseContentType(ToShapeId operationOrError, String documentContentType) { - String contentType = determineContentType(getResponseBindings(operationOrError).values(), documentContentType); - return Optional.ofNullable(contentType); + public Optional determineResponseContentType( + ToShapeId operationOrError, + String documentContentType, + String eventStreamContentType + ) { + Collection bindings = getResponseBindings(operationOrError).values(); + return Optional.ofNullable(determineContentType(bindings, documentContentType, eventStreamContentType)); } - private String determineContentType(Collection bindings, String documentContentType) { + private String determineContentType( + Collection bindings, + String documentContentType, + String eventStreamContentType + ) { for (HttpBinding binding : bindings) { if (binding.getLocation() == HttpBinding.Location.DOCUMENT) { return documentContentType; } if (binding.getLocation() == HttpBinding.Location.PAYLOAD) { - Shape target = model.getShape(binding.getMember().getTarget()).orElse(null); + if (binding.getMember().hasTrait(EventStreamTrait.class)) { + return eventStreamContentType; + } + Shape target = model.getShape(binding.getMember().getTarget()).orElse(null); if (target == null) { + // Can't determine the content-type because the model is broken :( + // Let other parts of the validation system point this out. break; - } - - // Use the @mediaType trait if available. - if (target.getTrait(MediaTypeTrait.class).isPresent()) { + } else if (target.isDocumentShape() || target.isStructureShape()) { + // Document type and structure targets are always the document content-type. + return documentContentType; + } else if (target.getTrait(MediaTypeTrait.class).isPresent()) { + // Use the @mediaType trait if available. return target.getTrait(MediaTypeTrait.class).get().getValue(); } else if (target.isBlobShape()) { return "application/octet-stream"; } else if (target.isStringShape()) { return "text/plain"; - } else if (target.isDocumentShape() || target.isStructureShape()) { - return documentContentType; } } } diff --git a/smithy-model/src/test/java/software/amazon/smithy/model/knowledge/HttpBindingIndexTest.java b/smithy-model/src/test/java/software/amazon/smithy/model/knowledge/HttpBindingIndexTest.java index ac49dbbbbc2..3c98b91429c 100644 --- a/smithy-model/src/test/java/software/amazon/smithy/model/knowledge/HttpBindingIndexTest.java +++ b/smithy-model/src/test/java/software/amazon/smithy/model/knowledge/HttpBindingIndexTest.java @@ -260,7 +260,7 @@ public void resolvesBlobBodyContentType() { } @Test - public void resolvesMediaTypeContentType() { + public void resolvesMediaType() { HttpBindingIndex index = model.getKnowledge(HttpBindingIndex.class); ShapeId operation = ShapeId.from("ns.foo#ServiceOperationWithMediaType"); Optional contentType = index.determineResponseContentType(operation, "application/json"); @@ -268,6 +268,25 @@ public void resolvesMediaTypeContentType() { assertThat(contentType, equalTo(Optional.of("application/xml"))); } + @Test + public void resolvesResponseEventStreamMediaType() { + HttpBindingIndex index = model.getKnowledge(HttpBindingIndex.class); + ShapeId operation = ShapeId.from("ns.foo#ServiceOperationWithEventStream"); + String expected = "application/vnd.amazon.eventstream"; + Optional contentType = index.determineResponseContentType(operation, "ignore/me", expected); + + assertThat(contentType, equalTo(Optional.of(expected))); + } + + @Test + public void resolvesDocumentMediaType() { + HttpBindingIndex index = model.getKnowledge(HttpBindingIndex.class); + ShapeId operation = ShapeId.from("ns.foo#ServiceOperationExplicitMembers"); + Optional contentType = index.determineResponseContentType(operation, "application/json"); + + assertThat(contentType, equalTo(Optional.of("application/json"))); + } + private static MemberShape expectMember(Model model, String id) { ShapeId shapeId = ShapeId.from(id); return model.expectShape(shapeId).asMemberShape().get(); diff --git a/smithy-model/src/test/resources/software/amazon/smithy/model/knowledge/http-index.json b/smithy-model/src/test/resources/software/amazon/smithy/model/knowledge/http-index.json index 7d77eb6a86f..f17192c3466 100644 --- a/smithy-model/src/test/resources/software/amazon/smithy/model/knowledge/http-index.json +++ b/smithy-model/src/test/resources/software/amazon/smithy/model/knowledge/http-index.json @@ -19,6 +19,9 @@ }, { "target": "ns.foo#WithLabels" + }, + { + "target": "ns.foo#ServiceOperationWithEventStream" } ] }, @@ -288,6 +291,39 @@ "value": { "target": "smithy.api#String" } + }, + "ns.foo#ServiceOperationWithEventStream": { + "type": "operation", + "output": { + "target": "ns.foo#ServiceOperationWithEventStreamOutput" + }, + "traits": { + "smithy.api#http": { + "uri": "/ServiceOperationWithEventStream", + "method": "POST", + "code": 200 + } + } + }, + "ns.foo#ServiceOperationWithEventStreamOutput": { + "type": "structure", + "members": { + "events": { + "target": "ns.foo#EventStream", + "traits": { + "smithy.api#httpPayload": true, + "smithy.api#eventStream": true + } + } + } + }, + "ns.foo#EventStream": { + "type": "structure", + "members": { + "foo": { + "target": "smithy.api#String" + } + } } } } diff --git a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/mappers/UnsupportedTraits.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/mappers/UnsupportedTraits.java index 051b5f0100f..9e54768cd03 100644 --- a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/mappers/UnsupportedTraits.java +++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/mappers/UnsupportedTraits.java @@ -35,8 +35,7 @@ */ public final class UnsupportedTraits implements OpenApiMapper { private static final Logger LOGGER = Logger.getLogger(UnsupportedTraits.class.getName()); - private static final Set TRAITS = SetUtils.of( - "eventStream", "eventPayload", "eventHeader", "streaming"); + private static final Set TRAITS = SetUtils.of("endpoint", "hostLabel"); @Override public byte getOrder() { diff --git a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AbstractRestProtocol.java b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AbstractRestProtocol.java index 48b6d17c479..a7ce550caf5 100644 --- a/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AbstractRestProtocol.java +++ b/smithy-openapi/src/main/java/software/amazon/smithy/openapi/fromsmithy/protocols/AbstractRestProtocol.java @@ -23,6 +23,8 @@ import java.util.Optional; import java.util.TreeMap; import software.amazon.smithy.jsonschema.Schema; +import software.amazon.smithy.model.knowledge.EventStreamIndex; +import software.amazon.smithy.model.knowledge.EventStreamInfo; import software.amazon.smithy.model.knowledge.HttpBinding; import software.amazon.smithy.model.knowledge.HttpBindingIndex; import software.amazon.smithy.model.knowledge.OperationIndex; @@ -61,6 +63,8 @@ */ abstract class AbstractRestProtocol implements OpenApiProtocol { + private static final String AWS_EVENT_STREAM_CONTENT_TYPE = "application/vnd.amazon.eventstream"; + /** The type of message being created. */ enum MessageType { REQUEST, RESPONSE, ERROR } @@ -102,11 +106,12 @@ public Optional createOperation(Context context, OperationShape op String uri = context.getOpenApiProtocol().getOperationUri(context, operation); OperationObject.Builder builder = OperationObject.builder().operationId(operation.getId().getName()); HttpBindingIndex bindingIndex = context.getModel().getKnowledge(HttpBindingIndex.class); + EventStreamIndex eventStreamIndex = context.getModel().getKnowledge(EventStreamIndex.class); createPathParameters(context, operation).forEach(builder::addParameter); createQueryParameters(context, operation).forEach(builder::addParameter); createRequestHeaderParameters(context, operation).forEach(builder::addParameter); - createRequestBody(context, bindingIndex, operation).ifPresent(builder::requestBody); - createResponses(context, bindingIndex, operation).forEach(builder::putResponse); + createRequestBody(context, bindingIndex, eventStreamIndex, operation).ifPresent(builder::requestBody); + createResponses(context, bindingIndex, eventStreamIndex, operation).forEach(builder::putResponse); return Operation.create(method, uri, builder); }); } @@ -223,17 +228,43 @@ private Map createHeaderParameters( private Optional createRequestBody( Context context, HttpBindingIndex bindingIndex, + EventStreamIndex eventStreamIndex, OperationShape operation ) { List payloadBindings = bindingIndex.getRequestBindings( operation, HttpBinding.Location.PAYLOAD); + + // Get the default media type if one cannot be resolved. String documentMediaType = getDocumentMediaType(context, operation, MessageType.REQUEST); - String mediaType = bindingIndex.determineRequestContentType(operation, documentMediaType).orElse(null); + + // Get the event stream media type if an event stream is in use. + String eventStreamMediaType = eventStreamIndex.getInputInfo(operation) + .map(info -> getEventStreamMediaType(context, info)) + .orElse(null); + + String mediaType = bindingIndex + .determineRequestContentType(operation, documentMediaType, eventStreamMediaType) + .orElse(null); + return payloadBindings.isEmpty() ? createRequestDocument(mediaType, context, bindingIndex, operation) : createRequestPayload(mediaType, context, payloadBindings.get(0)); } + /** + * Gets the media type of an event stream for the protocol. + * + *

By default, this method returns the binary AWS event stream + * media type, {@code application/vnd.amazon.eventstream}. + * + * @param context Conversion context. + * @param info Event stream info to provide the media type for. + * @return Returns the media type of the event stream. + */ + protected String getEventStreamMediaType(Context context, EventStreamInfo info) { + return AWS_EVENT_STREAM_CONTENT_TYPE; + } + private Optional createRequestPayload( String mediaTypeRange, Context context, @@ -277,15 +308,20 @@ private Optional createRequestDocument( private Map createResponses( Context context, HttpBindingIndex bindingIndex, + EventStreamIndex eventStreamIndex, OperationShape operation ) { Map result = new TreeMap<>(); OperationIndex operationIndex = context.getModel().getKnowledge(OperationIndex.class); + operationIndex.getOutput(operation).ifPresent(output -> { - updateResponsesMapWithResponseStatusAndObject(context, bindingIndex, operation, output, result); + updateResponsesMapWithResponseStatusAndObject( + context, bindingIndex, eventStreamIndex, operation, output, result); }); + for (StructureShape error : operationIndex.getErrors(operation)) { - updateResponsesMapWithResponseStatusAndObject(context, bindingIndex, operation, error, result); + updateResponsesMapWithResponseStatusAndObject( + context, bindingIndex, eventStreamIndex, operation, error, result); } return result; } @@ -293,19 +329,22 @@ private Map createResponses( private void updateResponsesMapWithResponseStatusAndObject( Context context, HttpBindingIndex bindingIndex, + EventStreamIndex eventStreamIndex, OperationShape operation, StructureShape shape, Map responses ) { Shape operationOrError = shape.hasTrait(ErrorTrait.class) ? shape : operation; String statusCode = context.getOpenApiProtocol().getOperationResponseStatusCode(context, operationOrError); - ResponseObject response = createResponse(context, bindingIndex, statusCode, operationOrError); + ResponseObject response = createResponse( + context, bindingIndex, eventStreamIndex, statusCode, operationOrError); responses.put(statusCode, response); } private ResponseObject createResponse( Context context, HttpBindingIndex bindingIndex, + EventStreamIndex eventStreamIndex, String statusCode, Shape operationOrError ) { @@ -313,7 +352,7 @@ private ResponseObject createResponse( responseBuilder.description(String.format("%s %s response", operationOrError.getId().getName(), statusCode)); createResponseHeaderParameters(context, operationOrError) .forEach((k, v) -> responseBuilder.putHeader(k, Ref.local(v))); - addResponseContent(context, bindingIndex, responseBuilder, operationOrError); + addResponseContent(context, bindingIndex, eventStreamIndex, responseBuilder, operationOrError); return responseBuilder.build(); } @@ -329,13 +368,24 @@ private Map createResponseHeaderParameters( private void addResponseContent( Context context, HttpBindingIndex bindingIndex, + EventStreamIndex eventStreamIndex, ResponseObject.Builder responseBuilder, Shape operationOrError ) { List payloadBindings = bindingIndex.getResponseBindings( operationOrError, HttpBinding.Location.PAYLOAD); + + // Get the default media type if one cannot be resolved. String documentMediaType = getDocumentMediaType(context, operationOrError, MessageType.RESPONSE); - String mediaType = bindingIndex.determineResponseContentType(operationOrError, documentMediaType).orElse(null); + + // Get the event stream media type if an event stream is in use. + String eventStreamMediaType = eventStreamIndex.getOutputInfo(operationOrError) + .map(info -> getEventStreamMediaType(context, info)) + .orElse(null); + + String mediaType = bindingIndex + .determineResponseContentType(operationOrError, documentMediaType, eventStreamMediaType) + .orElse(null); if (!payloadBindings.isEmpty()) { createResponsePayload(mediaType, context, payloadBindings.get(0), responseBuilder); diff --git a/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/OpenApiConverterTest.java b/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/OpenApiConverterTest.java index 2a24fa2aaf9..0277dac15df 100644 --- a/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/OpenApiConverterTest.java +++ b/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/OpenApiConverterTest.java @@ -274,4 +274,21 @@ public void mergesInSchemaDocumentExtensions() { assertThat(result.getMember("foo"), equalTo(Optional.of(Node.from("baz")))); } + + // Streaming traits are converted to just an application/octet-stream + @Test + public void convertsStreamingService() { + Model model = Model.assembler() + .addImport(getClass().getResource("streaming-service.smithy")) + .discoverModels() + .assemble() + .unwrap(); + ObjectNode result = OpenApiConverter.create() + .putSetting(OpenApiConstants.PROTOCOL, "aws.protocols#restJson1") + .convertToNode(model, ShapeId.from("smithy.example#Streaming")); + Node expectedNode = Node.parse(IoUtils.toUtf8String( + getClass().getResourceAsStream("streaming-service.openapi.json"))); + + Node.assertEquals(result, expectedNode); + } } diff --git a/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/mappers/UnsupportedTraitsPluginTest.java b/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/mappers/UnsupportedTraitsPluginTest.java index 1f3822cadc0..00700508ea1 100644 --- a/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/mappers/UnsupportedTraitsPluginTest.java +++ b/smithy-openapi/src/test/java/software/amazon/smithy/openapi/fromsmithy/mappers/UnsupportedTraitsPluginTest.java @@ -16,7 +16,7 @@ public class UnsupportedTraitsPluginTest { @BeforeAll public static void before() { model = Model.assembler() - .addImport(UnsupportedTraitsPluginTest.class.getResource("streaming-service.smithy")) + .addImport(UnsupportedTraitsPluginTest.class.getResource("endpoint-service.smithy")) .discoverModels() .assemble() .unwrap(); @@ -31,15 +31,15 @@ public static void after() { public void logsWhenUnsupportedTraitsAreFound() { OpenApiConverter.create() .putSetting(OpenApiConstants.IGNORE_UNSUPPORTED_TRAITS, true) - .convert(model, ShapeId.from("smithy.example#Streaming")); + .convert(model, ShapeId.from("smithy.example#EndpointService")); } @Test public void throwsWhenUnsupportedTraitsAreFound() { Exception thrown = Assertions.assertThrows(OpenApiException.class, () -> { - OpenApiConverter.create().convert(model, ShapeId.from("smithy.example#Streaming")); + OpenApiConverter.create().convert(model, ShapeId.from("smithy.example#EndpointService")); }); - Assertions.assertTrue(thrown.getMessage().contains("streaming")); + Assertions.assertTrue(thrown.getMessage().contains("endpoint")); } } diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/endpoint-service.smithy b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/endpoint-service.smithy new file mode 100644 index 00000000000..ba5559339ae --- /dev/null +++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/endpoint-service.smithy @@ -0,0 +1,13 @@ +// Endpoint traits are not currently supported. +namespace smithy.example + +@aws.protocols#restJson1 +service EndpointService { + version: "2018-01-01", + operations: [EndpointOperation] +} + +@http(method: "GET", uri: "/") +@readonly +@endpoint(hostPrefix: "prefix.") +operation EndpointOperation {} diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/streaming-service.openapi.json b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/streaming-service.openapi.json new file mode 100644 index 00000000000..0279d578b5d --- /dev/null +++ b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/streaming-service.openapi.json @@ -0,0 +1,27 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Streaming", + "version": "2018-01-01" + }, + "paths": { + "/": { + "get": { + "operationId": "StreamingOperation", + "responses": { + "200": { + "description": "StreamingOperation 200 response", + "content": { + "application/octet-stream": { + "schema": { + "type": "string", + "format": "byte" + } + } + } + } + } + } + } + } +} diff --git a/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/streaming-service.smithy b/smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/streaming-service.smithy similarity index 100% rename from smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/mappers/streaming-service.smithy rename to smithy-openapi/src/test/resources/software/amazon/smithy/openapi/fromsmithy/streaming-service.smithy