42
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

[Swift] URLSession+async/await+CodableでAPIクライアントを作る

Last updated at Posted at 2021-07-04

※注意
本記事は Xcode 13.1 beta の環境で動作確認したものです。正式版では動作が変わる可能性もあります。

前書き

過去に以下のような記事を上げていましたが、その async/await 版になります。

そのまんま置き換えというのは、厳しい部分がありましたがなんとか同じような書き方に集約しております。

導入

1.環境

- MacOS Big Sur
- Xcode 13 Beta1

エンコードの実装を考えたくないので、Alamofire の機能を使用します。(個々に入れてください)
また、入れずにエンコードを自前で実装しても構いません。

Carthageのエラー

Xcode13 beta1 でCarthageエラーが起こる場合は、回避するスクリプト(下記carthage.sh)で対応できます。

2.ネットワークプロトコルの作成

API ごとのリクエストを作成、雛形 Protocol を作成します。

こちらに関しては、上記でもあげた以下

にほぼ同じ記載があるので、解説を読みたい方はこちらを読んでください。
ここではコードのみ置いておきます。

- 基底のプロトコル

.swift
protocol BaseAPIProtocol {
    associatedtype ResponseType

    var method: HTTPMethod { get }
    var baseURL: URL { get }
    var path: String { get }
    var headers: [String : String]? { get }
}

extension BaseAPIProtocol {

    var baseURL: URL {
        return try! "http://localhost:3000".asURL() // エンドポイント例
    }

    var headers: [String : String]? { // ヘッダー例
        return [
            "Content-Type": "application/json; charset=utf-8",
            "User-Agent": ""
        ]
    }
}

- リクエストのプロトコル

.swift
protocol BaseRequestProtocol: BaseAPIProtocol, URLRequestConvertible {
    var parameters: Parameters? { get }
    var encoding: URLEncoding { get }
}

extension BaseRequestProtocol {
    var encoding: URLEncoding {
        return URLEncoding.default
    }

    func asURLRequest() throws -> URLRequest {
        var urlRequest = URLRequest(url: baseURL.appendingPathComponent(path))
        urlRequest.httpMethod = method.rawValue
        urlRequest.allHTTPHeaderFields = headers
        urlRequest.timeoutInterval = TimeInterval(30)
        if let params = parameters {
            urlRequest = try encoding.encode(urlRequest, with: params)
        }
        return urlRequest
    }
}

3.リクエスト・レスポンスの作成

API ごとのリクエスト・レスポンスを作成します。

こちらに関しては、上記でもあげた以下

に全く同じ記載があるので、解説を読みたい方はこちらを読んでください。

4.ネットワーククライアントの作成

実際に作成したコードになります。
(以下、@available(iOS 15, *)は全て省略しています。)

.swift
struct APICliant {
    
    // MARK: Variables

    private static let successRange = 200..<300
    private static let decoder: JSONDecoder = {
        let jsonDecoder = JSONDecoder()
        jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
        return jsonDecoder
    }()
    
    
    // MARK: Method
    
    // APIの呼び出し
    static func call<T, V>(_ request: T) async throws -> V
        where T: BaseRequestProtocol, V: Codable, T.ResponseType == V {
        
        let result = try await URLSession.shared.data(for: request.asURLRequest())
        let data = try validate(data: result.0, response: result.1)
        return try decoder.decode(V.self, from: data)
    }
    
    // ネットワークの精査
    static func validate(data: Data, response: URLResponse) throws -> Data {
        guard let code = (response as? HTTPURLResponse)?.statusCode else {
            throw NSError(domain: String(data: data, encoding: .utf8) ?? "Network Error", code: 0)
        }
        guard successRange.contains(code) else {
            throw NSError(domain: "out of statusCode range", code: code)
        }
        return data
    }
}

ステータスコードをより詳細にハンドリングしたいのであれば、分岐してあげると良いでしょう。(例として)

.swift
static func validateCode(data: Data, response: URLResponse) throws -> Data {
    switch (response as? HTTPURLResponse)?.statusCode {
    case .some(let code) where code == 401:
        throw NSError(domain: "Unauthorized", code: code)
        
    case .some(let code) where code == 404:
        throw NSError(domain: "Not Found", code: code)
        
    case .none:
        throw NSError(domain: "Irregular Error", code: 0)
        
    case .some:
        return data
    }
}

