takeda_san’s blog

KotlinとVRを頑張っていく方向。

Swiftでmultipart/form-dataのパーサを作ったよ

きっかけ

iOSアプリ内でWebサーバを立ち上げて、multipart/form-dataなファイルアップロードを実装しようとしたんだけど
使ってるWebサーバアプリにちょうどいい感じのパーサがなくて、自分で書きました。
そもそもスマホってクライアント側になることあれど、サーバ側になることはあんまりないから情報が少ないのよね…

コード

Swift 5のつもりで書いてます。

考え方

testと書かれたfile.txtをアップロードすると
base64エンコードして送信されてくるので、デコードしたあとはこんな感じのデータ。

Content-Type: multipart/form-data; boundary=----WebKitFormBoundary5aJB0dB2B5PV7h8H

------WebKitFormBoundary5aJB0dB2B5PV7h8H
Content-Disposition: form-data; name="userfile"; filename="file.txt"
Content-Type: text/plain

test
------WebKitFormBoundary5aJB0dB2B5PV7h8H
Content-Disposition: form-data; name="submit"

Upload
------WebKitFormBoundary5aJB0dB2B5PV7h8H--

boundary=のところの ----WebKitFormBoundary5aJB0dB2B5PV7h8H 文字列がセパレータで複数ファイルをアップロードされたときはこれでパースする。

バイナリファイルの苦悩

テキストファイルの処理は非常に簡単。
丸ごとbase64デコードして、セパレータでパースするだけ。

ところがバイナリファイルをアップロードするときはそうはいかない。
Data(base64Encoded: encodedString!)でデコードするとnilが返ってきてしまう。
恐らく、バイナリファイルのデータ部がデコードできなくて失敗としてnilが返ってきているのだと思う。
全体でデコードできないとなると、Data型で扱っての先頭から走査して順次、デコードしていく必要が出てくる。

なのでこんな感じでData型のまんま、boundaryでパースする処理を入れてファイルごとに分割して

extension Data {
    func splitByData(boundary: String) -> [Data]? {
        guard let boundaryData = boundary.data(using: .utf8) else {
            return nil
        }
        
        // With a line feed
        let withLinefeed = boundary + LINEFEED
        guard let withLinefeedData = withLinefeed.data(using: .utf8) else {
            return nil
        }
        
        var chunks: [Data] = []
        var pos = startIndex
        
        while let r = self[pos...].range(of: withLinefeedData) {
            if r.lowerBound > pos {
                chunks.append(self[pos..<r.lowerBound])
            }
            
            pos = r.upperBound
        }
        
        if pos < endIndex {
            if let endRange = self[pos...].range(of: boundaryData) {
                chunks.append(self[pos..<endRange.lowerBound])
            }
        }
        return chunks
    }
}

ファイルごとのデータを一行ごとに丁寧に処理していくことになる。

    private static func parseMultipartBodyData(bodyData: Data) -> MultipartBodyFile? {
        // Line Feed
        let lineFeed = LINEFEED.data(using: .utf8)
        
        // Split a first line
        guard let firstLineRange = bodyData.range(of: lineFeed!) else {
            return nil
        }
        let firstLine = bodyData[bodyData.startIndex..<firstLineRange.lowerBound]
        
        // Split a second line
        guard let secondLineRange = bodyData[firstLineRange.upperBound...].range(of: lineFeed!) else {
            return nil
        }
        let secondLine = bodyData[firstLineRange.upperBound..<secondLineRange.lowerBound]
        
        // Get a third line range
        guard let thirdLineRange = bodyData[secondLineRange.upperBound...].range(of: lineFeed!) else {
            return nil
        }
        
        guard let fileName = parseFileName(firstLine: firstLine) else {
            return nil
        }
        guard let contentType = parseContentType(secondLine: secondLine) else {
            return nil
        }
        
        return MultipartBodyFile(contentType: contentType, fileName: fileName, data: bodyData[thirdLineRange.upperBound...])
    }

普段何気なく使っているWebフレームワークの高機能さとありがたさが身に沁みます。

余談

Webサーバ自体はこのTelegraphがサクッとつかえて、高機能なのでありがたく使っております。

GitHub - Building42/Telegraph: Secure Web Server for iOS, tvOS and macOS