🥰

http4s と tapir で型安全なルーティング定義&Swagger ドキュメントの自動生成

2022/09/27に公開

この記事は以下の記事の続きです.

https://zenn.dev/110416/articles/5beb6e34b8b4fd

概要

Tapir の嬉しさ

  • ルーティング定義から Swagger ドキュメントを自動生成できるので、ドキュメントの手動管理していると起こりがちな仕様と実装のずれを減らせる
  • ルーティング定義に特化したライブラリなので Akka HTTP や HTTP4s の Routes と比較して、サーバーの実装に依存しにくいルーティング定義が書ける.
  • Interpreter モジュールを差し替えることでサーバーの実装を差し替えることもできる.
  • 型でしっかりモデリングされているのでコンパイラエラーで間違いを捕捉しやすい. 補完がバリバリ効くので楽にコーディングできる.
  • http サーバーの入出力周りのユースケースをカバーしているので典型的な処理のために無駄なコードが増えない

Tapir を使うことで下のコードのようにルーティングを型安全に定義できます. さらにそのルーティング定義から Swagger ドキュメントを自動生成できます.

protocol/EndpointDefs.sala
package protocol
import sttp.tapir._
import sttp.tapir.generic.auto._
import sttp.tapir.json.circe._
import io.circe.generic.auto._
import service.Greeting
import cats.effect._

case class ErrorResponse(message: String)

object EndpointDefs {
    // ベースとなる api / v1 を定義する
    // エラーは共通の ErrorResponse 型を使う
    val base :PublicEndpoint[Unit,ErrorResponse,Unit,Any] = 
        endpoint.in("api" / "v1").errorOut(jsonBody[ErrorResponse])
    // クエリパラメーターに name=...を入れると、
    // hello, <name> とレスポンスを返すエンドポイントを定義する.
    // api / v1 / hello
    val greetingRouting = base.in("hello")
      .in(
        query[String](name = "name").description("Who are you?")
      ).out(
        stringBody.description("greeting with given name")
      )
    // http ステータスだけを返すエンドポイントを定義する
    // api /v1/ status
    val status = base.in("status").out(emptyOutput)

解説

tapir はルーティング定義のための、あるいはルーティング定義とその実装を分離するためのライブラリです. ルーティング定義を tapir で記述し、実装部分(≒サーバー)は Akka HTTP や http4s など柔軟に差し替えることができます.

またルーティング定義と実装が分離しているおかげで、自然と通信プロトコル(http通信, serialize/deserialize 処理や tracing など)とユースケース/ドメインロジックが分離されたコードを書けます.

まず、build.sbt に tapir を追加します. この記事を書いている時点で 1.1.0 がリリースされていたので 1.1.0 を使います. 細かい部分は https://zenn.dev/110416/articles/5beb6e34b8b4fd の記事を見直してください.

build.sbt
Global / onChangedBuildSource := ReloadOnSourceChanges
// ...省略... 
val V = new {
    val scala213 = "2.13.8"
    // tapir は http4s 1.x 系をまだサポートしていないので 0.x 系を使います.
    val http4s = "1.0.0-M36"
    val http4sLegacy = "0.23.15"
    val circe = "0.15.0-M1"
    val CE = "3.3.12"
    val tapir = "1.1.0"
}

lazy val server = project.in(file("server"))
  // Docker イメージを作るための設定
  .enablePlugins(JavaAppPackaging)
  .enablePlugins(DockerPlugin)
  .settings(
    run / fork := true,
    scalaVersion := V.scala213,
    version := "0.1.2-SNAPSHOT",
    libraryDependencies ++= Seq(
        "org.http4s" %% "http4s-core" % V.http4sLegacy,
        "org.http4s" %% "http4s-dsl" % V.http4sLegacy,
        "org.http4s" %% "http4s-circe" % V.http4sLegacy,
        "org.http4s" %% "http4s-ember-server" % V.http4sLegacy,
        "io.circe" %% "circe-core" % V.circe,
        "io.circe" %% "circe-generic" % V.circe,
        "org.typelevel" %% "cats-effect" % V.CE,
        "com.softwaremill.sttp.tapir" %% "tapir-core" % V.tapir,
        "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % V.tapir,
        "com.softwaremill.sttp.tapir" %% "tapir-http4s-server" % V.tapir,
        "com.softwaremill.sttp.tapir" %% "tapir-swagger-ui-bundle" % V.tapir
    )
  )
  .settings(
    // ...省略...

tapir の Endpoint 型は以下のような型パラメーターを持ちます.

Endpoint[認証に使う型, 入力に使う型, エラー型, 出力に使う型, -R]

また、PublicEndpoint 型は認証を除いて、[入力に使う型, エラー型, 出力に使う型, -R]を取ります. 入力に使う型が Unit になっているエンドポイントはリクエストからなにも受け取らないエンドポイントです.

EndpointDefs.basePublicEndpoint[Unit,ErrorResponse,Unit,Any] ですが、greetingRoutingquery[String] を受け取っているので Endpoint[Unit,String,ErrorResponse,String,Any] 型になります.

    // api / v1 / hello
    val greetingRouting = base.in("hello") // in メソッドで path を定義する
      .in(
        query[String](name = "name").description("Who are you?") 
	// query 関数を使って queryParameters を定義する
	// e.g. api / v1 / hello ?name=... 
      ).out(
        stringBody.description("greeting with given name")
	// stringBody を使って、レスポンスが文字列型であることを示す
      )

エンドポイントが定義できたら、これをビジネスロジックをバインドする必要があります.

まず、ビジネスロジックを書きます. ビジネスロジックはエンドポイントで受け取った型を引数にとって F[成功時の型] または F[Either[エラー型,成功時の型]] を返す関数となるはずです.

今回は失敗しないはずの関数なので F[成功時の型] を返します. この関数をエンドポイントにバインドします.

service/Greeting.scala
package service
import cats.Monad
import org.http4s._
import org.http4s.dsl._
import org.http4s.dsl.io._
import scodec.bits.ByteVector

object Greeting {
    def apply[F[_]:Monad](name:String) = Monad[F].pure(s"Hello, $name") 
}

下のコードのように serverLogicSuccess メソッドを呼び出して、エンドポイントとビジネスロジックをバインドします. greetingRouting.serverLogicSuccessquery[String] を使うので関数 String => F[成功時の型] を受け取ります. status.serverLogicSuccessは引数を受け取らないので Unit => F[Unit] を使います.

ついでに、サーバーにこのルーティングとビジネスロジックをまとめて渡すために endpointsV1: List[ServerEndpoint[Any,IO] にエンドポイントをまとめます.

Routing.scala
package boot
import protocol.EndpointDefs
import service.Greeting
import cats.effect.IO
object Routing {
    val greeting = EndpointDefs
      .greetingRouting
      .serverLogicSuccess(Greeting[IO](_))
    val status = EndpointDefs
      .status.serverLogicSuccess(_ => IO.unit)
    val endpointsV1 = List(
      greeting,
      status
    )
}

あとは endpointsV1 を http4s から使えるようにするために少しコードを足します.

Server.scala
import boot.Routing
import cats.effect._
import com.comcast.ip4s._
import org.http4s.HttpRoutes
import org.http4s.dsl._
import org.http4s.dsl.io._
import org.http4s.server._
import org.http4s.ember.server.EmberServerBuilder
import protocol.EndpointDefs
// sttp を使うことでサーバーの実装を差し替えられるようにする.
import sttp._
import sttp.tapir._
import sttp.tapir.server.http4s.Http4sServerInterpreter
// swagger ドキュメントジェネレーターを追加する
import sttp.tapir.swagger.bundle.SwaggerInterpreter

object Main extends IOApp {
  def run(args: List[String]): IO[ExitCode] =
    EmberServerBuilder
      .default[IO]
      .withHost(ipv4"0.0.0.0")
      .withHttpApp(
        // Router を使って、ルートパスに api/v1 と docs をバインドする.
        Router(
          "/" -> apiV1,
          "/" -> docsAsHttp4sRoutes
        ).orNotFound
      )
      .build
      .useForever
      .as(ExitCode.Success)
 // ここでドキュメント用のルーティングを定義します.
  val apiDocs =
    SwaggerInterpreter()
      .fromEndpoints[IO](
        IORouting.endpointsV1.map(_.endpoint),
        "docs",
        "0.1.0-SNAPSHOT"
      )
  val docsAsHttp4sRoutes = Http4sServerInterpreter[IO]()
    .toRoutes(apiDocs)
  
  // api / v1 を http4s の Routes にマッピングします.
  def apiV1 = Http4sServerInterpreter[IO]()
    .toRoutes(
      IORouting.endpointsV1
    )
}

sbt シェルから server / run コマンドを打ってみましょう. http://localhost:8080/docs にドキュメントが、http://localhost:8080/api/v1/hellohttp://localhost:8080/api/v1/status にエンドポイントが生えているのが確認できるはずです.

試しに http://localhost:8080/api/v1/hello?name=john にアクセスすると hello, john と表示されるはずです. また、本来必要な name クエリーを無くした http://localhost:8080/api/v1/hello にアクセスすると Invalid value for: query parameter name (missing) が表示されるはずです.

次は、レスポンスに json を返してみましょう.

次のようなエンドポイントをEndpointDefs.scala に追加します. out に jsonBody[T] を指定することで Scala.の case class を Json に変換します.

  // api / v1 /search
  val search = base
    .in("search")
    .in(
      query[String](name = "haystack").description("where to search for")
    )
    .in(
      query[String](name = "needle").description("what you are searching for")
    )
    .out(
      jsonBody[Hits]
    )

また、次のようなサービスを定義します.

service/Search.scala
pacakge service
import cats.Monad

case class Hits(
    count: Long,
    items: List[Item]
)

case class Item(
    name: String,
    location: String,
)
object Search {
  def apply[F[_]: Monad](haystack: String, needle: String) = Monad[F].pure(
    (haystack, needle) match {
      case ("room", "glasses") =>
        Hits(
          count = 1,
          items = List(Item(name = "boston", location = "on top of your head"))
        )
      case _ => Hits(count = 0, items = Nil)
    }
  )
}

Routing.scala
package boot
import protocol.EndpointDefs
import service.Greeting
import cats.effect.IO
object Routing {
    val greeting = EndpointDefs
      .greetingRouting
      .serverLogicSuccess(Greeting[IO](_))
    val status = EndpointDefs
      .status.serverLogicSuccess(_ => IO.unit)
+    val search = EndpointDefs
+      .search.serverLogicSuccess{
+        case (haystack,needle) => Search[IO](haystack,needle)
+      }
    val endpointsV1 = List(
      greeting,
      status,
+.    search
    )
}

サーバーを再起動して http://localhost:8080/docs をみてみましょう. search エンドポイントのドキュメントが追加されたはずです.

「try it out」 を押して、haystack に room, needle に glasses と入力すると、Search.scala に書いたレスポンスが json で返ってくるはずです.

さて、今回は Swagger ドキュメントを生成しましたが、ルーティング定義から Http Client の自動生成も可能です.

https://tapir.softwaremill.com/en/latest/client/http4s.html

Scala の Typelevel エコシステムや softwaremill エコシステムには tapir のような実用的なライブラリがあります. 興味を持った方は http4s のドキュメント, tapirのドキュメントRock the JVM の記事・YouTube チャンネルを見てみましょう.

参考

ベースは少し古い記事ですが tapir の雰囲気をざっと掴むのにおすすめです.(2022/08 にScala 3 に合わせて少しリライトされたようです.)
https://qiita.com/yasuabe2613/items/070d973d4843cc7fe1fe

Discussion