[Swift] Alamofireの仕組みを使ってiOSからAWS S3のPre-Signed URLに画像をアップロードしてみる

はじめに

モバイルアプリサービス部の中安です。

Amazon S3Pre-Signed URL(署名付きURL) を利用すると、Amazon S3に限定的にアクセスしてファイルをアップロードすることができます。 この仕組みを使用すると、AWSの認証なしで直接Amazon S3にオブジェクトをアップロードすることが可能になります。(最近、教えてもらいました)

で、iOSアプリ開発でネットワーキングのライブラリといえば Alamofire を使うことが多いと思いますが、 この仕組みを使って、Pre-Signed URLに対して画像をアップロードすることに奮闘したので、その経緯と解決法をアウトプットしてみたいと思います。

ネットによくあるサンプル

Alamofireを利用して画像をアップロードするサンプルとしては、以下のようなソースコードがネット上ではよく引っかかります。

typealias ResponsedHandler = (Error?) -> Void

func upload(image: UIImage, responsed: @escaping ResponsedHandler) {
    let data = image.jpegData(compressionQuality: 1)!

    Alamofire.upload(
        multipartFormData: { formData in
            formData.append(data, withName: "test", fileName: "test.jpeg", mimeType: "image/jpeg")
        },
        to: "https://xxxxx.co.jp/image-upload/",
        encodingCompletion: { encodingResult in
            switch encodingResult {
            case .success(let uploadRequest, _, _):
                uploadRequest.response { response in
                    if let error = response.error {
                        // エラー処理
                        responsed(error)
                        return
                    }
                    // 成功処理
                    responsed(nil)
                }
            case .failure(let error):
                // エラー処理
                responsed(error)
            }
        }
    )
}

しかし、この 引数である to: のURLを Amazon S3 によって発行された Pre-Signed URL にしてみると、 403 Forbidden エラーになってしまいます。

何故ダメなのか

POSTではなく、PUTにする必要がある

Alamofire.upload メソッドは HTTPメソッド がデフォルトで POST になっていますが、これを PUT にしてあげる必要があるようです。

Content-Type 問題

Pre-Signed URLは、指定の仕方によりますが、アップロードする側の Content-Type を制限しています。

https://hogehoge.amazonaws.com/...&content-type=image/jpeg...

このようなURLの場合は、JPEG画像のみしか受け付けてくれません。 勝手気ままに色々なファイルがアップロードされるのも困るでしょうから、このように制限がかかるのでしょう。(上記の例はわかりやすく image/jpegと書いていますが、本来はURLエンコードされています)

そのためアップロードする側は、リクエストヘッダの Content-Typeimage/jpeg を指定してやり、 リクエストボディに含むデータもJPEGデータにしなくてはなりません。

改修してみる

上記のような問題があるため、回避策としてソースコードを以下のように変えてみました。
(結果として変えてみたところからハマってしまいました…汗)

typealias ResponsedHandler = (Error?) -> Void

func upload(image: UIImage, responsed: @escaping ResponsedHandler) {
    let data = image.jpegData(compressionQuality: 1)!

    Alamofire.upload(
        multipartFormData: { formData in
            formData.append(data, withName: "test", fileName: "test.jpeg", mimeType: "image/jpeg")
            formData.contentType = "image/jpeg"
        },
        to: "https://xxxxx.co.jp/image-upload/",
        method: .put,
        encodingCompletion: { encodingResult in
            switch encodingResult {
            case .success(let uploadRequest, _, _):
                uploadRequest.response { response in
                    if let error = response.error {
                        // エラー処理
                        responsed(error)
                        return
                    }
                    // 成功処理
                    responsed(nil)
                }
            case .failure(let error):
                // エラー処理
                responsed(error)
            }
        }
    )
}

前述の問題への対応として、ハイライトしている2箇所を変更してみました。 すると、結果としては 200/OK が返ってきました。

しかし、S3の中身を見てみると画像ファイルとして壊れた状態になってしまいました。

改修は何故うまくいかなかったのか

なぜ画像ファイルが壊れた状態になってしまったのか。 ここはちょっとハマりました。 しかし、ソースコードをよく見れば答えは自ずと出るのでした。

formData.append() で append しているものは?

uploadメソッドの encodingCompletion という引数は「エンコーディングが完了した」ときのコールバックです。

ここでいう「エンコーディング」というのは何だろうということですが、 そもそも Alamofire.upload というメソッドは、 MultipartFormData というもの (上記ソースでいうところの formData) が登場しているように、 HTMLのフォーム処理でよく使用する multipart/form-data + boundary を前提として作られているようです。

引数 multipartFormData のコールバックの中で formData.append() していくのですが、 Alamofire 内部のソースの中身を追っていくと、 データを追加していく分だけ boundary で区切っていっていることが分かりました。

区切ったものに対してエンコーディングの処理を加えていることから、 encodingCompletion でアップロードしようとしているデータには、 純粋な画像データだけではなく、それ以外の boundary 用の文字列なども含んでのエンコーディング結果ということになります。

余計なものを含んでいるゆえに、アップロードされた画像はファイルとしては不正なものになっていました。

再改修してみる

方針を変えてみよう

前述のように Alamofire.upload メソッドを利用することは、multipart/form-data + boundary 前提であるため、 たいていのアップロード処理には便利かもしれませんが、Amazon S3 Pre-Signed URL には向かないことが分かりました。

Alamofire.upload メソッドの中身を追っていくと、Alamofire.SessionManager というクラスのアップロード処理に行き当たります。 開発者が簡単に使えるようにその中身をマルチパートフォームデータ用にラップして提供されているということですね。

