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
を使って変換関数を渡し、クライアントを閉じる。Resource
をuse
するとただのIO
になる:
val parsedResult =
clientResource.flatMap(_.run(punchRequest)).use(parseResponse)
このパターンはどのようなクライアントを使っていてもどのようなエンドポイント定義でも共通なので覚えておくとよい。
結果を取出す
parsedResult
の型はIO[DecodeResult[Either[Unit, StampOutput]]]
である。ちょっと長いが順に見ていけば簡単だ。
- 一番内側の
Either
はHTTPやアプリケーションレベルでリクエストが成功したかどうかを表現している。例えばBad Requestなどが返された場合はここがLeft
になる。 - 次の層である
DecodeResult
は、より低いレベルでJSONのデコードと型変換に成功したかどうかを表現するトレイトだ。例えば壊れたJSONが返されたりcase classと適合できなければその旨が返される。成功すればValue()
が入っている。 - 最後に定番の
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
を使うべきなのか初学者にとっては判断が難しいと思う。