Header Input validation returns text/plain instead of application/JSON

So it’s not obvious to me but I have some Header inputs that I use validate on to make sure it’s not empty when I map it to an Authentication token

header[String]("foo").validate(Validator.nonEmptyString)

Short version: I want to map validation errors to application/json and set the status code.

Longer version:

There’s an issue when I generate the OpenApi doc where it converts it to a 400 and text/plain with the error message. I would prefer this to be in application/Json

Using openapi-generator it doesn’t generate the correct handling for this anyway. The client expects text/plain but the Tapir server generates “text/plain; charset=UTF8” (or something to the effect) and the generated client blows up parsing this as a mismatch.

Working around this I was able to set the defaultHandlers

case class Unknown(msg: String)
def myFailureResponse(m: String): ValuedEndpointOutput[_] = ValuedEndpointOutput(jsonBody[Unknown], Unknown(m))

val serverOptions: Http4sServerOptions[IO] = Http4sServerOptions.customiseInterceptors[IO].defaultHandlers(myFailureResponse).metricsInterceptor(prometheusMetrics.metricsInterceptor()).options

This does force it to return application/json and the Json message I would want, but the generated OpenApi doc still specifies this as text/plain, so the client blows up because the json returned is not text/plain.

You can specify error types using .errorOut, for example:

sealed trait ApiError

case class BadRequestError(code: Int, description: String) extends ApiError
case object UnauthorizedErr extends ApiError

// ...

sealed trait ApiError

case class BadRequestError(code: Int, description: String) extends ApiError
case object UnauthorizedErr extends ApiError

  val booksListing: Endpoint[Unit, String, ApiError, List[Book], Any] =
    endpoint.get
      .in("books" / "list" / "all")
      .in(header[String]("foo").validate(Validator.nonEmptyString))
      .out(jsonBody[List[Book]])
      .errorOut(
        oneOf[ApiError](
          oneOfVariant(statusCode(StatusCode.BadRequest).and(jsonBody[BadRequestError])),
          oneOfVariant(statusCode(StatusCode.Unauthorized).and(emptyOutputAs(UnauthorizedErr)))
        )
      )  

This will generate OpenApi specification with 400s represented with the BadRequestError schema and application/json as content type.
(Still, the interceptor part is required to return the actual JSON on failed header validation).

Ok cool that worked thanks for your help. The docs weren’t clear on this, when I get a moment I’ll create a PR to mention this in the docs.

As per the open api generated mismatch of text/plain and “text/plain; charset=UTF8” is that a defect that needs to be opened?

I think it’s a defect of the openapi-generator. In this case, the server is indicating that the response is of type "text/plain" and also providing information about the character encoding used in the response by specifying "charset=UTF-8" in the "content-type" header. This is actually a good practice because it allows the client to correctly interpret the response.