HTTP エンドポイントをタイプフルに記述してビジネスロジックを分離するライブラリ tapir の http4s への適用。
はじめに
関数型な Scala の界隈では記述と実行の分離がよく奨励されるが1、この考え方をHTTPのエンドポイントにも当てはめたのが tapir (Typed API descRiptions) で、エンドポイントの記述とビジネスロジックをいい感じに分離してくれる。
現時点で Akka Http と http4s に対応しているが、この記事では http4s との組み合わせを試してみる。
概要
こんな特徴がある
-
エンドポイントの記述とビジネスロジックの分離: エンドポイントをロジックから独立させることで、サーバーの受け口としても、クライアントからの呼び先としても、OpenAPI ドキュメント作成の入力としても、統一的に扱えて、再利用もしやすくなる。
-
読みやすい EDSL: tapir 自体について知らなくても、どういうエンドポイントなのか読めばわかる。OpenApi(Swagger)にも最初から準拠していて、関連するフィールドの指定も含めて、一連の fluent interface の流れの中で表現できる。
-
型と値と関数だけの Scala コード: ルート定義ファイルなどを書くこともないし、アノテーションもマクロも使わない。エディターや IDEとの相性もよいので補完や型推論がスムーズ。
-
シンプルな型: Finch のように Shapeless などを使い倒してると(これはこれで面白いけど)、開発が進むうちに型が巨大化してコンパイルに異常に時間がかかったり、エラーメッセージが複雑過ぎて読めなくなったりすることがままあるが、tapir はただの Generic なケースクラスしか使ってないので、人間にもコンパイラにも優しい。
-
3 と 4 の結果、合成・分解が簡単: ただの値と関数でしかないから、普通の extract 〜 系のリファクタリングテクニックが使いやすく、重複解消 → 再利用がはかどる。
エンドポイント型は下のようなケースクラスと型エイリアスとして提供される。
case class Endpoint[A, I, E, O, -R](
securityInput: EndpointInput[A],
input : EndpointInput[I],
errorOutput : EndpointOutput[E],
output : EndpointOutput[O],
info : EndpointInfo)
type PublicEndpoint[I, E, O, -R] = Endpoint[A, I, E, O, -R]
securityInput
は認証トークン等のセキュリティ関連パラメータを扱うが、必要ない場合は PublicEndpoint
が使える。型パラメータR
は、エンドポイントが WebSocket やストリームであるような場合に指定する。必要なければ Any
で良い。→ 公式doc
サンプル実装
上述の特徴を踏まえてサンプルコードを書いてみる。
簡単にするためにエラー型は Unit
にしたが、本当は例えば (StatusCode, ErrorInfo)
などのような型が使われる。また Effect としては、Task
でも F[_]
でもよかったが2、簡単のため IO
決め打ちにした。
各種バージョンは
- Scala: 3.1.3
- Cats: 2.8.0
- http4s: 0.23.14
- Tapir: 1.0.3
- その他
Hello, World
まず、エンドポイントが一つだけの最小のサンプルで試してみる。
http4s のドキュメント に、下のような Hello World が紹介されているが、、、
val helloWorldService = HttpRoutes.of[IO] {
case GET -> Root / "hello" / name =>
Ok(s"Hello, $name.")
}.orNotFound
tapir を使って分解してみると以下のようになる。
// エンドポイントの記述
val helloWorldEP: PublicEndpoint[String, Unit, String, Any] =
endpoint.get.in("hello" / path[String]("name")).out(stringBody)
// ビジネスロジック
def helloLogic(name: String): IO[Either[Unit, String]] =
s"Hello, $name.".asRight[Unit].pure[IO]
// エンドポイントとロジックを組み合わせて http4s の HttpRoutes に
val helloWorldRoute: HttpRoutes[IO] =
Http4sServerInterpreter[IO]().toRoutes(helloWorldEP serverLogic helloLogic)
// HttpRoutes に 404 を加味して Ember で実行可能な HttpApp に
val helloWorldService: HttpApp[IO] =
helloWorldRoute.orNotFound
少しコード増えたが型安全に分解できた。きれいに分解できると合成も簡単になり、共通要素をまとめたり抽象度をそろえたりしやすくなる。次にエンドポイントを複数にして、その効用をみてみる。
複数エンドポイント
以下のような3つのエンドポイントを例に考えてみる。
val helloEP: PublicEndpoint[String, Unit, String, Any] =
endpoint.get
.in("hello" / path[String]("name"))
.out(stringBody)
val hiEP: PublicEndpoint[String, Unit, String, Any] =
endpoint.get
.in("hi" / path[String]("name"))
.out(stringBody)
val byeEP: PublicEndpoint[String, Unit, String, Any] =
endpoint.get
.in("bye" / path[String]("name"))
.out(stringBody)
重複コードの抽出
上の3つのエンドポイントには何点か重複部分があるが、どの部分も普通の Scala コードなので、共通コードを抽出するリファクタリングが自然にできる。例えば次のような共通要素をくくりだすと、、、
-
String
を返すGET
リクエストである -
name
というパス変数がある
下のコードのように整理できる。
val nameParam = path[String]("name")
val greetEP: PublicEndpoint[Unit, Unit, String, Any] =
endpoint.get.out(stringBody)
val helloEP: PublicEndpoint[String, Unit, String, Any] =
greetEP.in("hello" / nameParam)
val hiEP: PublicEndpoint[String, Unit, String, Any] =
greetEP.in("hi" / nameParam)
val byeEP: PublicEndpoint[String, Unit, String, Any] =
greetEP.in("bye" / nameParam)
ちなみに OpenAPI の description
や example
のようなドキュメント用のフィールドも、nameParam
の定義部分で一緒に指定できる。
val nameParam: PathCapture[String] =
path[String]("name").description("名前").example("World")
URLパラメータやヘッダなども同様で、共通的に使われるパラメータを DRY に一括定義できる。アノテーションベースで Swagger 用のコーディングをしていると、こうした共通パラメータは得てしてコピペが多くなりがちだったが、tapir 方式の場合そうした不都合もなくなる。
ルートの合成
上で見たように、エンドポイントとビジネスロジックを toRoutes
で合成すると HttpRoutes
になる。tapir そのものの話題ではないが、この HttpRoutes
の合成も一応見ておく。
上の 3つのエンドポイントにそれぞれ対応する、下記のようなビジネスロジックがあるとする。
def hello(name: String): IO[Either[Unit, String]] =
s"Hello, $name".asRight[Unit].pure[IO]
def hi(name: String): IO[Either[Unit, String]] =
s"Hi, $name".asRight[Unit].pure[IO]
def bye(name: String): IO[Either[Unit, String]] =
s"Bye, $name".asRight[Unit].pure[IO]
エンドポイントと組み合わせると各々 HttpRoutes[IO]
になるが、HttpRoutes
は Kleisli
の型エイリアスなので、SemigroupK
の combineK
が使える3。たとえば以下のように書ける。
extension (ep: PublicEndpoint[String, Unit, String, Any])
def toRoute(logic: String => IO[Either[Unit, String]]) =
Http4sServerInterpreter[IO]().toRoutes(ep serverLogic logic)
val helloRoute: HttpRoutes[IO] = helloEP toRoutes hello
val hiRoute: HttpRoutes[IO] = hiEP toRoutes hi
val goodByeRoute: HttpRoutes[IO] = byeEP toRoutes goodBye
val greetingService: HttpApp[IO] =
helloRoute combineK
hiRoute combineK
goodByeRoute orNotFound
さらに以下のような HttpRoutes
の Semigroup
定義をスコープ内で見えるようにすれば、、、
given Semigroup[HttpRoutes[IO]] = _ combineK _
NonEmptyList
を使って以下のように reduce
することもできる。
val greetingService: HttpApp[IO] = NonEmptyList.of(
helloEP toRoutes hello,
hiEP toRoutes hi,
byeEP toRoutes goodBye
).reduce.orNotFound
Swagger/OpenAPI
Swagger/OpenAPI についてもざっと見てみる。
Swagger が必要とする API 情報はエンドポイントの記述だけがあれば良いので、ビジネスロジックから分離しておいたことによる扱いやすさが、ここでも利いてくる。例えば以下のように、上の3つのAPIエンドポイントから簡単に Swagger 用エンドポイントが得られる。
val swaggerEPs = SwaggerInterpreter().fromEndpoints[IO](
List(helloEP, hiEP, byeEP),
"http4s × tapir × Swagger",
"1.0")
古い Tapir では Swagger と http4s を連携させる部分は若干自前のコードを書く必要があったが4、最新では tapir-swagger-ui-bundle を依存ライブラリに加えるだけで使えるようになる5。
おわりに
Clojure の作者 Rich Hickey の有名なプレゼンSimple Made Easy でも、プログラムの構成要素を編み合わせる(complect)のを避けて、合成(compose)しやすい形で分離したままシンプルさを保つことの重要性が力説されていたが、関数型なスタイルに慣れていると tapir の記述と実行の分離といった考え方も自然に馴染むと思う。http4s だけでもすでにかなり関数型だけど、さらにワンランク上の関数型の良さを引き出すために併用してみたい。
※ Scala 3 と Cats Effect 3に合わせてサンプルコードを改め、本文も少し直した(2022-08-05)
参考
- tapir github
- tapir documentation
- Describe, then interpret: HTTP endpoints using tapir
- Three easy endpoints
- Embedding SwaggerUI into http4s projects
-
例えば Cats Effect の IO, Monix の Task, あるいは ZIO におけるプログラムの記述と実行(のエフェクト)の分離。Free Monad や Tagless Final ではさらに一段高い抽象レベルで分離されていた。逆に Scala 標準の Future は、記述と実行が分離していないことが嫌われていたりする。 ↩
-
もちろん実務のコードであればよほど小さくて単純なプログラムでない限り
F[_]
がよい。 ↩ -
cats でKleisli の MonoidK が提供されていて、MonoidK は SemigroupK の派生型だから。 ↩
-
swagger-ui だけではなく redoc も使える。また OpenAPI yaml に変換する部分と表示する部分を分けることもできる。→公式 ↩