Cómo Construir y Probar una API HTTP con Tapir en Scala

INEZA Felin-Michel

INEZA Felin-Michel

22 May 2026

Cómo Construir y Probar una API HTTP con Tapir en Scala

Apidog para empresas

Despliegue local

SSO & RBAC

Conforme con SOC 2

Explorar Apidog Enterprise

Tapir significa Typed API descRiptions. Es una biblioteca Scala que le permite describir un punto final HTTP como un valor simple: su método, ruta, entradas, salidas y casos de error, todo capturado en el sistema de tipos. A partir de esa única descripción, Tapir deriva la ruta del servidor, un cliente y un documento OpenAPI. Usted escribe el contrato una vez y el resto sigue.

Esto es lo que hace que Tapir sea diferente de escribir rutas directamente en un framework web. Un controlador de rutas acopla el cableado HTTP con la lógica. Un punto final de Tapir los separa: la descripción es un valor, la lógica de negocio es una función separada, y Tapir los une. Este tutorial construye una pequeña API de tareas, la conecta a un servidor, genera documentación y la prueba, usando Scala en todo momento.

Qué le ofrece Tapir

Tapir se basa en una idea simple con tres beneficios.

El primero es la seguridad de tipos. Las entradas y salidas de un punto final están tipadas, por lo que el compilador verifica que su controlador devuelve la forma correcta. Una falta de coincidencia entre lo que promete el punto final y lo que produce la lógica es un error de compilación, no un error de producción.

El segundo es una única fuente de verdad. Debido a que el punto final es un valor, usted genera la especificación OpenAPI, el intérprete del servidor y un cliente a partir de la misma descripción. No pueden separarse, porque solo hay una descripción. Esta es la misma disciplina detrás de las pruebas de contrato de API, impuesta por el compilador.

El tercero es la independencia del framework. La descripción del punto final no sabe si se ejecuta en Akka HTTP, Pekko, http4s o Netty. Usted elige el intérprete del servidor por separado, por lo que la descripción sobrevive a cualquier elección de framework.

Vale la pena aclarar lo que no es Tapir. No es un framework web; no maneja el socket, el pool de hilos ni el motor de enrutamiento. Es una capa de descripción que se sitúa sobre el framework que usted elija. Tampoco es un generador de código en el sentido habitual. Usted no ejecuta una herramienta que escribe archivos Scala que luego edita. Los valores del punto final son la fuente, y el servidor, el cliente y la documentación se computan a partir de ellos en tiempo de compilación. Eso evita que la descripción y el código en ejecución se desincronicen, lo cual es el modo de fallo recurrente de los flujos de trabajo basados en generadores donde el archivo generado se edita manualmente y la fuente diverge.

Configuración del proyecto

Añada Tapir a build.sbt. Necesita el módulo principal, un módulo OpenAPI, un intérprete de servidor y una biblioteca JSON. Este ejemplo utiliza Pekko HTTP, el sucesor de Akka HTTP mantenido activamente, con circe para 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 documentación actual de Tapir en tapir.softwaremill.com enumera cada módulo y las versiones coincidentes, ya que el ecosistema evoluciona rápidamente.

Definiendo un punto final tipado

Comience con los tipos de dominio y sus códecs JSON. Circe deriva los códecs de las clases de caso.

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)

Ahora describa los puntos finales. Cada uno es un valor construido encadenando combinadores a la base endpoint. Lea la cadena como una oración: esto es un GET a /tasks/{id} que toma una entrada de ruta entera y devuelve un ApiError con un 404 o un Task con 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]))

Los cuatro parámetros de tipo en PublicEndpoint son la entrada, la salida de error, la salida de éxito y las capacidades requeridas. Nada de esto se ejecuta todavía. Son descripciones puras, y es exactamente por eso que los mismos valores pueden producir un servidor, un cliente y documentación.

Algunos combinadores soportan la mayor parte del peso. in añade una entrada, ya sea un segmento de ruta, un parámetro de consulta, una cabecera o un cuerpo. out describe la respuesta exitosa. errorOut describe la respuesta de fallo, y un solo punto final puede modelar varios casos de error combinando variantes con oneOf. Cada combinador restringe el tipo del punto final, de modo que cuando la descripción está completa, su firma de tipo es una declaración precisa del contrato. Puede leer la firma de getTask y saber que toma un Int, puede fallar con un ApiError y, de lo contrario, devuelve un Task, sin leer una línea de implementación.

Este es el corazón práctico de Tapir. La descripción del punto final es documentación que el compilador impone. Un compañero de equipo no puede interpretar erróneamente el contrato, porque el contrato es un tipo, y el código que lo viola no se compila.

Añadiendo lógica del servidor

Una descripción se convierte en una ruta funcional cuando se le adjunta lógica con serverLogic. La lógica es una función que devuelve Either[ErrorType, SuccessType], envuelta en un tipo de efecto como Future. Un simple almacenamiento en memoria mantiene el ejemplo autocontenido.

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))
  }