ということで、Alamofire.SessionManagerが行っていることから、マルチパートフォームデータ用の処理を取り除く形で実装してみることにしました。

スレッドの処理を先に作っておく

Alamofire.SessionManager.upload でも実装されていますが、ネットワーキングは非同期処理を行うため、メインスレッド外で処理を行います。 先にそのあたりを作っておきます。

typealias ResponsedHandler = (Error?) -> Void

func upload(image: UIImage, responsed: @escaping ResponsedHandler) {
    DispatchQueue.global(qos: .utility).async {
        self.uploadOnSubThread(image: image, responsed: responsed)
    }
}

// ここはサブスレッド内で行われます
func uploadOnSubThread(image: UIImage, responsed: @escaping ResponsedHandler) {
    let data = image.jpegData(compressionQuality: 1)!
    
    // 後述
}

// メインスレッドに戻して処理を終えたいときは、これを呼び出す
func finishOnMainThread(error: Error?, responsed: @escaping ResponsedHandler) {
    DispatchQueue.main.async {
        responsed(error)
    }
}

Alamofireのアップロードリクエストを作る

やりたいこと

最初のサンプルソースを振り返って抜粋してみると

    Alamofire.upload(
        (・・・中略・・・)
        encodingCompletion: { encodingResult in
            switch encodingResult {
            case .success(let uploadRequest, _, _):
                uploadRequest.response { response in
        (・・・以下略・・・)

encodingCompletion: の中の encodingResult が成功の時には uploadRequest が渡されてきます。

他の Alamofireの処理も同様であるように、このオブジェクトに対して response()responseJSON() などを呼び出すことでネットワーキング処理が走り出します。つまり、このオブジェクトが生成できればこっちのものというわけです。

やってみる

func uploadOnSubThread(image: UIImage, responsed: @escaping ResponsedHandler) {
    let data = image.jpegData(compressionQuality: 1)!
    
    do {
        let url = URL(string: "https://(Pre-Signed URL)")!
        let urlRequest = try URLRequest(
            url: url,
            method: .put,
            headers: ["Content-Type": "image/jpeg"]
        )
        let uploadRequest = Alamofire.upload(data, with: urlRequest)
        
        // 後述
        
    } catch {
        finishOnMainThread(error: error, responsed: responsed)
    }
}

Alamofire では URLRequest を拡張して、HTTPメソッドHTTPヘッダを引数にとって生成してくれるイニシャライザが用意してくれています。 ありがたいのでこちらを使わせていただこうと思います。 ただし、例外を投げるイニシャライザなので、do-catch で囲ってやる必要があります。

で、Alamofire.uploadは数々のオーバロードがあるのですが、実は

func upload(_ data: Data, with urlRequest: URLRequestConvertible) -> UploadRequest

こんなにもシンプルなものが存在しておりました。。(これが見つけられずにハマるなど・・・)

♪探してたもんはこんなシンプルなもんだったんだ

by Mr.Children

このメソッドを使ってアップロードリクエストを取得してやります。

あとは、ネットワーキング処理

というわけで、uploadRequest を使ってアップロードを実行してやります。 下のソースのハイライトした部分になります。

func uploadOnSubThread(image: UIImage, responsed: @escaping ResponsedHandler) {
    let data = image.jpegData(compressionQuality: 1)!
    
    do {
        let url = URL(string: "https://(Pre-Signed URL)")!
        let urlRequest = try URLRequest(
            url: url,
            method: .put,
            headers: ["Content-Type": "image/jpeg"]
        )
        let uploadRequest = Alamofire.upload(data, with: urlRequest)
        
        uploadRequest.response { [weak self] response in
            guard let self = self else { return }
            
            if let error = response.error {
                self.finishOnMainThread(error: error, responsed: responsed)
                return
            }
            
            self.finishOnMainThread(error: nil, responsed: responsed)
        }
    } catch {
        finishOnMainThread(error: error, responsed: responsed)
    }
}

これで S3 にはJPEG画像が壊れずにアップロードされました。

まとめ

「Alamofire 画像アップロード」で検索すると、最初に書いたようにマルチパートフォームデータ前提の方法が引っかかります。 自分もそれらを参考にしてみたのですが、ちょっとその方法に引っ張られてしまいました。

後から冷静に考えれば「そりゃうまくいかないよね」って思うところなのですが、ハマる時ってそういうものですね。。。

Amazon S3 Pre-Signed URL に iOSアプリ から画像アップロードするサンプルがなかなか引っかからなかったので、 この記事がどなたかの参考になれば幸いです。

ちなみに Androidの場合は こちらの記事 (少し古め) も参考ください

One more thing...

おまけです。

Alamofireのリクエストオブジェクトの debugDescription を呼び出すと、curlコマンドが取得できます

let uploadRequest = Alamofire.upload(data, with: urlRequest)
print(uploadRequest.debugDescription)

こんな感じ

$ curl -v \
    -X PUT \
    -H "User-Agent: hogehoge Alamofire/x.x.x" \
    -H "Content-Type: image/jpeg" \
    -H "Accept-Language: ja-JP;q=1.0" \
    -H "Accept-Encoding: gzip;q=1.0, compress;q=0.5" \
    "https://(Pre-Signed URL)"

で、ここに -Tオプションでローカルマシン内のJPEG画像のファイルパスを与えてやり、 ターミナルで叩いてみるとS3にアクセスされます。

Pre-Signed URL側の問題だったのか、アプリ側の問題だったのかを切り分ける確認のためには サクッとできる方法だったので、ご紹介しておきます。

以上です。