Describe, then interpret: HTTP endpoints using tapir.

Adam Warski
SoftwareMill Tech Blog
6 min readFeb 25, 2019

--

There’s no shortage of great HTTP server libraries in Scala: akka-http, http4s, play, finch, just to name some of the more popular ones. However, a common pain point in all of these is generating documentation (e.g. Swagger/ OpenAPI).

Some solutions have emerged, such as annotating akka-http routes, generating scala code from YAML files or … writing YAML documentation by hand. But let’s be honest. Nobody wants or should be writing YAML files by hand, and annotations have severe drawbacks. What’s left then?

One of the defining themes of functional programming is using values and functions. By focusing on these two constructs, a common pattern that emerges is separating the description (e.g. of a side effect — see Monix’s Task or IO from ZIO) from interpretation.

Let’s apply the same approach to HTTP endpoints!

Describing an endpoint

What’s in an HTTP endpoint?

First, there are the request parameters: the method which the endpoint serves (GET, POST, etc.), the request path, the query parameters, headers and of course the body. These are the inputs of an endpoint. Each endpoint maps its inputs to application-specific types, according to application-specific formats.

Then, there are the response parameters: status code, headers and body. Typically, a request can either succeed or fail, returning different types of status codes/bodies in both cases. Hence, we can specify an endpoint’s error outputs and success outputs.

These three components: inputs, error outputs and success outputs are the basis of how an endpoint is described using tapir: a Scala library for creating typed API descriptions.

Tapir in the wild (well actually, in a ZOO)

The goal of tapir is to provide a programmer-friendly, discoverable API, with human-comprehensible types, that you are not afraid to write down. How does it look in practice?

Tapir endpoints

Each endpoint description starts with the endpoint value, an empty endpoint, which has the type Endpoint[Unit, Unit, Unit, Nothing]. The first three type parameters describe the types of inputs, error outputs and success outputs. Initially, they are all Units, which means “no input/ouput”. (The 4th type parameter relates to streaming, which will be covered in a later post.)

Let’s add some inputs and outputs! We take the empty endpoint and start modifying it:

What’s happening here? We’ve got two input parameters added with the Endpoint.in method: a json body which maps to a case class Book(...) and a String header, which holds the authentication token. These two inputs are represented as a tuple (Book, String).

There’s also one output parameter added with the Endpoint.out method, a Boolean, which in the endpoint’s type is represented as the type itself.

To sum up, we’ve created a description of an endpoint with the given input and output parameters. This description, an instance of the Endpoint class, is a regular case class, hence immutable and re-useable. We can take a partially-defined endpoint and customize it as we see fit.

Moreover, all of the inputs and outputs that we’ve used (jsonBody[Book], header[String]("X-Auth-Token") and plainBody[Boolean]) are also case class instances, all implementing the EndpointInput[T] trait. Likewise, they can be shared an re-used.

Methods & paths

But, our endpoint seems a bit incomplete! What about the path & method? Let’s specify it as well:

The type is the same as before! How come? First, we specify that the endpoint uses the POST method. While part of the description, this does not correspond to any values in the request — hence, it doesn’t contribute to the type.

Similarly with the path. Here, we don’t bind to any information within the path — the path is constant (/book/add). So the overall type stays the same.

Another endpoint might of course use information from the path, e.g. finding books by id. In this case, we’ll use a path-capturing input (path[String]), instead of a constant path:

The last part that might need explaining is how come a string has a / method? There’s an implicit conversion (the only one in tapir) which converts a literal string into a constant-path EndpointInput. An input has the and and / methods defined, which are the same and combine two inputs into one.

Interpreting as a server

Having a description of an endpoint is great, but what can you do with it? Well, one of the most obvious wishes it to turn it into a server.

Currently, tapir supports interpreting an endpoint as akka-http Routes/ Directives or http4s’s HttpRoutes. We’ll use akka-http here.

In order to turn the description into a server, we need to provide the business logic: what should actually happen when the endpoint is invoked. The description contains information on what should be extracted from the request, what are the formats of the inputs, how to parse them into application-specific data etc., but it lacks the actual code to turn a request into a response.

Let’s take another look at an endpoint e: Endpoint[I, E, O, _]. It takes parameters of type I and returns either an E, or an O. Plus, as we’re in akka-land, things will probably happen asynchronously (the response will be available at some point in the Future).

What we have just described, using plain words, is a function f: I => Future[Either[E, O]]. And that’s the logic we need to provide to turn an endpoint into a server:

The tapir.server.akkahttp adds the toRoute extension method to Endpoint, which, given the business logic (addBookLogic), returns an akka-http Route. It’s a completely normal route, which can be nested within other routes. Alternatively, an endpoint can be interpreted as a Directive[I], which can then be combined with other directives as usual.

Docs, docs, docs

We started with documentation, so where is it? First, let’s add some meta-data to our endpoints, so that we get human-readable descriptions in addition to all the details provided by the endpoint description:

Note that we have extracted the description of the Book json body as a val — the code is more readable this way. After all, we work with plain old Scala values, immutable case classes, so we can manipulate them as much as we’d like.

Second, we’ve added some meta-data: descriptions to the endpoint, body and header, as well as an example value of the appropriate type.

To interpret the endpoint as documentation, we proceed similarly as before: we import some extension methods & call them on the endpoint. Here however, we’ll proceed in two steps.

First, we’ll interpret the endpoint as OpenAPI documentation, getting an instance of the OpenAPI case class. Tapir contains a model of the OpenAPI constructs, represented as case classes. Thanks to that intermediate step, the documentation can be adjusted, tweaked and extended as need.

Second, we’ll serialize the OpenAPI model to YAML:

This YAML can now be served e.g. using the swagger ui.

Do try it at home

To try this by yourself, add the following dependencies to your project:

The tapir-core library has no transitive dependencies. The tapir-akka-http-server module depends on, quite obviously, akka-http. If you’d like to interpret using http4s, you should use the http4s dependency instead!

Then, you can start with the code available as an example in the repository: BooksExample, which contains some endpoint definitions, a server (which also exposes documentation) and client calls.

Customize and explore!

Summing up

This concludes the introduction to tapir. There’s so much more to cover: codecs, media types, streaming, multipart forms, generating sttp clients, just to mention a few!

What we’ve done so far, is creating a description of an endpoint using tapir’s API, using a couple of different inputs and outputs. Then, we’ve interpreted that description as a server and generated OpenAPI documentation from it.

All these opearations where typesafe, which is a very important feature that hasn’t been so far mentioned. The compiler checks that the business logic provided matches the types of the inputs and outputs, that they match the declared/expected endpoint type, and so on.

Tapir is a young project, under active development — if you have any suggestions, ideas, problems either create an issue on GitHub, or ask on gitter. If you are having doubts on the why or how something works, it probably means that the documentation, or code is unclear and can be improved for the benefit of all.

Finally, I’d like to thank two early testers & contributors to the project for their help: Kasper Kondzielski and Felix Palludan Hargreaves.

Stayed tuned for more articles on tapir. And if you think the project is interesting, please star it on GitHub!

--

--

Software engineer, Functional Programming and Scala enthusiast, SoftwareMill co-founder