5. 使用例

とてもスッキリしています。

.swift
Task {
    let request = UserRequest()
    let response = try await APICliant.call(request)
    debugPrint(response)
}

await のメソッドを呼び出すためには、async で明示的に囲う必要があります。
他にも asyncDetacheddetach などがあります。

await のメソッドを呼び出すためには、Task で明示的に囲う必要があります。
今回は説明しませんが、この辺を読んでおくと理解が深まると思います。

実用例

具体的に2つ出してみます。

1. 実装

① SwiftUI の実装例

CASE1

SwiftUI での簡単な呼び出し例を紹介します。

- UseCase

まずは UseCase を作ります。

.swift
final class APIUseCase {
    
    @Published var users = [UserModel]()
    
    func fetch() async {
        do {
            let request = UserRequest()
            let response = try await APICliant.call(request)
            users = response.data
        } catch let error {
            debugPrint(error.localizedDescription)
        }
    }
}

メソッドの後ろに asyncを付けたものでラップしています。
結果を @Published なプロパティでバインディングします。

UseCase という名前ですが、ViewModel と同じだと思っていただいて構いません。)

- View

.swift
struct ContentView:  View {
    
    private var useCase = APIUseCase()
    
    var body: some View {
        EmptyView()
            .task {
                await useCase.fetch()
            }
            .onReceive(useCase.$users) { users in
                print("users: ", users)
            }
    }
}

iOS15 から使えるようになった task を使用することで async で囲う必要がなくなります。
以下の説明にある通り、画面が表示された際に実行されます。
task
(引用:Developer - task(_:)

② Protocol で縛った実装例

case2

@Published を使用する場合、Protocol で縛れないので Combine を使っていきます。

- Inputs/Outputs

MVVM を実装する際に良く見られるパターンですが、入力と出力をProtocol で縛っていきます。

.swift
// 入力と出力の制限
protocol APIUseCasable {
    var inputs: APIUseCaseInputs { get }
    var outputs: APIUseCaseOutputs { get }
}

// 入力 ( View → UseCase )
protocol APIUseCaseInputs {
    func fetch() async
}

// 出力 ( UseCase → View )
protocol APIUseCaseOutputs {
    var users: PassthroughSubject<[UserModel], Never> { get set }
}

- UseCase

作ったものを適応した UseCase を作成します。

.swift
final class APIUseCase: APIUseCasable, APIUseCaseInputs, APIUseCaseOutputs {
    
    // MARK: APIUseCasable
    var inputs: APIUseCaseInputs { self }
    var outputs: APIUseCaseOutputs { self }
    
    // MARK: APIUseCaseOutputs
    var users = PassthroughSubject<[UserModel], Never>()
    
    // MARK: APIUseCaseInputs
    func fetch() async {
        do {
            let request = UserRequest()
            let response = try await APICliant.call(request)
            users.send(response.data)
        } catch let error {
            debugPrint(error.localizedDescription)
        }
    }
}

inputsoutputs を通してのみ、メソッドの実行や値のやりとりを行うことが保証されます。
また、CombinePassthroughSubject を使用して、View 側に値を返します。 

- View

.swift
struct ContentView:  View {
    
    private var useCase: APIUseCasable = APIUseCase()
    
    var body: some View {
        EmptyView()
            .task {
                await useCase.inputs.fetch()
            }
            .onReceive(useCase.outputs.users) { users in
                print("users: ", users)
            }
    }
}

UseCase プロパティを Protocol (APIUseCasable) で定義することで縛ります。
実行では inputs を通し、値を受け取る側では outputs を通しています。

この方法だと、UseCaseはそのまま UIKit でも使うことができるメリットもあります。

(ただし、SwiftUI 特有の Binding が必要な場合は、onReceive で受け取ってからViewのプロパティに再度バインディングさせるといったひと工夫が必要になります)

あとがき

Combine が出てきたと思えば、あっという間に新しい技術が出てきてしまいました。
(議論は前からされていたけど...)

まだ、見切れていない部分もあるので、今回記載したものよりもっと良い実装ができるかもしれません。

コードの指摘等あればお願いしますmm
動作するリポジトリを置いておきます

42
25
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
42
25

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?