Scala
Akka
Akka-HTTP
pay.jp

Scalaで実装されたPAY.JPの非公式クライアント

決済プラットフォームのAPIクライアントをScalaで作ってみようと思い、手始めにPAY.JP版を雑に作ったので共有します。

成果物はここ。
https://github.com/j5ik2o/pay-jp-scala

使いかた

build.sbtに以下を追加

resolvers += "Sonatype OSS Release Repository" at "https://oss.sonatype.org/content/repositories/releases/"

libraryDependencies += "com.github.j5ik2o" %% "pay-jp-scala" % "1.0.4"

ApiClientContextからMerchantApiClientもしくはPlatformApiClientを生成して使ってください。ほぼすべてのAPIをカバーしています。各APIがクライアントクラスのメソッドに対応付くように実装されています。メソッドの戻り値はmonix.eval.Taskです。

import monix.execution.Scheduler.Implicits.global
import scala.concurrent.Await

val apiClientContext: ApiClientContext   = new ApiClientContext("api.pay.jp", 443)
val merchantApiClient: MerchantApiClient = apiClientContext.createMerchantApiClient(sys.env("MERCHANT_SECRET_KEY"))

// Charge API
val future = merchantApiClient.createCharge(
  amountAndCurrency = Some((Amount(10000L), Currency("jpy"))), 
  productId = None,
  customerId = Some(user1.id),
  tokenId = None,
  description = None,
  capture = None,
  expiryDays = None,
  metadata = Map.empty,
  platformFee = None).runAsync

val result = Await.result(future, 3 seconds)

apiClientContext.shutdownSender()

monix.eval.Task

monix.eval.Taskは、非同期計算を制御するデータ型です。という話をするとscala.concurrent.Futureを想起しますが、Taskを生成した際に実行をトリガーしません。#runAsync, #foreachなどによって初めてトリガーされます。詳しくは以下のドキュメントを参照してください。

https://monix.io/docs/2x/eval/task.html#introduction

実装

トランスポートはakka-http

cachedHostConnectionPoolHttpsによってコネクションを管理します。コネクションプールの設定を調整したい場合は、公式サイト をみてください。

https://github.com/j5ik2o/pay-jp-scala/blob/master/library/src/main/scala/com/github/j5ik2o/payjp/scala/HttpRequestSenderImpl.scala#L27

  private val poolClientFlow = 
    Http().cachedHostConnectionPoolHttps[Int](config.host, config.port)

コネクションを維持したままリクエストを受け付ける

リクエストの都度、RunnableGraphを作っていては非効率なので、akka-streamSourceQueueWithCompleteを使って、常時起動しているストリームに対してOfferできるようにします。

  private val requestQueue: SourceQueueWithComplete[PromiseWithHttpRequest] = Source
    .queue[PromiseWithHttpRequest](config.requestBufferSize, OverflowStrategy.dropNew)
    .map {
      case p @ PromiseWithHttpRequest(_, request) => (request, p)
    }
    .via(sendRequestFlow)
    .map {
      case (triedResponse, PromiseWithHttpRequest(promise, _)) =>
        promise.complete(triedResponse)
    }
    .toMat(Sink.ignore)(Keep.left)
    .run()

https://github.com/j5ik2o/pay-jp-scala/blob/master/library/src/main/scala/com/github/j5ik2o/payjp/scala/HttpRequestSenderImpl.scala#L113-L124

ちなみに、ストリームの終了は#shutdownで可能です。内部的にはSourceQueueWithComplete#completeを呼び出し完了を待つだけです。

  override def shutdown(): Unit = {
    requestQueue.complete()
    Await.result(requestQueue.watchCompletion(), Duration.Inf)
  }

https://github.com/j5ik2o/pay-jp-scala/blob/master/library/src/main/scala/com/github/j5ik2o/payjp/scala/HttpRequestSenderImpl.scala#L126-L129

モデルとJSON

レスポンスモデルはこちら。

https://github.com/j5ik2o/pay-jp-scala/tree/master/library/src/main/scala/com/github/j5ik2o/payjp/scala/model

JSON化にはio.circeを使っていますが、io.circe.generic.auto._がうまく使えなかったのでforProduct??を使って気合いで実装。

case class Customer(id: CustomerId,
                    liveMode: Boolean,
                    emailOpt: Option[String],
                    descriptionOpt: Option[String],
                    defaultCardIdOpt: Option[String],
                    metadataOpt: Option[Map[String, String]],
                    cards: Collection[Card],
                    created: ZonedDateTime)

object Customer extends JsonImplicits {

// ...

  implicit val CustomerDecoder: Decoder[Customer] =
    Decoder.forProduct8("id", "livemode", "email", "description", "default_card", "metadata", "cards", "created")(
      Customer.apply
    )

}

作ってみた感想

テスト用カードトークンが発行できないのでCIが実行できない

セキュリティの観点でサーバサイドでカード情報を扱わないトレンドがあるわけですが、CIするときはテスト用カード情報が扱えないとテストが書けません…。当初はなかったのですが、PAY.JPに相談したら早速作ってくれました。ありがとうございます!

https://pay.jp/docs/api/#token-%E3%83%88%E3%83%BC%E3%82%AF%E3%83%B3
X-Payjp-Direct-Token-Generate: trueを指定してリクエストするとテスト用トークンが取得できます。

APIドキュメントを見ても仕様がわかりにくい

APIドキュメントがわかりにくかった。些細なものとしては以下。

  • パラメータが必須か任意かわかりにくい
  • レスポンスに含まれる値がnullを返すか返さないかがわからない
  • 列挙値の値の範囲がわかりにくい

集約を中心としたエンドポイント設計になっていない

一番わかりみが少ないと思ったのは、以下のエンドポイント仕様。

  • 顧客の定期課金リストを取得

https://pay.jp/docs/api/#%E9%A1%A7%E5%AE%A2%E3%81%AE%E5%AE%9A%E6%9C%9F%E8%AA%B2%E9%87%91%E3%83%AA%E3%82%B9%E3%83%88%E3%82%92%E5%8F%96%E5%BE%97

/v1/customers/${customerId}/subscriptions

エンドポイントだけをみると、顧客オブジェクトが持つ定期課金を集合で返すように見える。つまり、定期課金の集合は顧客オブジェクトの一部であるように見えるが、顧客オブジェクトを返すエンドポイントには定期課金は含まれていないし、定期課金だけを取得するエンドポイントがある。つまり、定期課金は顧客とは関連はあるものの、独立したオブジェクト(DDDでは集約と呼ぶ)だった。

独立したオブジェクトであれば、エンドポイントは以下のようになるのでは?と個人的に思った。これに類したAPIが他にもいくつかあった。

/v1/subscription?customer_id=${customerId}

ので、APIクライアントでも以下のようにわかりやすい名前に変えている。

  • getSubscriptionByCustomerId(顧客の定期課金の取得)
  • getSubscriptionsByCustomerId(顧客の定期課金リストの取得)
  • getChargesByTransferId(入金に対応する支払いリストの取得)

イケてないところばかり書いてしまって恐縮ですが、問い合わせすると迅速に対応してくれるのでそれはよかったです。まぁ、わざわざ自前でクライアント実装するからこういう話になるだけで、この辺の公式ライブラリを使うといいと思います。