🐾

今更ながらAPIクライアントを自作してみる

2023/04/05に公開

APIクライアントの仕様

  • 通信結果はResult型にして返す
  • Requestクラスでエンドポイント, header部, Body部を指定できる
  • ライブラリは使用しない

実装

1. Requestクラスの作成

1-1 Httpメソッドのenumを用意

enum HttpMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
    case patch = "PATCH"
}

1-2 エラー用のenumを用意

enum ApiError: Error {
    case url
    case network
    case response
    case emptyResponse
    case parse
    case error(status: Int, data: Data)
}

extension ApiError: LocalizedError {
    var errorDescription: String? {
        switch self {
        case .url:
            return "URLが無効"
        case .network:
            return "ネットワーク未接続"
        case .response:
            return "レスポンス取得失敗"
        case .emptyResponse:
            return "レスポンスが空"
        case .parse:
            return "パースエラー"
        case .error(status: let status, data: let data):
            return "エラー statusCode: \(status), data: \(data)"
        }
    }
}

1-3 Header用のクラスを用意

class HttpHeader {
    private var header: [String: String]
    
    init(_ header: [String: String]) {
        self.header = header
    }
    
    func addValues(_ values: [String: String]) -> HttpHeader {
        var header = self.header
        
        values.forEach { (key, value) in
            header[key] = value
        }

        return HttpHeader(header)
    }
    
    func values() -> [String:String] {
        return self.header
    }
}

[String:String]のヘッダーに値をaddValues()で追加するために作りました。
Dictionaryを拡張しても良かったのですが、スコープが大きいので避けました。

1-4 空レスポンス用の構造体を用意

struct EmptyResponse: Codable {}

QiitaAPIを叩き台にして動作確認を行ったところ、DELETEメソッドの成功時、空のレスポンスが返ってくるためデコードエラーになりました。空レスポンスでも、ステータスコードで結果が判断できる時はこちらをResponseに指定します。

1-5 Protocolの用意

protocol HttpRequestable {
    associatedtype Response: Decodable
    
    var baseURL: String { get }
    var path: String { get }
    var method: HttpMethod { get }
    var header: HttpHeader { get }
    var httpBody: Encodable? { get }
}

Request用のクラスでこのプロトコルを継承し、各変数に値をセットします。

1-6 Requestの作成

struct QiitaApiPostArticleRequest: HttpRequestable {
    // デコード用のクラスを設定
    typealias Response = QiitaApiPostArticleResponseJSON
    
    var baseURL: String {
        return AppConstant.Api.qiitaBaseURL
    }
    
    var path: String {
        return "items/"
    }
    
    var method: HttpMethod {
        return .post
    }
    
    var header: HttpHeader {
        return HttpHeader(ApiHeaderConstant.qiita)

        // 定数ヘッダーに値を追加するには下記のようにします。
        return HttpHeader(ApiHeaderConstant.qiita)
            .addValues([:])
            .addValues([:])
    }
    
    var httpBody: Encodable?
}

2. 通信を行うクラス作成する

仕様

  • ステータスコードが200台の場合
    • デコードに成功
      • 成功を返す
    • デコードに失敗
      • 空レスポンスが成功パターンの時、レスポンスが空であれば、成功を返す
      • ↑を満たさない場合、失敗を返す
  • ステータスコードが200台以外の場合
    • 失敗を返す
class ApiClient {
    
    let session: URLSession
    
    init(session: URLSession) {
        self.session = session
    }
    
    func request<T: HttpRequestable>(
        _ request: T,
        completion: @escaping ((Result<T.Response, ApiError>) -> Void)
    ) {
        guard let url = URL(string: request.baseURL + request.path) else {
            completion(Result<T.Response, ApiError>.failure(.url))
            return
        }
        
        // 各種設定のセット
        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = request.method.rawValue
        urlRequest.allHTTPHeaderFields = request.header.values()
        if let httpBody = request.httpBody,
           let bodyData = try? JSONEncoder().encode(httpBody) {
            urlRequest.httpBody = bodyData
        }
        
        print("start \(urlRequest.httpMethod ?? "") \(urlRequest.url?.absoluteString ?? "")")
        let task = session.dataTask(with: urlRequest) { data, response, err in
            if let err = err {
                if let _ = err as? URLError {
                    completion(Result<T.Response, ApiError>.failure(.network))
                    return
                }
                
                completion(Result<T.Response, ApiError>.failure(.response))
                return
            }
            
            guard let response = response as? HTTPURLResponse,
                  let data = data else {
                completion(Result<T.Response, ApiError>.failure(.emptyResponse))
                return
            }
            
            // ログ
            let statusCode = response.statusCode
            print("ステーテスコード: \(statusCode)")
            if let json = try? JSONSerialization.jsonObject(with: data) {
                print("レスポンス: \(json)")
            }
            
            if (200...299).contains(statusCode) {
                // 成功
                do {
                    let object = try JSONDecoder().decode(T.Response.self, from: data)
                    completion(Result<T.Response, ApiError>.success(object))
                } catch {
                    // 空レスポンスが成功の場合
                    if String(data: data, encoding: .utf8) == "" {
                        if T.Response.self == EmptyResponse.self {
                            completion(Result<T.Response, ApiError>.success(EmptyResponse() as! T.Response))
                            return
                        }
                    }
                    
                    completion(Result<T.Response, ApiError>.failure(.parse))
                }
            } else {
                completion(Result<T.Response, ApiError>.failure(.error(status: statusCode, data: data)))
            }
        }
        
        task.resume()
    }
}

使い方

class QiitaApiPostArticle {
    func execute(
        article: QiitaApiPostArticleRequestJSON,
        completion: @escaping ((Result<QiitaApiPostArticleResponseJSON, ApiError>) -> Void)
    ) {
        let request = QiitaApiPostArticleRequest(httpBody: article)
        ApiClient(session: URLSession.shared).request(request, completion: completion)
    }
}
let article = QiitaApiPostArticleRequestJSON(
            body: "本文",
            private: true,
            tags: [
                .init(name: "swift", versions: ["0.0.1"])
            ],
            title: "title",
            tweet: false
        )
let api = QiitaApiPostArticle()
api.execute(article: article) { result in
    switch result {
    case .success(let response):
        self.showAlert(title: "成功", message: "\(response)")
    case .failure(let err):
        self.showAlert(title: "失敗", message: "\(err)")
    }
}

最後に

https://github.com/HikaruKurodq/ApiClientTest

断片的になっているので、全体が確認できるようにリポジトリを用意しました。
Combine版とConcurrency版も用意しています。

マサカリ歓迎なので、考慮不足な箇所や間違っている箇所があれば、教えていただけると嬉しいです。

GitHubで編集を提案
カラビナテクノロジー デベロッパーブログ

Discussion