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):
- 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?
- 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