堅牢で使いやすいAPIクライアントをSwiftで実装したい
昨年末にはてなの@cockscombさんと@yashiganiさんがつくっていたSwiftでenumとジェネリクスを活用したかっこいいAPIクライアントを書くが 面白かったので、これを参考にしつつSwiftらしい堅牢で使いやすいAPIクライアントを考えてみました。 目標としたのは以下の3つの条件を満たすことです。
- レスポンスはモデルオブジェクトとして受け取る (便利)
- 個々のリクエスト/レスポンスの定義は1箇所で済ます (変更しやすくしたい)
- リクエストオブジェクトはAPIクライアントから分離させたい
例にはGitHub System Status APIを使用しています。 サンプルコードはGitHubに上がっています。
APIクライアントのインターフェース
APIの呼び出し用に用意されたメソッドはcall
のみで、call
に渡すリクエストによってレスポンスの型が変わる仕組みになっています。
以下は、LastMessageRequestというリクエストがMessageというモデルオブジェクトを結果として返すという例です。
クロージャーの引数のresponse
はSuccess, FailureのEitherとなっており、Successはモデルオブジェクト(Message)、
FailureはNSErrorを持っています。
client.call(GitHub.LastMessageRequest()) { response in
switch response {
case .Success(let box):
println(box.value) // Message
case .Failure(let box):
println(box.value) // NSError
}
}
ジェネリクスでレスポンスを型付け
個々のAPIに期待するモデルオブジェクトは決まっているので、リクエストにレスポンスの型情報を持たせます。
抽象的なリクエストはプロトコルとして定義してtypealias Response
を持たせることで、
func call<T: Request>(request: T, handler: (T.Response) -> Void)
といった形でリクエストの種類に応じた
モデルオブジェクトを結果として受け取ることができるようになります。
このようなプロトコルにtypealiasを持たせて汎用的にするテクニックはSwiftの標準ライブラリ上で頻繁に使われています(ForwardIndexTypeのDistanceなど)。
protocol Request {
var method: String { get }
var path: String { get }
typealias Response: Any
func convertJSONObject(object: AnyObject) -> Response?
}
このプロトコルに適合する個々のAPIは以下のようにして定義できます。 新たなAPIはLastMessageのようにRequestに適合したクラスを定義すれば利用できるようになり、コードの変更はこの箇所だけで済みます。
class LastMessageRequest: Request {
let method = "GET"
let path = "/api/last-message.json"
typealias Response = Message
func convertJSONObject(object: AnyObject) -> Response? {
var message: Message?
if let dictionary = object as? NSDictionary {
message = Message(dictionary: dictionary)
}
return message
}
}
この例ではAPIのパラメーターがありませんが、必要な場合はinit
で受け取るようにし、そのクラス内でパラメーターを組み立てるのが良いと思います。
この方法は、call(method: HTTPMethod, path: String, parameters: NSDicitonary)
のような形式でAPIを呼び出す場合と比べて、以下のようなメリットがあります。
- どのようなキーを受け付けるのかドキュメントを見なくてもわかる
- キーの間違いを防ぐことができる
- 値の型を間違えることがない
レスポンスをEitherで返す
はじめはcall<T: Request>(request: T, handler: (T.Response?, NSError?) -> Void)
のような形式で結果を戻そうとしていたのですが、
リクエストの成否に関わらずT.ResponseもNSErrorもオプショナルで返ってしまい、”成功したけどT.Responseがnilかもしれない”と考える必要が出てしまったため、
Eitherで返すことにしました。
本当は以下のように定義したかったのですが、Swiftのコンパイラ(1.1, 1.2)が”unimplemented IR generation feature non-fixed multi-payload enum layout”と言っていたので、
enum Response<T> {
case Success(T)
case Failure(NSError)
}
仕方なくBoxという名前の入れ物を用意して凌ぐことにしました。これは将来のSwiftでもっと素直に書けるようになると思います。
class Box<T> {
let value: T
init(_ value: T) {
self.value = value
}
}
enum Response<T> {
case Success(Box<T>)
case Failure(Box<NSError>)
init(_ value: T) {
self = .Success(Box(value))
}
init(_ error: NSError) {
self = .Failure(Box(error))
}
}
サンプルコード
https://github.com/ishkawa/sandbox/blob/master/SwiftAPIClientに上がっています。 今回はサードパーティのライブラリを使わずに実装しましたが、AlamofireやMantleを利用すればもっと上手く実装できるかもしれません。