Closed7

isowords の ApiClient の実装を把握する

アイカワアイカワ

isowords の ApiClient は swift-url-routing を利用したものとなっており、ただ swift-url-routing を利用するだけではなく、Dependency として機能する struct に function をいくつか定義することで実装を良い感じに抽象化している。
この実装方法について簡単にまとめてみる。

確か https://www.pointfree.co/collections/tours/isowords/ep144-a-tour-of-isowords-part-3 以降あたりで、isowords の ApiClient はアプリ側・サーバー側で利用するコードを良い感じに共通化しているという話があった気はしているが、今回はサーバー側の話はあまり考慮せずに見ていく。

アイカワアイカワ

関係ありそうなディレクトリとそこに何が入っているかを詳しくみる前にまとめる。

  • Sources/ApiClient
    • Dependency における interface, TestDependencyKey への準拠, DependencyValues などが定義されている
    • interface の形は普通の Dependency と異なり、いくつかの function が定義されているがその意味については後ほど見てみる
  • Sources/ApiClientLive
    • Dependency における DependencyKey への準拠のための実装が定義されている
    • 確か A Tour of isowords の中で ApiClientApiClientLive をモジュール分割している意味について解説されていた気がするので、後で確認してみる
  • Sources/ServerRouter
    • swift-url-routing を利用して API のあらゆるエンドポイントに対応した Router を作り出している
  • Sources/SharedModels
    • API から返却された Data を decode するための Model だったり、ApiError のようなものだったり、アプリにおけるさまざまな Model が定義されている
アイカワアイカワ

一番最初に、ApiClient から依存される ServerRouter の実装を軽く見ておく。

ServerRouter は Point-Free が開発している swift-url-routing を利用しているため、その記法に慣れていれば そこまで難しい部分はなさそう。

public struct ServerRouter: ParserPrinter {
  let date: () -> Date
  let decoder: JSONDecoder
  let encoder: JSONEncoder
  let secrets: [String]
  let sha256: (Data) -> Data

  // ...

  public var body: some Router<ServerRoute> {
    OneOf {
      Route(.case(ServerRoute.api)) {
        Path { "api" }
        // ...
      }
    }
  }

  // ...
}

コード中に登場している ServerRouteSources/SharedModels に定義されており、API のエンドポイントをモデリングしたものとして階層的に定義されている。

public enum ServerRoute: Equatable {
  case api(Api)
  case appSiteAssociation
  // ...
  case download
  // ...

  public struct Api: Equatable {
    public let accessToken: AccessToken
    public let isDebug: Bool
    public let route: Route

    // ...

    public enum Route: Equatable, Sendable {
      case changelog(build: Build.Number)
      case config(build: Build.Number)
      // ...
      verifyReceipt(Data)
    }
  }

  // ...
}

エンドポイントは開発が進むにつれどんどん増えていくので、階層的に表現できるこの方法はなかなか良さそう。
また、swift-url-routing を利用しているため、ここまでに出てきた ServerRouterServerRoute を使って router.baseURL(...).request(for: route) のように簡潔に API Request が行えることも魅力的。

アイカワアイカワ

次に ServerRouter を実際に利用している ApiClient 周辺を見ていく。

ApiClient については、「interface と TestDependencyKey への準拠を担う ApiClient モジュール」「DependencyKey への準拠を担う ApiClientLive モジュール」に分割されている。
interface 部分と DependencyKey への準拠、つまり liveValue の実装を行う部分は同じモジュールに定義することもあるが、ここで分割されている理由については https://www.pointfree.co/collections/tours/isowords/ep144-a-tour-of-isowords-part-3 で話されていた。
例えば、liveValue に依存しない処理において、ApiClient モジュールにだけ依存しておけばコンパイル時間を減らせるというメリットがあるという話が紹介されている。
ここでは、ApiClient モジュールは swift-url-routing に依存している ServerRouter に依存しなくて良くなるため、ApiClient モジュールだけのコンパイルであれば非常に軽量という話になる。( 他にも CryptoKit などに依存しているため、その辺りも指しているのかもしれない )
isowords の中にはAudioPlayerClient という CoreAudio に依存しているモジュールが存在しているが、CoreAudio はコンパイル的にそんなに大きくないものであるため、そちらは interface と liveValue 実装部分を同じモジュールに共存させているらしい。

interface である ApiClient/Client.swift を見てみる。

