[Swift] Alamofire+RxSwiftでUpload APIクライアントを作る


はじめに

アプリ内に画像のアップロード機能を実装したことがあるでしょうか?

だいぶ前に書いた「Alamofire+RxSwift+CodableでAPIクライアントを作る」において、
アップロードのクライアントに関しては、触れただけで説明していませんでした。

というわけで、実際に作る必要に迫られたので、せっかくなので延長としてここにまとめておきます。

導入

1. 準備

Alamofire+RxSwift+CodableでAPIクライアントを作る」の記事内で行なっている

1. ライブラリのインストール
2. ネットワークプロトコルの作成

上記までを必ず行なってください。
(次の手順と続いているため)

2. Upload Request用のProtocolを作成

「1. 準備」が完了していれば、BaseRequestProtocolが作成されているはずです。
このプロトコルを元にアップロード用のプロトコル(BaseUploadProtocol)を作ります。

今回はアップロード成功時にも特定にレスポンスを受け取れる想定で作っていきます。
(ない場合は空のレスポンスクラスを作れば良いので大丈夫です。)

BaseUploadProtocol.swift
protocol BaseUploadProtocol: BaseRequestProtocol {
    var contentType: String { get }
    var boundary: String { get }
    func encoded(data: MultipartFormData)
}

Alamofireでは画像を扱う際のメソッドがいくつかサポートされていますが、今回はMultipartFormDataで対応します。
(これが一番面倒な実装なので、これができれば他のはもっと簡単にできるかと思います)

BaseUploadProtocol.swift
extension BaseUploadProtocol {

    // MultipartFormDataにセットするためのもの
    var contentType: String {
        return "multipart/form-data;"
    }

    // MultipartFormDataにセットするためのもの
    var boundary: String {
        return ""
    }

    // `BaseRequestProtocol`内の`URLRequestConvertible`からこれを追加
    func asURLRequest() throws -> URLRequest {
        var urlRequest = URLRequest(url: baseURL.appendingPathComponent(path))
        urlRequest.httpMethod = method.rawValue
        urlRequest.allHTTPHeaderFields = headers
        urlRequest.timeoutInterval = TimeInterval(20)        
        return urlRequest
    }
}

上記は、サーバーの設定によって少し変わるかと思うので、臨機応変に設定を変えていただきたいです。

3.Request/Responseの作成

先に述べたようにサーバーから何も返ってこない実装が多いかと思いますが、、、
今回は、簡易な例として以下のレスポンスが返ってくることを想定します。

{
    "message": "success"
    "result": true
}

① Responseの作成

import Alamofire

struct UploadResponse: Codable {
    let message: String
    let result: Bool
}

②Requestの作成

先ほど作成したBaseUploadProtocolに準拠させます。

UploadRequest.swift
import Alamofire

struct UploadRequest: BaseUploadProtocol {
    typealias ResponseType = UploadResponse

    // 画像データをData型として渡す
    private let imageData: Data

    init(imageData: Data) {
        self.imageData = imageData
    }

    var method: HTTPMethod {
        return .post
    }

    var path: String {
        return "xxx/xxx"
    }

    var parameters: Parameters? {
        return nil
    }

    // 必要な形にエンコードする
    func encoded(data: MultipartFormData) {
        // 以下の設定はあくまでも実装例なので、各自の実装に合わせてください。
        data.contentType = contentType
        data.boundary = boundary
        data.append(
            imageData, 
            withName: "image",
            fileName: "image.jpg",
            mimeType: "image/jpeg"
        )
    }
}

画像データはそのまま送れないので、

  • .jpegData(compressionQuality:)
  • .pngData()

などを使ってData型に変換しましょう。

また、画像に付与されるパラメータは各自の実装で変わってくるので各自の設定に変更してください。

appendの中身に関しては、以下の解説を参考にすると良いでしょう。
- Alamofireで画像&パラメータを送信

4.Network Cliantの作成

Alamofireを使ったアップロードでは2つの段階を踏むことになります。

① エンコードの成功・失敗
② 通信の成功・失敗

では、1つずつ進めていきます。
先にAPICliantクラスを作成しておきましょう。

また、結果をsuccess/failureで分岐するために以下を作成しておきます。

enum APIResult {
    case success(Codable)
    case failure(Error)
}

では、クライアントを作っていきます。

① Alamofire呼び出し部分の作成

Alamofireのアップロードメソッドを呼び出すメソッドを作成します。

// Alamofire内のSessionManagerクラス
typealias EncodingResult = SessionManager.MultipartFormDataEncodingResult

static func requestUpload<T>(
    _ request: T,
    completion: @escaping (EncodingResult) -> Void
) where T: BaseUploadProtocol {

        Alamofire.upload(
            multipartFormData: request.encoded,
            with: request,
            encodingCompletion: completion
        )
}

MultipartFormDataの型が長いのでtypealiasで置いていますが、置かなくても大丈夫です。
先ほど作成した、request内のencodedメソッドはこちらで使用されます。

② アップロード部分の作成

通信をバリデーションするコードを最初に作成します。

static func validate(_ request: DataRequest) -> DataRequest {
    return request
        .validate(statusCode: 200..<400)
        .validate(contentType: ["application/json"])
} 

こちらは、画像ではない通常のAPIと共通化して使用できるので、切り出しておく方が使いがってがよくおすすめです。

続いて、アップロードのコードです。

static let queue = DispatchQueue(label: "queue.APICliant", attributes: .concurrent)

static func callForUpload<T, V>(
    _ request: T,
    _ upload: UploadRequest,
    completion: @escaping (APIResult) -> Void
) where T: BaseUploadProtocol, T.ResponseType == V {

        _ = validate(upload).responseData(queue: queue) { res in
            switch res.result.flatMap(request.decode) {
            case let .success(data): 
                completion(.success(data))
            case let .failure(error): 
                completion(.failure(error))
            }
        }
}

アップロードの通信と、その後の結果をデコードしています。
queueは指定しなくても動作しますが、コントロールしたい場合は設定しておくと良いでしょう。
(しておいた方が、複数の通信が走った場合は順番に流すことができる)

③ エンコード呼び出し部分の作成

static func callForEncode<T, V>(
    _ request: T,
    completion: @escaping (APIResult) -> Void
) where T: BaseUploadProtocol, T.ResponseType == V {

        requestUpload(request) { result in
            switch result {
            case let .success(rq, _, _): // もし読み込みプログレスを出すならこの辺を修正する必要あり
                callForUpload(request, rq, completion: completion)
            case let .failure(errer):
                completion(.failure(errer))
            }
        }
}

アップロードの通信前に、画像データのエンコードが問題ないか確認する必要があります。
ここではそれを行なっており、成功した場合のみアップロードの通信を行なっています。

コメントにもありますが、アップロード進行中のプログレスを出す場合は、alamofireから取得可能なので、そこで実装しましょう。

④ Rxで呼び出す部分

static func observeUpload<T, V>(_ request: T) -> Single<V>
    where T: BaseUploadProtocol, T.ResponseType == V {

        return Single<V>.create { observer in
            callForEncode(request) { response in
                switch response {
                case let .success(result): 
                    observer(.success(result as! V)) // responseがsuccessの段階で型が決まっており問題ないので強制キャストしています。
                case let .failure(errer): 
                    observer(.error(errer))
                }
            }
            return Disposables.create()
        }
}

Rxになるようcreateしています。

まとめ

上記で1つずつ分割して作成したコードを、1つのクライアントコードとして列挙しました。

APICliant.swift
struct APICliant {

    // MARK: Typealias

    typealias EncodingResult = SessionManager.MultipartFormDataEncodingResult


    // MARK: Static Variables

    private static let successRange = 200..<400
    private static let contentType = ["application/json"]
    private static let queue = DispatchQueue(label: "queue.APICliant", attributes: .concurrent)

    // MARK: Static Methods

    static func observeUpload<T, V>(_ request: T) -> Single<V>
        where T: BaseUploadProtocol, T.ResponseType == V {

            return Single<V>.create { observer in
                callForEncode(request) { response in
                    switch response {
                    case let .success(result): 
                        observer(.success(result as! V))
                    case let .failure(errer): 
                        observer(.error(errer))
                    }
                }
                return Disposables.create()
            }
    }

    private static func callForEncode<T, V>(
        _ request: T,
        completion: @escaping (APIResult) -> Void
    ) where T: BaseUploadProtocol, T.ResponseType == V {

            requestUpload(request) { result in
                switch result {
                case let .success(rq, _, _):
                    callForUpload(request, rq, completion: completion)
                case let .failure(errer):
                    completion(.failure(errer))
                }
            }
    }

    private static func callForUpload<T, V>(
        _ request: T,
        _ upload: UploadRequest,
        completion: @escaping (APIResult) -> Void
    ) where T: BaseUploadProtocol, T.ResponseType == V {

            _ = validate(upload).responseData(queue: queue) { res in
                switch res.result.flatMap(request.decode) {
                case let .success(data):
                    completion(.success(data))
                case let .failure(error):
                    completion(.failure(error))
                }
            }
    }

    private static func requestUpload<T>(
        _ request: T,
        completion: @escaping (EncodingResult) -> Void
    ) where T: BaseUploadProtocol {

            Alamofire.upload(
                multipartFormData: request.encoded,
                with: request,
                encodingCompletion: completion
            )
    }

    private static func validate(_ request: DataRequest) -> DataRequest {
        return request
                .validate(statusCode: successRange)
                .validate(contentType: contentType)
    }
}

というわけで無事にクライアントコードが完成しました!

使用例

任意の画像データをアップロードする場合に

let disposeBag = DisposeBag()

let imageData: Data = /* 任意の画像データ */
let request = UploadRequest(imageData: imageData)

APICliant.observeUpload(request)
    .observeOn(MainScheduler.instance)
    .subscribe(onSuccess: { response in
        print("onSuccess")
    }, onError: { error in
        print("onError")
    })
    .disposed(by: disposeBag)

という感じでかけます。
普通に書くと長くなってしまいがちなので、通常の書き方よりはだいぶシュッとしたと思います。

先にも同じ記述をしましたが、今回に何かしらレスポンスが返ってくる想定で作成しましたが、ない場合でも空のレスポンスで対応可能です。
(そもそもレスポンスクラスを作らない作りにもできますが、その場合はクライアントコードを自分で修正してください笑)

終わりに

Uploadなので少しコアだったかなとは思いつつ、、笑

一部リネームして表記しているので動かなかったらすいません、、、
誤字脱字あればお願いいたします!

その他