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

一番最初に、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" }
// ...
}
}
}
// ...
}
コード中に登場している ServerRoute
は Sources/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 を利用しているため、ここまでに出てきた ServerRouter
と ServerRoute
を使って 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 として定義されている apiRequest
や request
は直接利用されるわけではなく、あくまで実コードからは 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
を次に見ていく。
ここでは ApiClient
を TestDependencyKey
に準拠させたり 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
}()
↑ で登場している OK
は ApiClient/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 は作れそう。