Lambdaカクテル

京都在住Webエンジニアの日記です

Invite link for Scalaわいわいランド

Scala + tapirで定義したエンドポイントにhttp4sクライアントでアクセスするパターン

ScalaでHTTPエンドポイントを定義するためのライブラリであるTapirは最近よく使われており、Star数もとても多い(今見たら1.2kだった)。具体的なHTTPライブラリから独立した定義ができるため、メンテナビリティが高いのが特徴である。OpenAPIの定義を生成する機能もあるため、便利なライブラリだ。

Tapirはエンドポイントを定義する汎用ライブラリなので、サーバのルーティングに使うこともできるし、クライアントのリクエストを生成することもできるが、公式ドキュメントはどちらかといえばサーバサイドでルーティング生成を行うことに重心を置いていて、あまりクライアント側利用の情報が載っていない。後学のためにはクライアント側のユースケースを具体的に示すことが重要であると考えたため、セキュリティトークンが必要な現実のAPIにアクセスするサンプルコードを書き、動作させることができたので、そのコードを示す。

利用したHTTPライブラリはhttp4sである。クライアント実装はhttp4sに同梱のEmberを使った。また、Scalaのcase classとJSONとを相互変換するJSONライブラリとしてcirceを使った。非同期ランタイムはCats Effect3である。

ビルド定義

build.sbtは以下の通り:

val scala3Version = "3.2.0"
val http4sVersion = "0.23.19"

lazy val root = project
  .in(file("."))
  .settings(
    name := "ak4-lambda",
    version := "0.1.0-SNAPSHOT",
    scalaVersion := scala3Version,
    libraryDependencies ++= Seq(
      "com.softwaremill.sttp.tapir" %% "tapir-core" % "1.4.0",
      "com.softwaremill.sttp.tapir" %% "tapir-http4s-client" % "1.4.0",
      "com.softwaremill.sttp.tapir" %% "tapir-json-circe" % "1.4.0",
      "org.http4s" %% "http4s-ember-client" % http4sVersion,
      "com.github.nscala-time" %% "nscala-time" % "2.32.0"
    )
  )

エンドポイント定義

ここでは例として、勤怠ツールであるAKASHIの打刻APIの仕様を定義する。 tokenクエリパラメータによる認証が存在するため、endpoint.securityInを指定している。

package com.github.windymelt.ak4lambda.endpoint

import com.github.nscala_time.time.Imports._
import com.github.windymelt.ak4lambda.endpoint.codec.DateTime._
import io.circe.Decoder
import io.circe.Encoder
import io.circe.Json
import io.circe.generic.auto._
import sttp.tapir.SchemaType.SString
import sttp.tapir._
import sttp.tapir.generic.auto._
import sttp.tapir.json.circe._

object Ak4 {
  private val authInput: EndpointInput[String] = query[String]("token")
  private lazy val base =
    endpoint.securityIn(authInput).in("api" / "cooperation")

  lazy val punch =
    base.post
      .in(path[String] / "stamps")
      .in(jsonBody[StampInput])
      .out(jsonBody[StampOutput])

  val JST = +9

  // Schemaではどのlow levelな表現に対応するかだけ示せばよい(実際の変換はcirceのcodecが行う)。
  // ここでは、DateTimeはJSON上ではStringで表現されることだけ規定している。
  implicit val DTSchema: Schema[org.joda.time.DateTime] =
    Schema
      .string[org.joda.time.DateTime]

  type TZString = "+09:00" // 型として固定する
  implicit val TZStringSchema: Schema[TZString] = Schema.string[TZString]
  case class StampInput(
      `type`: Int,
      stampedAt: DateTime,
      timezone: TZString
  )

  case class StampOutput(
      success: Boolean,
      response: StampResponse,
      errors: Option[Seq[ErrorResponse]]
  )

  case class StampResponse(
      login_company_code: String,
      staff_id: Long,
      `type`: Long,
      stampedAt: String
  )
  case class ErrorResponse(code: String, message: String)
}

こんな感じ。打刻タイプはenumを使ってもよいけれど、簡単のために直でLongを使うことにした。

Circeのコーデック

また、AkashiはDateTimeを渡すときに独自の形式を利用するので、JSONライブラリであるcirceがDateTimeを変換するために使うコーデックを手で定義する:

package com.github.windymelt.ak4lambda.endpoint.codec

import com.github.nscala_time.time.Implicits._
import io.circe.Decoder
import io.circe.Encoder
import io.circe.HCursor
import io.circe.Json
import org.joda.time.DateTimeZone
import org.joda.time.format.DateTimeFormatter

object DateTime {
  import org.joda.time.DateTime
  val JST = +9

  implicit val encodeDateTime: Encoder[DateTime] = new Encoder[DateTime] {
    final def apply(dt: DateTime): Json =
      Json.fromString(
        dt.withZone(DateTimeZone.forOffsetHours(JST))
          .toString("""yyyy/MM/dd HH:mm:ss""")
      )
  }

  implicit val decodeDateTime: Decoder[DateTime] = new Decoder[DateTime] {
    final def apply(s: HCursor): Decoder.Result[DateTime] = {
      ??? // 今回はデコードは使わないので省略
    }
  }
}

エンドポイント定義をhttp4sのクライアントで利用する

さて、実際に呼び出す局面は以下の通り:

package com.github.windymelt.ak4lambda

import cats.effect.IO
import cats.effect.IOApp
import com.github.nscala_time.time.Implicits._
import org.http4s.client._
import org.http4s.ember.client.EmberClientBuilder
import org.http4s.implicits._
import org.joda.time.DateTime
import sttp.tapir.DecodeResult.Value
import sttp.tapir.client.http4s.Http4sClientInterpreter

