🐾
今更ながらAPIクライアントを自作してみる
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)")
}
}
最後に
断片的になっているので、全体が確認できるようにリポジトリを用意しました。
Combine版とConcurrency版も用意しています。
マサカリ歓迎なので、考慮不足な箇所や間違っている箇所があれば、教えていただけると嬉しいです。
![カラビナテクノロジー デベロッパーブログ](https://storage.googleapis.com/zenn-user-upload/avatar/7af46f1aaf.jpeg)
株式会社 カラビナテクノロジーは「命綱や支点を素早く確実に繋ぐカラビナ。そんなカラビナのような役割をテクノロジーで実現したい」という想いのもと、福岡で設立。 主にシステム開発・アプリ開発・ Webサイト制作を行っています。採用情報→karabiner.tech/recruit/requirements/
Discussion