will and way

ただの自分用メモを人に伝える形式で書くことでわかりやすくまとめてるはずのブログ

汎用的なAPIClientの設計と実装

TL; DR

Swift4でDecodableを使いつつ、
フレキシブルなAPIレスポンスの設計をしていったら結局APIクライアント書いてたという話。

その設計・実装の流れを綴りました。

長くて読みきれないっていう場合はソースコード読んでもらったほうがいいと思います。

github.com

利用ライブラリ

  • Alamofire
  • Result
  • RxSwift

今回は上記を用いてGithubのSearch APIのクライアントを書いてみました

APIClientの抽象化ポイント

まずは抽象化したいポイント、いわゆるジェネリクスで振る舞いの差し替え可能なポイントをさがします。

  1. リクエスト
    1. エンドポイント
    2. パラメータ
  2. レスポンス
    1. パース
    2. エラー

上記の通り、APIクライアントはリクエストに対し対応するレスポンスがあるという点がジェネリクスとの相性がいいです。

リクエストから抽象化していきましょう

リクエスト

protocol Request {
    var endpoint: URL { get }
    var parameters: Alamofire.Parameters { get }
    var responseFormat: ResponseFormat { get }
    var encoding: ParameterEncoding { get }
}

リクエストはこのようにエンドポイントとパラメータを抽象化します。

responseFormatはenumで対象とするフォーマットを列挙しておきます。
今回はレスポンスの型にDecodableを使うので、期待しているレスポンスのフォーマットに応じてデコーダーを差し替える必要があるので、
予めリクエストに仕込むのがいいです。

まずはエンドポイントから

protocol Endpoint {
    var endpoint: URL { get }
    var root: URL { get }
    var path: String { get }
}

URLの構成としてはルートが有り、そちらにパスを付けてURLを完成する形式が汎用的でよいです。

例えば、GithubのSearch APIのレスポンス

// MARK: - GitHubAPIEndpoint
protocol GitHubAPIEndpoint: Endpoint {
    var functionPath: String { get }
}

extension GitHubAPIEndpoint {
    var root: URL {
        return URL(string: URLConstants.GithubAPIURLRoot)!
    }
    
    var endpoint: URL {
        return root.appendingPathComponent([functionPath, path].joined(separator: "/"))
    }
}

enum GithubSearchAPIEndpoint: String, GitHubAPIEndpoint {
    case repositories
    
    var path: String {
        return self.rawValue
    }

    var functionPath: String {
        return "search"
    }
}

GithubのAPIはroot+[機能]+[機能を絞ったパス]という設計なので、functionPathを追加しています。

そこで、機能毎にenumを定義することで[機能を絞ったパス]はenumにまかせています。

パラメータの抽象化とenumで扱えるようにする

protocol ParameterKey: Hashable {
    var key: String { get }
}

protocol AlamofireParameters {
    var parameters: Alamofire.Parameters { get }
}

protocol Parameter {
    associatedtype Key: ParameterKey
    
    var parameter: [Self.Key: Any] { get set }
    mutating func setParameter(_ value: Any, forKey key: Key)
}

extension Parameter {
    mutating func setParameter(_ value: Any, forKey key: Key) {
        parameter[key] = value
    }
    mutating func setParameter<T: RawRepresentable>(_ value: T, forKey key: Key) where T.RawValue == String {
        parameter[key] = value.rawValue
    }
}

extension AlamofireParameters where Self: Parameter {
    var parameters: Alamofire.Parameters {
        return parameter.associate { (key, value) in
            return (key.key, value)
        }
    }
}

検索APIのパラメータは下記の通り

struct SearchAPIParameter: Parameter { 
    var parameter: [SearchAPIParameterKey : Any] = [:]
    
    typealias Key = SearchAPIParameterKey
    
    init() {}
    
    enum SearchAPIParameterKey: String, ParameterKey {
        case q
        case sort
        case order
    
