Hacking oneOfBody to deal with legacy APIs when converting to tAPIr

Hi,
I’m dealing with a legacy application, and we’re refactoring APIs with tapir to provide static contract with schemas, and a better API in general.
One of the past sins we’re trying to fix, was “joker APIs”.
Basically, the old API looks something like GET /api/v1/files/${path},
and the server simply looks at path, and if it corresponds to a file, returns it with appropriate content-type.
e.g: GET /api/v1/files/dir/subdir/report.html will return report.html if exists with content type text/html.

Now, obviously this is a shitty API, looks like a security breach (it’s not really, there’s code to check given path has authorized access for user), no schema, not even a statically known format, etc’…

One interesting thing to note about this, is that most of the files are written in multiple formats.
e.g: the report.html which may contain some table with data, resides next to report.txt, which has an ascii-art of the same table and data. Also, report.csv, report.xml, report.json, etc’…

For some paths, there’s also custom code to pull Array[Byte] of the content from other places, when no actual file exists.

So, we’re thinking to add multiple APIs for entities like that report. Since I don’t really have the report entity type (yet; some of the files can be written by another service), I was thinking of doing the following:

GET /api/v2/entities/Report but using oneOfBody to reply with best format according to Accept header.
With tapir, since I don’t really have the Report type, nor any codec that can serialize it, I was thinking of implementing this API as follows:

sealed trait EntityContent {
  def format: CodecFormat
  def asInputStream: InputStream
}

case class TxtFileContent(txt: File) extends  EntityContent {
  override def format: CodecFormat = CodecFormat.TextPlain()
  override def asInputStream: InputStream = new FileInputStream(txt)
}

case class HtmlFileContent(html: File) extends  EntityContent {
  override def format: CodecFormat = CodecFormat.TextHtml()
  override def asInputStream: InputStream = new FileInputStream(html)
}

case class TextCsv() extends CodecFormat {
  override val mediaType: MediaType = MediaType.TextCsv
}

case class BinaryCsvContent(csv: Array[Byte]) extends  EntityContent {
  override def format: CodecFormat = TextCsv()
  override def asInputStream: InputStream = new ByteArrayInputStream(csv)
}

// etc'…

and then use some “body” in out of type List[EntityContent].
Perhaps something like:

// very bad code. decode is benign, encode can throw on empty get
def getBodyFor(cf: CodecFormat): EndpointIO.Body[InputStream, List[ReportContent]] =
  inputStreamBody.map[List[ReportContent]](_ => List.empty) { possibleContents =>
    possibleContents.find(_.format == cf).get.asInputStream
  }

So we can:

endpoint
  .in("api" / "v2" / "entities" / "report")
  .get
  .out(oneOfBody(
    getBodyFor(CodecFormat.TextPlain()),
    getBodyFor(CodecFormat.TextHtml()),
    getBodyFor(TextCsv())))

But I have several issues with it.
I don’t always have all possible formats. I also can’t control which formats are available for me.
So the .get may throw, even if I have another format I can serve.
So this would mean a client has to know which format to ask in Accept.

TBH, it is not very different from current situation, where if an API is called for a non-existing file, it will return 404.

So, to my question(s):

  1. advice: going forward (into the long future) I do plan to have a hold of the exact entities, and be able to serve al possible formats, so the API I plan also serves a better API evolution (no need to evolve), but: is there a better way? currently safer?
  2. Is there a way to control the response when .get throws? Can I force it to return 404 with a custom message saying you need to retry with one of the currently existing formats?

Thank you :pray:

I think I would try to approach this a bit differently: so that the server logic produces a single EntityContent (basing on the Accept header content), and then this is properly encoded using oneOfBody.

The 404 fallback can then be specified using a ContentTypeRange.AnyRange.

The upside is that you’ll get proper documentation and server behavior. The downside is that you’ll have to implement some of the content-type matching manually in the server logic.

The pseudo code might be sth like this: (definitely won’t compile :wink: )

sealed trait EntityContent
case class TxtFileContent(...) extends EntityContent
...
case class ContentNotFound() extends EntityContent

val e = endpoint
  .in(extractFromRequest(_.acceptsContentTypes)).in(...)
  .out(oneOfBody(
    (ContentTypeRange.exact(MediaType.TextPlain), txtBody /* body for TxtFileContent */),
    ...,
    (ContentTypeRange.AnyRange, emptyOutputAs[ContentNotFound].and(statusCode(NotFound))
  ))

In the server logic, you can then use MediaType.matches(ContentTypeRange) to choose the right representation to return to the server logic. That’s the manual part, but I don’t see a way to skip this.

1 Like

Thanks, will try this :slight_smile: