LoginSignup
1
1

More than 1 year has passed since last update.

URLSession × Combine でAPI通信処理を書いてみる

Last updated at Posted at 2022-11-05

サポート対象がiOS13,14以上のアプリが増えていく中で、より需要を増していると思われるCombine。
中でも今回は、Combineを使った非同期処理の書き方を記事にしてみました。

せっかくなので、API処理をRequest/Responseをジェネリクス・プロトコルで共通化し、エラーハンドリングもちゃんと行いたいと思います。

完成コード

protocol APIClientProtocol: AnyObject {
    func send<T: RequestProtocol>(_ request: T)
    -> AnyPublisher<T.Response, APIError>
}

final class APIClient: APIClientProtocol {
    private init() {}
    static let shared = APIClient()
    private var cancellables = Set<AnyCancellable>()
    
    func send<T: RequestProtocol>(_ request: T)
    -> AnyPublisher<T.Response, APIError> {
        // baseURL + 各Requestのパスを合わせてURLを生成
        guard let url = URL(string: request.baseURL)?
            .appendingPathComponent(request.path) else {
            return Fail<T.Response, APIError>(
                error: APIError.failedToCreateURL
            ).eraseToAnyPublisher()
        }
        
        guard var componets = URLComponents(
            url: url,
            resolvingAgainstBaseURL: true
        ) else {
            return Fail<T.Response, APIError>(
                error: APIError.failedToCreateComponents(url)
            ).eraseToAnyPublisher()
        }
        // クエリ(パラメーター)を設定
        componets.queryItems = request.parameters?.compactMap {
            .init(name: $0.key, value: "\($0.value)")
        }
        
        // URLRequestの生成
        guard var urlRequest =
                componets.url.map({ URLRequest(url: $0) }) else {
            return Fail<T.Response, APIError>(
                error: APIError.failedToCreateURL
            ).eraseToAnyPublisher()
        }
        urlRequest.httpMethod = request.method.rawValue
        
        urlRequest.allHTTPHeaderFields = [
            "Content-Type": "application/json"
        ]
        
        // 通信実行
        return URLSession.shared.dataTaskPublisher(for: urlRequest)
            .tryMap() { element -> Data in
                // responseのチェック
                guard let response =
                        element.response as? HTTPURLResponse else {
                    throw APIError.noResponse
                }
                // HTTPステータスコードのチェック
                guard 200 ..< 300 ~= response.statusCode else {
                    throw APIError.unacceptableStatusCode(
                        response.statusCode
                    )
                }
                return element.data
            }
            .decode(type: T.Response.self, decoder: JSONDecoder())
            .mapError { error -> APIError in
                if let error = error as? DecodingError {
                    // Decodeエラーのハンドリング
                    return APIError
                        .parserError(error.localizedDescription)
                } else if let error = error as? APIError {
                    // 上流のPublisherでエラーが発生していればここで返す
                    return error
                } else {
                    return APIError
                        .unknown(error.localizedDescription)
                }
            }
            .flatMap{ decodedData -> AnyPublisher<T.Response, APIError> in
                // Responseデータのstatus項目が正常値であることをチェック
                guard decodedData.status == "ok" else {
                    return Fail<T.Response, APIError>(
                        error: APIError.responseStatusError
                    ).eraseToAnyPublisher()
                }
                return Just(decodedData)
                    .setFailureType(to: APIError.self)
                    .eraseToAnyPublisher()
            }
            .eraseToAnyPublisher()
    }
}

APIに関しては、こちらのAPIを使う場合を想定しています。なので今回は基本的にGET通信を想定して作ってます。
※異なるAPIを使う場合もちょっとだけアレンジ必要ですが、基本的な構造は同じだと思います。

これから、この完成コードに至るまでのポイントとなるコードをいくつかピックアップして解説していきます。

共通化するためのRequestを実装する

完成コードのfunc send<T: RequestProtocol>(_ request: T)-> AnyPublisher<T.Response, APIError>となっている箇所は、ジェネリクスによる共通化を行いました。

呼び出すAPIごとにRequestパラメーターとResponseの型は異なりますが、API通信をしてエラーハンドリングする部分は共通なため、こういった形でどんどん共通化していきます。

プロトコルを定義する

共通化する中で全てのRequestが共通で持っているプロパティやメソッドをプロトコルにまとめます。
こうすることで、先ほどの完成コード内で、抽象的な型であっても、各プロパティにアクセスができるようになります。

// MARK: - RequestProtocol
protocol RequestProtocol {
    associatedtype Response: DataStructure
    var method: HTTPMethod { get }
    var baseURL: String { get }
    var path: String { get }
    var parameters: [String : Any]? { get set }
}

