24

Describe, Then Interpret: HTTP Endpoints Using Tapir

 5 years ago
source link: https://www.tuicool.com/articles/hit/ZrUfmaa
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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 scale 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.

qMRRzae.jpg!web

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 Unit s, which means “no input/ouput” (yhe fourth type of 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:

val addBook: Endpoint[(Book, String), Unit, Boolean, Nothing] =
  endpoint
    .post
    .in("book" / "add")
    .in(jsonBody[Book])
    .in(header[String]("X-Auth-Token"))
    .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 and Paths

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

val addBook: Endpoint[(Book, String), Unit, Boolean, Nothing] =
  endpoint
    .post
    .in("book" / "add")
    .in(jsonBody[Book])
    .in(header[String]("X-Auth-Token"))
    .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.

We do something similar 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:

val findBook: Endpoint[String, Unit, Option[Book], Nothing] = 
  endpoint
    .get
    .in("book" / "find" / path[String])
    .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 is to turn it into a server.

Currently, tapir supports interpreting an endpoint as akka-http Route s/ 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 the formats of the inputs are, 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:

def addBookLogic(b: Book, authToken: String): Future[Either[Unit, Boolean]] = ...
val addBook: Endpoint[(Book, String), Unit, Boolean, Nothing] = ...

import tapir.server.akkahttp._
import akka.http.scaladsl.server.Route
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:

val bookBody = jsonBody[Book]
  .description("The book")
  .example(Book("Pride and Prejudice", "Novel", 1813))

val addBook: Endpoint[(Book, String), Unit, Boolean, Nothing] =
  endpoint
    .description("Adds a new book, if the user is authorized")
    .post
    .in("book" / "add")
    .in(bookBody)
    .in(header[String]("X-Auth-Token")
      .description("The token is 'secret'"))
    .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 and 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:

import tapir.docs.openapi._
import tapir.openapi.circe.yaml._

val docs: OpenAPI = List(addBook, findBook).toOpenAPI(
  "The Tapir Library", "1.0")

val docsYaml: String = docs.toYaml

This YAML can now be served e.g. using the Swagger UI .

qaaeMvJ.png!web

Do Try it at Home

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

"com.softwaremill.tapir" %% "tapir-core" % "0.1"
"com.softwaremill.tapir" %% "tapir-akka-http-server" % "0.1"
"com.softwaremill.tapir" %% "tapir-json-circe" % "0.1"
"com.softwaremill.tapir" %% "tapir-openapi-docs" % "0.1"
"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!

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 operations where typesafe, which is a very important feature that hasn’t yet been 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, or problems, you can 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 and contributors to the project for their help: Kasper Kondzielski and Felix Palludan Hargreaves .

Stayed tuned for more articles on tapir (will be published on our blog, here ). And if you think the project is interesting, please star it on GitHub !


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK