Skip to content

Commit 504091b

Browse files
authored
codegen: Disambiguate when shared response model shared between multiple codes in same group, and handle 'default' error codes (#5370)
Closing #5284 and #5286 Re-uses the same 'wrapper' declarations we use for response headers, to handle the case where two status codes for the same method declare the same response body. This approach _does_ mean that there's a bunch of `sealed traits`s that get created for such endpoints, but they all inherit from the same parent `StatusCodeDisambig`. A given code is represented by a single object which inherits from the trait for each endpoint that can return that code. This permits some generic handling of errors. Example generated code: ```scala sealed trait StatusCodeDisambig sealed trait DeleteInlineSimpleObjectResponseCode extends StatusCodeDisambig sealed trait DeleteInlineSimpleObjectResponseErrCode extends StatusCodeDisambig case object StatusCodeDisambig200 extends DeleteInlineSimpleObjectResponseCode case object StatusCodeDisambig201 extends DeleteInlineSimpleObjectResponseCode case object StatusCodeDisambig400 extends PutInlineSimpleObjectResponseErrCode case object StatusCodeDisambig401 extends DeleteInlineSimpleObjectResponseErrCode with PutInlineSimpleObjectResponseErrCode case object StatusCodeDisambig402 extends DeleteInlineSimpleObjectResponseErrCode type DeleteInlineSimpleObjectEndpoint = Endpoint[Unit, Unit, DeleteInlineSimpleObjectResponseErrCode, DeleteInlineSimpleObjectResponseCode, Any] lazy val deleteInlineSimpleObject: DeleteInlineSimpleObjectEndpoint = endpoint .delete .in(("inline" / "simple" / "object")) .errorOut(oneOf[DeleteInlineSimpleObjectResponseErrCode]( oneOfVariantValueMatcher(sttp.model.StatusCode(401), emptyOutput.description("empty response 3").and(emptyOutputAs(StatusCodeDisambig401))){ case StatusCodeDisambig401 => true}, oneOfVariantValueMatcher(sttp.model.StatusCode(402), emptyOutput.description("empty response 4").and(emptyOutputAs(StatusCodeDisambig402))){ case StatusCodeDisambig402 => true})) .out(oneOf[DeleteInlineSimpleObjectResponseCode]( oneOfVariantValueMatcher(sttp.model.StatusCode(200), emptyOutput.description("empty response 1").and(emptyOutputAs(StatusCodeDisambig200))){ case StatusCodeDisambig200 => true}, oneOfVariantValueMatcher(sttp.model.StatusCode(201), emptyOutput.description("empty response 2").and(emptyOutputAs(StatusCodeDisambig201))){ case StatusCodeDisambig201 => true})) ``` for an endpoint declared as ```yaml '/inline/simple/object': delete: responses: "200": description: empty response 1 "201": description: empty response 2 "401": description: empty response 3 "402": description: empty response 4 ``` This handles 'default' codes by mapping to a case class wrapper rather than an object. So, for ```yaml ... responses: "204": description: "No response" "404": description: Not found content: application/json: schema: $ref: '#/components/schemas/NotFoundError' default: description: Generic error content: application/json: schema: $ref: '#/components/schemas/SimpleError' ``` you get ```scala ... case class StatusCodeDisambigDefault(code: sttp.model.StatusCode) extends GetOneofErrorSecParamTestResponseErrCode ... .errorOut(oneOf[(Error, GetOneofErrorSecParamTestResponseErrCode)]( oneOfVariantValueMatcher(sttp.model.StatusCode(404), jsonBody[NotFoundError].description("Not found").and(emptyOutputAs(StatusCodeDisambig404))){ case (_: NotFoundError, StatusCodeDisambig404) => true }, oneOfVariantValueMatcher(jsonBody[SimpleError].description("Generic error").and(statusCode.map(StatusCodeDisambigDefault(_))(_.code))){ case (_: SimpleError, (_: StatusCodeDisambigDefault)) => true })) .out(statusCode(sttp.model.StatusCode(204)).description("No response")) ``` (if no other 'competing' error codes, a default is more simply reducable, and ends up as just e.g. `.errorOut(jsonBody[SimpleError].description("Generic error").and(statusCode))`, but is still included as a param in the error param type) which I think is pretty good? I really wanted to explicitly avoid passing around a raw status code since it wasn't type-safe in terms of the status codes that can be represented on an endpoint... The shared parent trait and common 'tuple companions' make generic handling relatively nice, from what I've tried. Added a behaviour flag with default false so as to not break existing code. One issue with this is that if you had, say, 400, 401 and 403, and the 400 had a different response type, you'd still have that extra status code param for the 400 response, even though we could disambiguate without it. Also you can cause a match error at runtime by specifying a status code valid for a response of a different type. But it's a real pain to codegen something that'll work better here, because you still need that unified parent type -- so you'd need to introduce proper wrappers, rather than just tupling it... Since by far the most common use-case for this functionality is gonna be error responses, all the real specs I've seen that don't already have different json schemas for different error status codes use the same model for all error codes, and since generic handling becomes a bit more of a faff with wrappers rather than pairs, I think this still works out pretty well, so I'm happy enough with it.
1 parent e89d83d commit 504091b

24 files changed

Lines changed: 722 additions & 306 deletions

File tree

doc/generator/sbt-openapi-codegen.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,8 @@ openapiPackageDependencies Map.empty[String, String] Allow
5858
to a Map[String, Seq[String]] in the near future to permit multiple 'inheritance', and there may be bugs in the implementation.
5959
openapiSeperateFilesForModels false When true, models will be written to individual files under $pkg.models, with type aliases and helpers living under `package.scala` in a package object
6060
openapiAlwaysGenerateParamSupport false When true, all enums will be generated with param & json support, even if not used in those positions. This is useful for definitions that will be reused with `openapiPackageDependencies`
61+
openapiAddDisambiguationCodes false When true, if multiple status codes in same group (i.e. all error, or all success) return the same schema, they will be paired with a status code object as (T, StatusCode).
62+
'Default' codes will map to an additional StatusCode output. When false, the type will remain a T and default codes will be treated as 400s.
6163
===================================== ==================================== ==================================================================================================
6264
```
6365

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/RootGenerator.scala

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ object RootGenerator {
3636
useCustomJsoniterSerdes: Boolean,
3737
packageReuse: PackageReuseContext,
3838
seperateFilesForModels: Boolean,
39-
alwaysGenerateParamSupport: Boolean
39+
alwaysGenerateParamSupport: Boolean,
40+
addDisambiguationCodes: Boolean = true
4041
): GenerationInfo = {
4142
val doc = unNormalisedDoc.resolveAllOfSchemas
4243
NameValidation.validateDocumentNames(doc, useHeadTagForObjectNames)
@@ -83,7 +84,7 @@ object RootGenerator {
8384
endpointsByTag,
8485
queryOrPathParamRefs,
8586
enumsDefinedOnEndpointParams,
86-
EndpointDetails(jsonParamRefs, inlineDefns, xmlParamRefs, securityWrappers)
87+
EndpointDetails(jsonParamRefs, inlineDefns, xmlParamRefs, securityWrappers, _)
8788
) =
8889
endpointGenerator.endpointDefs(
8990
doc,
@@ -96,7 +97,8 @@ object RootGenerator {
9697
validators,
9798
generateValidators,
9899
packageReuse,
99-
seperateFilesForModels
100+
seperateFilesForModels,
101+
addDisambiguationCodes
100102
)
101103
val modelPackagePath = s"$packagePath.models"
102104
val GeneratedClassDefinitions(

openapi-codegen/core/src/main/scala/sttp/tapir/codegen/endpoints/EndpointGenerator.scala

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import io.circe.Json
44
import sttp.tapir.codegen.dedup.PackageReuseContext
55
import sttp.tapir.codegen.endpoints.SimpleTypes.mapSchemaSimpleTypeToType
66
import sttp.tapir.codegen.endpoints.InAndOutComponents._
7+
import sttp.tapir.codegen.endpoints.OutComponent.{generateSharedStatusCodeDisambiguators, statusCodeDisambigBaseTrait}
78
import sttp.tapir.codegen.json.JsonSerdeLib.JsonSerdeLib
89
import sttp.tapir.codegen.openapi.models._
910
import sttp.tapir.codegen.openapi.models.GenerationDirectives._
@@ -35,13 +36,15 @@ case class EndpointDetails(
3536
jsonParamRefs: Set[String],
3637
inlineDefns: Seq[String],
3738
xmlParamRefs: Set[String],
38-
securityWrappers: Set[SecurityWrapperDefn]
39+
securityWrappers: Set[SecurityWrapperDefn],
40+
statusCodeDisambig: Seq[(String, String)] = Nil
3941
) {
4042
def merge(that: EndpointDetails) = EndpointDetails(
4143
jsonParamRefs ++ that.jsonParamRefs,
4244
inlineDefns ++ that.inlineDefns,
4345
xmlParamRefs ++ that.xmlParamRefs,
44-
securityWrappers ++ that.securityWrappers
46+
securityWrappers ++ that.securityWrappers,
47+
statusCodeDisambig ++ that.statusCodeDisambig
4548
)
4649
}
4750
object EndpointDetails {
@@ -86,7 +89,8 @@ class EndpointGenerator {
8689
validators: ValidationDefns,
8790
generateValidators: Boolean,
8891
packageReuse: PackageReuseContext,
89-
seperateFilesForModels: Boolean
92+
seperateFilesForModels: Boolean,
93+
addDisambiguationCodes: Boolean
9094
): EndpointDefs = {
9195
val capabilities = capabilityImpl(streamingImplementation)
9296
val components = Option(doc.components).flatten
@@ -109,10 +113,19 @@ class EndpointGenerator {
109113
validators,
110114
generateValidators,
111115
packageReuse,
112-
seperateFilesForModels
116+
seperateFilesForModels,
117+
addDisambiguationCodes
113118
)
114119
)
115120
.foldLeft(GeneratedEndpoints(Nil, Set.empty, false, EndpointDetails.empty))(_ merge _)
121+
val statusCodeDisambig = details.statusCodeDisambig.distinct
122+
val inlineDefnsWithStatusCodeDisambig =
123+
if (statusCodeDisambig.isEmpty) details.inlineDefns
124+
else
125+
Seq(s"sealed trait $statusCodeDisambigBaseTrait") ++ details.inlineDefns ++ generateSharedStatusCodeDisambiguators(
126+
statusCodeDisambig
127+
).toSeq
128+
val enrichedDetails = details.copy(inlineDefns = inlineDefnsWithStatusCodeDisambig)
116129
val endpointDecls = endpointsByFile.map { case GeneratedEndpointsForFile(maybeFileName, ge) =>
117130
val definitions = ge
118131
.map { case GeneratedEndpoint(name, definition, maybeInlineDefns, types) =>
@@ -137,7 +150,7 @@ class EndpointGenerator {
137150
|$allEP
138151
|""".stripMargin
139152
}.toMap
140-
EndpointDefs(endpointDecls, queryOrPathParamRefs, definesEnumQueryParam, details)
153+
EndpointDefs(endpointDecls, queryOrPathParamRefs, definesEnumQueryParam, enrichedDetails)
141154
}
142155

143156
private[codegen] def generatedEndpoints(
@@ -151,7 +164,8 @@ class EndpointGenerator {
151164
validators: ValidationDefns,
152165
generateValidators: Boolean,
153166
packageReuse: PackageReuseContext,
154-
seperateFilesForModels: Boolean
167+
seperateFilesForModels: Boolean,
168+
addDisambiguationCodes: Boolean
155169
)(p: OpenapiPath): GeneratedEndpoints = {
156170
val parameters = components.map(_.parameters).getOrElse(Map.empty)
157171
val securitySchemes = components.map(_.securitySchemes).getOrElse(Map.empty)
@@ -206,7 +220,7 @@ class EndpointGenerator {
206220
packageReuse,
207221
seperateFilesForModels
208222
)
209-
val (outDecl, outTypes, errTypes, inlineDefns) =
223+
val (outDecl, outTypes, errTypes, inlineDefns, statusCodeDisambig) =
210224
OutComponent.outs(
211225
m.responses.map(_.resolve(doc)),
212226
streamingImplementation,
@@ -220,7 +234,8 @@ class EndpointGenerator {
220234
generateValidators,
221235
isReused,
222236
packageReuse,
223-
seperateFilesForModels
237+
seperateFilesForModels,
238+
addDisambiguationCodes
224239
)
225240
val allTypes = EndpointTypes(
226241
maybeSecurityPath.toSeq.flatMap(_._2) ++ securityTypes.toSeq,
@@ -280,7 +295,7 @@ class EndpointGenerator {
280295
(
281296
(maybeTargetFileName, GeneratedEndpoint(name, definition, maybeLocalEnums.filterNot(_ => isReused), allTypes)),
282297
(queryOrPathParamRefs, jsonParamRefs, xmlParamRefs),
283-
(maybeLocalEnums.isDefined && !isReused, inlineDefn, securityWrappers)
298+
(maybeLocalEnums.isDefined && !isReused, inlineDefn, securityWrappers, statusCodeDisambig)
284299
)
285300
} catch {
286301
case e: NotImplementedError => throw e
@@ -293,7 +308,10 @@ class EndpointGenerator {
293308
.groupBy(_._1)
294309
.toSeq
295310
.map { case (maybeTargetFileName, defns) => GeneratedEndpointsForFile(maybeTargetFileName, defns.map(_._2)) }
296-
val (definesParams, inlineDefns, securityWrappers) = inlineParamInfo.unzip3
311+
val definesParams = inlineParamInfo.map(_._1)
312+
val inlineDefns = inlineParamInfo.map(_._2)
313+
val securityWrappers = inlineParamInfo.map(_._3)
314+
val statusCodeDisambigs = inlineParamInfo.map(_._4)
297315
GeneratedEndpoints(
298316
namesAndParamsByFile,
299317
unflattenedQueryParamRefs.foldLeft(Set.empty[String])(_ ++ _),
@@ -302,7 +320,8 @@ class EndpointGenerator {
302320
unflattenedJsonParamRefs.foldLeft(Set.empty[String])(_ ++ _),
303321
inlineDefns.flatten,
304322
xmlParamRefs.flatten.toSet,
305-
securityWrappers.flatten.toSet
323+
securityWrappers.flatten.toSet,
324+
statusCodeDisambig = statusCodeDisambigs.flatten
306325
)
307326
)
308327
}
@@ -329,4 +348,4 @@ class EndpointGenerator {
329348
}
330349

331350
case class MappedContentType(bodyImpl: String, bodyType: String, inlineDefns: Option[String] = None, inlineTypes: Seq[String] = Nil)
332-
case class MappedOutGroup(decls: Option[String], types: Option[String], defns: Option[String])
351+
case class MappedOutGroup(decls: Option[String], types: Option[String], defns: Option[String], statusCodeDisambig: Seq[(String, String)] = Nil)

0 commit comments

Comments
 (0)