musings

Serving static content and OpenAPI docs with Scala and tapir

The tapir documentation has good pages on OpenAPI generation and serving static content, but there's no guidance on how to do both on the same server.

To do so, I figured that I needed to concatenate my API, static content, and OpenAPI endpoints in the server interpreter's toRoute() method:

import scala.concurrent.Future
import sttp.tapir._
import sttp.tapir.files.staticFilesGetServerEndpoint
import sttp.tapir.server.ServerEndpoint
import sttp.tapir.server.pekkohttp.PekkoHttpServerInterpreter
import sttp.tapir.swagger.bundle.SwaggerInterpreter

// API definition
val hello = endpoint.get
  .in("hello")
  .in(query[String]("name"))
  .out(stringBody)

val endpoints: List[ServerEndpoint[Any, Future]] = List(
  // API logic
  hello.serverLogic(name => Future(Right(s"Hello, world!"))),
  
  // Static content (`./frontend` directory) exposed at http://localhost:8080
  staticFilesGetServerEndpoint(emptyInput)("frontend")
)

// Swagger UI exposed at http://localhost:8080/docs
val docs = SwaggerInterpreter().fromServerEndpoints(endpoints, "API", "0.0.1")

// Add everything to the server routes
val routes = PekkoHttpServerInterpreter().toRoute(endpoints ++ docs)

Here is the static content I made:

frontend/index.html

<h1>welcome</h1>

frontend/fruits.html

<ul>
<li>apple</li>
<li>banana</li>
</ul>

If I start the server, I can load the home page:

$ curl http://localhost:8080
<h1>welcome</h1>

$ curl http://localhost:8080/index.html
<h1>welcome</h1>

As well as the fruits page:

$ curl http://localhost:8080/fruits.html
<ul>
<li>apple</li>
<li>banana</li>
</ul>

Calling the API returns a message:

$ curl http://localhost:8080/hello?name=Goku
Hello, Goku!

$ curl http://localhost:8080/hello?name=Vegeta
Hello, Vegeta!

But the documentation doesn't load:

$ curl -i http://localhost:8080/docs/docs.yaml
HTTP/1.1 404 Not Found
Server: pekko-http/1.1.0
Date: Thu, 01 May 2025 11:59:21 GMT
Content-Length: 0

After a lot of experimenting with the SwaggerInterpreter configration and staticFilesGetServerEndpoint() method, I found that both were fine in my original code.

The actual problem was with the order in which I defined my endpoints. Tapir maps routes in the order they appear in toRoute() and keeps the first mapping if there are conflicts or duplicates.

The order I have is:

  1. the GET /hello endpoint
  2. the static content
  3. the /docs page

I'm serving static content at the base URI with staticFilesGetServerEndpoint(emptyInput), so I can get the home page at http://localhost:8080 or http://localhost:8080/index.html, and the fruits at http://localhost:8080/fruits.html. But this means that every route mapping afterwards will be ignored because they all start with the base URI:

To avoid this problem, we have to make sure the static content are at the end of the routes list:

val endpoints: List[ServerEndpoint[Any, Future]] = List(
  hello.serverLogic(name => Future(Right(s"Hello, $name!"))),
)

val docs = SwaggerInterpreter().fromServerEndpoints(endpoints, "API", "0.0.1")

val staticContent: List[ServerEndpoint[Any, Future]] = List(
  staticFilesGetServerEndpoint(emptyInput)("frontend")
)

// NOTE: The staticContent must be at the end
val routes = PekkoHttpServerInterpreter().toRoute(endpoints ++ docs ++ staticContent)

Now the documentation shows up correctly:

NOTE: The /docs/docs.yaml route returns the OpenAPI spec, while the /docs route in a browser shows the interactive Swagger UI documentation

$ curl http://localhost:8080/docs/docs.yaml
openapi: 3.1.0
info:
  title: API
  version: 0.0.1
paths:
  /hello:
    get:
      operationId: getHello
      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

Some might take this URL hiding/overriding behavior as a given, but it's not mentioned in the documentation and definitely tripped me up. Writing this will help me remember in the future.

#openapi #scala #scala:tapir