tapirで定義したエンドポイントをマッピングしてシンプルなAPI G/Wを実装する
はじめに
前回の記事で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ではさらに副作用を伴う処理が含まれるのでここまでシンプルにはいかないですが、必要最小限なコンポーネントを抽出してインターフェース化してみるのは興味深い作業でした。