2. Auto-generating OpenAPI docs

Note

The tutorial is also available as a video.

We already know how to expose an endpoint as an HTTP server. Let’s now generate documentation for the API in the OpenAPI format, and expose it using the Swagger UI.

OpenAPI is a widely used format for describing HTTP APIs, which can be serialized to JSON or YAML. Such a description can be later used to generate client code in a given programming language, server stubs or programmer-friendly documentation. In our case, we’ll use the last option, with the help of Swagger UI, which allows both browsing and invoking the endpoints. There are also alternative UIs, such as Redoc.

Generating the OpenAPI specification and exposing the Swagger UI might be done separately. However, we’ll use a bundle, which first interprets the provided tapir endpoints into OpenAPI and then returns another set of endpoints, which expose the UI together with the generated specification. We’ll need to add a dependency:

//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:1.10.10

We’ll also define and expose two endpoints as an HTTP server, as described in the previous tutorial. Hence, our starting setup of docs.scala is as follows:

//> using dep com.softwaremill.sttp.tapir::tapir-core:1.10.10
//> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.10.10
//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:1.10.10

import sttp.tapir.*
import sttp.tapir.server.netty.sync.NettySyncServer

@main def tapirDocs(): Unit =
  val e1 = endpoint
    .get.in("hello" / "world").in(query[String]("name"))
    .out(stringBody)
    .handleSuccess(name => s"Hello, $name!")

  val e2 = endpoint
    .post.in("double").in(stringBody)
    .errorOut(stringBody)
    .out(stringBody)
    .handle { s => 
      s.toIntOption.fold(Left(s"$s is not a number"))(n => Right((n*2).toString)) 
    }

  NettySyncServer().port(8080)
    .addEndpoints(List(e1, e2))
    .startAndWait()

We’ve got two endpoints, one corresponding to GET /hello/world, the other a POST /double. We can test them from the command line as before:

# first console
% scala-cli docs.scala

# another console
% curl -XPOST "http://localhost:8080/double" -d "21"
42

% curl -XPOST "http://localhost:8080/double" -d "XYZ"
XYZ is not a number

NettySyncServer is a server interpreter: it takes a list of endpoints (in our case, List(e1, e2)), and, basing on their description and the server logic, exposes them as an HTTP server. Similarly, a documentation interpreter takes a list of endpoints and, based on their description only (server logic is not needed here), generates the OpenAPI documentation.

One possibility is to generate the YAML or JSON file, save it to disk, share it with other projects, etc. But in our case, as mentioned at the beginning, we’ll use the rendered specification to expose a UI, allowing browsing our API. The UI itself needs to be exposed using HTTP. Hence, we’ll need to generate tapir endpoints, which will serve the appropriate resources (such as the UI’s index.html, the CSS files, and the generated OpenAPI specification).

This amounts to invoking the SwaggerInterpreter:

val swaggerEndpoints = SwaggerInterpreter()
  .fromEndpoints[Identity](List(e1, e2), "My App", "1.0")

By default, the generated endpoints will expose the UI using the /docs path, but this can be customized using the options.

Note

You might wonder what is the purpose and meaning of the Identity type parameter, which is used when calling the fromEndpoints method.

Tapir integrates with multiple Scala stacks, including Pekko, Akka, cats-effect, or ZIO. We’ll learn how to use them in subsequent tutorials. They all use different types to represent effectful or asynchronous computations. So far, we’ve used direct-style, synchronous code, which doesn’t use any “wrapper” types to represent computations.

In some cases, tapir has dedicated APIs to work with direct-style, such as providing the server logic for an endpoint using .handle (when using the IO effect from cats-effect, server logic is provided using the serverLogic[IO] method). In other cases, there’s a single set of APIs for direct and “wrapped” style - such as the API to generate the documentation & swagger endpoints above.

The Identity type constructor is a simple type alias: type Identity[X] = X. It can be used whenever a type constructor parameter (typically called F[_]) is required, and when we’re using direct-style, meaning that computations run synchronously in a blocking way.

And that’s almost all the code changes that we need to introduce! We only need to additionally expose the swaggerEndpoints using our HTTP server:

//> using dep com.softwaremill.sttp.tapir::tapir-core:1.10.10
//> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.10.10
//> using dep com.softwaremill.sttp.tapir::tapir-swagger-ui-bundle:1.10.10

import sttp.shared.Identity
import sttp.tapir.*
import sttp.tapir.server.netty.sync.NettySyncServer
import sttp.tapir.swagger.bundle.SwaggerInterpreter

@main def tapirDocs(): Unit =
  val e1 = endpoint
    .get.in("hello" / "world").in(query[String]("name"))
    .out(stringBody)
    .handleSuccess(name => s"Hello, $name!")

  val e2 = endpoint
    .post.in("double").in(stringBody)
    .out(stringBody)
    .errorOut(stringBody)
    .handle { s => 
      s.toIntOption.fold(Left(s"$s is not a number"))(n => Right((n*2).toString)) 
    }

  val swaggerEndpoints = SwaggerInterpreter()
    .fromServerEndpoints[Identity](List(e1, e2), "My App", "1.0")
 
  NettySyncServer().port(8080)
    .addEndpoints(List(e1, e2))
    .addEndpoints(swaggerEndpoints)
    .startAndWait()

Try running the code, and opening http://localhost:8080/docs in your browser:

% scala-cli docs.scala

# Now open http://localhost:8080/docs in your browser

Browse the Swagger UI and invoke the endpoints. The generated OpenAPI specification should be available at http://localhost:8080/docs/docs.yaml:

openapi: 3.1.0
info:
  title: My App
  version: '1.0'
paths:
  /hello/world:
    get:
      operationId: getHelloWorld
      parameters:
      - name: name
        in: query
        required: true
        schema:
          type: string
      responses:
        '200':
          description: ''
          content:
            text/plain:
              schema:
                type: string
        '400':
          description: 'Invalid value for: query parameter name'
          content:
            text/plain:
              schema:
                type: string
  /double:
    post:
      operationId: postDouble
      requestBody:
        content:
          text/plain:
            schema:
              type: string
        required: true
      responses:
        '200':
          description: ''
          content:
            text/plain:
              schema:
                type: string
        default:
          description: ''
          content:
            text/plain:
              schema:
                type: string

This wraps up the tutorial on generating and exposing OpenAPI documentation. If you’d like to customize some of the options, tapir’s OpenAPI reference documentation should help.