zioとtapirでAPIクライアントを作ってみた
はじめに
tapirは内部DSLによってHTTP APIのエンドポイント定義を記述できるScalaのライブラリです。記述したエンドポイントは対応しているアダプタと組み合わせることでそのままサーバのRoute定義やHTTPクライアントとして使用できます。
今回はtapirを使ってAPI定義を記述しHTTPクライアントとzioアプリケーションを実装してみました。
今回のネタ
今回はcircleci API (v2)に対応するクライアントを実装します。実装したAPIは以下の2つです。
実装
各エンドポイントの定義は以下のようになりました。記述してみて気づいた点は下記になります。
- 各エンドポイントに共通の要素(認証、エラーレスポンス)をBasicEndpointに定義すると楽
- 階層化されているリソースのパスを表すinputは再利用できるのでまとめておく
- オプションのクエリパラメータなどは最初は使わなくてもcase classにマッピングしておくと変更が楽(後述)
package circleci.api.endpoints import circleci.api.model._ import sttp.tapir._ import sttp.tapir.generic.auto._ import sttp.tapir.json.circe._ trait Endpoints { //ベースエンドポイント lazy val BasicEndpoint = endpoint .securityIn(auth.apiKey(inputs.apiKeyHeader)) .errorOut(jsonBody[ErrorResponse]) /** GET /insights/:projectslug/workflows/:workflow/jobs/:job */ lazy val GetRecentRunsOfWorkflowJob = BasicEndpoint.get .in( "insights" / inputs.projectSlugPath / "workflows" / inputs.workflowPath / "jobs" / inputs.jobPath ) .out(jsonBody[ChunkedItems[RecentRunJob]]) /** GET /project/:projectslug */ lazy val ProjectEndpoint = BasicEndpoint.in("project" / inputs.projectSlugPath) object inputs { /** `Circle-Token` Header */ lazy val apiKeyHeader: EndpointIO.Header[APIKey] = header[String]("Circle-Token").map[APIKey](APIKey(_))(_.key) /** ProjectSlug */ lazy val projectSlugPath = (path[String] and path[String] and path[String]).mapTo[ProjectSlug] /** workflow */ lazy val workflowPath = path[String].mapTo[Workflow] /** job */ lazy val jobPath = path[String].mapTo[Job] } object codecs { implicit val apiKeyCodec: Codec[String, APIKey, CodecFormat.TextPlain] = Codec.string.map(APIKey)(_.key) } }
上記でパスパラメータに使われているProjectSlugなどのモデルは下記のようなアノテーションを付与したcase classです。
package circleci.api.model import circleci.api.model.ProjectSlug._ import sttp.tapir.EndpointIO.annotations._ @endpointInput final case class ProjectSlug( @path projectType: ProjectType, @path orgName: OrgName, @path repoName: RepoName ) object ProjectSlug { type ProjectType = String type OrgName = String type RepoName = String }
HTTPクライアント
上記のエンドポイント定義をsttp + zioで実行できるようにしたのが下記のコンポーネントです。
package usecase import circleci._ import circleci.api.model._ import layers.{Context, Result} import sttp.tapir.client.sttp.SttpClientInterpreter import zio._ trait GetRecentJobRuns { def get( project: ProjectSlug, workflow: Workflow, job: Job ): Task[Result[ChunkedItems[RecentRunJob]]] } object GetRecentJobRuns { def get( project: ProjectSlug, workflow: Workflow, job: Job ): RIO[GetRecentJobRuns, Result[ChunkedItems[RecentRunJob]]] = ZIO.serviceWithZIO(_.get(project, workflow, job)) final case class GetRecentJobRunsLive(ctx: Context) extends GetRecentJobRuns { override def get( project: ProjectSlug, workflow: Workflow, job: Job ): Task[Result[ChunkedItems[RecentRunJob]]] = SttpClientInterpreter() .toSecureClient( GetRecentRunsOfWorkflowJob, Some(ctx.baseURL), ctx.backend ) .apply(ctx.apikey) .apply((project, workflow, job)) } object Live { val layer: URLayer[Context, GetRecentJobRunsLive] = (GetRecentJobRunsLive(_)).toLayer } }
インタプリタが返す関数の型
SttpClientInterpreterにエンドポイント定義を渡すとinput → response
の形の関数を生成してこれに各inputを渡せばリクエストできるのですが、細々としたクエリパラメータをあとで増やすとこの関数のシグネチャが変わるので、パラメータはなるべくcase classにまとめた方が変更が楽になると思います。
例えば上記ではインタプリタが返す関数の型は以下ですが、
APIKey => (Project, Workflow, Job) => Response
以下のようにクエリパラメータを追加すると...
lazy val GetRecentRunsOfWorkflowJob = BasicEndpoint.get .in( "insights" / inputs.projectSlugPath / "workflows" / inputs.workflowPath / "jobs" / inputs.jobPath ) .in(query[Option[String]]("branch")) .out(jsonBody[ChunkedItems[RecentRunJob]])
シグネチャはこうなります。クエリパラメータ1つ1つに型をつけていても、これを毎回クライアントに反映するのは手間なのでcase classにマッピングしてしまった方が変更が楽かと思います。
APIKey => (Project, Workflow, Job, Option[String]) => Response
まとめ
tapirで定義したエンドポイントをつかってHTTPクライアントを実装してみました。