LoginSignup
9
9

More than 3 years have passed since last update.

SwiftでURLSessionを用いて汎用的なAPI Clientを作成

Posted at

はじめに

Swiftを勉強し始めたひよこです。
API処理周りで結構苦労したので記録用に記載します。
普段はAWS周りのインフラレイヤ触ることが多いので、やってることが正しいのかどうかすら自身ありません、、

課題

Alamofireなどのライブラリを活用せず、標準ライブラリであるURLSessionを用いたAPI Clientを作成したかったのですが、思い通りに動作させるのに下記の課題にぶち当たりました。

  • 任意の型のResponseに対応できない
  • Responseだけでなく、エラーも呼び出し元に返したい
  • テスト時にスタブに差し替えたい

解決方法

どうやって解決したか

それぞれの課題について下記の対応で乗り切りました。

  • 任意の型のResponse → Genericsで型を抽象化
  • エラーも呼び出し元に返す → Responseとエラーを含む構造体を作成し、内容を詰める
  • スタブ差し替え → Protocolを実装

作成したコード

最終的に作成したコードは下記です。
簡単のためPost部分のみ記載しています。
Headerとか決め打ちなのは一旦気にしないでください。

import Foundation

struct Output<T> {
    var response : T?
    var transformError : ErrorInfo?
    var connectionError : Error?
}

protocol APIClientProtocol {
    func post<T:Codable>(url urlString: String, params: [String: Any], type: T.Type, completion: @escaping ((Output<T>) -> Void))
}

class APIClient: APIClientProtocol {    
    func post<T:Codable>(url urlString: String, params: [String: Any], type: T.Type, completion: @escaping ((Output<T>) -> Void)) {
        guard let url = URL(string: urlString)
            else {return}

        var request = URLRequest(url: url)
        request.httpMethod = "POST"
        request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
        let paramsString: String = params.enumerated().reduce("") { (input, tuple) -> String in
            switch tuple.element.value {
            case let int as Int: return input + tuple.element.key + "=" + String(int) + (params.count - 1 > tuple.offset ? "&" : "")
            case let string as String: return input + tuple.element.key + "=" + string + (params.count - 1 > tuple.offset ? "&" : "")
            default: return input
            }
        }
        request.httpBody = paramsString.data(using: String.Encoding.utf8)!        
        var output = Output<T>()

        let task = URLSession.shared.dataTask(with: request) {
            (data, response, error) in
            if error != nil {
                output.connectionError = error
            }
            guard let data = data else { return }

            do {
                let json = try JSONDecoder().decode(T.self, from: data)
                output.response = json
            } catch {
                let errorJson = try? JSONDecoder().decode(ErrorInfo.self, from: data)
                output.transformError = errorJson
            }
            DispatchQueue.main.async {
                completion(output)
            }
        }
        task.resume()
    }
}

任意の型のResponseに対応する

実装

今回作成しているAPI Clientの責務として、ResponseのDecodeまでは担わせたいと考えておりました。
その場合、API Client内でResponseの型にDecodeする必要があるのですが、アプリの中で呼び出すAPIは一つではないので、当然実行内容によって想定されるResponseの型は異なってきます。

例えば認証セッションを取得する際に、下記の型でResponseを受け取りたいと考えます。

struct Session: Codable {
    let statusCode: String
    let userId: String
    let AccessToken: String
}

このとき、型SessionをそのままAPI Client内でDecodeしようとすると、他のAPI実行時に使いまわせずにAPI毎にClientを実装することとなり、非常に非効率です。
という愚痴を社内Slackでこぼしたら、「Generics使えばええやん」って即答もらったので調べて実装しました。

API Client内で、Decodeしたい型をGenericsで抽象化し、 T という型にDecodeするように記載しています。

func post<T:Codable>(url urlString: String, params: [String: Any], type: T.Type, completion: @escaping ((Output<T>) -> Void)) {
// ~略~
   let json = try JSONDecoder().decode(T.self, from: data)
// ~略~
}
使い方

これにより、API Client呼び出し元でDecodeしたい型を指定するだけで、同じAPI Clientをアプリ全体で再利用することができるようになりました。

