tapirで定義したエンドポイントをマッピングしてシンプルなAPI G/Wを実装する

tapirを定義したエンドポイントからHTTP APIクライアントを作ってみました。今回はtapirで定義したエンドポイントとこのクライアントを組み合わせてプライベートなAPIをラップするパブリックAPIを作成してみました。
2022.04.26

はじめに

前回の記事でtapirを定義したエンドポイントからHTTP APIクライアントを作ってみました。今回はtapirで定義したエンドポイントとこのクライアントを組み合わせてプライベートなAPIをラップするパブリックAPIを作成してみます。

エンドポイント定義

パブリック(public object) なAPIとプライベート(internal object) なAPIの定義は以下の通りです。

package http
import cats.implicits._
import io.circe.generic.JsonCodec
import sttp.tapir.EndpointIO.annotations
import sttp.tapir._
import sttp.tapir.generic.auto._
import sttp.tapir.json.circe._

package object endpoints {

  object public {
    val getConures = endpoint
      .in(query[String]("name").mapTo[ConureQuery])
      .out(jsonBody[Conure])

    @JsonCodec
    final case class Conure(name: String)

    @annotations.endpointInput
    @JsonCodec
    final case class ConureQuery(@annotations.query name: String)

  }

  object internal {
    val findBirds =
      endpoint
        .in(query[String]("kind").and(query[String]("name")).mapTo[BirdQuery])
        .out(jsonBody[Bird])

    @JsonCodec
    final case class Bird(
        kind: String,
        name: String,
        color: String
    )
    @annotations.endpointInput
    @JsonCodec
    final case class BirdQuery(
        @annotations.query kind: String,
        @annotations.query name: String
    )
  }

}

APIのマッピング

2つのAPIのマッピングには以下の4つが必要です。

  • パブリックAPIのエンドポイント定義
  • プライベートAPIのエンドポイント定義
  • 公開APIのパラメータ → プライベートAPIへの変換
  • プライベートAPIのレスポンス → 公開APIのレスポンスへの変換

またこれらに加えて実装として以下が必要です。

  • プライベートAPIへのリクエストを送信するコンポーネント(HTTPクライアント)
  • パブリックAPIを具体的なHTTPサービスとして公開するコンポーネント(HTTPサーバ)

とはいえまずは最初の4つを受け取るインタフェースを定義しておきます。2つのコンポーネントを作った後で戻ってきてAPIのマッピングを実装します。

package http.endpoints.mapper

import cats.Functor
import cats.implicits.toFunctorOps
import http.endpoints.HttpDispatcher
import sttp.tapir.{DecodeResult, Endpoint}

final case class BindEndpoint[AIn, AOut, BIn, BOut, AErr, BErr](
    public: Endpoint[
      Unit,
      AIn,
      AErr,
      AOut,
      Any
    ],
    internal: Endpoint[Unit, BIn, BErr, BOut, Any],
    mapInput: AIn => BIn,
    mapResult: Either[BErr, BOut] => Either[AErr, AOut],
    decodeErr: DecodeResult.Failure => Either[AErr, AOut]
)

HTTPクライアント

HTTP クライアントはエンドポイント定義とパラメータを受け取って結果を返します。

以下のようにインターフェースと実装を定義します。

package http.endpoints

import cats.effect.Async
import org.http4s.ember.client.EmberClientBuilder
import sttp.client3.httpclient.zio.SttpClient
import sttp.tapir.client.http4s.Http4sClientInterpreter
import sttp.tapir.client.sttp.SttpClientInterpreter
import sttp.tapir.{DecodeResult, Endpoint}
import zio.Task

trait HttpDispatcher[F[_]] {
  def apply[Auth, In, Err, Out](endpoint: Endpoint[Auth, In, Err, Out, Any])(
      auth: Auth,
      in: In
  ): F[DecodeResult[Either[Err, Out]]]
}

object HttpDispatcher {

  implicit def http4sClientDispatcher[F[_]: Async]: HttpDispatcher[F] =
    new HttpDispatcher[F] {
      override def apply[Auth, In, Err, Out](
          endpoint: Endpoint[Auth, In, Err, Out, Any]
      )(auth: Auth, in: In): F[DecodeResult[Either[Err, Out]]] =
        Http4sClientInterpreter()
          .toSecureRequest(endpoint, None)
          .apply(auth)
          .apply(in) match {
          case (req, res) =>
            EmberClientBuilder.default[F].build.use(_.run(req).use(res))
        }
    }

  implicit def sttpClientDispatcher(implicit
      backend: SttpClient,
      interpreter: SttpClientInterpreter
  ): HttpDispatcher[Task] = new HttpDispatcher[zio.Task] {
    override def apply[Auth, In, Err, Out](
        endpoint: Endpoint[Auth, In, Err, Out, Any]
    )(auth: Auth, in: In): Task[DecodeResult[Either[Err, Out]]] =
      interpreter.toSecureClient(endpoint, None, backend).apply(auth)(in)
  }
}

