LoginSignup
13
5

More than 1 year has passed since last update.

http4s × tapir 〜 エンドポイントにおける記述と実行の分離

Last updated at Posted at 2019-06-15

HTTP エンドポイントをタイプフルに記述してビジネスロジックを分離するライブラリ tapir の http4s への適用。

はじめに

関数型な Scala の界隈では記述と実行の分離がよく奨励されるが1、この考え方をHTTPのエンドポイントにも当てはめたのが tapirTyped API descRiptions) で、エンドポイントの記述とビジネスロジックをいい感じに分離してくれる。

現時点で Akka Http と http4s に対応しているが、この記事では http4s との組み合わせを試してみる。

概要

こんな特徴がある

  1. エンドポイントの記述とビジネスロジックの分離: エンドポイントをロジックから独立させることで、サーバーの受け口としても、クライアントからの呼び先としても、OpenAPI ドキュメント作成の入力としても、統一的に扱えて、再利用もしやすくなる。

  2. 読みやすい EDSL: tapir 自体について知らなくても、どういうエンドポイントなのか読めばわかる。OpenApi(Swagger)にも最初から準拠していて、関連するフィールドの指定も含めて、一連の fluent interface の流れの中で表現できる。

  3. 型と値と関数だけの Scala コード: ルート定義ファイルなどを書くこともないし、アノテーションもマクロも使わない。エディターや IDEとの相性もよいので補完や型推論がスムーズ。

  4. シンプルな型: Finch のように Shapeless などを使い倒してると(これはこれで面白いけど)、開発が進むうちに型が巨大化してコンパイルに異常に時間がかかったり、エラーメッセージが複雑過ぎて読めなくなったりすることがままあるが、tapir はただの Generic なケースクラスしか使ってないので、人間にもコンパイラにも優しい。

  5. 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 の descriptionexample のようなドキュメント用のフィールドも、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] になるが、HttpRoutesKleisli の型エイリアスなので、SemigroupKcombineK が使える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

さらに以下のような HttpRoutesSemigroup 定義をスコープ内で見えるようにすれば、、、

given Semigroup[HttpRoutes[IO]] = _ combineK _

NonEmptyList を使って以下のように reduce することもできる。

val greetingService: HttpApp[IO] = NonEmptyList.of(
  helloEP toRoutes hello,
  hiEP    toRoutes hi,
  byeEP   toRoutes goodBye
).reduce.orNotFound

Main3.scala

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

以下のような画面になる。
Screenshot from 2022-08-06 00-58-56.png

SwaggerMain.scala

おわりに

Clojure の作者 Rich Hickey の有名なプレゼンSimple Made Easy でも、プログラムの構成要素を編み合わせる(complect)のを避けて、合成(compose)しやすい形で分離したままシンプルさを保つことの重要性が力説されていたが、関数型なスタイルに慣れていると tapir の記述と実行の分離といった考え方も自然に馴染むと思う。http4s だけでもすでにかなり関数型だけど、さらにワンランク上の関数型の良さを引き出すために併用してみたい。

※ Scala 3 と Cats Effect 3に合わせてサンプルコードを改め、本文も少し直した(2022-08-05)

参考

  1. 例えば Cats Effect の IO, Monix の Task, あるいは ZIO におけるプログラムの記述と実行(のエフェクト)の分離。Free Monad や Tagless Final ではさらに一段高い抽象レベルで分離されていた。逆に Scala 標準の Future は、記述と実行が分離していないことが嫌われていたりする

  2. もちろん実務のコードであればよほど小さくて単純なプログラムでない限り F[_] がよい。

  3. cats でKleisli の MonoidK が提供されていて、MonoidK は SemigroupK の派生型だから。

  4. 例えばこのように

  5. swagger-ui だけではなく redoc も使える。また OpenAPI yaml に変換する部分と表示する部分を分けることもできる。→公式

13
5
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
13
5