Tapir significa Typed API descRiptions (descrições de API tipadas). É uma biblioteca Scala que permite descrever um endpoint HTTP como um valor simples: seu método, caminho, entradas, saídas e casos de erro, todos capturados no sistema de tipos. A partir dessa única descrição, o Tapir deriva a rota do servidor, um cliente e um documento OpenAPI. Você escreve o contrato uma vez e o resto se segue.
Isso é o que torna o Tapir diferente de escrever rotas diretamente em um framework web. Um handler de rota acopla a fiação HTTP à lógica. Um endpoint Tapir os separa: a descrição é um valor, a lógica de negócios é uma função separada, e o Tapir os une. Este tutorial constrói uma pequena API de tarefas, a conecta a um servidor, gera documentação e a testa, usando Scala de ponta a ponta.
O que o Tapir oferece
O Tapir é construído sobre uma ideia simples com três benefícios.
O primeiro é a segurança de tipos. As entradas e saídas de um endpoint são tipadas, então o compilador verifica se o seu handler retorna o formato correto. Uma incompatibilidade entre o que o endpoint promete e o que a lógica produz é um erro de compilação, não um bug em produção.
O segundo é uma única fonte de verdade. Como o endpoint é um valor, você gera a especificação OpenAPI, o interpretador do servidor e um cliente a partir da mesma descrição. Eles não podem se desviar, porque há apenas uma descrição. Essa é a mesma disciplina por trás do teste de contrato de API, imposto pelo compilador.
O terceiro é a independência de framework. A descrição do endpoint não sabe se será executada em Akka HTTP, Pekko, http4s ou Netty. Você escolhe o interpretador do servidor separadamente, de modo que a descrição sobrevive a qualquer escolha de framework.
Vale a pena deixar claro o que o Tapir não é. Não é um framework web; ele não lida com o socket, o pool de threads ou o motor de roteamento. É uma camada de descrição que se baseia em um framework que você escolher. Também não é um gerador de código no sentido usual. Você não executa uma ferramenta que escreve arquivos Scala que você edita. Os valores do endpoint são a fonte, e o servidor, o cliente e a documentação são calculados a partir deles em tempo de compilação. Isso evita que a descrição e o código em execução se desatualizem, que é o modo de falha recorrente de fluxos de trabalho baseados em geradores, onde o arquivo gerado é editado manualmente e a fonte diverge.
Configurando o projeto
Adicione Tapir ao build.sbt. Você precisa do módulo principal, um módulo OpenAPI, um interpretador de servidor e uma biblioteca JSON. Este exemplo usa Pekko HTTP, o sucessor ativamente mantido do Akka HTTP, com 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"
)
A documentação atual do Tapir em tapir.softwaremill.com lista todos os módulos e as versões correspondentes, pois o ecossistema se move rapidamente.
Definindo um endpoint tipado
Comece com os tipos de domínio e seus codecs JSON. O circe deriva os codecs das 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)
Agora, descreva os endpoints. Cada um é um valor construído encadeando combinators ao `endpoint` base. Leia a cadeia como uma frase: este é um `GET` para `/tasks/{id}` que recebe uma entrada de caminho inteira e retorna um `ApiError` com um `404` ou uma `Task` com um `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]))
Os quatro parâmetros de tipo em `PublicEndpoint` são entrada, saída de erro, saída de sucesso e capacidades necessárias. Nada aqui é executado ainda. São descrições puras, e é exatamente por isso que os mesmos valores podem produzir um servidor, um cliente e documentação.
Alguns combinators carregam a maior parte do peso. `in` adiciona uma entrada, seja um segmento de caminho, um parâmetro de consulta, um cabeçalho ou um corpo. `out` descreve a resposta de sucesso. `errorOut` descreve a resposta de falha, e um único endpoint pode modelar vários casos de erro combinando variantes com `oneOf`. Cada combinator restringe o tipo do endpoint, de modo que, no momento em que a descrição é concluída, sua assinatura de tipo é uma declaração precisa do contrato. Você pode ler a assinatura de `getTask` e saber que ela recebe um `Int`, pode falhar com um `ApiError` e, caso contrário, retorna uma `Task`, sem ler uma linha de implementação.
Este é o coração prático do Tapir. A descrição do endpoint é uma documentação que o compilador impõe. Um colega de equipe não pode interpretar mal o contrato, porque o contrato é um tipo, e o código que o viola não será compilado.
Adicionando lógica do servidor
Uma descrição se torna uma rota funcional quando você anexa lógica com `serverLogic`. A lógica é uma função que retorna `Either[ErrorType, SuccessType]`, envolvida em um tipo de efeito como `Future`. Um armazenamento simples em memória mantém o exemplo autocontido.
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))
}
O compilador impõe o contrato aqui. `getTask` promete um erro do tipo `ApiError`, então `toRight` deve fornecer um `ApiError`. Retorne o tipo errado e o código não compilará. Os detalhes HTTP, o `404`, a codificação JSON, já são tratados pela descrição.
Converta os endpoints do servidor em rotas reais com o interpretador Pekko e inicie o 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)
A API agora responde na porta 8080. Você alterou a preocupação do framework em um único lugar, o interpretador, sem tocar em nenhuma descrição de endpoint.
Gerando documentação OpenAPI
Como os endpoints são valores, o documento OpenAPI é uma função deles. Sem anotações, sem arquivo de especificação separado para manter.
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 produz um documento OpenAPI completo. Adicione o módulo tapir-swagger-ui-bundle e o Tapir serve uma interface interativa do Swagger UI diretamente do seu servidor. A especificação não pode mentir sobre a API, porque ambos vêm dos mesmos valores de endpoint.
Esse arquivo OpenAPI também é sua ponte para outras ferramentas. Importe-o para o Apidog e você obterá uma referência de API navegável, um console de requisições para verificações manuais e um servidor mock gerado, tudo sem sair da especificação que seu código Scala já produziu. É uma maneira prática de dar aos colegas de equipe de frontend algo para trabalhar enquanto o serviço Scala evolui.
Há um fluxo de trabalho que vale a pena destacar aqui. O Tapir produz a especificação a partir do seu código fonte Scala, então a especificação está sempre atualizada. Uma ferramenta de mocking que consome essa especificação, então, oferece ao seu frontend um endpoint funcional antes que o servidor real seja implantado em qualquer lugar. O contrato flui em uma direção: os tipos Scala o definem, o OpenAPI o transporta, e o mock e o frontend o consomem. Ninguém escreve o contrato duas vezes manualmente, então ninguém pode ter as duas cópias desalinhadas. Para equipes Scala que entregam APIs das quais outras equipes dependem, este é o principal motivo para manter a exportação OpenAPI conectada à build.
Testando a API
Os endpoints do Tapir são testáveis em dois níveis.
O primeiro nível verifica a lógica do endpoint sem HTTP, usando o interpretador stub. Você constrói um backend de teste a partir dos endpoints do servidor e envia requisições através de um cliente sttp. Isso é rápido porque nada se vincula a uma porta.
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 }
}
}
O segundo nível é um teste de integração completo: vincule o servidor real a uma porta e chame-o com um cliente HTTP. Use isso com moderação para confirmar a fiação, e confie nos testes baseados em stub para a maior parte da sua cobertura, pois eles são executados muito mais rápido. A divisão entre verificações rápidas e isoladas e uma fina camada de testes reais espelha boas práticas em testes automatizados em geral.
Uma propriedade útil decorre do design do Tapir: como a descrição do endpoint é compartilhada, um teste que constrói requisições a partir de `getTask` não pode enviar uma requisição que o servidor de produção rejeitaria. O contrato é imposto em ambos os lados pelo mesmo valor. Para fazer o round-trip desse contrato através de exploração manual e um servidor mock, baixe o Apidog e importe o arquivo OpenAPI que o Tapir gera.
Perguntas Frequentes
O que é Tapir em Scala?
Tapir, abreviação de Typed API descRiptions, é uma biblioteca Scala para descrever endpoints HTTP como valores tipados. A partir de uma descrição, ela deriva a rota do servidor, um cliente e um documento OpenAPI, de modo que o contrato é definido uma vez e permanece consistente em todos os três.
Preciso usar Akka HTTP com Tapir?
Não. O Tapir separa a descrição do endpoint do interpretador do servidor. Você pode executar os mesmos endpoints em Pekko HTTP, http4s, Netty, Vert.x ou outros, escolhendo um módulo interpretador diferente. As descrições não mudam.
Como o Tapir gera documentação OpenAPI?
O módulo OpenAPI do Tapir lê os valores dos seus endpoints e produz um documento OpenAPI completo a partir deles. Como a documentação e o servidor são derivados das mesmas descrições, a documentação não pode se desatualizar em relação à API real.
Como testo uma API Tapir sem iniciar um servidor?
Use o interpretador stub do Tapir. Ele constrói um backend de teste sttp a partir dos seus endpoints de servidor, permitindo que você envie requisições e faça asserções sobre as respostas sem vincular a uma porta. Esses testes são rápidos, e você mantém um pequeno número de testes de integração completos para confirmar a fiação real.
Posso usar a saída do Tapir com outras ferramentas de API?
Sim. O documento OpenAPI que o Tapir gera é uma especificação padrão. Você pode importá-lo para ferramentas como o Apidog para obter uma referência de API interativa, um console de requisições e um servidor mock gerado, o que é útil para compartilhar a API com desenvolvedores frontend.
