Skip to content

codegen: Disambiguate when shared response model shared between multiple codes in same group, and handle 'default' error codes#5370

Merged
adamw merged 9 commits into
softwaremill:masterfrom
hughsimpson:disambiguate_codes
Jul 13, 2026
Merged

codegen: Disambiguate when shared response model shared between multiple codes in same group, and handle 'default' error codes#5370
adamw merged 9 commits into
softwaremill:masterfrom
hughsimpson:disambiguate_codes

Conversation

@hughsimpson

@hughsimpson hughsimpson commented Jul 1, 2026

Copy link
Copy Markdown
Contributor

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 traitss 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:

  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

  '/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

...
      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

  ...
  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.

@jducoeur

jducoeur commented Jul 1, 2026

Copy link
Copy Markdown

Taking a quick look, that's not too far from how our legacy code generator handles the same problem, for much the same reasons. Not identical -- our version uses case class instead of case object, and the logic passes the response body and headers as fields of that -- but the goals are similar. I entirely agree that focusing on strongly-typed "response codes" is a good idea here.

@hughsimpson

hughsimpson commented Jul 3, 2026

Copy link
Copy Markdown
Contributor Author

I'm going to try some other variants next week because I hate the per-case handling, especially on clients.

EDIT: Tried something else and I think it ends up a lot nicer

@hughsimpson hughsimpson force-pushed the disambiguate_codes branch 2 times, most recently from 1c40c04 to 5c29f4a Compare July 8, 2026 07:59
@hughsimpson hughsimpson changed the title codegen: Disambiguate when shared response model shared between multiple codes in same group (WIP) codegen: Disambiguate when shared response model shared between multiple codes in same group Jul 12, 2026
@hughsimpson hughsimpson marked this pull request as ready for review July 12, 2026 22:07
@hughsimpson

hughsimpson commented Jul 12, 2026

Copy link
Copy Markdown
Contributor Author

Ok I don't hate this now, having tried to use it. Seems ergonomic enough to me. Although I think this is an important one to get right so I'm not gonna push it if there are objections or suggestions for improvement (breaking changes on this in the future could be a massive pita for anyone using it...)

I guess the primary candidate for an alternative would be for the 'leaf' types to be case class Status401[T](t: T), all inheriting from a sealed trait Status[T] { def t: T }, which would get away from the ability to represent invalid combinations in code; but since 99% of the use cases for this is error codes, I don't think that gives you as much as you might expect.

I think it extends tolerably well to 'default' if you make an extra case class DefaultError(code: StatudCode) leaf type. But not tried that yet... This worked out as desired.

@hughsimpson hughsimpson marked this pull request as draft July 13, 2026 07:42
@hughsimpson hughsimpson marked this pull request as ready for review July 13, 2026 09:44
@hughsimpson hughsimpson changed the title codegen: Disambiguate when shared response model shared between multiple codes in same group codegen: Disambiguate when shared response model shared between multiple codes in same group, and handle 'default' error codes Jul 13, 2026
@hughsimpson

Copy link
Copy Markdown
Contributor Author

@adamw Long time coming but I think this might be ready now

@adamw

adamw commented Jul 13, 2026

Copy link
Copy Markdown
Member

Great, thanks :)

@adamw adamw merged commit 504091b into softwaremill:master Jul 13, 2026
16 checks passed
@hughsimpson hughsimpson deleted the disambiguate_codes branch July 13, 2026 11:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants