Swiftでenumとジェネリクスを活用したかっこいいAPIクライアントを書く

この記事ははてなエンジニアアドベントカレンダー2014の16日目です。昨日はid:nobuokaによる「【Retrofit を読む】 利用者が定義したインターフェイスに実装を提供する Java ライブラリの作り方 【リフクレション】」でした。


こんにちは。はてなアプリケーションエンジニアのid:cockscombです。

Webと連携するスマートフォンアプリを開発するとき、Web APIを抽象化したAPIクライアントを作ることがよくあります。これはWeb APIのエンドポイントとメソッドを紐付け、パラメータに名前をつけて、返ってくるJSONのレスポンスを何らかのクラスに当てはめ型付けする、といったようなものになります。

Swiftのモダンな言語機能を利用して、このAPIクライアントを書きましたので、以下に詳解します。例としてGitHubのStatus APIを取り上げています。

またネットワーク通信部分にAFNetworking、JSONからモデルクラスへの対応付けにMantleを使いました。

enumでエンドポイントを列挙する

enum Endpoint {
    case Status
    case LastMessage
    case Messages

    func request<T: MTLModel where T: MTLJSONSerializing>(manager: AFHTTPSessionManager, parameters: [String: String]?, handler:(response: Response<T>) -> Void) -> NSURLSessionDataTask {
        let success: ((NSURLSessionDataTask!, AnyObject!) -> Void) = {
            handler(response: Response<T>.parse($1))
        }
        let failure: ((NSURLSessionDataTask!, NSError!) -> Void) = {
            handler(response: Response.Error($1))
        }

        switch (self) {
        case .Status:
            return manager.GET("/api/status.json", parameters: parameters, success: success, failure: failure)
        case .LastMessage:
            return manager.GET("/api/last-message.json", parameters: parameters, success: success, failure: failure)
        case .Messages:
            return manager.GET("/api/messages.json", parameters: parameters, success: success, failure: failure)
        }
    }
}

enum Endpointでエンドポイントを列挙

APIのエンドポイントはSwiftのenumを利用して列挙します。Swiftのswitch文ではenumの全てのケースを網羅しないとコンパイルエラーになりますから、実装の漏れを防ぐこともできます。

上の例にはありませんが、共用型のenumとすることでエンドポイントのパスに文字列などを埋め込むことも簡単です。

enum Endpoint {
    case User(name: String)

    func request<T: MTLModel where T: MTLJSONSerializing>(manager: AFHTTPSessionManager, parameters: [String: String]?, handler:(response: Response<T>) -> Void) -> NSURLSessionDataTask {
        ...

        switch (self) {
        case let .User(name):
            return manager.GET("/api/\(name)/status", parameters: parameters, success: success, failure: failure)
        }
    }
}

ジェネリクスを使ってレスポンスに型付けする

public enum Response<T: MTLModel where T: MTLJSONSerializing> {
    case One(@autoclosure() -> T)
    case Many(@autoclosure() -> [T])
    case Error(NSError?)

    static func parse<T: MTLModel where T: MTLJSONSerializing>(JSON: AnyObject) -> Response<T> {
        var error: NSError?
        if let array = JSON as? [AnyObject] {
            if let xs = MTLJSONAdapter.modelsOfClass(T.self, fromJSONArray: array, error: &error) as? [T] {
                return .Many(xs)
            }
        } else if let object = JSON as? [NSObject: AnyObject] {
            if let x = MTLJSONAdapter.modelOfClass(T.self, fromJSONDictionary: object, error: &error) as? T {
                return .One(x)
            }
        }
        return .Error(error)
    }
}

ジェネリクスでMantleを利用する

APIからの結果を共用型のenum Responseで表します。JSONのオブジェクト、JSONの配列、エラー、の3つのパターンを共用型で表します。

@autoclosure() -> Tなどとなっているのは、T[T]にした場合に現在のSwift (Swift 1.1) のコンパイラが"unimplemented IR generation feature non-fixed multi-payload enum layout"というエラーを出すので、そのワークアラウンドです。将来のSwiftで解消されればもっと素直に書けるでしょう。

ここでstatic func parse<T: MTLModel where T: MTLJSONSerializing>(JSON: AnyObject) -> Response<T>という関数に着目します。これはMantleを利用してJSONから具体的なクラスのインスタンスを作るための関数ですが、具体的なクラスを書かずに型パラメータとして<T: MTLModel where T: MTLJSONSerializing>という風にTを設定しています。

func testStatus() {
    let expectation = expectationWithDescription("Status")
    client.status { (response) -> Void in

        switch (response) {
        case .One(let status):
            XCTAssertEqual(status().status!, "good", "Status is good")
        default:
            XCTFail("Response must have one status")
        }

        expectation.fulfill()
    }
    waitForExpectationsWithTimeout(10, handler: { (error) -> Void in

    })
}

テストコードにおける利用例

テストコードで結果を取得しているところをみると、switchで分岐して実際の結果を得ています。少し冗長に見えますが、例えばenum Responseに関数を追加することで、一般的なケースでより簡単に書けるようにできるかもしれません。また@autoclosure()しているためにstatus()となっているところがあります。

APIクライアント

public class APIClient {
    let manager = AFHTTPSessionManager(baseURL: NSURL(string: "https://status.github.com/")!)

    public func status(handler: (response: Response<Status>) -> Void) {
        Endpoint.Status.request(manager, parameters: nil, handler: handler)
    }

    public func lastMessage(handler: (response: Response<Message>) -> Void) {
        Endpoint.LastMessage.request(manager, parameters: nil, handler: handler)
    }

    public func messages(handler: (response: Response<Message>) -> Void) {
        Endpoint.Messages.request(manager, parameters: nil, handler: handler)
    }
}

APIClientの実装

これらの実装を利用することで、APIClientは上記のように簡単に書くことができます。JSONから変換されるクラスは、それぞれのhandlerに型パラメータとして指定されています。func lastMessageでもfunc messagesでも同じMessageクラスを型パラメータとして渡していますが、実際にはそれぞれResponse.One<Message>Response.Many<Message>が返ってきます。


こうしてできあがったAPIクライアントの実装をGitHubで公開しています。

APIClient.swiftAPIClientTests.swiftのふたつのファイルを見ると、おおよそどういった感じになっているか掴めるかと思います。

高機能になったenumやジェネリクスといったSwiftのモダンな言語機能を利用することで、Objective-Cよりもスマートに実装できます。Swiftの高い表現力でかっこいいコードを書いていきたいものですね。

スマートなおしらせ

本記事の内容は、筆者とid:yashigani_wが日常的なディスカッションを重ねた結果として生み出されました。私たちといっしょにSwiftでスマートにプログラミングすることに興味をお持ちの方は、こちらからエントリーください。


明日はid:astjさんです。