コネヒト株式会社でiOSエンジニアをやっています ohayoukenchan です。
この記事は コネヒト Advent Calendar 2021 23日目の記事です。
最近コネヒトのママリのAPIクライアントをAPIKit
からURLSession+Combineにしました。
変更する動機
ママリではHTTPリクエストに、APIKitを使用していましたが、
GETパラメータにArray
を使えるようにしたいなどの理由で本家APIKit
をforkしたものを利用していました。
健全性の観点で、folkしているライブラリを使用し続ける将来的な不安と、依存ライブラリを減らす理由からAPIKit
を外す決断をしました。
変更方針
- 新しくエンドポイントが追加されたときは、リクエストとレスポンスを作成するのみに留めたい
- 非同期処理を適切に処理したい
新しくエンドポイントが追加されたときは、リクエストとレスポンスの作成のみに留めたい
新しくエンドポイントを追加するときに、何度もURLRequest処理まわりに変更を加えたくない。例えば以下のような感じ
リクエスト定義
struct SampleRequest { struct Suggest { typealias Response = SearchQueryResponse var method: HTTPMethodType { return .get } var path: String { return "path/to.json" } let query: String var queryItems: [URLQueryItem]? { return [ .init(name: "query", value: query) ] } }
レスポンス定義
struct SearchQueryResponse: Decodable { let suggestionSearchQueries: [SearchQuery] }
これを実現するために汎用的なプロトコルを実装していきます。
まず、APIRequestType
プロトコルを定義して準拠すべき実装を定義していきましょう。
Responseはエンドポイントごとに型を柔軟に変更したいのでassociatedtype Response
とする。
SwiftのDecodableでdecodeしたいのでDecodable
に準拠させておく。
さらに、path
(エンドポイント)やHTTP リクエストメソッドなど、リクエストごとに変わるプロパティを定義していきます。
そのリクエストはGET
なのかPOST
なのか、エンドポイントやクエリストリングなどは各リクエストごとに変わるのでリクエストの定義側で処理していきます。(前述SampleRequest
の部分)
protocol APIRequestType { associatedtype Response: Decodable var path: String { get } var queryItems: [URLQueryItem]? { get } var method: HTTPMethodType { get } }
次にAPIRequestType
にextensionを生やしてどのエンドポイントでも一意な定義を追加していきます。
最終的にbuildRequest()
を通してURLRequest
を作成します。
例
extension APIRequestType { // ママリで使用しているAPIのバージョンをheaderに追加する必要があるので登録 var apiVersion: String { return "2.0.0" } // 各エンドポイントがリクエストする基底のURL var baseURL: URL { guard let url = URL(string: "\(Const.API_URL)/api/") else { fatalError() } return url } var headerFields: [String: String] { let header = [ // headerフィールドに追加すべき項目 "Accept-Encoding": "gzip", ] return header } func buildRequest() -> URLRequest { let pathURL = URL(string: self.path, relativeTo: baseURL)! let httpMethod = self.method var urlComponents = URLComponents(url: pathURL, resolvingAgainstBaseURL: true)! urlComponents.queryItems = self.queryItems let characterSet = CharacterSet(charactersIn: "/+:").inverted urlComponents.percentEncodedQuery = urlComponents.percentEncodedQuery? .addingPercentEncoding(withAllowedCharacters: characterSet) var request = URLRequest(url: urlComponents.url!) request.httpMethod = httpMethod.rawValue request.addValue("application/json", forHTTPHeaderField: "Content-Type") headerFields.forEach { key, value in request.addValue(value, forHTTPHeaderField: key) } // その他Basic認証などHeaderFieldに追加すべき項目をrequest.addValueで追加していますが長くなるので省略します } }
APIRequestType
が実装を強制させているプロパティであるpath
,queryItems
,method
についてそれぞれ見ていくと
path
はbaseURL
を基底としてURL型に変換しています。
queryItems
は[URLQueryItems]
として保持しているのでそのままURLComponents.queryItems
に追加することができます。URLQueryItems
の実態は(name: "query", value: query)
の引数名付きのタプル。これでURLにクエリストリング?query=foo
を追加することができます。
method
はrequest.httpMethod
にrawValue
でString
をセットしています。
これについてはHTTPリクエストメソッドを網羅するenumを定義しています。
public enum HTTPMethodType: String { case get = "GET" case post = "POST" case put = "PUT" case head = "HEAD" case delete = "DELETE" case patch = "PATCH" case trace = "TRACE" case options = "OPTIONS" case connect = "CONNECT" }
他にハマったこととして、URLComponents
はRFC3986
に準拠しているため+
や:
などは予約語とみなされエンコードされないので
2021-11-11T16:28:46+0900
のようなクエリストリングをエンコードするためにaddingPercentEncoding
を使ってエンコードする文字を指定する必要がありました。
ここまでで、エンドポイントの追加に対して、リクエストとレスポンスを作成し、buildRequest()
を通してURLRequest
を作成することが出来ました。
冒頭の再掲になりますが、エンドポイントの追加時は以下のようなStructを作成するだけです。
struct SampleRequest { struct Suggest { typealias Response = SearchQueryResponse var method: HTTPMethodType { return .get } var path: String { return "path/to.json" } let query: String var queryItems: [URLQueryItem]? { return [ .init(name: "query", value: query) ] } }
長くなってしまったのでURLSession
を使って非同期処理していくところは中編でお伝えできればと思います。