Swiftでmultipart/form-dataのリクエストを実装してみる

最近、埼玉県飯能市にあるムーミンのテーマパーク、ムーミンバレーパークに行って来ました。ムーミンの原作の絵が怖すぎて、作者であるトーベ・ヤンソンの人生が気になります🙄💭

さて、最近、複数の画像をmultipart/form-dataの形式でアップロードしたいと思う事があり、知見もあまりなかったので、学習も兼ねてライブラリを使わずに自前で実装してみました。

実装コードはこちらに載せているので、興味のある方は覗いてみてください💁‍♀️

multipart/form-data のリクエストの中身

一言で説明すると、HTTPヘッダには、Content-Type(multipart/form-data)とバウンダリ文字列を指定し、HTTPボディにはバウンダリ文字列で区切りながら複数のデータを指定する事でデータ送信ができました。
バウンダリ文字列とは複数データの境界を示す文字列で、リクエスト毎に変わります。
今回実装した仕組みでは、以下のような情報が生成されました。

HTTPヘッダ

Content-Type: multipart/form-data; boundary=-----------------------------6F4794EE-A72F-44C9-AF1C-8DADBF5C35E0

boundary=-----------------------------6F4794EE-A72F-44C9-AF1C-8DADBF5C35E0バウンダリ文字列ですね。今回は、リクエスト毎にUUIDから生成するようにしました。

HTTPボディ

-----------------------------6F4794EE-A72F-44C9-AF1C-8DADBF5C35E0
Content-Disposition: form-data; name="id";

12345
-----------------------------6F4794EE-A72F-44C9-AF1C-8DADBF5C35E0
Content-Disposition: form-data; name="file"; filename="image.jpg"
Content-Type: image/jpeg

{画像データ}
-----------------------------6F4794EE-A72F-44C9-AF1C-8DADBF5C35E0--

HTTPヘッダで指定したものと同じバウンダリ文字列で情報を区切ります。
また、文字列を送ると画像データを送る場合とで、Content-Disposition Content-Typeに指定する内容が変わっていますが、これはデータの形式によって適宜変える必要があります。

実装

実装コードはこちらにも載せています。

import Foundation
import UIKit

enum HTTPMethod: String {
    case get = "GET"
    case post = "POST"
    case put = "PUT"
    case delete = "DELETE"
}

enum RequestError: Error {
    case invalidRequest
}

struct APIRequest {
    var method: HTTPMethod
    var path: String
    var parameters: [String: Any]
}

final class APIClient {
    let session: URLSession = {
        let configuration = URLSessionConfiguration.default
        let session = URLSession(configuration: configuration)
        return session
    }()
    private let baseURL = URL(string: "https://XXXX.com")!
        
    func buildMultipartFormURLRequest(from request: APIRequest) throws -> URLRequest {
        guard case .post = request.method else { throw RequestError.invalidRequest }
        //今回はUUIDを使っていますが、他の方法でも良さそうです。arc4random()使っているパターンも見かけた。
        let uniqueId = UUID().uuidString
        let boundary = "---------------------------\(uniqueId)"
        let url = baseURL.appendingPathComponent(request.path)

        var urlRequest = URLRequest(url: url)
        urlRequest.httpMethod = request.method.rawValue
        urlRequest.url = url
        urlRequest.httpBody = try multiData(withJSONObject: request.parameters, boundary: boundary, uniqueId: uniqueId)
        urlRequest.addValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")

        return urlRequest
    }
        
    private func multiData(withJSONObject objects: [String: Any], boundary: String, uniqueId: String) throws -> Data {
        let boundaryText = "--\(boundary)\r\n"
        var data = Data()

        for (key, value) in objects {
            switch value {
            case let image as UIImage:
                guard let imageData = image.jpegData(compressionQuality: 1.0) else {
                    throw RequestError.invalidRequest
                }
                data.append(boundaryText.data(using: .utf8)!)
                data.append("Content-Disposition: form-data; name=\"\(key)\"; filename=\"\(uniqueId).jpg\"\r\n".data(using: .utf8)!)
                data.append("Content-Type: image/jpeg\r\n".data(using: .utf8)!)
                data.append("\r\n".data(using: .utf8)!)
                data.append(imageData)
                data.append("\r\n".data(using: .utf8)!)
            case let string as String:
                data.append(boundaryText.data(using: .utf8)!)
                data.append("Content-Disposition: form-data; name=\"\(key)\"\r\n".data(using: .utf8)!)
                data.append("\r\n".data(using: .utf8)!)
                data.append(string.data(using: .utf8)!)
                data.append("\r\n".data(using: .utf8)!)
            case let int as Int:
                data.append(boundaryText.data(using: .utf8)!)
                data.append("Content-Disposition: form-data; name=\"\(key)\"\r\n".data(using: .utf8)!)
                data.append("\r\n".data(using: .utf8)!)
                data.append(String(int).data(using: .utf8)!)
                data.append("\r\n".data(using: .utf8)!)
            default:
                //今回はUIImage, String, Intしか受け付けないようにしたけど、必要に応じて追加すると良さそう
                throw RequestError.invalidRequest
            }
        }
        //これを書き忘れるうっかりミスをしてしまった..💦
        data.append("--\(boundary)--\r\n".data(using: .utf8)!)

        return data
    }
}

実際に使う時には

こんな感じで呼び出す想定です。

func sendMultipartFormRequest() {
    guard let image = UIImage(named: "image") else {
        fatalError("Invalid imageName")
    }

    let apiClient = APIClient()
    let request = APIRequest(
        method: .post,
        path: "/files",
        parameters: [
            "file": image,
            "id": 1234,
    ])

    do {
        let urlRequest = try apiClient.buildMultipartFormURLRequest(from: request)
        let task = apiClient.session.dataTask(with: urlRequest) { data, response, error in
            //成功した時の処理
        }
        task.resume()
    } catch {
        //エラーハンドリング
    }
}

勝手に難しそうなイメージを持っていたけれど、意外とシンプルでした!良かった良かった。

参考文献