HTTP API erstellen und testen mit Tapir in Scala

INEZA Felin-Michel

INEZA Felin-Michel

22 May 2026

HTTP API erstellen und testen mit Tapir in Scala

Apidog für Unternehmen

On-Premises-Bereitstellung

SSO & RBAC

SOC 2 konform

Apidog Enterprise entdecken

Tapir steht für Typed API descRiptions. Es ist eine Scala-Bibliothek, mit der Sie einen HTTP-Endpunkt als einfachen Wert beschreiben können: seine Methode, seinen Pfad, seine Eingaben, Ausgaben und Fehlerfälle werden alle im Typsystem erfasst. Aus dieser einzigen Beschreibung leitet Tapir die Server-Route, einen Client und ein OpenAPI-Dokument ab. Sie schreiben den Vertrag einmal, und der Rest folgt.

Das unterscheidet Tapir vom direkten Schreiben von Routen in einem Web-Framework. Ein Routen-Handler koppelt die HTTP-Verdrahtung an die Logik. Ein Tapir-Endpunkt trennt sie: Die Beschreibung ist ein Wert, die Geschäftslogik ist eine separate Funktion, und Tapir verbindet sie. Dieses Tutorial erstellt eine kleine Aufgaben-API, verbindet sie mit einem Server, generiert Dokumente und testet sie, durchweg mit echtem Scala.

Was Tapir Ihnen bietet

Tapir basiert auf einer einfachen Idee mit drei Vorteilen.

Der erste ist die Typsicherheit. Die Eingaben und Ausgaben eines Endpunkts sind typisiert, sodass der Compiler prüft, ob Ihr Handler die richtige Form zurückgibt. Eine Diskrepanz zwischen dem, was der Endpunkt verspricht, und dem, was die Logik produziert, ist ein Kompilierungsfehler, kein Produktionsfehler.

Der zweite ist eine einzige Quelle der Wahrheit. Da der Endpunkt ein Wert ist, generieren Sie die OpenAPI-Spezifikation, den Server-Interpreter und einen Client aus derselben Beschreibung. Sie können nicht auseinanderdriften, da es nur eine Beschreibung gibt. Dies ist dieselbe Disziplin, die hinter dem API-Vertragstesting steht und vom Compiler erzwungen wird.

Der dritte ist die Framework-Unabhängigkeit. Die Endpunktbeschreibung weiß nicht, ob sie auf Akka HTTP, Pekko, http4s oder Netty läuft. Sie wählen den Server-Interpreter separat, sodass die Beschreibung jede Framework-Wahl überdauert.

Es sollte klar sein, was Tapir nicht ist. Es ist kein Web-Framework; es verwaltet weder den Socket, den Thread-Pool noch die Routing-Engine. Es ist eine Beschreibungsebene, die auf einem von Ihnen gewählten Framework aufsetzt. Es ist auch kein Code-Generator im üblichen Sinne. Sie führen kein Tool aus, das Scala-Dateien schreibt, die Sie dann bearbeiten. Die Endpunktwerte sind die Quelle, und der Server, der Client und die Dokumente werden zur Kompilierungszeit aus ihnen berechnet. Das verhindert, dass Beschreibung und laufender Code jemals auseinanderfallen, was der wiederkehrende Fehler bei generatorbasierten Workflows ist, bei denen die generierte Datei manuell bearbeitet wird und die Quelle abweicht.

Projekt einrichten

Fügen Sie Tapir zu build.sbt hinzu. Sie benötigen das Kernmodul, ein OpenAPI-Modul, einen Server-Interpreter und eine JSON-Bibliothek. Dieses Beispiel verwendet Pekko HTTP, den aktiv gepflegten Nachfolger von Akka HTTP, mit circe für JSON.

val tapirVersion = "1.11.7"

libraryDependencies ++= Seq(
  "com.softwaremill.sttp.tapir" %% "tapir-core" % tapirVersion,
  "com.softwaremill.sttp.tapir" %% "tapir-pekko-http-server" % tapirVersion,
  "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % tapirVersion,
  "com.softwaremill.sttp.tapir" %% "tapir-openapi-docs" % tapirVersion,
  "com.softwaremill.sttp.apispec" %% "openapi-circe-yaml" % "0.11.3"
)

