1. Hello, world!
To start our adventure with tapir, we’ll define and expose a single endpoint using an HTTP server.
Note
The tutorial is also available as a video.
Prerequisites
We’ll use scala-cli to run the code, so you’ll need to install it beforehand. You can use any text editor to write the code, but we recommend using either Metals if you’re familiar with VSCode, or IntelliJ IDEA with the Scala plugin.
You’ll also need Java 21 or higher, as we’ll be using virtual threads, which allow us to use a synchronous programming model, without sacrificing performance. If you don’t have Java 21 installed, we recommend using sdkman to manage multiple Java versions locally.
Going forward, we’ll edit a hello.scala
file. Let’s start by adding the tapir dependency. First, you’ll need the
tapir-core
module to describe the endpoint. Secondly, you’ll need an HTTP server implementation. Tapir integrates with
multiple servers, but we’ll choose the simplest (and also one of the fastest!), which is based on Netty,
available through the tapir-netty-server-sync
module:
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.24
//> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.24
Endpoint description
Once we have that, we can start describing our endpoint. This is done by taking an empty endpoint, available through
the sttp.tapir.endpoint
value, and gradually adding more details, such as the method, path, and other input/output
parameters.
Endpoint inputs are the values that are mapped to HTTP requests; endpoint outputs are the values that are mapped to
HTTP responses. Inputs can be added to an existing endpoint description using the Endpoint.in(...)
method, given
the input description as a parameter. An updated endpoint description is returned.
Input/output descriptions are created by methods available in the sttp.tapir
package, hence it’s often easiest to
import it entirely, using import sttp.tapir.*
.
Let’s start by defining the method and path of our endpoint:
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.24
//> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.24
import sttp.tapir.*
@main def helloWorldTapir(): Unit =
val helloWorldEndpoint = endpoint
.get
.in("hello" / "world")
println(helloWorldEndpoint.show)
You can now run the example from the command line. We are outputting a human-friendly description of the endpoint’s structure, so you should see the following:
% scala-cli hello.scala
Compiling project (Scala 3.4.2, JVM (21))
Compiled project (Scala 3.4.2, JVM (21))
GET /hello /world -> -/-
So far, we’ve added three inputs to the endpoint: a constant method (GET
), with .get
; and two constant path inputs
combined using /
. Next, we’ll add a query parameter input, but this time, it will extract the provided value instead
of requiring it to be a fixed value (a constant):
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.24
//> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.24
import sttp.tapir.*
@main def helloWorldTapir(): Unit =
val helloWorldEndpoint = endpoint
.get
.in("hello" / "world")
.in(query[String]("name"))
println(helloWorldEndpoint.show)
After running, the output should now be GET /hello /world ?name -> -/-
.
The query[String]("name")
method creates a data structure describing a query parameter input. The description
specifies that the value should be deserialized to a String
- we’ll learn how to deserialize to other data types in
subsequent tutorials. Next, using .in
we add this description to the data structure describing the endpoint as a
whole.
Finally, let’s add an output to the endpoint. We’ll return the response as a string body:
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.24
//> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.24
import sttp.tapir.*
@main def helloWorldTapir(): Unit =
val helloWorldEndpoint = endpoint
.get
.in("hello" / "world")
.in(query[String]("name"))
.out(stringBody)
println(helloWorldEndpoint.show)
Server-side logic
Let’s add the logic to run once the endpoint is invoked. This can be done using
the .handleSuccess
method on the endpoint. We’re using the “success” variant, since in this simple endpoint
we don’t differentiate between success and failure cases (200 and 4xx responses).
The server logic needs to take the String
, extracted from the query parameter, and return another String
, which
will be sent as a response:
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.24
//> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.24
import sttp.tapir.*
@main def helloWorldTapir(): Unit =
val helloWorldEndpoint = endpoint
.get
.in("hello" / "world")
.in(query[String]("name"))
.out(stringBody)
.handleSuccess(name => s"Hello, $name!")
println(helloWorldEndpoint.show)
Nothing changes in the output provided by .show
, however the helloWorldEndpoint
is now an instance of the
ServerEndpoint
class, which combines an endpoint description with a matching server logic. It’s checked at
compile-time that the shape of the server’s logic function matches the types of inputs & outputs that we’ve defined in
the endpoint!
Exposing the server
We can now expose the server to the outside world. First, we’ll need to import the server implementation. Then,
using the NettySyncServer()
builder class, we can add endpoints, which the server should expose. In our
example, we’ll bind to localhost
(which is the default), and to the port 8080:
//> using dep com.softwaremill.sttp.tapir::tapir-core:1.11.24
//> using dep com.softwaremill.sttp.tapir::tapir-netty-server-sync:1.11.24
import sttp.tapir.*
import sttp.tapir.server.netty.sync.NettySyncServer
@main def helloWorldTapir(): Unit =
val helloWorldEndpoint = endpoint
.get
.in("hello" / "world")
.in(query[String]("name"))
.out(stringBody)
.handleSuccess(name => s"Hello, $name!")
NettySyncServer()
.port(8080)
.addEndpoint(helloWorldEndpoint)
.startAndWait()
The startAndWait()
method blocks indefinitely. Once the above code compiles and runs successfully, we can test our
endpoint:
# first console
% scala-cli hello.scala
Compiling project (Scala 3.4.2, JVM (21))
Compiled project (Scala 3.4.2, JVM (21))
# another console
% curl "http://localhost:8080/hello/world?name=Alice"
Hello, Alice!
And that’s it - our first tapir endpoint is exposed as an HTTP server!
Recap
In this tutorial, we learned the basic concepts needed to bootstrap a tapir-based application:
endpoints are defined as values, which describe the API. Such a description captures the types that are specified when creating the inputs/outputs.
before exposing an endpoint, server logic needs to be attached to the description. It’s function that transforms the data extracted from the request, to data that will be used to create the response. It must match the types used for the inputs & outputs.
a server can be started by providing basic configuration and a list of server endpoints.