diff --git a/build.sc b/build.sc index 292db90d..fcf58fa1 100644 --- a/build.sc +++ b/build.sc @@ -161,7 +161,7 @@ object core extends BaseJavaModule { object protobuf extends BaseJavaModule {} val scalaVersionsMap = - Map("2.13" -> "2.13.7", "2.12" -> "2.12.17", "3" -> "3.3.0") + Map("2.13" -> "2.13.15", "2.12" -> "2.12.17", "3" -> "3.3.0") object openapi extends Cross[OpenapiModule](scalaVersionsMap.keys.toList) trait OpenapiModule extends BaseCrossScalaModule { val crossVersion = crossValue diff --git a/modules/openapi/src/alloy/openapi/AlloyOpenApiExtension.scala b/modules/openapi/src/alloy/openapi/AlloyOpenApiExtension.scala index 7d997167..6e9a531c 100644 --- a/modules/openapi/src/alloy/openapi/AlloyOpenApiExtension.scala +++ b/modules/openapi/src/alloy/openapi/AlloyOpenApiExtension.scala @@ -41,7 +41,8 @@ final class AlloyOpenApiExtension() extends Smithy2OpenApiExtension { new RemoveEmptyComponents(), new AddTags(), new ExternalDocumentationMapperOpenApi(), - new DiscriminatedUnionMemberComponents() + new DiscriminatedUnionMemberComponents(), + new MakeHeadersOptionalMapper() ).asJava override def getJsonSchemaMappers(): ju.List[JsonSchemaMapper] = List( diff --git a/modules/openapi/src/alloy/openapi/MakeHeadersOptionalMapper.scala b/modules/openapi/src/alloy/openapi/MakeHeadersOptionalMapper.scala new file mode 100644 index 00000000..be4be319 --- /dev/null +++ b/modules/openapi/src/alloy/openapi/MakeHeadersOptionalMapper.scala @@ -0,0 +1,109 @@ +/* Copyright 2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package alloy.openapi + +import software.amazon.smithy.openapi.fromsmithy.OpenApiMapper +import software.amazon.smithy.openapi.fromsmithy.Context +import software.amazon.smithy.openapi.model.OpenApi +import scala.jdk.CollectionConverters._ +import scala.jdk.OptionConverters._ +import software.amazon.smithy.model.traits.Trait +import software.amazon.smithy.openapi.fromsmithy.protocols.ExtensionKeys +import software.amazon.smithy.model.node.Node +import software.amazon.smithy.openapi.model.PathItem +import software.amazon.smithy.openapi.model.OperationObject +import software.amazon.smithy.openapi.model.Ref +import software.amazon.smithy.openapi.model.ParameterObject +import software.amazon.smithy.openapi.model.ResponseObject + +/** Will make all headers optional for responses which have the + * `ExtensionKeys.shouldMakeHeadersOptional` set to true. + */ +final class MakeHeadersOptionalMapper extends OpenApiMapper { + override def after( + context: Context[_ <: Trait], + openapi: OpenApi + ): OpenApi = { + val openBuilder = openapi.toBuilder() + openapi.getPaths().asScala.foreach { case (pathKey, pathItem) => + val pathBuilder = pathItem.toBuilder() + pathItem.getOperations().asScala.foreach { case (opMethod, op) => + val opBuilder = op.toBuilder() + op.getResponses().asScala.foreach { case (responseKey, response) => + val responseBuilder = response.toBuilder() + val shouldMakeHeadersOptional = response + .getExtension(ExtensionKeys.shouldMakeHeadersOptional) + .toScala + .contains(Node.from(true)) + if (shouldMakeHeadersOptional) { + response.getHeaders().asScala.foreach { + case (headerKey, headerRef) => + makeHeadersOptional( + openapi, + pathBuilder, + opMethod, + opBuilder, + responseKey, + responseBuilder, + headerKey, + headerRef + ) + } + } + } + } + openBuilder.putPath(pathKey, pathBuilder.build()) + + } + openBuilder.build() + } + + private def makeHeadersOptional( + openapi: OpenApi, + pathBuilder: PathItem.Builder, + opMethod: String, + opBuilder: OperationObject.Builder, + responseKey: String, + responseBuilder: ResponseObject.Builder, + headerKey: String, + headerRef: Ref[ParameterObject] + ): PathItem.Builder = { + val deref = headerRef.deref(openapi.getComponents()) + val updated = deref.toBuilder().required(false).build() + opBuilder.putResponse( + responseKey, + responseBuilder + .putHeader(headerKey, Ref.local[ParameterObject](updated)) + .removeExtension(ExtensionKeys.shouldMakeHeadersOptional) + .build() + ) + updatePathItem(pathBuilder, opMethod, opBuilder.build()) + } + + private def updatePathItem( + pi: PathItem.Builder, + method: String, + op: OperationObject + ): PathItem.Builder = method.toUpperCase match { + case "GET" => pi.get(op) + case "PUT" => pi.put(op) + case "POST" => pi.post(op) + case "DELETE" => pi.delete(op) + case "OPTIONS" => pi.options(op) + case "HEAD" => pi.head(op) + case "TRACE" => pi.trace(op) + } +} diff --git a/modules/openapi/src/software/amazon/smithy/openapi/fromsmithy/protocols/AlloyAbstractRestProtocol.scala b/modules/openapi/src/software/amazon/smithy/openapi/fromsmithy/protocols/AlloyAbstractRestProtocol.scala index 817b9724..8962c7b4 100644 --- a/modules/openapi/src/software/amazon/smithy/openapi/fromsmithy/protocols/AlloyAbstractRestProtocol.scala +++ b/modules/openapi/src/software/amazon/smithy/openapi/fromsmithy/protocols/AlloyAbstractRestProtocol.scala @@ -120,11 +120,70 @@ abstract class AlloyAbstractRestProtocol[T <: Trait] createRequestBody(context, bindingIndex, operation) .foreach(builder.requestBody) createResponses(context, bindingIndex, operation) - .foreach { case (k, v) => builder.putResponse(k, v) } + .foreach { case (k, values) => + combineResponseContent(values, k).foreach(v => + builder.putResponse(k, v) + ) + } Operation.create(method, uri, builder) }) .asJava + def combineResponseContent( + responses: List[ResponseObject], + statusCode: String + ): Option[ResponseObject] = { + responses match { + case Nil => None + case head :: Nil => Some(head) + case head :: tail => + val all = head +: tail + val mediaTypeObjects: List[MediaTypeObject] = + all.flatMap(_.getContent().asScala.toList.map { + case (_, mediaTypeObject) => mediaTypeObject + }) + val schemas = + mediaTypeObjects.flatMap(_.getSchema().asScala) + val newSchema = Schema.builder().oneOf(schemas.asJava).build() + val newExamples = mediaTypeObjects + .flatMap(_.getExamples().asScala.toList) + .toMap + .map { case (k, exampleObj) => k -> exampleObj.toNode() } + val media = MediaTypeObject.builder + .examples( + newExamples.asJava + ) + .schema(newSchema) + .build() + // `AlloyAbstractRestProtocol` only supports a single content-type, application/json + // This is why we can just use the content from `head` here + val newContent = + head.getContent().asScala.map { case (k, _) => k -> media } + val newHeaders = + responses.map(_.getHeaders().asScala).reduce(_ ++ _) + // if all headers are the same, no need to make them all optional + // but if headers differ between error responses, we have to make + // them all optional as they won't all be used in every case + val shouldMakeHeadersOptional: Boolean = + responses.map(_.getHeaders().asScala.toSet).distinct.length != 1 + val responseBuilder = head.toBuilder() + + if (shouldMakeHeadersOptional) { + responseBuilder.putExtension( + ExtensionKeys.shouldMakeHeadersOptional, + true + ) + } + Some( + responseBuilder + .content(newContent.asJava) + .headers(newHeaders.asJava) + .description(s"$statusCode Response") + .build() + ) + } + } + def createPathParameters( context: Context[T], operation: OperationShape @@ -401,7 +460,7 @@ abstract class AlloyAbstractRestProtocol[T <: Trait] // operation shape. val updatedModel = context.getModel().toBuilder().addShape(operation).build() - val result = new util.TreeMap[String, ResponseObject] + val result = new util.TreeMap[String, List[ResponseObject]] val operationIndex = OperationIndex.of(updatedModel) operationIndex .getOutputShape(operation) @@ -467,7 +526,7 @@ abstract class AlloyAbstractRestProtocol[T <: Trait] bindingIndex: HttpBindingIndex, operation: OperationShape, shape: StructureShape, - responses: util.Map[String, ResponseObject] + responses: util.Map[String, List[ResponseObject]] ) = { val operationOrError = reorganizeExampleTraits(operation, shape) val statusCode = context.getOpenApiProtocol.getOperationResponseStatusCode( @@ -480,7 +539,14 @@ abstract class AlloyAbstractRestProtocol[T <: Trait] statusCode, operationOrError ) - responses.put(statusCode, response) + val currentResponses: Option[List[ResponseObject]] = Option( + responses.get(statusCode) + ) + val updatedResponses = currentResponses match { + case Some(current) => current :+ response + case None => List(response) + } + responses.put(statusCode, updatedResponses) } private def createResponse( diff --git a/modules/openapi/src/software/amazon/smithy/openapi/fromsmithy/protocols/ExtensionKeys.scala b/modules/openapi/src/software/amazon/smithy/openapi/fromsmithy/protocols/ExtensionKeys.scala new file mode 100644 index 00000000..555128d6 --- /dev/null +++ b/modules/openapi/src/software/amazon/smithy/openapi/fromsmithy/protocols/ExtensionKeys.scala @@ -0,0 +1,20 @@ +/* Copyright 2022 Disney Streaming + * + * Licensed under the Tomorrow Open Source Technology License, Version 1.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://disneystreaming.github.io/TOST-1.0.txt + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package software.amazon.smithy.openapi.fromsmithy.protocols + +object ExtensionKeys { + val shouldMakeHeadersOptional = "SHOULD_MAKE_HEADERS_OPTIONAL" +} diff --git a/modules/openapi/test/resources/foo.json b/modules/openapi/test/resources/foo.json index 279749e9..9cb6b639 100644 --- a/modules/openapi/test/resources/foo.json +++ b/modules/openapi/test/resources/foo.json @@ -116,6 +116,17 @@ } } }, + "404": { + "description": "404Response", + "headers": { + "x-error-one": { + "schema": { + "type": "string" + }, + "required": true + } + } + }, "500": { "description": "GeneralServerError500response", "content": { @@ -144,6 +155,11 @@ "in": "testinput" } }, + "THREE": { + "value": { + "in": "testinputthree" + } + }, "TWO": { "value": { "in": "testinputtwo" @@ -173,13 +189,42 @@ } }, "404": { - "description": "NotFound404response", + "description": "404Response", + "headers": { + "x-one": { + "schema": { + "type": "string" + } + }, + "x-three": { + "schema": { + "type": "string" + } + }, + "x-two": { + "schema": { + "type": "string" + } + } + }, "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/NotFoundResponseContent" + "oneOf": [ + { + "$ref": "#/components/schemas/NotFoundResponseContent" + }, + { + "$ref": "#/components/schemas/NotFoundTwoResponseContent" + } + ] }, "examples": { + "THREE": { + "value": { + "messageTwo": "Notfoundmessagetwo" + } + }, "TWO": { "value": { "message": "Notfoundmessage" @@ -424,6 +469,17 @@ "message" ] }, + "NotFoundTwoResponseContent": { + "type": "object", + "properties": { + "messageTwo": { + "type": "string" + } + }, + "required": [ + "messageTwo" + ] + }, "SomeValue": { "oneOf": [ { diff --git a/modules/openapi/test/resources/foo.smithy b/modules/openapi/test/resources/foo.smithy index a525217f..a1de1dc0 100644 --- a/modules/openapi/test/resources/foo.smithy +++ b/modules/openapi/test/resources/foo.smithy @@ -2,203 +2,245 @@ $version: "2" namespace foo -use alloy#simpleRestJson +use alloy#dataExamples use alloy#discriminated +use alloy#jsonUnknown use alloy#nullable +use alloy#simpleRestJson use alloy#untagged -use alloy#dataExamples -use alloy#jsonUnknown @simpleRestJson -@externalDocumentation( - "API Homepage": "https://www.example.com/", - "API Ref": "https://www.example.com/api-ref", -) +@externalDocumentation("API Homepage": "https://www.example.com/", "API Ref": "https://www.example.com/api-ref") service HelloWorldService { - version: "0.0.1", - errors: [GeneralServerError], - operations: [Greet, GetUnion, GetValues, TestErrorsInExamples] -} - -@externalDocumentation( - "API Homepage 2": "https://www.example2.com/", - "API Ref 2": "https://www.example2.com/api-ref", -) + version: "0.0.1" + errors: [ + GeneralServerError + ] + operations: [ + Greet + GetUnion + GetValues + TestErrorsInExamples + ] +} + +@externalDocumentation("API Homepage 2": "https://www.example2.com/", "API Ref 2": "https://www.example2.com/api-ref") @readonly @http(method: "GET", uri: "/hello/{name}/{ts}") operation Greet { - input: Person, - output: Greeting + input: Person + output: Greeting + errors: [ + GreetErrorOne + GreetErrorTwo + ] +} + +@error("client") +@httpError(404) +structure GreetErrorOne { + @httpHeader("x-error-one") + @required + headerOneA: String +} + +@error("client") +@httpError(404) +structure GreetErrorTwo { + @httpHeader("x-error-one") + @required + headerOneB: String } @error("client") @httpError(404) structure NotFound { - @required - message: String + @httpHeader("x-one") + headerOne: String + + @required + message: String +} + +@error("client") +@httpError(404) +structure NotFoundTwo { + @httpHeader("x-two") + headerTwo: String + + @httpHeader("x-three") + @required + headerThree: String + + @required + messageTwo: String } @examples([ - { - title: "ONE" - input: { - in: "test input" + { + title: "ONE" + input: { in: "test input" } + output: { out: "test output" } } - output: { - out: "test output" - } - } - { - title: "TWO" - input: { - in: "test input two" + { + title: "TWO" + input: { in: "test input two" } + error: { + shapeId: NotFound + content: { message: "Not found message" } + } } - error: { - shapeId: NotFound - content: { - message: "Not found message" + { + title: "THREE" + input: { in: "test input three" } + error: { + shapeId: NotFoundTwo + content: { messageTwo: "Not found message two", headerThree: "test" } } } - } ]) @http(method: "POST", uri: "/test_errors") operation TestErrorsInExamples { - input := { - @required - in: String - } - output := { - @required - out: String - } - errors: [NotFound] + input := { + @required + in: String + } + + output := { + @required + out: String + } + + errors: [ + NotFound + NotFoundTwo + ] } @readonly @http(method: "GET", uri: "/default") operation GetUnion { - output: GetUnionResponse + output: GetUnionResponse } @readonly @http(method: "GET", uri: "/values") operation GetValues { - output: ValuesResponse + output: ValuesResponse } structure Person { - @httpLabel - @required - name: String, + @httpLabel + @required + name: String - @httpHeader("X-Bamtech-Partner") - partner: String, + @httpHeader("X-Bamtech-Partner") + partner: String - @httpHeader("when") - when: Timestamp, + @httpHeader("when") + when: Timestamp - @httpHeader("whenAlso") - @timestampFormat("http-date") - whenTwo: Timestamp, + @httpHeader("whenAlso") + @timestampFormat("http-date") + whenTwo: Timestamp - @httpHeader("whenThree") - @timestampFormat("date-time") - whenThree: Timestamp, + @httpHeader("whenThree") + @timestampFormat("date-time") + whenThree: Timestamp - @httpHeader("whenFour") - @timestampFormat("epoch-seconds") - whenFour: Timestamp, + @httpHeader("whenFour") + @timestampFormat("epoch-seconds") + whenFour: Timestamp - @httpQuery("from") - from: Timestamp, + @httpQuery("from") + from: Timestamp - @httpLabel - @required - ts: Timestamp + @httpLabel + @required + ts: Timestamp } structure Greeting { - @required - @httpPayload - message: String + @required + @httpPayload + message: String } @error("server") @httpError(500) structure GeneralServerError { - message: String, + message: String - @nullable - count: Integer + @nullable + count: Integer } structure GetUnionResponse { - intOrString: IntOrString, - doubleOrFloat: DoubleOrFloat, - catOrDog: CatOrDog + intOrString: IntOrString + doubleOrFloat: DoubleOrFloat + catOrDog: CatOrDog } union IntOrString { - int: Integer, - string: String + int: Integer + string: String } union DoubleOrFloat { - float: Float, - double: Double + float: Float + double: Double } -@dataExamples([{ - smithy: { - name: "Meow" - } -}]) +@dataExamples([ + { + smithy: { name: "Meow" } + } +]) structure Cat { - name: String + name: String } -@dataExamples([{ - json: { - name: "Woof" - } -}]) +@dataExamples([ + { + json: { name: "Woof" } + } +]) structure Dog { - name: String, - breed: String - @jsonUnknown - attributes: Attributes + name: String + + breed: String + + @jsonUnknown + attributes: Attributes } map Attributes { - key: String - value: Document + key: String + value: Document } -@dataExamples([{ - string: "{\"values\": []}" -}]) +@dataExamples([ + { + string: "{\"values\": []}" + } +]) structure ValuesResponse { - values: Values + values: Values } list Values { - member: SomeValue + member: SomeValue } @untagged union SomeValue { - message: String, - value: Integer + message: String + value: Integer } @discriminated("type") -@externalDocumentation( - "Homepage": "https://www.example.com/", - "API Reference": "https://www.example.com/api-ref", -) +@externalDocumentation(Homepage: "https://www.example.com/", "API Reference": "https://www.example.com/api-ref") union CatOrDog { - cat: Cat, - dog: Dog + cat: Cat + dog: Dog } - -