import scala.concurrent.ExecutionContext.global

object Main extends IOApp.Simple {
  def run = punch.debug("result: ") >> IO.unit
}

def punch: IO[Either[Unit, StampOutput]] = {
  // Http4sClientInterpreterを使ってエンドポイント定義をhttp4sのリクエストに変換する
  val (punchRequest, parseResponse) =
    Http4sClientInterpreter[IO]()
      .toSecureRequest(
        endpoint.Ak4.punch,
        baseUri = Some(uri"https://atnd.ak4.jp/")
      )
      .apply("トーーーーークン")( // security inputを先に渡し、inputを次に渡す
        "企業ID",
        endpoint.Ak4.StampInput(
          endpoint.Ak4.StampType.退勤.code.toInt,
          DateTime.now(),
          "+09:00"
        )
      )

  // http4sクライアントをデフォルト設定で作成する
  val clientResource = EmberClientBuilder.default[IO].build

  // ここがミソ
  val parsedResult =
    clientResource.flatMap(_.run(punchRequest)).use(parseResponse)

  for {
    pr <- parsedResult
    v <- pr match {
      case Value(Right(v)) => IO(Right(v))
      case Value(Left(e))  => IO(Left(e))
      case otherwise       => IO(Left(()))
    }
  } yield v
}

これだけでAPI呼び出しが可能になる。ここからは、各部のコツについて説明していく。

Http4sClientInterpreter

Tapirのエンドポイント定義は汎用的なので、http4sなどのライブラリに特化した形に変換してから使う。今回はhttp4sのクライアントからエンドポイントを利用するので、Http4sClientInterpreterを利用する。

Http4sClientInterpreterは、エンドポイント定義を(リクエスト, レスポンスを変換する関数)のペアに変換する。このときセキュリティーパラメータや各種入力を渡す。今回の例ではトークンや企業ID、打刻APIに必要なcase classを渡した。

EmberClientBuilder

http4sは複数のバックエンドに対応しているので、クライアントとして色々な実装を使うことができる。今回はEmberを利用してAPIを呼ぶ。

クライアントはBuilderを使って構築する。Emberの場合はEmberClientBuilderがそれだ。今回はデフォルト設定でよいので、EmberClientBuilder.default[IO].buildで済ませた。

クライアント・リクエスト・レスポンス変換関数を結合する

道具は全て揃ったので、「クライアントを使ってリクエストを送信し、レスポンスを変換する」処理として各パーツを合成する。 型がやや複雑なためここが一番コツが必要だった。

まずHTTPクライアントはResourceに包まれた形で提供される。その中身を使うために、flatMapを使ってクライアントでリクエストを発射する処理と合成する。runもまたResourceを返すので、合成のためにflatMapが必要である。これで1つのResourceができる:

clientResource.flatMap(_.run(punchRequest))

次に、このリクエストの結果を変換する。変換したらもうクライアントに用はないので、useを使って変換関数を渡し、クライアントを閉じる。ResourceuseするとただのIOになる:

  val parsedResult =
    clientResource.flatMap(_.run(punchRequest)).use(parseResponse)

このパターンはどのようなクライアントを使っていてもどのようなエンドポイント定義でも共通なので覚えておくとよい。

結果を取出す

parsedResultの型はIO[DecodeResult[Either[Unit, StampOutput]]]である。ちょっと長いが順に見ていけば簡単だ。

  1. 一番内側のEitherはHTTPやアプリケーションレベルでリクエストが成功したかどうかを表現している。例えばBad Requestなどが返された場合はここがLeftになる。
  2. 次の層であるDecodeResultは、より低いレベルでJSONのデコードと型変換に成功したかどうかを表現するトレイトだ。例えば壊れたJSONが返されたりcase classと適合できなければその旨が返される。成功すればValue()が入っている。
  3. 最後に定番のIO型が付いている。

成功パターンだけ考慮すればよいのであれば、以下のように処理すればよい:

  for {
    pr <- parsedResult // IOから取出す
    v <- pr match {
      case Value(Right(v)) => IO(Right(v)) // 値の取出しに成功し、APIも成功したならRight
      case Value(Left(e))  => IO(Left(e))  // 値の取出しに成功したがAPIが失敗したならLeft
      case otherwise       => IO(Left(()))  // それ以外はLeft
    }
  } yield v

今回はエラーの場合の出力について何も規定していないので、APIがエラーを返した場合はUnitが返る。

実行

最終的には、これを実行するだけで退勤打刻が実行される(結果を表示するためにdebugを付加している):

object Main extends IOApp.Simple {
  def run = punch.debug("result: ") >> IO.unit
}
$ sbt run
result: : Succeeded: Right(StampOutput(true,StampResponse(企業ID,******,**,2023/05/25 20:56:59),None))

お疲れ様でした。

結語

ここからエラー処理を追加することは容易だろう。Enumをリクエスト/レスポンスに折り込むことも可能なはずだ。複数のエンドポイントを使う場合は、ある程度は処理を共通化できるかもしれない。明らかに、http4sのレスポンスに変換するコードはボイラープレートだ。

Resourceは簡単なようでいて合成が難解な、Cats Effectの鬼門である。HTTPクライアント、またそれを使ってリクエストを実行する操作がResourceの形をしているのが理解を難しくしている。さらにレスポンスはすぐに変換したいのだが、mapを使うべきなのかuseを使うべきなのか初学者にとっては判断が難しいと思う。

★記事をRTしてもらえると喜びます
Webアプリケーション開発関連の記事を投稿しています.読者になってみませんか?