TapirはTyped API descRiptions(型付きAPI記述)の略です。これは、HTTPエンドポイントをプレーンな値として記述できるScalaライブラリです。メソッド、パス、入力、出力、およびエラーケースのすべてが型システムで捉えられます。その単一の記述から、Tapirはサーバールート、クライアント、およびOpenAPIドキュメントを導き出します。契約を一度書けば、残りはそれに従います。
これが、TapirがWebフレームワークで直接ルートを書くことと異なる点です。ルートハンドラーはHTTPの配線とロジックを結びつけます。Tapirのエンドポイントはそれらを分離します。記述は一つの値であり、ビジネスロジックは別の関数であり、Tapirがそれらを結合します。このチュートリアルでは、小さなタスクAPIを構築し、それをサーバーに配線し、ドキュメントを生成し、実際のScalaを使ってテストします。
Tapirが提供するもの
Tapirはシンプルなアイデアに基づいており、3つの利点があります。
1つ目は型安全性です。エンドポイントの入力と出力は型付けされているため、コンパイラはハンドラーが正しい形式を返すことをチェックします。エンドポイントが約束するものとロジックが生成するものの不一致は、本番環境のバグではなく、コンパイルエラーとなります。
2つ目は単一の真実源です。エンドポイントが値であるため、同じ記述からOpenAPI仕様、サーバーインタープリター、およびクライアントを生成します。記述が1つしかないため、それらがずれることはありません。これは、API契約テストの背後にある規律と同じで、コンパイラによって強制されます。
3つ目はフレームワークからの独立性です。エンドポイントの記述は、それがAkka HTTP、Pekko、http4s、Nettyのいずれで動作するかを知りません。サーバーインタープリターは別途選択するため、記述はフレームワークの選択を超えて存在します。
Tapirが何ではないかを明確にしておく価値があります。それはWebフレームワークではありません。ソケット、スレッドプール、ルーティングエンジンを処理しません。これは、選択したフレームワークの上に位置する記述レイヤーです。また、通常の意味でのコードジェネレーターでもありません。編集するScalaファイルを書き出すツールを実行するわけではありません。エンドポイントの値がソースであり、サーバー、クライアント、ドキュメントはコンパイル時にそれらから計算されます。これにより、記述と実行中のコードが同期を失うことがなく、生成されたファイルが手動で編集されてソースが分岐するというジェネレーターベースのワークフローで発生する一般的な失敗モードを回避できます。
プロジェクトのセットアップ
Tapirをbuild.sbtに追加します。コアモジュール、OpenAPIモジュール、サーバーインタープリター、およびJSONライブラリが必要です。この例では、Akka HTTPの後継である活発にメンテナンスされているPekko HTTPと、JSON用のcirceを使用しています。
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"
)
エコシステムは急速に進化するため、tapir.softwaremill.comの現在のTapirドキュメントには、すべてのモジュールと一致するバージョンが記載されています。
型付きエンドポイントの定義
まず、ドメインタイプとそのJSONコーデックから始めます。circeはケースクラスからコーデックを導出します。
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)
次に、エンドポイントを記述します。それぞれは、endpointベースにコンビネーターを連結して構築される値です。この連結を文章として読んでください。これは、整数パス入力を受け取り、404のApiErrorまたは200のTaskのいずれかを返す/tasks/{id}へのGETです。
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]))
PublicEndpointの4つの型パラメーターは、入力、エラー出力、成功出力、および必要な機能です。ここではまだ何も実行されません。これらは純粋な記述であり、まさに同じ値がサーバー、クライアント、およびドキュメントを生成できる理由です。
いくつかのコンビネーターが主要な役割を果たします。inは、パスセグメント、クエリパラメーター、ヘッダー、ボディなど、入力を追加します。outは成功応答を記述します。errorOutは失敗応答を記述し、単一のエンドポイントはoneOfでバリアントを組み合わせることにより、複数のエラーケースをモデル化できます。各コンビネーターはエンドポイントの型を絞り込むため、記述が完了する頃には、その型シグネチャは契約を正確に表明するものになります。getTaskのシグネチャを読めば、実装のコードを一行も読まずに、Intを受け取り、ApiErrorで失敗する可能性があり、それ以外の場合はTaskを返すことがわかります。
これがTapirの実践的な核心です。エンドポイントの記述は、コンパイラが強制するドキュメントです。契約が型であり、それに違反するコードはビルドされないため、チームメイトが契約を誤読することはありません。
サーバーロジックの追加
serverLogicでロジックをアタッチすると、記述は機能するルートになります。ロジックは、Futureのようなエフェクト型でラップされたEither[ErrorType, SuccessType]を返す関数です。シンプルなインメモリストアが、例を自己完結型に保ちます。
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))
}
コンパイラはここで契約を強制します。getTaskはApiError型のエラーを約束するため、toRightはApiErrorを提供する必要があります。間違った型を返すと、コードはコンパイルされません。HTTPの詳細、404、JSONエンコーディングは、記述によってすでに処理されています。
Pekkoインタープリターを使用してサーバーエンドポイントを実際のルートに変換し、サーバーを起動します。
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)
APIは現在ポート8080で応答しています。インタープリターという一箇所でフレームワークの懸念を変更しましたが、単一のエンドポイント記述に触れることはありませんでした。
OpenAPIドキュメントの生成
エンドポイントが値であるため、OpenAPIドキュメントはそれらの関数です。アノテーションも、別途メンテナンスする仕様ファイルもありません。
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は完全なOpenAPIドキュメントを生成します。tapir-swagger-ui-bundleモジュールを追加すると、Tapirはサーバーから直接インタラクティブなSwagger UIを提供します。仕様とAPIの両方が同じエンドポイント値から派生しているため、仕様がAPIについて嘘をつくことはありません。
そのOpenAPIファイルは、他のツールへの橋渡しでもあります。Apidogにインポートすると、閲覧可能なAPIリファレンス、手動チェック用のリクエストコンソール、および生成されたモックサーバーが、Scalaコードがすでに生成した仕様からすべて得られます。これは、Scalaサービスが進化する間、フロントエンドのチームに何か作業対象を与えるための実用的な方法です。
ここで強調すべきワークフローがあります。TapirはScalaソースから仕様を生成するため、仕様は常に最新です。その仕様を消費するモックツールは、実際のサーバーがどこにもデプロイされる前に、フロントエンドに動作するエンドポイントを提供します。契約は一方向に流れます。Scalaの型がそれを定義し、OpenAPIがそれを運び、モックとフロントエンドがそれを消費します。誰も契約を2回手書きしないため、2つのコピーがずれることはありません。他のチームが依存するAPIを出荷するScalaチームにとって、OpenAPIエクスポートをビルドに組み込む主な理由がこれです。
APIのテスト
Tapirのエンドポイントは2つのレベルでテスト可能です。
最初のレベルでは、スタブインタープリターを使用して、HTTPをまったく介さずにエンドポイントロジックをチェックします。サーバーエンドポイントからテストバックエンドを構築し、sttpクライアントを介してリクエストを送信します。ポートがバインドされないため、これは高速です。
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 }
}
}
2番目のレベルは完全な統合テストです。実際のサーバーをポートにバインドし、HTTPクライアントで呼び出します。これは配線を確認するために控えめに使用し、高速で実行されるため、カバレッジの大部分はスタブベースのテストに依存します。高速な分離されたチェックと、薄い層の実際のチェックという分割は、自動テスト全般における良い実践を反映しています。
Tapirの設計から有用な特性が生まれます。エンドポイントの記述が共有されているため、getTaskからリクエストを構築するテストは、本番サーバーが拒否するようなリクエストを送信できません。契約は同じ値によって両側で強制されます。手動での探索とモックサーバーを介してその契約を往復させるには、Apidogをダウンロードし、Tapirが生成するOpenAPIファイルをインポートしてください。
よくある質問
ScalaにおけるTapirとは何ですか?
TapirはTyped API descRiptions(型付きAPI記述)の略で、HTTPエンドポイントを型付きの値として記述するためのScalaライブラリです。単一の記述からサーバールート、クライアント、OpenAPIドキュメントを導出するため、契約は一度定義され、この3つすべてで一貫性が保たれます。
TapirでAkka HTTPを使用する必要がありますか?
いいえ。Tapirはエンドポイントの記述とサーバーインタープリターを分離しています。異なるインタープリターモジュールを選択することで、Pekko HTTP、http4s、Netty、Vert.x、またはその他の上で同じエンドポイントを実行できます。記述は変更されません。
TapirはどのようにOpenAPIドキュメントを生成しますか?
TapirのOpenAPIモジュールは、エンドポイントの値を読み取り、それらから完全なOpenAPIドキュメントを生成します。ドキュメントとサーバーの両方が同じ記述から派生しているため、ドキュメントが実際のAPIと同期がずれることはありません。
サーバーを起動せずにTapir APIをテストするにはどうすればよいですか?
Tapirスタブインタープリターを使用してください。サーバーエンドポイントからsttpテストバックエンドを構築するため、ポートをバインドせずにリクエストを送信し、応答をアサートできます。これらのテストは高速であり、実際の配線を確認するための少数の完全な統合テストを維持します。
Tapirの出力を他のAPIツールで使用できますか?
はい。Tapirが生成するOpenAPIドキュメントは標準仕様です。Apidogのようなツールにインポートすることで、インタラクティブなAPIリファレンス、リクエストコンソール、生成されたモックサーバーを得ることができ、これはAPIをフロントエンド開発者と共有するのに役立ちます。
