Tapir signifie Typed API descRiptions. C'est une bibliothèque Scala qui vous permet de décrire un point d'accès HTTP comme une valeur simple : sa méthode, son chemin, ses entrées, ses sorties et ses cas d'erreur sont tous capturés dans le système de types. À partir de cette description unique, Tapir dérive la route du serveur, un client et un document OpenAPI. Vous écrivez le contrat une fois et le reste suit.
C'est ce qui distingue Tapir de l'écriture directe de routes dans un framework web. Un gestionnaire de route couple le câblage HTTP à la logique. Un point d'accès Tapir les sépare : la description est une valeur, la logique métier est une fonction distincte, et Tapir les unit. Ce tutoriel construit une petite API de tâches, la connecte à un serveur, génère de la documentation et la teste, en utilisant du Scala réel partout.
Ce que Tapir vous apporte
Tapir est construit sur une idée simple avec trois avantages.
Le premier est la sûreté des types. Les entrées et sorties d'un point d'accès sont typées, de sorte que le compilateur vérifie que votre gestionnaire renvoie la bonne forme. Une non-concordance entre ce que le point d'accès promet et ce que la logique produit est une erreur de compilation, pas un bug de production.
Le second est une source unique de vérité. Parce que le point d'accès est une valeur, vous générez la spécification OpenAPI, l'interpréteur de serveur et un client à partir de la même description. Ils ne peuvent pas diverger, car il n'y a qu'une seule description. C'est la même discipline que celle qui sous-tend les tests de contrat d'API, appliquée par le compilateur.
Le troisième est l'indépendance vis-à-vis du framework. La description du point d'accès ne sait pas si elle s'exécute sur Akka HTTP, Pekko, http4s ou Netty. Vous choisissez l'interpréteur de serveur séparément, de sorte que la description perdure au-delà de tout choix de framework.
Il est important de clarifier ce que Tapir n'est pas. Ce n'est pas un framework web ; il ne gère pas le socket, le pool de threads ou le moteur de routage. C'est une couche de description qui se superpose à un framework de votre choix. Ce n'est pas non plus un générateur de code au sens habituel. Vous n'exécutez pas un outil qui écrit des fichiers Scala que vous modifiez ensuite. Les valeurs de point d'accès sont la source, et le serveur, le client et la documentation sont calculés à partir de celles-ci au moment de la compilation. Cela empêche la description et le code en cours d'exécution de se désynchroniser, ce qui est le mode de défaillance récurrent des flux de travail basés sur des générateurs où le fichier généré est édité manuellement et la source diverge.
Configuration du projet
Ajoutez Tapir à build.sbt. Vous avez besoin du module core, d'un module OpenAPI, d'un interpréteur de serveur et d'une bibliothèque JSON. Cet exemple utilise Pekko HTTP, le successeur activement maintenu d'Akka HTTP, avec circe pour le 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"
)
La documentation actuelle de Tapir sur tapir.softwaremill.com liste chaque module et les versions correspondantes, car l'écosystème évolue rapidement.
Définition d'un point d'accès typé
Commencez par les types de domaine et leurs codecs JSON. circe dérive les codecs à partir des case classes.
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)
Décrivez maintenant les points d'accès. Chacun est une valeur construite en chaînant des combinateurs sur la base endpoint. Lisez la chaîne comme une phrase : c'est un GET vers /tasks/{id} qui prend une entrée de chemin entier et renvoie soit une ApiError avec un 404, soit une Task avec un 200.
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]))
Les quatre paramètres de type de PublicEndpoint sont l'entrée, la sortie d'erreur, la sortie de succès et les capacités requises. Rien ne s'exécute encore ici. Ce sont de pures descriptions, c'est précisément pourquoi les mêmes valeurs peuvent produire un serveur, un client et de la documentation.
Quelques combinateurs portent l'essentiel du poids. in ajoute une entrée, qu'il s'agisse d'un segment de chemin, d'un paramètre de requête, d'un en-tête ou d'un corps. out décrit la réponse de succès. errorOut décrit la réponse d'échec, et un point d'accès unique peut modéliser plusieurs cas d'erreur en combinant des variantes avec oneOf. Chaque combinateur affine le type du point d'accès, de sorte qu'au moment où la description est complète, sa signature de type est une déclaration précise du contrat. Vous pouvez lire la signature de getTask et savoir qu'il prend un Int, peut échouer avec une ApiError, et sinon renvoie une Task, sans lire une ligne d'implémentation.
C'est le cœur pratique de Tapir. La description du point d'accès est une documentation que le compilateur applique. Un coéquipier ne peut pas mal interpréter le contrat, car le contrat est un type, et le code qui le viole ne compile pas.
Ajout de la logique serveur
Une description devient une route fonctionnelle lorsque vous y attachez une logique avec serverLogic. La logique est une fonction renvoyant Either[ErrorType, SuccessType], enveloppée dans un type d'effet comme Future. Un simple stockage en mémoire maintient l'exemple autonome.
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))
}
Le compilateur applique le contrat ici. getTask promet une erreur de type ApiError, donc toRight doit fournir une ApiError. Retournez le mauvais type et le code ne compilera pas. Les détails HTTP, le 404, l'encodage JSON, sont déjà gérés par la description.
Convertissez les points d'accès du serveur en routes réelles avec l'interpréteur Pekko et démarrez le serveur.
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)
L'API répond maintenant sur le port 8080. Vous avez modifié la préoccupation du framework à un seul endroit, l'interpréteur, sans toucher une seule description de point d'accès.
Génération de la documentation OpenAPI
Parce que les points d'accès sont des valeurs, le document OpenAPI est une fonction de ceux-ci. Pas d'annotations, pas de fichier de spécification séparé à maintenir.
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 produit un document OpenAPI complet. Ajoutez le module tapir-swagger-ui-bundle et Tapir sert une interface utilisateur Swagger interactive directement depuis votre serveur. La spécification ne peut pas mentir sur l'API, car les deux proviennent des mêmes valeurs de point d'accès.
Ce fichier OpenAPI est également votre passerelle vers d'autres outils. Importez-le dans Apidog et vous obtiendrez une référence d'API navigable, une console de requêtes pour les vérifications manuelles et un serveur de maquette généré, le tout sans quitter la spécification que votre code Scala a déjà produite. C'est un moyen pratique de donner aux coéquipiers frontend quelque chose sur quoi travailler pendant que le service Scala évolue.
Il y a un flux de travail qui mérite d'être souligné ici. Tapir produit la spécification à partir de votre code source Scala, de sorte que la spécification est toujours à jour. Un outil de maquettage qui consomme cette spécification donne ensuite à votre frontend un point d'accès fonctionnel avant que le serveur réel ne soit déployé. Le contrat circule dans une seule direction : les types Scala le définissent, OpenAPI le transporte, et la maquette ainsi que le frontend le consomment. Personne n'écrit le contrat à la main deux fois, de sorte que personne ne peut désynchroniser les deux copies. Pour les équipes Scala qui livrent des API dont d'autres équipes dépendent, c'est la raison principale de maintenir l'exportation OpenAPI intégrée au build.
Test de l'API
Les points d'accès Tapir sont testables à deux niveaux.
Le premier niveau vérifie la logique du point d'accès sans aucun HTTP, en utilisant l'interpréteur de stub. Vous construisez un backend de test à partir des points d'accès du serveur et envoyez des requêtes via un client sttp. C'est rapide car rien ne lie un port.
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 }
}
}
Le second niveau est un test d'intégration complet : liez le serveur réel à un port et appelez-le avec un client HTTP. Utilisez-le avec parcimonie pour confirmer le câblage, et fiez-vous aux tests basés sur des stubs pour l'essentiel de votre couverture car ils s'exécutent beaucoup plus rapidement. La séparation entre des vérifications isolées rapides et une fine couche de vérifications réelles reflète les bonnes pratiques en matière de tests automatisés en général.
Une propriété utile découle de la conception de Tapir : parce que la description du point d'accès est partagée, un test qui construit des requêtes à partir de getTask ne peut pas envoyer une requête que le serveur de production rejetterait. Le contrat est appliqué des deux côtés par la même valeur. Pour boucler ce contrat via une exploration manuelle et un serveur de maquette, téléchargez Apidog et importez le fichier OpenAPI que Tapir génère.
Questions fréquemment posées
Qu'est-ce que Tapir en Scala ?
Tapir, abréviation de Typed API descRiptions, est une bibliothèque Scala pour décrire les points d'accès HTTP comme des valeurs typées. À partir d'une description, il dérive la route du serveur, un client et un document OpenAPI, de sorte que le contrat est défini une fois et reste cohérent sur les trois.
Dois-je utiliser Akka HTTP avec Tapir ?
Non. Tapir sépare la description du point d'accès de l'interpréteur de serveur. Vous pouvez exécuter les mêmes points d'accès sur Pekko HTTP, http4s, Netty, Vert.x ou d'autres en choisissant un module d'interpréteur différent. Les descriptions ne changent pas.
Comment Tapir génère-t-il la documentation OpenAPI ?
Le module OpenAPI de Tapir lit les valeurs de vos points d'accès et en produit un document OpenAPI complet. Étant donné que la documentation et le serveur dérivent tous deux des mêmes descriptions, la documentation ne peut pas se désynchroniser de l'API réelle.
Comment tester une API Tapir sans démarrer de serveur ?
Utilisez l'interpréteur de stub Tapir. Il construit un backend de test sttp à partir de vos points d'accès serveur, ce qui vous permet d'envoyer des requêtes et d'effectuer des assertions sur les réponses sans lier de port. Ces tests sont rapides, et vous conservez un petit nombre de tests d'intégration complets pour confirmer le câblage réel.
Puis-je utiliser la sortie de Tapir avec d'autres outils API ?
Oui. Le document OpenAPI généré par Tapir est une spécification standard. Vous pouvez l'importer dans des outils comme Apidog pour obtenir une référence d'API interactive, une console de requêtes et un serveur de maquette généré, ce qui est utile pour partager l'API avec les développeurs frontend.
