8
7

More than 1 year has passed since last update.

Web APIのリクエスト仕様を型で表現したい。Alamofire編 2021秋

Last updated at Posted at 2021-09-26

はじめに

Web APIをアプリから利用する場合、そのAPIリクエストの仕様をSwiftで型として扱いたい場合があります。それでAPI仕様書と照らし合わせてミスがないか、齟齬がないかを確認したりトラブル時のきりわけをしたかったりするわけです(私は)。そういう場合の型の定義についてAlamofireを利用してどうやるかを書いておきます。他に良いやり方があるかもしれないのでそういう場合はコメントをもらえれば助かります。

具体的には何らかのWebAPIリクエストをつぎのようにできるのが良いんじゃないかという話です。

// MARK: - リクエスト型の定義側
struct 何らかのWebAPIリクエスト: WebAPIRequest { // protocol WebAPIRequest: URLRequestConvertible { ... }
     typealias Response = User // struct User: Decodable { ... }

    // MARK: - WebAPIRequest実装とリクエストパラメータ

    let baseURL: URL
    let accessToken: String?
    var path: String { "..." }
    let method = HTTPMethod.put

    var parameters: Parameters {
        [/*key*/: 何らかのパラメータ]
    }

    // MARK: - 

    let 何らかのパラメータ: String

    init(baseURL: URL, accessToken: String, 何らかのパラメータ: String) {
        ...
    }

     // MARK: - 想定されているAPIごとのエラーコードとそのエラー

     enum ResponseError: Error {
         case message(String)
         case server
         static func error(from statusCode: Int, _ data: Data?) -> ResponseError? {
             switch statusCode {
             case ...:
             case ...:
             default:
                 assertionFailure(/*API仕様が間違ってるかサーバの動作異常*/)
                 return nil
             }
     }

     // MARK: - ステータスコードによってエラーをハンドリングさせるためのカスタムなバリデーション

     static func validate(statusCode: Int, data: Data?) -> Result<(), Error> {
     }
}

// MARK: - 利用側
let request = 何らかのWebAPIリクエスト(パラメータ)
AF.request(request)
    .validate { _, response, data in
        何らかのWebAPIリクエスト.validate(statusCode: reesponse.statusCode, data: data)
    }
    .responseDecodable(of: 何らかのWebAPIリクエスト.Response.self) { response in
        // response: DataResponse<Success, AFError> を利用した結果のハンドリング
        // ...
    }

以降細かくどうやるかを書いておきます。

やりたいことの整理

  • リクエスト仕様の表現
    • リクエストに対するレスポンス型の明示
      • 成功時のJSONを型として変換した際の表現
    • 呼び出し側で自由にパラメータを変更できるのではなく、最低限のパラメータとしたい
      • なぜ?
        • パラメータを変更できるようにしすぎるとトラブル時の環境によって何が起こっているかわからない
    • どうやる?
      • 型の持つ定数として利用時にインスタンス化したい
  • レスポンスについて
    • 正常時
      • レスポンスのデータを型としたい
    • 異常時
      • エラーの可能性も列挙したい
        • デコードする
      • エラー時のハンドリング
        • ステータスコードを列挙してそれごとの処理を網羅
        • Errorとしてユーザ表示するものがあればデコードする

やり方の整理

  • リクエストの表現
    • AlamofireのURLRequestConvertibleプロトコルに準拠した型を作る
  • レスポンス型の明示
    • 正常系
      • structでつくってリクエストパラメータの型にtypealiasでアクセスしやすくしとく
    • 異常系
      • Alamofireのvalidateクロージャを使う

Alamofireのつかいたい部分のために整理する図は以下

名称未設定ファイル.drawio.png

AlamofireのURLRequestConvertibleプロトコルを使う

AlamofireにはいつからかURLRequestConvertibleプロトコルがあり、これがURLRequest自体を作成するメソッドを持ちます。

/// Types adopting the `URLRequestConvertible` protocol can be used to safely construct `URLRequest`s.
public protocol URLRequestConvertible {
    /// Returns a `URLRequest` or throws if an `Error` was encountered.
    ///
    /// - Returns: A `URLRequest`.
    /// - Throws:  Any error thrown while constructing the `URLRequest`.
    func asURLRequest() throws -> URLRequest
}

これはAF(つまりSessionのdefult)がリクエスト送るときの引数として利用することで通信の内容を決めます。使い方は次のような感じ。

AF.request(request)
    .validate()
    .responseData { ... }

そんで、このURLRequestConvertibleに準拠させたプロトコルを用意してリクエスト仕様を表現できるようにしようというわけです。

URLRequestConvertibleに準拠させて次のようにアプリで利用するプロトコルを決めます。

protocol WebAPIRequest: URLRequestConvertible {
    // APIリクエストごとに変わったり変わらなかったりする
    var baseURL: URL { get }
    var path: String { get }
    var method: Alamofire.HTTPMethod { get }
    var parameters: Alamofire.Parameters { get }

    // 認証が不要な場合はnil
    var accessToken: String? { get }
}

複数の各リクエストが上記プロトコルに準拠させて利用させますが、今回はaccessTokenによる認可の方式を想定としていて利用側が必要だったらセットするし、必要なかったらセットしないようにしています。大抵の場合、基本的には必要で、ログインAPIなどは必要じゃなかったりするでしょう。

URLRequestConvertibleのメソッドasURLRequest()を実装しておき、各リクエストがデフォルトで同じ処理を行うようにしておきます。上記プロトコルで決めたプロパティから単にURLRequestを組み立てるだけです。

extension WebAPIRequest {
    // デフォルト実装としてprotoocl extensionsで実装を書いておく
    public func asURLRequest() throws -> URLRequest {
        let request = URLRequest(url: baseURL.appendingPathComponent(path))

        // ここではデフォルトとしてpost, put, patchの場合にJSONでパラメータを送るようにしています。
        // もちろんそれはAPI仕様によって変わりますのでそれぞれの仕様に合わせるのが良いでしょう。
        switch method {
        case .post, .put, .patch:
            return try Alamofire.JSONEncoding.default.encode(defaultIntercept(request), with: parameters)
        default:
            return try Alamofire.URLEncoding.queryString.encode(defaultIntercept(request), with: parameters)
        }
    }
}

private extension WebAPIRequest {
    // デフォルトでアクセストークンがある前提
    func addAccessToken(
        _ accessToken: String,
        to urlRequest: Foundation.URLRequest
    ) -> Foundation.URLRequest {
        var urlRequest = urlRequest

        // OAuthだいたいこんな感じだったような...
        let headers = [
            "Authorization": "Bearer \(accessToken)",
            "Connection": "Close",
        ]

        headers.forEach { key, value in
            urlRequest.addValue(value, forHTTPHeaderField: key)
        }

        return urlRequest
    }
}

これでWebAPIRequestプロトコルに準拠したリクエストを渡すことで、アクセストークンが必要な場合はデフォルトでそれをヘッダーに追記したりしてくれるわけです。

利用例は変わってません。

// ここでWebAPIRequestプロトコルに準拠したリクエスト渡しつつURLRequestを組み立て
AF.request(request) 
    .validate()
    .responseData { ... }

サンプルとしてWebAPIRequestに準拠したstructを考えてみます。

架空のユーザ一覧を取得するAPIの場合、つぎのような感じでしょう。

struct UsersGetRequest: WebAPIRequest {
    public typealias Response = [User]

    let baseURL: URL
    let accessToken: String?

    var path: String { "/users" }
    let method = HTTPMethod.get

    let page: Int

    var parameters: Parameters {
        ["per_page": page]
    }

    init(baseURL: URL, accessToken: String, page: Int) {
        self.baseURL = baseURL
        self.accessToken = accessToken
        self.page = page
    }
}

レスポンスも使ってほしいので

// ここでWebAPIRequestプロトコルに準拠したリクエスト渡しつつURLRequestを組み立て
AF.request(request) 
    .validate()
    .responseDecodable(of: UsersGetRequest.Response.self) { ... }
    // ここでレスポンス明示するんじゃなくてジェネリクスで決まれば良さそう...

.responseDecodable(of:)でDecodableな型を指定すればデコードしてくれるんですが、呼び出し側で変更することなんてないので自動で決まると良いですね、とは思うもののそれはそのままにしておきます。

エラーハンドリングのために

場合によってはステータスコードによってサーバサイドから作りたくなります。

たとえばユーザ情報を取得するWeb APIが仮にステータスコード400や500を返すとあらかじめ決められている場合を考えます。400と500が来た際、400だとエラーメッセージがレスポンスにあるから取り出したい。

public struct UsersGetRequest: WebAPIRequest {
    public typealias Response = Content

    let baseURL: URL
    let accessToken: String?

    var path: String { "/users" }
    let method = HTTPMethod.get

    let page: Int

    var parameters: Parameters {
        ["per_page": page]
    }

    init(baseURL: URL, accessToken: String, page: Int) {
        self.baseURL = baseURL
        self.accessToken = accessToken
        self.page = page
    }

    // 仕様上定義されている既知のエラー
    // 例えば400, 500エラーが想定されていて、400のときにレスポンスされるJSONにエラー情報がある。
    enum ResponseError: Error {
        case paramter(String)
        case server

        static func error(from statusCode: Int, _ data: Data?) -> ResponseError? {
            switch statusCode {
            case 400:
                let response = try! JSONDecoder().decode(ErrorMessage.self, from: data!)
                return .parameter(response.message)
            case 500:
                return .server
            default:
                return nil
            }
        }
    }

    static func validate(statusCode: Int, data: Data?) -> Result<(), Error> {
        if let error = ResponseError.error(from: statusCode, data) {
            // 既知のエラーをResultで返すようにする
            return .failure(error)
        } else if (200..<300).contains(statusCode) {
            return .success(())
        } else {
            // 原因がわからないvalidationエラーはとりあえずAFErrorに用意されてるインタフェースを利用
            let reason = AFError.ResponseValidationFailureReason.unacceptableStatusCode(code: statusCode)
            return .failure(AFError.responseValidationFailed(reason: reason))
        }
    }
}

最後に、利用例はつぎのような感じ。

// ここでWebAPIRequestプロトコルに準拠したリクエスト渡しつつURLRequestを組み立て
AF.request(request)
    .validate { _, response, data in
        UsersGetRequest.validate(statusCode: reesponse.statusCode, data: data)
    }
    .responseDecodable(of: UsersGetRequest.Response.self) { ... }
        // response: DataResponse<Success, AFError> を利用した結果のハンドリング
        // ...
    }    

DataRequestにはvalidate(_ validation: @escaping Validation) -> Selfメソッドが有り、クロージャでカスタムな処理を渡せるわけです。

その他

AlamofireにはRequestInterceptorがある

AlamofireにはRequestInterceptorがあり、request利用時にURLリクエストに対する置き換えやリトライ条件の設定を行うことができます。

注意するところとしては、これを利用するのはリクエスト呼び出し時です。

AF.request(request, interceptor: interceptor) 
    .validate()
    .responseDecodable(of: UsersGetRequest.Response.self) { ... }

RequestInterceptorでは例えばアクセストークンをセットするなどのインターセプトを行えますが、これはリクエストを型としたWebAPIRequestのprotocol extensionsを使うほうがAPIの仕様をまとめるために私は良い気がします(つまり公式の機能としてRequestInterceptorがあるが使わない)。

リトライについては呼び出し側が制御したいので、公式のRequestInterceptorを使えば良いとは思います。

すでに自分で似たようなことを書いていた

8
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
8
7