Die aktuelle Tapir-Dokumentation unter tapir.softwaremill.com listet jedes Modul und die passenden Versionen auf, da sich das Ökosystem schnell entwickelt.

Einen typisierten Endpunkt definieren

Beginnen Sie mit den Domänentypen und ihren JSON-Codecs. circe leitet die Codecs aus den Case Classes ab.

import io.circe.generic.auto._
import sttp.tapir._
import sttp.tapir.json.circe._
import sttp.tapir.generic.auto._

case class Task(id: Int, title: String, done: Boolean)
case class NewTask(title: String)
case class ApiError(message: String)

Beschreiben Sie nun die Endpunkte. Jeder ist ein Wert, der durch Aneinanderreihen von Kombinatoren an der endpoint-Basis erstellt wird. Lesen Sie die Kette als Satz: Dies ist ein GET an /tasks/{id}, der eine Ganzzahl als Pfadeingabe nimmt und entweder einen ApiError mit einem 404 oder einen Task mit einem 200 zurückgibt.

val getTask: PublicEndpoint[Int, ApiError, Task, Any] =
  endpoint.get
    .in("tasks" / path[Int]("id"))
    .errorOut(statusCode(sttp.model.StatusCode.NotFound).and(jsonBody[ApiError]))
    .out(jsonBody[Task])

val listTasks: PublicEndpoint[Unit, Unit, List[Task], Any] =
  endpoint.get
    .in("tasks")
    .out(jsonBody[List[Task]])

val createTask: PublicEndpoint[NewTask, Unit, Task, Any] =
  endpoint.post
    .in("tasks")
    .in(jsonBody[NewTask])
    .out(statusCode(sttp.model.StatusCode.Created).and(jsonBody[Task]))

Die vier Typparameter von PublicEndpoint sind Eingabe, Fehler-Ausgabe, Erfolgs-Ausgabe und erforderliche Fähigkeiten. Nichts davon läuft bisher. Dies sind reine Beschreibungen, genau deshalb können dieselben Werte einen Server, einen Client und eine Dokumentation erzeugen.

Einige Kombinatoren tragen das meiste Gewicht. in fügt eine Eingabe hinzu, sei es ein Pfadsegment, ein Abfrageparameter, ein Header oder ein Body. out beschreibt die Erfolgsantwort. errorOut beschreibt die Fehlerantwort, und ein einzelner Endpunkt kann mehrere Fehlerfälle modellieren, indem er Varianten mit oneOf kombiniert. Jeder Kombinator schränkt den Typ des Endpunkts ein, sodass bei Abschluss der Beschreibung seine Typsignatur eine präzise Aussage über den Vertrag ist. Sie können die Signatur von getTask lesen und wissen, dass sie einen Int akzeptiert, mit einem ApiError fehlschlagen kann und ansonsten einen Task zurückgibt, ohne eine Zeile Implementierung lesen zu müssen.

Dies ist das praktische Herzstück von Tapir. Die Endpunktbeschreibung ist eine Dokumentation, die der Compiler durchsetzt. Ein Teammitglied kann den Vertrag nicht falsch interpretieren, denn der Vertrag ist ein Typ, und Code, der ihn verletzt, kompiliert nicht.

Serverlogik hinzufügen

Eine Beschreibung wird zu einer funktionierenden Route, wenn Sie Logik mit serverLogic anhängen. Die Logik ist eine Funktion, die Either[ErrorType, SuccessType] zurückgibt, verpackt in einem Effekttyp wie Future. Ein einfacher In-Memory-Speicher hält das Beispiel eigenständig.

import scala.concurrent.Future
import scala.collection.concurrent.TrieMap
import sttp.tapir.server.ServerEndpoint

val store = TrieMap[Int, Task](
  1 -> Task(1, "Write the Tapir tutorial", done = false)
)

val getTaskServer: ServerEndpoint[Any, Future] =
  getTask.serverLogic { id =>
    Future.successful(
      store.get(id).toRight(ApiError(s"No task with id $id"))
    )
  }

val listTasksServer: ServerEndpoint[Any, Future] =
  listTasks.serverLogic(_ => Future.successful(Right(store.values.toList)))

val createTaskServer: ServerEndpoint[Any, Future] =
  createTask.serverLogic { newTask =>
    val id = store.size + 1
    val task = Task(id, newTask.title, done = false)
    store.put(id, task)
    Future.successful(Right(task))
  }

