Swiftのプロトコルやジェネリクスを駆使して汎用性の高いAPIクライアントを実装する

正直いまだにジェネリクス周りがそんなに得意じゃないのですが、色々苦戦しながらも割と汎用的で使いやすいAPIクライアントをジェネリクスを使って実装することができた気がするので、それについてご紹介したいと思います。

「プロトコルやジェネリクスを駆使して」っていうほどのことはしてないのでタイトルは煽りですがw、参考になる方がいれば幸いです。

APIクライアントの実装にはAPIKitとCombineを使用しています。



APIクライアントの仕様

以下の要件を満たすように実装しました。

  • APIレスポンス(JSON)をDecodableでデコードする
  • Authorizationヘッダを付加できる
  • なるべく共通化して、個別のRequestの型の実装が最小限になるようにする


もっともシンプルなRequestの実装はこうなります。

struct UserListEntity: Decodable {
    let users: [UserEntity]
}

struct UserListRequest: HogeAPIBaseRequest {
    typealias Response = UserListEntity
    let method = APIKit.HTTPMethod.get
    let path = "/users"
}


UserListRequestは、/usersというAPIパスに対してGETリクエストを送信し、レスポンスのJSONをUserListEntityにデコードします。

デコードやAuthorizationヘッダに関するロジックを実装する必要がないのがポイントです。
それらのロジックがどこでどのように実装されているのか見ていきましょう。


デコード処理の共通化

APIKitでは生のレスポンスデータを任意のオブジェクトにマッピングするために、APIKit.Requestプロトコルのresponse(from:urlResponse:)メソッドを実装する必要があります。

個別のRequestの型(例えばUserListRequest)でこのメソッドを実装することもできますが、同じような実装をあちこちに書くことは避けたいので、BaseRequestというプロトコルを用意してそこで共通化します。

以下がBaseRequestの実装です。

protocol BaseRequest: Request where Response: Decodable {
    var decoder: JSONDecoder { get }
}

extension BaseRequest {
    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Response {
        let data = try JSONSerialization.data(withJSONObject: object, options: [])
        return try decoder.decode(Response.self, from: data)
    }
}


先にresponse(from:urlResponse:)を見ます。

ここではAny型のobjectを一旦JSONSerializationでJSONオブジェクト化(Data型)して、そのあとJSONDecoderでResponse型のオブジェクトに変換します。
ResponseはAPIKit.Requestのassociatedtypeです。

JSONDecoderでデコードするためには、ResponseがDecodableに準拠していなければいません。
そこで、BaseRequestプロトコルを以下のように宣言しています。

protocol BaseRequest: Request where Response: Decodable


これは、「BaseRequestプロトコルに準拠させるときは、Responseの型がDecodableに準拠していなければならないこと」を意味しています。
このように制約をつけることで、response(from:urlResponse:)メソッドの中でResponseはDecodableに準拠した型として使用することができます。

ちなみに、以下のようにextension側に型制約をつけると若干意味が変わります。

extension BaseRequest where Response: Decodable


これは、「Responseの型がDecodableに準拠してるときのみ、このメソッドが実装される」ことを意味しています。

例えば以下のようなRequestを実装したとします。

struct Foo {}

private struct FooRequest: BaseRequest {
    typealias Response = Foo
    let method = APIKit.HTTPMethod.get
    let path = "/foo"
}


BaseRequestのプロトコル宣言に型制約をつけている場合、以下のエラーが表示されます。

Type 'FooRequest.Response' (aka 'Foo') does not conform to protocol 'Decodable'

前述した「BaseRequestプロトコルに準拠させるときは、Responseの型がDecodableに準拠していなければならないこと」というルールに違反していることがわかります。

一方、extension側に型制約をつけている場合、以下のエラーが表示されます。

Type 'FooRequest' does not conform to protocol 'Request'

これはAPIKit.Requestプロトコルのresponse(from:urlResponse:)メソッドが実装されていないことが原因です。
前述したように、「Responseの型がDecodableに準拠してるときのみ、このメソッドが実装される」のですが、Foo型はDecodableに準拠していないため、response(from:urlResponse:)メソッドが実装されません。



ちょっと脱線しましたが、BaseRequestプロトコルの実装の説明に戻ります。

BaseRequestプロトコルにはdecoderというJSONDecoder型のプロパティを持たせていて、response(from:urlResponse:)内でそれを使用しています。
APIによってレスポンスに含まれる日付のフォーマットが違うなどといったケースに対応できるように、個別のRequestを実装するときに任意の設定を施したJSONDecoderを指定できるようにしています。


ベースURLやHTTPヘッダの共通化

最初に例示したUserListRequestはHogeAPIBaseRequestプロトコルに準拠していました。

APIのホスト名や共通で設定するHTTPヘッダはHogeAPIBaseRequestで実装しています。

以下がHogeAPIBaseRequestプロトコルの実装です。

protocol HogeAPIBaseRequest: BaseRequest {}