        var key: String {
            return self.rawValue
        }
    }
}

extension SearchAPIParameter:  AlamofireParameters {}
varr parameter = SearchAPIParameter()
parameter.setParameter(q, forKey: .q)

こんな感じで、クエリがenumで指定できるようにします。

これで、パラメータとエンドポイントをprotocol化して汎用的なリクエストを作ることができるようになりました。

クライアント

基本方針はGenericsによって返り値の型を特定し、結果がマップされるようにします。

protocol APIClient {
    var sessionManager: SessionManager { get }
    func get<Response: Decodable>(apiRequest: Request) -> Observable<Response>
}

internal extension APIClient {
    internal func _get<Response>(apiRequest: Request) -> Observable<Response> where Response: Decodable {
        return request(method: .get, apiRequest: apiRequest)
    }
}

private extension APIClient {
    private func request<Response: Decodable>(method: HTTPMethod, apiRequest: Request) -> Observable<Response> {
        return Single.create { observer in
            weak var request = self.sessionManager.request(apiRequest.endpoint, method: method, parameters: apiRequest.parameters, encoding: apiRequest.encoding)
            request?
                .validate()
                .responseData(queue: DispatchQueueManager.shared.queue) {
                    self.handleResponse(response: $0, observer: observer)
            }
            return Disposables.create {
                request?.cancel()
            }
        }.asObservable()
    }

    private func handleResponse<Response: Decodable>(response: DataResponse<Data>, observer: ((SingleEvent<Response>) -> Void)) {
        guard let data = response.data else {
            return observer(.error(APIError()))
        }
        
        let jsonDecoder = JSONDecoder()
        do {
            let parsed = try jsonDecoder.decode(Response.self, from: data)
            observer(.success(parsed))
        } catch let error {
            observer(.error(error))
        }
    }
}

基本形はこのようになります。

これで各クライアントに APIClientのプロトコルを付加すれば通信できるようになりました

また、戻り値の中身であるResponseにDecodableである制約を設けています。
こうすることによってパースの戦略は各Responseに任せつつ、Responseの型が差し替え可能になるので
拡張に開くことができています。

レスポンスのハンドリング

APIの中身をパースして異常だった場合にそのハンドリングをしたいケースもありえますが
ただ、Response型に欲しい型を指定した場合にそのインターセプトができません、

また、ベースとなるAPIのレスポンスのフォーマットがある場合何度も書くのは面倒です

例えば、下記の場合、Itemsの中身だけがT型として変わる場合です。

{
    total_count: 841,
    incomplete_results: false,
    items: [
        {}
    ]
}

そこで、

  1. Result型でsuccess, failureを透過的に扱う
  2. Result型からsuccessの時はResultからResponseをアンラップする

という要件が出てきます

まずはItemのような差し替え可能な型を内包するProtocolを定義します

protocol BasicAPIResponse: Decodable {
    associatedtype ResponseT: Decodable
}

次に、Githubに特化したベースとなるレスポンスの型を定義します

こんな感じで、T(itemsの型)を差し替え可能なようにしておきます

struct GithubAPIResponseBase<T: Decodable>: BasicAPIResponse {
    typealias ResponseT = T
    typealias ErrorT = APIError
        
    private let total_count: Int
    private let incomplete_results: Bool
    private let items: ResponseT?
}

次に結果をResultで受け取れるようにします
Result型はsuccess, failureの2択で結果を透過的に扱えるようにしているenumのライブラリです

protocol APIResult {
    associatedtype ResponseT: Decodable
    associatedtype ErrorT: Swift.Error
    
    var result: Result<ResponseT, ErrorT> { get }
}

extension GithubAPIResponseBase: APIResult {
    var result: Result<ResponseT, APIError> {
        guard let response = items else { return .failure(APIError()) }
        return .success(response)
    }
}