extension RequestProtocol {
    var baseURL: String {
        return "https://newsapi.org/v2/"
    }
}

// MARK: - HTTPMethod
enum HTTPMethod: String {
    case POST = "POST"
    case GET = "GET"
}

// MARK: - DataStructure
protocol DataStructure: Encodable, Decodable {
    var status: String { get }
}

最も重要なポイントとしては、associatedtype Response: DataStructureのところです。
このように書くことで、実際にRequestProtocolを継承したオブジェクトを作る際に、RequestとResponseを1対1で紐づけることができます。

リクエストのサンプル

例えば、リクエストはこんな感じになります。

struct TopNewsRequest: RequestProtocol {
    typealias Response = NewsEntity
    
    var method: HTTPMethod = .GET
    var path: String = "top-headlines"
    var parameters: [String : Any]?
    
    init(country: Country) {
        parameters = [
            "country": country.rawValue,
            "apiKey": APIKey.newsAPI
        ]
    }
}

typealias Response = NewsEntityとすることで、TopNewsRequestのResponseの型はNewsEntityである。というのを確定しています。

Responseについて

ResponseはCodableによるDecode処理を行うため、EncodableDecodableに準拠したStructにしておきます。
あとは、各APIに応じてプロパティを設定するだけですので、ここでは割愛します🙏

sendメソッドを呼び出す時のポイント

呼び出す時は、このような形で呼び出します。

func fetchTopNews(country: Country) -> AnyPublisher<NewsEntity, APIError> {
    let request = TopNewsRequest(country: country)
    return client.send(request)
}

func send<T: RequestProtocol>(_ request: T)-> AnyPublisher<T.Response, APIError>
となっているので、メソッドの戻り値は、T.Response
つまり、TopNewsRequestであれば、先ほどtypealias Response = NewsEntityとしているため、T.Responseは、NewsEntityであるということが確定します。

エラーハンドリングについて

1番最初に貼った完成コードのエラーハンドリングについて、1つ1つ解説していきます。

通信前にエラーが発生した場合の処理

URLSessionの非同期処理が走る前に、URLが変換できないなどの内部エラーが発生した場合は、
Failを使います。

Failは、Justのエラー版だと考えていただければわかりやすいと思います。下記のような感じで即エラーを返すことができます。

Fail<T.Response, APIError>(error: APIError.failedToCreateURL).eraseToAnyPublisher()

1番最初のtryMap(_:)

CombineにおけるtryMap(_:)は、下記のように定義されています。

tryという名前の通り、上流のPublisherを受け取り、その結果によって、自分自身でエラーをthrowすることができます。
今回の場合、受け取ったResponseのHTTPステータスコードが正常値の範囲を超えていたら、エラーをthrowしています。

2番目のmapError(_:)

mapError(_:)は、上流のPublisherで発生したエラーを任意のエラーに変換することができます。
先ほどのtryMap(_:)は、自分でエラーを発生させるというものですが、mapError(_:)は、既に発生しているエラーの型を変換したいときに利用します。

本来、Codableを使ったDecode処理は、try-catchで囲まなくてはいけないため、.decode(type: T.Response.self, decoder: JSONDecoder())はエラーが発生する可能性があります。
今回はOSが発生させるエラーを、アプリ内で定義しているエラー型へ変換したいため、mapError(_:)を使っているということになります。

また、上流のPublisher(tryMap(_:))で自分が発生させたエラーもこちらで処理しておきます。

3番目のflatmap(maxpublishers:_:)

最後のハンドリングは、取得したResponseが持っているStatusCode(HTTPステータスコードとは別のもの)が、"ok"ではない時はエラーとして扱いたいというものです。

ここちょっとポイントなのですが、Responseの値によって自分でエラーを発生させたいということで、tryMap(_:)を使うのが適切のように思われます。
しかし、tryMap(_:)を使ってしまうと下記のようなコンパイルエラーが発生します。

「Cannot convert return expression of type 'AnyPublisher<T.Response, Error>' to return type 'AnyPublisher<T.Response, APIError>'」

理由としては、tryMap(_:)は、自分が定義したAPIErrorという独自の型を返すことができず、Apple純正のError型を返すためです。

そこで使うのが、flatmap(maxpublishers:_:)です。
flatMapの中で、Failを返すようにすることで、AnyPublisher<T.Response, APIError>の型を保ったまま、下流のPublisherに処理をチェーンすることができるようになります。

おわりに

今回は、Combineを使ったAPI通信処理について書かせていただきました。
エラーハンドリングは、アプリによって異なってくると思うので、今回のこちらの記事をベースに、皆様のアプリ開発のお役に立てれば幸いです。

ではまた👋

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