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:
- the
GET /helloendpoint - the static content
- the
/docspage
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:
Since
/docspage comes after the static content, tapir tries to read the contents offrontend/docsinstead of returning the generated Swagger UI page.The same thing would happen if
GET /hellocame after the static content in the routes list. Tapir would try to read a nonexistentfrontend/helloinstead of calling the endpoint.
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.yamlroute returns the OpenAPI spec, while the/docsroute 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.