client = APIClient()
client.post(url: url, params: params, type: Session.self, completion: { response in
  // code
}

エラーも呼び出し元に返す

実装

初期実装の間、正常にDecodeできたResponseしか呼び出し元に返せないような実装になっていました。

class APIClient: APIClientProtocol {
    func post<T:Codable>(url urlString: String, params: [String: Any], type: T.Type, completion: @escaping ((T?) -> Void)) {
        guard let url = URL(string: urlString) else {return}
        // ~略~
        let task = URLSession.shared.dataTask(with: request) {
            (data, response, error) in
            // ~略~
            do {
                let json = try JSONDecoder().decode(T.self, from: data)
                DispatchQueue.main.async {
                    completion(json)
                }
            } catch {
                return
            }
        }
        task.resume()
    }
}

これ作ってたときは、postメソッドの引数としてGenericsで指定した型 T しかクロージャ内で渡せないとなぜか思っていて、catchの中で拾ったエラーを握りつぶしてしまっていました。
で、いろいろ考えていたんですが、errorも含んだ一つ抽象度の高い型を別途作成して、そこに結果を詰めて返せばいいんじゃないか?と思い、下記のように修正しました。

struct Output<T> {
    var response : T?
    var transformError : ErrorInfo?
    var connectionError : Error?
}

class APIClient: APIClientProtocol {
    func get<T:Codable> (url urlString: String, type: T.Type, completion: @escaping ((Output<T>) -> Void)) {
// ~略~
        let task = URLSession.shared.dataTask(with: request) {
            (data, response, error) in
            if error != nil {
                output.connectionError = error
            }
            guard let data = data else { return }

            do {
                let json = try JSONDecoder().decode(T.self, from: data)
                output.response = json
            } catch {
                let errorJson = try? JSONDecoder().decode(ErrorInfo.self, from: data)
                output.transformError = errorJson
            }
            DispatchQueue.main.async {
                completion(output)
            }
        }
        task.resume()
    }
}

Output という構造体を別途定義し、Optional型でエラーとレスポンスを詰めれるようにしました。
そして、postメソッドの中で、エラーを拾う、あるいはレスポンスを正常にDecodeできるとOutput型の変数に詰めて、最後に DispatchQueue.main.async を呼び出してOutput型の変数 output をメインスレッドに渡すようにしました。

使い方

例えば、受け取ったエラーをUIAlertControllerで表示させる場合、下記のようにします。
(Alertの実装は今回関係ないので割愛します)

client = APIClient()
client.post(url: url, params: params, type: Session.self, completion: {output in            
    // transformErrorの場合
    if output.transformError != nil {
        let transformError = output.transformError
        error(transformError)
    }

func error(error: ErrorInfo?) {
        // エラー表示
        let alert = Alert()
        let vcUtil = ViewControllerUtil()
        if error != nil {
            alert.display(fromViewController: vcUtil.getTopVC()!, error: error!)
        }
    }

テストでスタブに差し替えられるようにする

実装

これはSwiftに置ける基本的なDIの考え方だと思うのですが、スタブに差し替えて注入したいモジュール系はProtocolをImplementする形で実装することが一般的かと思います。
今回API Clientも、UnitTest内では実際のAPI実行ではなくて適当なデータを返すように実装したかったので、ProtocolをImplementする形で実装しています。

protocol APIClientProtocol {
    func post<T:Codable>(url urlString: String, params: [String: Any], type: T.Type, completion: @escaping ((Output<T>) -> Void))
}

class APIClient: APIClientProtocol {
// ~略~
}
使い方

UnitTest内では、 APIClientProtocol を実装したFakeのAPI Clientを利用します。

class FakeAPIClient : APIClientProtocol {
    let fakeResponse : Codable
    init(fakeResponse : Codable) {
        self.fakeResponse = fakeResponse
    }

    func post<T>(url urlString: String, params: [String : Any], type: T.Type, completion: @escaping ((Output<T>) -> Void)) where T : Decodable, T : Encodable {
        completion(self.fakeResponse as! Output<T>)
    }
}

あとはテストコードの中でFakeAPIClientのインスタンスを注入してあげればおkです。

まとめ

ある程度汎用的なAPI Clientを実装する上で下記の点工夫したので、メモとして残しました。

  • 任意の型のResponse → Genericsで型を抽象化
  • エラーも呼び出し元に返す → Responseとエラーを含む構造体を作成し、内容を詰める
  • スタブ差し替え → Protocolを実装

誰かの参考になれば幸いです。
また、もっといい実装あるよ、などのコメントいただけると非常に嬉しいです。

9
9
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
9
9