いくつか isowords 固有の property が定義されていそうなので、その辺りを適当に省略した定義を一部抜粋すると以下のようになっている。

import Foundation
import SharedModels

public struct ApiClient {
  public var apiRequest: @Sendable (ServerRoute.Api.Route) async throws -> (Data, URLResponse)
  // ...
  public var request: @Sendable (ServerRoute) async throws -> (Data, URLResponse)

  // ...

  public func apiRequest(
    route: ServerRoute.Api.Route,
    file: StaticString = #file,
    line: UInt = #line
  ) async throws -> (Data, URLResponse) {
    do {
      let (data, response) = try await self.apiRequest(route)
      #if DEBUG
        print(
          """
          API: route: \(route), \
          status: \((response as? HTTPURLResponse)?.statusCode ?? 0), \
          receive data: \(String(decoding: data, as: UTF8.self))
          """
        )
      #endif
      return (data, response)
    } catch {
      throw ApiError(error: error, file: file, line: line)
    }
  }

  public func apiRequest<A: Decodable>(
    route: ServerRoute.Api.Route,
    as: A.Type,
    file: StaticString = #file,
    line: UInt = #line
  ) async throws -> A {
    let (data, _) = try await self.apiRequest(route: route, file: file, line: line)
    do {
      return try apiDecode(A.self, from: data)
    } catch {
      throw ApiError(error: error, file: file, line: line)
    }
  }

  public func request(
    route: ServerRoute,
    file: StaticString = #file,
    line: UInt = #line
  ) async throws -> (Data, URLResponse) {
    do {
      let (data, response) = try await self.request(route)
      #if DEBUG
        print(
          """
          API: route: \(route), \
          status: \((response as? HTTPURLResponse)?.statusCode ?? 0), \
          receive data: \(String(decoding: data, as: UTF8.self))
          """
        )
      #endif
      return (data, response)
    } catch {
      throw ApiError(error: error, file: file, line: line)
    }
  }

  public func request<A: Decodable>(
    route: ServerRoute,
    as: A.Type,
    file: StaticString = #file,
    line: UInt = #line
  ) async throws -> A {
    let (data, _) = try await self.request(route: route, file: file, line: line)
    do {
      return try apiDecode(A.self, from: data)
    } catch {
      throw ApiError(error: error, file: file, line: line)
    }
  }

  // ...  
}

Dependency としては少し特殊な定義となっていて、Dependency に通常必要となる property の定義だけではなく、いくつかの function を定義していることがわかる。
コードをもう少し見てみると、実は property として定義されている apiRequestrequest は直接利用されるわけではなく、あくまで実コードからは apiRequest(route:file:line)request(route:file:line) などを呼んでいることがわかる。

