プロトコル拡張を使ったAPIクライアントのサンプル

こんにちは。久しぶりの更新です。

思いつきでAPIクライアントのサンプルをさくって書いてみたので載せておきます。

私がプライベートで開発しているアプリでは、ishikawaさんのAPIKitを参考にしたAPIクライアントを実装しています。リクエストクラスが適合すべきであるプロトコルを用意する形の実装ですね。

私が実装している、プロトコルの実装をそのまま載せると次のようになります。

protocol Requestable{
    typealias ResponseType: Decodable, DefaultApiResponse

    var root: String{ get }
    var endpoint: String{ get }
    var method: Alamofire.Method{ get }

    func parameter() -> [String: AnyObject]
}

typealiasでレスポンスの型を指定します(**Swift 2.2からtypealiasではなくassociatedtypeを使うようになるようです)。レスポンスはDecodable(HimotokiでJSONをデコードできる)であり、DefaultApiResponseに適合する必要があります。「DefaultApiResponse」は、エラーメッセージやエラーコードがレスポンスに含まれることを意味しています。

rootは、APIのURLのベース部分を指定します。endpointにはエンドポイント、methodにはHTTPのメソッドを指定します。ライブラリにAlamofireを使っているのでAlamofireのMethodを指定します。parameterメソッドはHTTPリクエストのパラメータを返します。

そして上記プロトコルを使った実装、例えば認証部分で使うリクエストの場合

struct AuthRequest: Requestable{
    typealias ResponseType = APIResponse.AuthResponse

    let root = ApiEndpoints.ROOT
    let endpoint = ApiEndpoints.AUTH
    let method = Alamofire.Method.POST

    // Parameter
    let uuid: String

    func parameter() -> [String: AnyObject] {
        return ["Uuid": self.uuid]
    }
}

このように実装しています。これもそのまま実装コードをコピペしているだけです(説明していない変数・クラス等は察してください)。

これちょっと汎用性に問題がありまして・・・。具体的に上げてみると

  • リクエストのプロパティが増える場合、大変そう
  • ResponseType の縛りがきつい

って感じです。

一つ目の問題である「リクエストのプロパティが増える場合、大変そう」について。
仕事で何度か経験したことがあるのですが叩くAPIごとに変動するパラメータが必要な場合があります。例えば「HTTPリクエストのヘッダー」や「タイムアウトの秒数」などです。

Protocolにパラメータを追加する

まずRequestableプロトコルにパラメータ追加してみましょう。

protocol Requestable{
    typealias ResponseType: Decodable, DefaultApiResponse

    var root: String{ get }
    var endpoint: String{ get }
    var method: Alamofire.Method{ get }
    var timeoutInterval: Int{ get }
    func header() -> [String: String]
    func parameter() -> [String: AnyObject]
}

Requestableを適合するクラス or 構造体でヘッダーやタイムアウト時間を設定します。

struct AuthRequest: Requestable{
    typealias ResponseType = APIResponse.AuthResponse

    let root = ApiEndpoints.ROOT
    let endpoint = ApiEndpoints.AUTH
    let method = Alamofire.Method.POST
    let timeoutInterval = 10

    // Parameter
    let uuid: String

    func parameter() -> [String: AnyObject] {
        return ["Uuid": self.uuid]
    }

    func header() -> [String: String]{
        return ["Content-Type": "application/json"]
    }
}

さきほどのAuthRequestを使うと、↑のようになりますね。

デフォルトの実装を使って、必要な時だけクラスや構造体で実装するようにすれば、記述量を減らすことができます。

extension Requestable{
    var timeoutInterval: Int{
        return 10
    }

    func header() -> [String: String]{
        return ["Content-Type": "application/json"]
    }
}

アプリケーション内で、リクエストのプロパティに種類がある場合、Procotolを増やしてデフォルトの実装を種類別に実装することもできます。。。が、正直微妙なのでもっと良い方法があったら知りたいです。

// DefaultRequestに適合したクラス or 構造体にデフォルトの実装を与える
protocol DefaultRequest{}

extension Requestable where Self: DefaultRequest{
    var timeoutInterval: Int{
        return 10
    }

    func header() -> [String: String]{
        return ["Content-Type": "application/json"]
    }
}

とりあえずリクエストのプロパティ(条件)の追加については問題なさそうですね。

RequestTypeの条件をどうにかする

今の実装だとtypealiasのResponseTypeは「Decodable」と「DefaultApiResponse」に適合していなければいけません。

レスポンスがJSONだけならば良いのですが、xmlを返してくるAPIもあるので、この束縛はやめたいです。ということでとりあえず、ResponseTypeの型縛りをやめてみます。

protocol RequestType{
    typealias ResponseType

    var root: String{ get }
    var endpoint: String{ get }
    var method: String{ get }

    func parameter() -> [String: AnyObject]
}

ここまではいいですが、今のアプリの実装だとResponseTypeは絶対Decodableであると思って実装しているので、Requestableのextensionで

extension Requestable where ResponseType.DecodedType == ResponseType{
    func response() -> Operation<ResponseType, ApiError>{
        return Operation{observer in
            let request = API.sendRequest(self){result in
                switch result{
                case .Success(let response):
                    observer.next(response)
                    observer.success()
                case .Failure(let error):
                    observer.failure(error)
                }
            }

            return BlockDisposable{
                request.cancel()
            }
        }
    }
}

と実装しています。ちなみにAPIのsendRequestメソッドのなかで、Himotokiをつかってレスポンスをデコードしています。そもそもResponseTypeはすでにDecodableとは限らないので、ResponseType.DecodedType == ResponseTypeの部分はエラーになってしまいます。そこで一つだけ条件を追加します。

extension Requestable where ResponseType: Decodable, ResponseType.DecodedType == ResponseType{
    func responseJson() -> Operation<ResponseType, ApiError>{
        return Operation{observer in
            let request = API.sendRequest(self){result in
                switch result{
                case .Success(let response):
                    observer.next(response)
                    observer.success()
                case .Failure(let error):
                    observer.failure(error)
                }
            }

            return BlockDisposable{
                request.cancel()
            }
        }
    }
}

このようにすれば問題ありませんね。またresponseメソッドを「responseJson」メソッドに変えています。

レスポンスがXMLの場合は、XMLDecodableというプロトコルがあったとして

extension Requestable where ResponseType: XMLDecodable{
    // リクエストを投げてxmlをパースする処理を書く
    func responseXml(){}
}

として、XMLのリクエスト処理からパースの処理までをextension実装すれば、複数のレスポンスタイプに対応することができます。

レスポンスがDecodableのリクエストでは「responseJson」しかコールすることできず「responseXml」をコールできません。逆にレスポンスがXMLDecodableの場合は「responseXml」しかコールできません。

まとめ

ちょっと良さげになったかな。。。と思っています。そろそろその「開発中のアプリ」とやらの開発を再開しないとまずいのですがコミットが2ヶ月くらい前で止まっています。

最近暇になってきたし、暖かくなってきたので再開したいなぁと思うばかり。

サンプルは、gist(ref: https://gist.github.com/rb-de0/bd228eecf1ad4138b823) にも投稿してあります。