extension HogeAPIBaseRequest {
    var baseURL: URL { URL(string: "https://api.hoge.com")! }
    var headerFields: [String: String] { baseHeaders }
    var decoder: JSONDecoder { JSONDecoder.default }

    var baseHeaders: [String: String] {
        var params: [String: String] = [:]
        params["Accept-Encoding"] = "gzip"
        params["Content-Type"] = "application/json"
        return params
    }
}


HogeAPIBaseRequestはBaseRequestを継承しています。

そして、HogeAPIへのリクエストで共通となるURLやHTTPヘッダをエクステンションで実装しています。
こうすることで、HogeAPIBaseRequestに準拠するRequestの型はHTTPメソッドとAPIパスを実装するだけで良くなります。

struct UserListRequest: HogeAPIBaseRequest {
    typealias Response = UserListEntity
    let method = APIKit.HTTPMethod.get
    let path = "/users"
}


HogeAPI以外の例えばBarAPIを使用することになった場合は、BarAPIBaseRequestプロトコルを用意して同じようにURLや共通のHTTPヘッダを実装してあげればOKです。
後述のAPIクライアントはHogeAPIBaseRequestもBarAPIBaseRequestも同様に扱うことができます。


Authorizationヘッダを付加する仕組み

APIにアクセスするためにHTTPヘッダに認証トークンを設定するケースがあります。

例えば、認証トークンがログイン時に発行され、それをUserDefaultsやKeychainなどに保存しておいて、APIリクエスト時にそれを参照するという方式への対応を考えます。

HTTPヘッダに認証トークンを付加するためには、APIKit.RequestのheaderFieldsを実装する必要があります。

前述のHogeAPIBaseRequestを以下のように実装すれば実現できますが、UserDefaults等のデータストアへのアクセスが含まれるとテストがしづらくなってしまいます。

extension HogeAPIBaseRequest {
    var baseURL: URL { URL(string: "https://api.hoge.com")! }
    var headerFields: [String: String] { baseHeaders }
    var decoder: JSONDecoder { JSONDecoder.default }

    var baseHeaders: [String: String] {
        var params: [String: String] = [:]
        params["Accept-Encoding"] = "gzip"
        params["Content-Type"] = "application/json"

        params["Authorization"] = ...  // UserDefaults等からトークンを取得

        return params
    }
}


どうすべきか悩んでいたところ、こちらの記事を見つけました。

engineering.linecorp.com


Proxyパターンを使うことで、共通のHTTPヘッダに対して後から任意のヘッダを差し込むことができるようにします。

ProxyパターンはGoFのデザインパターンの一つで、以下のような目的で使用されます。

  • 遅延初期化(必要になったときに本体を初期化する)
  • キャッシュ(Proxyでキャッシュして、データがなければ本体のメソッドから取得する)
  • 前後処理の追加

今回は3つ目のユースケースが該当します。


Proxyパターンを実装するとき抑えておくべきことは以下の3つかなと思います。

  1. Proxyは前後処理を追加したい本体のオブジェクト(以下、サブジェクト)と同じインタフェースを持つ
  2. Proxyオブジェクトは、サブジェクトをプロパティに保持する
  3. 前後処理をしたあと、本来の処理はサブジェクトに移譲する


これらを踏まえた上で、APIKit.RequestのProxyであるRequestProxyを以下のように定義します。

protocol RequestProxy: APIKit.Request {
    associatedtype Request: APIKit.Request
    var request: Request { get }
}

今回サブジェクトととなるのはAPIKit.Requestに準拠したオブジェクトです。
Proxyはサブジェクトと同じインタフェースを持つようにしたいので、RequestProxyもAPIKit.Requestを継承したプロトコルとして宣言します。
これで上記の1を満たすことができます。

次に、RequestProxyにサブジェクトの型のプロパティを持たせます。
サブジェクトの型はAPIKit.Requestに準拠したものにしたいので、associatedtypeで型の制約をつけています。
これで2を満たします。

ここまでできたら、RequestProxyに準拠したProxyオブジェクトを実装します。
AuthorizedRequestProxyという構造体を以下のように実装しました。

struct AuthorizedRequestProxy<R: Request>: RequestProxy {
    let request: R
    let authTokenProvider: AuthTokenProvider?

    var headerFields: [String: String] {
        guard let authTokenProvider = authTokenProvider else { return request.headerFields }
        var headers = request.headerFields
        headers["Authorization"] = authTokenProvider.token()
        return headers
   }

    var baseURL: URL { request.baseURL }
    var method: APIKit.HTTPMethod { request.method }
    var path: String { request.path }
    var parameters: Any? { request.parameters }
    var queryParameters: [String: Any]? { request.queryParameters }
    var bodyParameters: BodyParameters? { request.bodyParameters }
    var dataParser: DataParser { request.dataParser }

    func intercept(urlRequest: URLRequest) throws -> URLRequest {
        try request.intercept(urlRequest: urlRequest)
    }