El compilador impone el contrato aquí. getTask promete un error de tipo ApiError, por lo que toRight debe proporcionar un ApiError. Devuelva el tipo incorrecto y el código no compilará. Los detalles HTTP, el 404, la codificación JSON, ya son manejados por la descripción.

Convierta los puntos finales del servidor en rutas reales con el intérprete Pekko e inicie el servidor.

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)

La API ahora responde en el puerto 8080. Cambió la preocupación del framework en un solo lugar, el intérprete, sin tocar una sola descripción de punto final.

Generando documentación OpenAPI

Debido a que los puntos finales son valores, el documento OpenAPI es una función de ellos. Sin anotaciones, sin archivo de especificación separado que mantener.

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 produce un documento OpenAPI completo. Añada el módulo tapir-swagger-ui-bundle y Tapir servirá una interfaz de usuario Swagger interactiva directamente desde su servidor. La especificación no puede mentir sobre la API, porque ambos provienen de los mismos valores de punto final.

Ese archivo OpenAPI es también su puente a otras herramientas. Impórtelo en Apidog y obtendrá una referencia de API navegable, una consola de solicitudes para verificaciones manuales y un servidor simulado generado, todo sin salir de la especificación que su código Scala ya produjo. Es una forma práctica de dar a los compañeros de equipo de frontend algo con lo que trabajar mientras el servicio Scala evoluciona.

Hay un flujo de trabajo que vale la pena destacar aquí. Tapir produce la especificación a partir de su código fuente Scala, por lo que la especificación siempre está actualizada. Una herramienta de mocking que consume esa especificación le da a su frontend un punto final funcional antes de que el servidor real se implemente en cualquier lugar. El contrato fluye en una dirección: los tipos Scala lo definen, OpenAPI lo transporta, y el mock y el frontend lo consumen. Nadie escribe el contrato a mano dos veces, por lo que nadie puede desincronizar las dos copias. Para los equipos de Scala que distribuyen APIs de las que dependen otros equipos, esta es la razón principal para mantener la exportación OpenAPI conectada a la compilación.

Probando la API

Los puntos finales de Tapir son probables en dos niveles.

El primer nivel verifica la lógica del punto final sin HTTP en absoluto, utilizando el intérprete stub. Se construye un backend de prueba a partir de los puntos finales del servidor y se envían solicitudes a través de un cliente sttp. Esto es rápido porque nada enlaza un puerto.

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 }
  }
}

El segundo nivel es una prueba de integración completa: vincule el servidor real a un puerto y llámelo con un cliente HTTP. Úselo con moderación para confirmar el cableado y confíe en las pruebas basadas en stubs para la mayor parte de su cobertura, ya que se ejecutan mucho más rápido. La división entre verificaciones rápidas y aisladas y una fina capa de reales refleja las buenas prácticas en las pruebas automatizadas en general.

Una propiedad útil se deriva del diseño de Tapir: debido a que la descripción del punto final se comparte, una prueba que construye solicitudes a partir de getTask no puede enviar una solicitud que el servidor de producción rechazaría. El contrato se impone en ambos lados por el mismo valor. Para realizar un viaje de ida y vuelta de ese contrato a través de la exploración manual y un servidor simulado, descargue Apidog e importe el archivo OpenAPI que genera Tapir.

Preguntas frecuentes

¿Qué es Tapir en Scala?

Tapir, abreviatura de Typed API descRiptions, es una biblioteca Scala para describir puntos finales HTTP como valores tipados. A partir de una descripción, deriva la ruta del servidor, un cliente y un documento OpenAPI, por lo que el contrato se define una vez y se mantiene consistente en los tres.

¿Tengo que usar Akka HTTP con Tapir?

No. Tapir separa la descripción del punto final del intérprete del servidor. Puede ejecutar los mismos puntos finales en Pekko HTTP, http4s, Netty, Vert.x u otros, eligiendo un módulo de intérprete diferente. Las descripciones no cambian.

¿Cómo genera Tapir la documentación OpenAPI?

El módulo OpenAPI de Tapir lee los valores de su punto final y produce un documento OpenAPI completo a partir de ellos. Debido a que la documentación y el servidor se derivan de las mismas descripciones, la documentación no puede desincronizarse con la API real.

¿Cómo pruebo una API de Tapir sin iniciar un servidor?

Utilice el intérprete stub de Tapir. Construye un backend de prueba sttp a partir de sus puntos finales de servidor, para que pueda enviar solicitudes y verificar las respuestas sin vincular un puerto. Estas pruebas son rápidas, y usted mantiene un pequeño número de pruebas de integración completas para confirmar el cableado real.

¿Puedo usar la salida de Tapir con otras herramientas de API?

Sí. El documento OpenAPI que genera Tapir es una especificación estándar. Puede importarlo en herramientas como Apidog para obtener una referencia de API interactiva, una consola de solicitudes y un servidor simulado generado, lo cual es útil para compartir la API con los desarrolladores frontend.

Practica el diseño de API en Apidog

Descubre una forma más fácil de construir y usar APIs