コネヒト開発者ブログ

コネヒト開発者ブログ

APIクライアントをAPIKit+RxSwiftからURLSession+Combineにしました(前編)

コネヒト株式会社でiOSエンジニアをやっています ohayoukenchan です。

この記事は コネヒト Advent Calendar 2021 23日目の記事です。

最近コネヒトのママリのAPIクライアントをAPIKitからURLSession+Combineにしました。

変更する動機

ママリではHTTPリクエストに、APIKitを使用していましたが、 GETパラメータにArrayを使えるようにしたいなどの理由で本家APIKitをforkしたものを利用していました。

健全性の観点で、folkしているライブラリを使用し続ける将来的な不安と、依存ライブラリを減らす理由からAPIKitを外す決断をしました。

変更方針

  1. 新しくエンドポイントが追加されたときは、リクエストとレスポンスを作成するのみに留めたい
  2. 非同期処理を適切に処理したい

新しくエンドポイントが追加されたときは、リクエストとレスポンスの作成のみに留めたい

新しくエンドポイントを追加するときに、何度も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についてそれぞれ見ていくと pathbaseURLを基底としてURL型に変換しています。 queryItems[URLQueryItems]として保持しているのでそのままURLComponents.queryItemsに追加することができます。URLQueryItemsの実態は(name: "query", value: query)の引数名付きのタプル。これでURLにクエリストリング?query=fooを追加することができます。 methodrequest.httpMethodrawValueStringをセットしています。

これについては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"
}

他にハマったこととして、URLComponentsRFC3986に準拠しているため+:などは予約語とみなされエンコードされないので 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を使って非同期処理していくところは中編でお伝えできればと思います。