Der Compiler erzwingt hier den Vertrag. getTask verspricht einen Fehler vom Typ ApiError, daher muss toRight einen ApiError liefern. Geben Sie den falschen Typ zurück, und der Code kompiliert nicht. Die HTTP-Details, der 404, die JSON-Kodierung, werden bereits von der Beschreibung behandelt.

Wandeln Sie die Server-Endpunkte mit dem Pekko-Interpreter in tatsächliche Routen um und starten Sie den Server.

import sttp.tapir.server.pekkohttp.PekkoHttpServerInterpreter
import org.apache.pekko.http.scaladsl.Http
import org.apache.pekko.actor.typed.ActorSystem
import org.apache.pekko.actor.typed.scaladsl.Behaviors

implicit val system: ActorSystem[Any] = ActorSystem(Behaviors.empty, "task-api")
import system.executionContext

val routes = PekkoHttpServerInterpreter().toRoute(
  List(getTaskServer, listTasksServer, createTaskServer)
)

Http().newServerAt("localhost", 8080).bind(routes)

Die API antwortet nun auf Port 8080. Sie haben das Framework-Anliegen an einer Stelle, dem Interpreter, geändert, ohne eine einzige Endpunktbeschreibung anzupassen.

OpenAPI-Dokumentation generieren

Da die Endpunkte Werte sind, ist das OpenAPI-Dokument eine Funktion von ihnen. Keine Anmerkungen, keine separate Spezifikationsdatei, die gepflegt werden müsste.

import sttp.tapir.docs.openapi.OpenAPIDocsInterpreter
import sttp.apispec.openapi.circe.yaml._

val docs = OpenAPIDocsInterpreter().toOpenAPI(
  List(getTask, listTasks, createTask),
  "Task API",
  "1.0"
)

println(docs.toYaml)

docs.toYaml erzeugt ein vollständiges OpenAPI-Dokument. Fügen Sie das Modul tapir-swagger-ui-bundle hinzu, und Tapir stellt eine interaktive Swagger-Benutzeroberfläche direkt von Ihrem Server bereit. Die Spezifikation kann nicht über die API lügen, da beide von denselben Endpunktwerten stammen.

Diese OpenAPI-Datei ist auch Ihre Brücke zu anderen Tools. Importieren Sie sie in Apidog und Sie erhalten eine durchsuchbare API-Referenz, eine Anforderungskonsole für manuelle Prüfungen und einen generierten Mock-Server, alles ohne die Spezifikation zu verlassen, die Ihr Scala-Code bereits erstellt hat. Dies ist ein praktischer Weg, um Frontend-Teammitgliedern etwas zu geben, womit sie arbeiten können, während sich der Scala-Dienst entwickelt.

Hier gibt es einen Workflow, der erwähnenswert ist. Tapir generiert die Spezifikation aus Ihrem Scala-Quellcode, sodass die Spezifikation immer aktuell ist. Ein Mocking-Tool, das diese Spezifikation konsumiert, stellt Ihrem Frontend dann einen funktionierenden Endpunkt zur Verfügung, bevor der echte Server irgendwo eingesetzt wird. Der Vertrag fließt in eine Richtung: Scala-Typen definieren ihn, OpenAPI transportiert ihn, und der Mock sowie das Frontend konsumieren ihn. Niemand schreibt den Vertrag zweimal manuell, sodass die beiden Kopien nicht auseinanderdriften können. Für Scala-Teams, die APIs bereitstellen, auf die andere Teams angewiesen sind, ist dies der Hauptgrund, den OpenAPI-Export in den Build zu integrieren.

Die API testen

Tapir-Endpunkte sind auf zwei Ebenen testbar.

Die erste Ebene prüft die Endpunktlogik ganz ohne HTTP, mithilfe des Stub-Interpreters. Sie erstellen ein Test-Backend aus den Server-Endpunkten und senden Anfragen über einen sttp-Client. Dies ist schnell, da kein Port gebunden wird.

import org.scalatest.flatspec.AsyncFlatSpec
import org.scalatest.matchers.should.Matchers
import sttp.client3._
import sttp.client3.testing.SttpBackendStub
import sttp.tapir.server.stub.TapirStubInterpreter
import sttp.client3.circe._
import io.circe.generic.auto._

