How define proper content negotiation with oneOfBody & InputStream?

My goal is to expose a visualization API for some “graphable” content.
I’m using graphviz, and trying to do something like:

val os = new ByteArrayOutputStream()

Which returns an InputStream.
The format parameter has many cool options I would like to use for the API with content negotiation like: svg, png, dot, etc’…

So, I would like to obtain the Accept header value (if exists), and pass it as the format parameter, and then provide with oneOfBody all the options. But the inputStreamBody will always take the same type (InputStream), and I couldn’t figure out from the docs if my use case is possible.

Any help would be greatly appreciated :pray:

If I understand the problem correctly, you’d like to dynamically decide on the resulting content-type, but not based on the Accept header?

If that’s the case, I’m afraid oneOfBody won’t be useful, as the server interpreter always uses Accept to determine which representation to choose.

But I think you should be able to implement this using oneOf, or just using .out(header[String](HeaderNames.ContentType)).out(inputStreamBody). Or is the problem more complex?

Thanks @adamw for taking the time to respond.

I do want to use the accept header, but I’m not sure how to use this if the type is just InputStream.
The actual data can be an image, or plain text…
Flow should be: Accept => decide on format => render with format => result is InputStream => response sent with proper content-type

Thanks :pray:

Perhaps I didn’t explain myself well enough.

In tAPIr every EndpointIO is symmetric.
This makes sense since an endpoint can be interpreted either as a server route, or as client request.

So, if I have some type I own, I should be able to both parse it and format it if I want to use it in my endpoint definition.
So far, everything works great, and I even did it with great success before:

The problem I have with the Graphviz type, is that it’s asymmetric, in the sense that I can format it, but usually not parse it.

The Graphviz renderer can output dot format, which it can also parse back into a graph, but also png, svg, and a bunch of other formats, some binary, some textual, which I can’t or won’t parse back into a Graphviz graph object.

Currently, I ended up with something like:

case class Png() extends CodecFormat {
  override val mediaType: MediaType = MediaType.ImagePng
case class Svg() extends CodecFormat {
  override val mediaType: MediaType = MediaType("image", "svg+xml")

def pngDecodeIsNotSupported(is: InputStream): DecodeResult.Error =
    "data:image/png;base64," + Base64.getEncoder.encodeToString(ByteStreams.toByteArray(is)),
    new UnsupportedOperationException("Parsing a png image back into graphviz is not supported")

def svgDecodeIsNotSupported(is: InputStream): DecodeResult.Error =
    new String(ByteStreams.toByteArray(is), StandardCharsets.UTF_8),
    new UnsupportedOperationException("Parsing a svg image back into graphviz is not supported")

def setHeightAndRender(format: Format): GraphvizWithHeight => Renderer = gwh => {
  val gv =
    if (gwh.height <= 0) gwh.graph
    else gwh.graph.height(gwh.height)

val rendererToStream: Renderer => InputStream = rr => {
  val os = new ByteArrayOutputStream()

case class GraphvizWithHeight(graph: Graphviz, height: Int)

val dotBody: EndpointIO.Body[String, GraphvizWithHeight] = => GraphvizWithHeight(Graphviz.fromString(s), -1))(setHeightAndRender(Format.DOT).andThen(_.toString))

val pngBody: EndpointIO.Body[InputStream, GraphvizWithHeight] =

val svgBody: EndpointIO.Body[InputStream, GraphvizWithHeight] =

type VisualizeAPI = PublicEndpoint[(UUID, Int), MyError, GraphvizWithHeight, Any]
val visualize: VisualizeAPI = endpoint.get
  .in("api" / "visualize" / path[UUID]("id"))
  .out(oneOfBody(dotBody, pngBody, svgBody))

A minor issue is the asymmetricity of the API. Obviously I will not parse back a binary png image back to a graph, but always failing feels a bit weird (tried my best to at least make the errors informative).

But the bigger problem is that I don’t have the suitable construct.
With the other issue, I could’ve used stringBodyUtf8AnyFormat, or customCodecJsonBody.
But I can’t figure out what should I use for input stream. There is no inputStreamAnyFormat or customCodecInputStreamBody. Maybe it doesn’t makes sense (because if it does, it means there should be so many other constructs like customCodecByteArrayBody, customCodecByteBufferBody, etc’…

What I get without it, as expected, is just this:
Screen Shot 2023-11-01 at 20.18.58
Screen Shot 2023-11-01 at 20.20.09

wrong mimetype, and input stream bodies override each other.

Hope it’s now clearer.

@adamw after some digging I managed to do what I wanted.
The code is now modified as:

val pngBody: EndpointIO.Body[InputStream, GraphvizWithHeight] =

val svgBody: EndpointIO.Body[InputStream, GraphvizWithHeight] =

I’m not sure if that what I should have done all along, and I couldn’t find any documentation for it, but it now works.
Is this the right thing to do?

(also, is there a way to define asymmetric endpoints? i.e. endpoints that treats the H and L types of the codec differently, depending on whether it’s interpreted as server route, or as client request, the yielded type would be H or L respectively…)

Thanks for the details, I think I understand the problem much better now :slight_smile:

Starting from the bottom: there’s no way to define asymmetric endpoints. I think throwing a meaningful exception if somebody tries to POSTs a svg to the endpoint (as a client) is the best you can do.

I don’t think there’s a particular reason that there’s no inputStreamBodyAnyFormat, other than nobody ever added it :slight_smile: But I think that’s essentially what you did: if you extract the common parts from your pngBody, svgBody, you’ll probably arrive at a method resembling the *AnyFormat methods:

def graphVizBody(codecFormat: CodecFormat, graphvizFormat: Format): EndpointIO.Body[InputStream, GraphvizWithHeight] =

val pngBody = graphVizBody(Png(), Format.PNG)
val svgBody = graphVizBody(Svg(), Format.SVG)

I assume you later use these values inside of a oneOfBody? If so - that’s the intended usage of the construct: to delegate deciding using which codec to encode the high-level representation, basing on the Accept header.

You could also do it entirely dynamically, outside of tapir, by reading the Accept header, and using an oneOf with some discriminator to choose the right branch, where each would contain a fixed content type, and an input stream. But I suppose if you can delegate content negotiation out of your code, why not do it :slight_smile:

1 Like

I don’t think anyone would POST an SVG to the server, but a sttp client that interprets the same tapir endpoint, and calls the API to download a SVG, will try to “parse” it back to Graphviz.
I do intend to expose the endpoints to be used from another service, but obviously this API is for humans only.

Thanks for the reuse tip (the decode failures varies between PNG and SVG, since I need to return original string in the Decode result error… but perhaps I can factor that out as well:))

Again, thank you so much for this wonderful library!