HTTPサーバ

HTTPサーバはエンドポイント定義を受け取って具体的なHTTPサーバのルート定義を返します。

以下はtraitとhttp4sでの実装です。

package http.endpoints.mapper

import cats.data.OptionT
import cats.effect.Async
import cats.~>
import org.http4s._
import sttp.tapir.server.ServerEndpoint.Full
import sttp.tapir.server.http4s.Http4sServerInterpreter

trait RouteBuilder[F[_], A] {
  def apply[In, Err, Out](
      route: Full[Unit, Unit, In, Err, Out, Any, F]
  ): A
}

object RouteBuilder {
  implicit def http4sRoute[F[_]: Async] =
    http4sRouteK[F, F](λ[F ~> F](identity(_)), λ[F ~> F](identity(_)))
  implicit def http4sRouteK[F[_]: Async, G[_]: Async](
      fk: F ~> G,
      gk: G ~> F
  ): RouteBuilder[F, HttpRoutes[G]] =
    new RouteBuilder[F, HttpRoutes[G]] {
      override def apply[In, Err, Out](
          route: Full[Unit, Unit, In, Err, Out, Any, F]
      ): HttpRoutes[G] = Http4sServerInterpreter[F]()
        .toRoutes(route)
        .dimap[Request[G], Response[G]](_.mapK(gk))(_.mapK(fk))
        .mapK(λ[OptionT[F, *] ~> OptionT[G, *]](_.mapK(fk)))
    }

}

BindEndpointの実装

準備ができたのでBindEndpoint の実装を進めます。HTTPクライアントとルーターを受け取って、パラメータの変換、HTTPリクエストの送信、レスポンスの変換を行います。

final case class BindEndpoint[AIn, AOut, BIn, BOut, AErr, BErr](
    public: Endpoint[
      Unit,
      AIn,
      AErr,
      AOut,
      Any
    ],
    internal: Endpoint[Unit, BIn, BErr, BOut, Any],
    mapInput: AIn => BIn,
    mapResult: Either[BErr, BOut] => Either[AErr, AOut],
    decodeErr: DecodeResult.Failure => Either[AErr, AOut]
) {

  def routes[F[_]: Functor, A](
      dispatcher: HttpDispatcher[F],
      route: RouteBuilder[F, A]
  ): A =
    route.apply[AIn, AErr, AOut](
      public.serverLogic { bIn =>
        dispatcher.apply(internal)((), mapInput(bIn)).map {
          case e: DecodeResult.Failure => decodeErr(e)
          case DecodeResult.Value(v)   => mapResult(v)
        }
      }
    )
}

マッピングの定義

準備ができたのでマッピングを定義します。ここではAPI同士の関係だけを定義して実際に使用するHTTPコンポーネントは外部から渡すことにします。

object routes {
    def queryConures =
      mapper.BindEndpoint[
        public.ConureQuery,
        public.Conure,
        internal.BirdQuery,
        internal.Bird,
        Unit,
        Unit
      ](
        public = public.getConures, //公開API
        internal = internal.findBirds, //プライベートAPI
        mapInput = c => internal.BirdQuery("conure", c.name), //パラメータの変換
        mapResult = _.map(b => public.Conure(b.name)), //レスポンスの変換
        decodeErr = _ => ().asLeft
      )

  }

エントリポイント

作成したエンドポイントを実際に起動するのは以下のコードです。さきほど指定していなかったHTTPコンポーネントを指定しています。

import cats.effect._
import http.endpoints.HttpDispatcher
import http.endpoints.mapper.RouteBuilder
import org.http4s.HttpRoutes
import org.http4s.blaze.server.BlazeServerBuilder
import org.http4s.server._

object Main2 extends cats.effect.IOApp.Simple {
  val dispatcher = HttpDispatcher.http4sClientDispatcher[IO]

  val routeBuilder: RouteBuilder[IO, HttpRoutes[cats.effect.IO]] =
    RouteBuilder.http4sRoute

  val route =
    http.endpoints.routes.queryConures.routes(dispatcher, routeBuilder)
  val server =
    BlazeServerBuilder[cats.effect.IO]
      .bindHttp(8080, "localhost")
      .withHttpApp(Router("/" -> route).orNotFound)
      .serve
      .compile
      .drain

  override def run: IO[Unit] = server
}

この例では1つのAPIを公開するルートしか定義していませんが実際には以下のような追加の実装が必要になるでしょう。

  • 複数のAPIを定義できるようにする
  • 下流のAPIに対するリトライなどの考慮
  • (今回はNothingでごまかした) 認証、認可の実装

まとめ

簡単にですがtapirを使ってAPIどうしのマッピングを実装してみました。実際のBFFではさらに副作用を伴う処理が含まれるのでここまでシンプルにはいかないですが、必要最小限なコンポーネントを抽出してインターフェース化してみるのは興味深い作業でした。