このように定義されていることには理由がある。( 別の話題だけど https://github.com/pointfreeco/swift-composable-architecture/discussions/1723#discussioncomment-4327193 などで少し話されていたりもする )
例えば apiRequest(route:as:file:line) は function の定義に Generics を利用しているが、通常の property だと property で Generics を利用することは難しい。( できなくはないが、定義や利用側が複雑になったりしてしまう )
この ApiClient は、Dependency としては簡単に依存を注入可能な property を用意しておいて、実際には function を利用するような interface として Client を定義することによって、簡潔な定義・利用方法に留めることができていると言えそう。

アイカワアイカワ

ApiClient モジュールに定義されている TestKey.swift を次に見ていく。

ここでは ApiClientTestDependencyKey に準拠させたり DependencyValues に property を生やしたりしているが、その辺りは基本的な Dependency の定義と変わらないため言及はしない。

ここで注目すべき実装は override という mutating function があること。
実際の定義を抜粋すると以下のようになっている。

extension ApiClient {
  // ...

  public mutating func override(
    route matchingRoute: ServerRoute.Api.Route,
    withResponse response: @escaping @Sendable () async throws -> (Data, URLResponse)
  ) {
    let fulfill = expectation(description: "route")
    self.apiRequest = { @Sendable [self] route in
      if route == matchingRoute {
        fulfill()
        return try await response()
      } else {
        return try await self.apiRequest(route)
      }
    }
  }

  public mutating func override<Value>(
    routeCase matchingRoute: CasePath<ServerRoute.Api.Route, Value>,
    withResponse response: @escaping @Sendable (Value) async throws -> (Data, URLResponse)
  ) {
    let fulfill = expectation(description: "route")
    self.apiRequest = { @Sendable [self] route in
      if let value = matchingRoute.extract(from: route) {
        fulfill()
        return try await response(value)
      } else {
        return try await self.apiRequest(route)
      }
    }
  }
}

ここで定義されている override function は、どちらも interface に定義されている apiRequest を override できるように実装されている。
そこまで実装が複雑ではないヘルパー的な関数ではあるが、これを用意しておくことで例えば PreviewApp やテストなどにおいて、特定のエンドポイントで返却されるレスポンスを簡単にモックすることができる。
例えば PreviewApp の ChangelogView.swift では以下のように利用されている。

$0.apiClient = {
  var apiClient = ApiClient.noop
  apiClient.override(
    routeCase: /ServerRoute.Api.Route.changelog(build:),
    withResponse: { _ in
      try await OK(
       update(Changelog.current) {
         $0.changes.append(
           Changelog.Change(
             version: "1.0",
             build: 60,
             log: "We launched!"
           )
         )
       }
     )
   }
 )
 return apiClient
}()

↑ で登場している OKApiClient/Helpers.swift に以下のように定義されている。

#if DEBUG
  import Foundation

  public func OK<A: Encodable>(
    _ value: A, encoder: JSONEncoder = .init()
  ) async throws -> (Data, URLResponse) {
    (
      try encoder.encode(value),
      HTTPURLResponse(
        url: URL(string: "/")!, statusCode: 200, httpVersion: nil, headerFields: nil)!
    )
  }

  public func OK(_ jsonObject: Any) async throws -> (Data, URLResponse) {
    (
      try JSONSerialization.data(withJSONObject: jsonObject, options: []),
      HTTPURLResponse(
        url: URL(string: "/")!, statusCode: 200, httpVersion: nil, headerFields: nil)!
    )
  }
#endif

このように ApiClient 自体は ServerRouter に依存しないような設計となっているため、API の特定のエンドポイントをモックするために、ApiClient.apiRequest を書き換えたり、override function を利用して特定の処理を override することを実現できている。

アイカワアイカワ

最後に ApiClientLive/LiveKey.swift を見てみる。

ApiClientLive はそこまで特殊なことはやっておらず、ServerRouter を使いつつ、interface として定義してあるいくつかの property を liveValue として提供している。

extension ApiClient: DependencyKey {
  public static let liveValue = Self.live(
    sha256: { Data(SHA256.hash(data: $0)) }
  )

  public static func live(
    baseUrl defaultBaseUrl: URL = URL(string: "http://localhost:9876")!,
    sha256: @escaping (Data) -> Data
  ) -> Self {
    #if DEBUG
      let baseUrl = UserDefaults.standard.url(forKey: baseUrlKey) ?? defaultBaseUrl
    #else
      let baseUrl = URL(string: "https://www.isowords.xyz")!
    #endif

    let router = ServerRouter(
      date: Date.init,
      decoder: decoder,
      encoder: encoder,
      secrets: secrets.split(separator: ",").map(String.init),
      sha256: sha256
    )

    actor Session {
      nonisolated let baseUrl: Isolated<URL>
      nonisolated let currentPlayer: Isolated<CurrentPlayerEnvelope?>
      private let router: ServerRouter

      // ...
    }

    let session = Session(baseUrl: baseUrl, router: router)

    return Self(
      apiRequest: { try await session.apiRequest(route: $0) },
      authenticate: { try await session.authenticate(request: $0) },
      baseUrl: { session.baseUrl.value },
      currentPlayer: { session.currentPlayer.value },
      logout: { await session.logout() },
      refreshCurrentPlayer: { try await session.refreshCurrentPlayer() },
      request: { try await session.request(route: $0) },
      setBaseUrl: { await session.setBaseUrl($0) }
    )
  }
}

// ...
アイカワアイカワ

ちなみに swift-url-routing には URLRoutingClient (https://github.com/pointfreeco/swift-url-routing/blob/main/Sources/URLRouting/Client/Client.swift) というものが定義されているので、それを利用して ApiClient っぽいものを作るということもできる。

API Request とか Response が返ってきた時の処理をカスタマイズしたいとかいった場合には、利用が難しいシーンもありそうだが、おおよそのケースでは URLRoutingClient を利用すれば良い感じの REST API Client は作れそう。

このスクラップは2023/11/26にクローズされました