汎用的なAPIClientの設計と実装
TL; DR
Swift4でDecodableを使いつつ、
フレキシブルなAPIレスポンスの設計をしていったら結局APIクライアント書いてたという話。
その設計・実装の流れを綴りました。
長くて読みきれないっていう場合はソースコード読んでもらったほうがいいと思います。
利用ライブラリ
- Alamofire
- Result
- RxSwift
今回は上記を用いてGithubのSearch APIのクライアントを書いてみました
APIClientの抽象化ポイント
まずは抽象化したいポイント、いわゆるジェネリクスで振る舞いの差し替え可能なポイントをさがします。
- リクエスト
- エンドポイント
- パラメータ
- レスポンス
- パース
- エラー
上記の通り、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を完成する形式が汎用的でよいです。
// 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: [ {} ] }
そこで、
- Result型でsuccess
, failureを透過的に扱う - 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クライアントにしたり、レスポンスをデコードする戦略を書き換えたりしても他の層には基本的に影響がない実装ができました。
ここまで、自分で読み返してもうまく説明できてる気がしないのでソースコードを見ることをおすすめします(笑)