    func intercept(object: Any, urlResponse: HTTPURLResponse) throws -> Any {
        try request.intercept(object: object, urlResponse: urlResponse)
    }

    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Request.Response {
        try request.response(from: object, urlResponse: urlResponse)
    }
}


前述の3の処理はheaderFieldsプロパティに実装されています。
ここではサブジェクトのheaderFieldsを取り出して、そこにAuthorizationヘッダを追加して値を返すようになっています。
サブジェクトが持つHTTPヘッダの情報を失わず、後からヘッダを追加することができています。

AuthTokenProviderは認証トークンを取得する処理を持つプロトコルです。
本番環境ではデータストア等から認証トークンを取り出すことになると思いますが、プロトコルにしているので、テスト時はただ認証トークン文字列を返すだけのモックオブジェクトを突っ込むことができます。

baseURL以下のプロパティやメソッドは、Proxyによる前後処理の追加等がないのでサブジェクトのプロパティやメソッドに処理を委譲しています。

これでProxyパターンを使ったAuthorizationヘッダを追加する処理を実装することができました。


なお、以下のようなエクステンションを実装しておくと、Proxyは前後処理を追加したいプロパティやメソッドだけ実装すれば良くなります。

extension RequestProxy {
    var baseURL: URL { request.baseURL }
    var method: APIKit.HTTPMethod { request.method }
    var path: String { request.path }
    var parameters: Any? { request.parameters }
    var queryParameters: [String: Any]? { request.queryParameters }
    var bodyParameters: BodyParameters? { request.bodyParameters }
    var headerFields: [String: String] { request.headerFields }
    var dataParser: DataParser { request.dataParser }

    func intercept(urlRequest: URLRequest) throws -> URLRequest {
        try request.intercept(urlRequest: urlRequest)
    }

    func intercept(object: Any, urlResponse: HTTPURLResponse) throws -> Any {
        try request.intercept(object: object, urlResponse: urlResponse)
    }

    func response(from object: Any, urlResponse: HTTPURLResponse) throws -> Request.Response {
        try request.response(from: object, urlResponse: urlResponse)
    }
}
struct AuthorizedRequestProxy<R: APIKit.Request>: RequestProxy {
    let request: R
    let authTokenProvider: AuthTokenProvider?

    var headerFields: [String: String] {
        guard let authTokenProvider = authTokenProvider else { return request.headerFields }
        var headers = request.headerFields
        headers["Authorization"] = authTokenProvider()
        return headers
   }
}


APIクライアントの実装

上記で作ってきたものとCombineを組み合わせてAPIClientクライアントを実装します。

final class APIClient {
    private let session: Session
    private let callbackQueue: CallbackQueue
    private let authTokenProvider: AuthTokenProvider?

    init(
        session: Session = .shared,
        callbackQueue: CallbackQueue = .main,
        authTokenProvider: AuthTokenProvider?
    ) {
        self.session = session
        self.callbackQueue = callbackQueue
        self.authTokenProvider = authTokenProvider
    }

    func send<T: BaseRequest>(request: T) -> AnyPublisher<T.Response, APIError> {
        let req = AuthorizedRequestProxy(request: request, authTokenProvider: authTokenProvider)
        return session.sessionTaskPublisher(for: req, callbackQueue: callbackQueue)
            .mapError { APIErrorConverter.convert($0) }
            .eraseToAnyPublisher()
    }
}


引数で受け取るRequestの型はBaseRequestに準拠しているようにします。
前述したようにBaseRequestではレスポンスのマッピング処理を実装しているので、APIClient内でマッピング処理を実装する必要がありません。

受け取ったRequestはAuthorizedRequestProxyに渡し、Authorizationヘッダを追加するようにしています。
認証トークンはauthTokenProvider経由で取得します。

ここでは詳細は割愛しますが、APIErrorConverterというエラーのコンバーターを使って、エラーをAPIErrorという型に変換しています。
何もしないとAPIKit.SessionTaskErrorという型になりますが、APIKitへの依存はこのレイヤにとどめておきたいのと、よりアプリで扱いやすいエラーに変換しておくと便利なのでこうしています。

APIKitのエラーハンドリングについてまとめた記事を以前書いたので、こちらも参考にしてみてください。

bamboo-hero.com


ご覧のように、APIClientの実装はとてもシンプルになりました。

Requestに関する共通の処理はBaseRequestに、Authorizationヘッダを追加する処理はAuthorizedRequestProxyにといったように責務が適切に分かれていて、かつそれぞれの単位は小さいので、テストも簡単に書くことができます。


おわりに

APIClientの実装はプロトコルやジェネリクスを理解するのにとても良い題材だなと思いました。

そして、この記事を書くことでより理解が深まりました。

人は誰かに教えるという行為を通して、より深く学ぶことができるということを改めて実感しました。

プロトコルやジェネリクスの世界はまだまだ深いので、もっと色んな記事を書いていきたいと思います。