zioとtapirでAPIクライアントを作ってみた

tapirは内部DSLによってHTTP APIのエンドポイント定義を記述できるScalaのライブラリです。記述したエンドポイントは対応しているアダプタと組み合わせることでそのままサーバのRoute定義やHTTPクライアントとして使用できます。 今回はtapirを使ってAPI定義を記述しHTTPクライアントとアプリケーションを実装してみました。
2022.03.31

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

はじめに

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クライアントを実装してみました。