class TaskApiSpec extends AsyncFlatSpec with Matchers {

  val backend = TapirStubInterpreter(SttpBackendStub.asynchronousFuture)
    .whenServerEndpointRunLogic(getTaskServer)
    .whenServerEndpointRunLogic(createTaskServer)
    .backend()

  "GET /tasks/1" should "return the seeded task" in {
    basicRequest
      .get(uri"http://test.com/tasks/1")
      .response(asJson[Task])
      .send(backend)
      .map { resp =>
        resp.code.code shouldBe 200
        resp.body.map(_.title) shouldBe Right("Write the Tapir tutorial")
      }
  }

  "GET /tasks/999" should "return a 404 with an error body" in {
    basicRequest
      .get(uri"http://test.com/tasks/999")
      .response(asJson[ApiError])
      .send(backend)
      .map { resp => resp.code.code shouldBe 404 }
  }
}

Die zweite Ebene ist ein vollständiger Integrationstest: Binden Sie den echten Server an einen Port und rufen Sie ihn mit einem HTTP-Client auf. Verwenden Sie dies sparsam, um die Verdrahtung zu bestätigen, und verlassen Sie sich für den Großteil Ihrer Abdeckung auf die stub-basierten Tests, da diese viel schneller laufen. Die Trennung zwischen schnellen isolierten Prüfungen und einer dünnen Schicht echter Prüfungen spiegelt die bewährte Praxis im Bereich des automatisierten Testens im Allgemeinen wider.

Eine nützliche Eigenschaft ergibt sich aus dem Design von Tapir: Da die Endpunktbeschreibung geteilt wird, kann ein Test, der Anfragen aus getTask erstellt, keine Anfrage senden, die der Produktionsserver ablehnen würde. Der Vertrag wird auf beiden Seiten durch denselben Wert erzwungen. Um diesen Vertrag durch manuelle Erkundung und einen Mock-Server zu testen, laden Sie Apidog herunter und importieren Sie die von Tapir generierte OpenAPI-Datei.

Häufig gestellte Fragen

Was ist Tapir in Scala?

Tapir, kurz für Typed API descRiptions, ist eine Scala-Bibliothek zur Beschreibung von HTTP-Endpunkten als typisierte Werte. Aus einer Beschreibung leitet sie die Server-Route, einen Client und ein OpenAPI-Dokument ab, sodass der Vertrag einmal definiert wird und über alle drei konsistent bleibt.

Muss ich Akka HTTP mit Tapir verwenden?

Nein. Tapir trennt die Endpunktbeschreibung vom Server-Interpreter. Sie können dieselben Endpunkte auf Pekko HTTP, http4s, Netty, Vert.x oder anderen ausführen, indem Sie ein anderes Interpreter-Modul wählen. Die Beschreibungen ändern sich nicht.

Wie generiert Tapir OpenAPI-Dokumentation?

Das OpenAPI-Modul von Tapir liest Ihre Endpunktwerte und erstellt daraus ein vollständiges OpenAPI-Dokument. Da sowohl die Dokumentation als auch der Server von denselben Beschreibungen abgeleitet werden, kann die Dokumentation nicht mit der tatsächlichen API auseinanderdriften.

Wie teste ich eine Tapir-API, ohne einen Server zu starten?

Verwenden Sie den Tapir Stub-Interpreter. Er erstellt ein sttp-Test-Backend aus Ihren Server-Endpunkten, sodass Sie Anfragen senden und Antworten überprüfen können, ohne einen Port zu binden. Diese Tests sind schnell, und Sie behalten eine kleine Anzahl vollständiger Integrationstests bei, um die tatsächliche Verdrahtung zu bestätigen.

Kann ich Tapir's Ausgabe mit anderen API-Tools verwenden?

Ja. Das von Tapir generierte OpenAPI-Dokument ist eine Standardspezifikation. Sie können es in Tools wie Apidog importieren, um eine interaktive API-Referenz, eine Anforderungskonsole und einen generierten Mock-Server zu erhalten, was für die gemeinsame Nutzung der API mit Frontend-Entwicklern hilfreich ist.

Praktizieren Sie API Design-First in Apidog

Entdecken Sie eine einfachere Möglichkeit, APIs zu erstellen und zu nutzen