How do you describe and interpret endpoints?
'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?
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 'Unit's, 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:
1 import tapir._ 2 val addBook: Endpoint[(Book, String), Unit, Boolean, Nothing] = 3 endpoint 4 .in(jsonBody[Book]) 5 .in(header[String]("X-Auth-Token")) 6 .out(plainBody[Boolean])
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:
1 val addBook: Endpoint[(Book, String), Unit, Boolean, Nothing] = 2 endpoint 3 .post 4 .in("book" / "add") 5 .in(jsonBody[Book]) 6 .in(header[String]("X-Auth-Token")) 7 .out(plainBody[Boolean])
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:
1 val findBook: Endpoint[String, Unit, Option[Book], Nothing] = 2 endpoint 3 .get 4 .in("book" / "find" / path[String]) 5 .out(jsonBody[Option[Book]])
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.
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:
1 def addBookLogic(b: Book, authToken: String): Future[Either[Unit, Boolean]] = ... 2 val addBook: Endpoint[(Book, String), Unit, Boolean, Nothing] = ... 3 4 import tapir.server.akkahttp._ 5 import akka.http.scaladsl.server.Route 6 val addBookRoute: Route = addBook.toRoute(addBookLogic _)
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:
1 val bookBody = jsonBody[Book] 2 .description("The book") 3 .example(Book("Pride and Prejudice", "Novel", 1813)) 4 5 val addBook: Endpoint[(Book, String), Unit, Boolean, Nothing] = 6 endpoint 7 .description("Adds a new book, if the user is authorized") 8 .post 9 .in("book" / "add") 10 .in(bookBody) 11 .in(header[String]("X-Auth-Token") 12 .description("The token is 'secret'")) 13 .out(plainBody[Boolean])
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:
1 import tapir.docs.openapi._ 2 import tapir.openapi.circe.yaml._ 3 4 val docs: OpenAPI = List(addBook, findBook).toOpenAPI( 5 "The Tapir Library", "1.0") 6 7 val docsYaml: String = docs.toYaml
Do try it at home
To try this by yourself, add the following dependencies to your project:
1 "com.softwaremill.tapir" %% "tapir-core" % "0.1" 2 "com.softwaremill.tapir" %% "tapir-akka-http-server" % "0.1" 3 "com.softwaremill.tapir" %% "tapir-json-circe" % "0.1" 4 "com.softwaremill.tapir" %% "tapir-openapi-docs" % "0.1" 5 "com.softwaremill.tapir" %% "tapir-openapi-circe-yaml" % "0.1"
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!
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.
Stayed tuned for more articles on tapir. And if you think the project is interesting, please star it on GitHub!'