これで、特別なAPIのパース処理を書きたい時はGithubAPIResponseBaseのイニシャライザを用意すれば良くなりました。
しかしながらResult型はAPIの結果なのでクライアントを呼び出す側では意識したくありません。 そこでClientでResultをアンラップする仕組みを考えます

ここで使えるのがType Erasureです

struct AnyAPIResult<ResponseT: Decodable, ErrorT: Swift.Error>: APIResult {

    let _result: () -> Result<ResponseT, ErrorT>
    init<Base: APIResult> (_ base: Base) where Base.ResponseT == ResponseT, Base.ErrorT == ErrorT {
        _result = { () -> Result<ResponseT, ErrorT> in
            return base.result
        }
    }

    var result: Result<ResponseT, ErrorT> {
        return _result()
    }
}

protocol AnyAPIResultConvartibleType: APIResult {
    var anyAPIResult: AnyAPIResult<Self.ResponseT, Self.ErrorT> { get }
}

extension AnyAPIResultConvartibleType {
    var anyAPIResult: AnyAPIResult<Self.ResponseT, Self.ErrorT> {
        return AnyAPIResult(self)
    }
}

TypeErasureを使ってAnyAPIResultを定義することで、

extension GithubAPIResponseBase: AnyAPIResultConvartibleType {}

GithubAPIResponseBaseをAnyAPIResultConvartibleTypeとして扱うことができるようになりました。
結果的にResult<Response, Error>の型がわかっていなくても、Anyがそこを回避してアンラップ処理を共通化することができるようになりました

extension Observable {
    func unwrapResult<T, E>() -> Observable<T> where Element == AnyAPIResult<T, E> {
        return self
            .asObservable()
            .map { result in
                switch result.result {
                case .success(let t):
                    return t
                case .failure(let e):
                    return Observable.error(e)
                }
            }
    }
}

最終的なGithubAPIClient

struct GithubAPIClient: APIClient {
    var sessionManager: SessionManager
    
    init() {
        sessionManager = Alamofire.SessionManager(configuration: URLSessionConfiguration.default)
    }
}

extension GithubAPIClient {
    func get<Response>(apiRequest: Request) -> Observable<Response> where Response : Decodable {
        typealias BasicResponse = GithubAPIResponseBase<Response>
        return _get(apiRequest: apiRequest)
            .map { (res: BasicResponse) in
                return res.anyAPIResult
            }
            .unwrapResult()
    }
}

ということで、外から欲しい型を注入し、戻り値の型から欲しい型を決めてレスポンスをパースするということが実現できました。

Github Search APIをたたくサービスをつくる

あとは、Itemsの中身の型を定義して

struct GithubRepositoryDTO: Decodable {
    let id: Int    
//以下略
}

Clientを使うサービスを定義し、戻り値の型に戻り値としたい型を指定するだけです。

protocol APIService {
    var client: APIClient { get }
}

protocol SearchAPI: APIService {
    func searchRepository(q: String) -> Observable<[GithubRepositoryDTO]>
}

struct SearchAPIService: SearchAPI {
    
    var client: APIClient {
        return _client
    }
    
    var _client = GithubAPIClient()

    func searchRepository(q: String) -> Observable<[GithubRepositoryDTO]> {
        var parameter = SearchAPIParameter()
        parameter.setParameter(q, forKey: .q)
        return client.get(apiRequest: APIRequest(apiEndpoint: GithubSearchAPIEndpoint.repositories, apiParameters: parameter, encoding: URLEncoding.default))
    }
}

まとめ

ということで、ほぼ、サービスやHTTPクライアントに依存しないインターフェイスができました。

APIClientの実装をAlamofireから別のHTTPクライアントにしたり、レスポンスをデコードする戦略を書き換えたりしても他の層には基本的に影響がない実装ができました。

ここまで、自分で読み返してもうまく説明できてる気がしないのでソースコードを見ることをおすすめします(笑)

github.com