From e383d188f2d67c499fee2b6a46e9d6ef8283648a Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Mon, 16 Nov 2020 15:24:07 -0800 Subject: [PATCH 1/4] Add a way to encode multipart forms to a file --- Readme.md | 8 +- Sources/Osiris/MultipartFormEncoder.swift | 192 +++++++++++++++--- Sources/Osiris/RequestBuilder.swift | 2 +- .../MultipartFormEncoderTests.swift | 37 ++-- 4 files changed, 183 insertions(+), 56 deletions(-) diff --git a/Readme.md b/Readme.md index da08d3e..acc666d 100644 --- a/Readme.md +++ b/Readme.md @@ -14,20 +14,20 @@ Create an encoder and then add parts to it as needed: ```Swift let avatarData = UIImage(from: somewhere).jpegData(compressionQuality: 1) let encoder = MultipartFormEncoder() -let body = encoder.encode(parts: [ +let body = try encoder.encodeData(parts: [ .text(name: "email", text: "somebody@example.com"), .text(name: "password", text: "secret"), .binary(name: "avatar", type: "image/jpeg", data: avatarData, filename: "avatar.jpg"), ]) ``` -The entire form is encoded as `Data` in memory so you may not want to use this for more than a few megabytes at a time: +The form can be encoded as `Data` in memory, or to a file. There's a hard limit of 50 MB on encoding to memory but in practice you probably never want to go that high purely in memory. If you're adding any kind of image or video file then it's probably better to stream to a file. ```Swift -let body = encoder.encode(parts: [/* ... */]) +let body = try encoder.encodeFile(parts: [/* ... */]) var request = URLRequest(url: URL(string: "https://example.com/accounts")!) request.httpMethod = "POST" -request.httpBody = body.data +request.httpBodyStream = InputStream(url: body.url) request.addValue(body.contentType, forHTTPHeaderField: "Content-Type") request.addValue("\(body.contentLength)", forHTTPHeaderField: "Content-Length") // ... whatever you normally do with requests diff --git a/Sources/Osiris/MultipartFormEncoder.swift b/Sources/Osiris/MultipartFormEncoder.swift index 78bbd30..4744a5d 100644 --- a/Sources/Osiris/MultipartFormEncoder.swift +++ b/Sources/Osiris/MultipartFormEncoder.swift @@ -24,7 +24,7 @@ import Foundation extension MultipartFormEncoder { - struct Body { + struct BodyData { let contentType: String let data: Data @@ -33,62 +33,196 @@ extension MultipartFormEncoder { } } + struct BodyFile { + let contentType: String + let url: URL + let contentLength: Int64 + } + struct Part { enum Content { case text(String) - case binary(Data, type: String, filename: String) + case binaryData(Data, type: String, filename: String) + case binaryFile(URL, size: Int64, type: String, filename: String) } let name: String let content: Content - static func text(name: String, value: String) -> Part { + static func text(_ value: String, name: String) -> Part { Part(name: name, content: .text(value)) } - static func binary(name: String, data: Data, type: String, filename: String) -> Part { - Part(name: name, content: .binary(data, type: type, filename: filename)) + static func data(_ data: Data, name: String, type: String, filename: String) -> Part { + Part(name: name, content: .binaryData(data, type: type, filename: filename)) + } + + static func file(_ url: URL, name: String, type: String, filename: String? = nil) throws -> Part { + let attributes = try FileManager.default.attributesOfItem(atPath: url.path) + guard let size = attributes[.size] as? Int64 else { + throw Error.invalidFile(url) + } + return Part(name: name, content: .binaryFile(url, size: size, type: type, filename: filename ?? url.lastPathComponent)) } } } final class MultipartFormEncoder { + enum Error: Swift.Error { + case invalidFile(URL) + case invalidOutputFile(URL) + case streamError + case emptyData + case tooMuchDataForMemory + } + let boundary: String init(boundary: String? = nil) { - self.boundary = boundary ?? "LifeIsMadeOfSeconds-\(UUID().uuidString)" + self.boundary = boundary ?? "VidjoIsCool-\(UUID().uuidString)" } - func encode(parts: [Part]) -> Body { - var bodyData = Data() - for part in parts { - // Header - bodyData.append(Data("--\(boundary)\r\n".utf8)) - switch part.content { - case .text: - bodyData.append(Data("Content-Disposition: form-data; name=\"\(part.name)\"\r\n".utf8)) - - case let .binary(data, type, filename): - bodyData.append(Data("Content-Disposition: form-data; name=\"\(part.name)\"; filename=\"\(filename)\"\r\n".utf8)) - bodyData.append(Data("Content-Type: \(type)\r\n".utf8)) - bodyData.append(Data("Content-Length: \(data.count)\r\n".utf8)) - } - bodyData.append(Data("\r\n".utf8)) - - // Body + func encodeData(parts: [Part]) throws -> BodyData { + let totalSize: Int64 = parts.reduce(0, { size, part in switch part.content { case let .text(string): - bodyData.append(Data(string.utf8)) + return size + Int64(string.lengthOfBytes(using: .utf8)) - case let .binary(data, _, _): - bodyData.append(data) + case let .binaryData(data, _, _): + return size + Int64(data.count) + + case let .binaryFile(_, fileSize, _, _): + return size + fileSize } - bodyData.append(Data("\r\n".utf8)) + }) + guard totalSize < 50_000_000 else { + throw Error.tooMuchDataForMemory + } + + let stream = OutputStream(toMemory: ()) + stream.open() + + for part in parts { + try encodePart(part, to: stream) } // Footer - bodyData.append(Data("--\(boundary)--".utf8)) + try encode(string: "--\(boundary)--", to: stream) - return Body(contentType: "multipart/form-data; boundary=\"\(boundary)\"", data: bodyData) + stream.close() + + guard let bodyData = stream.property(forKey: .dataWrittenToMemoryStreamKey) as? Data else { + throw Error.streamError + } + return BodyData(contentType: "multipart/form-data; boundary=\"\(boundary)\"", data: bodyData) + } + + func encodeFile(parts: [Part]) throws -> BodyFile { + let fm = FileManager.default + let outputURL = tempFileURL() + guard let stream = OutputStream(url: outputURL, append: false) else { + _ = try? fm.removeItem(at: outputURL) + throw Error.invalidFile(outputURL) + } + stream.open() + + for part in parts { + try encodePart(part, to: stream) + } + + // Footer + try encode(string: "--\(boundary)--", to: stream) + + stream.close() + + let attributes = try FileManager.default.attributesOfItem(atPath: outputURL.path) + guard let size = attributes[.size] as? Int64 else { + throw Error.invalidOutputFile(outputURL) + } + let contentType = "multipart/form-data; boundary=\(boundary)" + return BodyFile(contentType: contentType, url: outputURL, contentLength: size) + } + + private func tempFileURL() -> URL { + let timestamp = Int(Date().timeIntervalSince1970) + let filename = "multipart-\(timestamp)-\(Int.random(in: 0 ... .max))" + let url = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent(filename) + return url + } + + private func encodePart(_ part: Part, to stream: OutputStream) throws { + // Header + try encode(string: "--\(boundary)\r\n", to: stream) + switch part.content { + case .text: + try encode(string: "Content-Disposition: form-data; name=\"\(part.name)\"\r\n", to: stream) + + case let .binaryData(data, type, filename): + try encode(string: "Content-Disposition: form-data; name=\"\(part.name)\"; filename=\"\(filename)\"\r\n", to: stream) + try encode(string: "Content-Type: \(type)\r\n", to: stream) + try encode(string: "Content-Length: \(data.count)\r\n", to: stream) + + case let .binaryFile(_, size, type, filename): + try encode(string: "Content-Disposition: form-data; name=\"\(part.name)\"; filename=\"\(filename)\"\r\n", to: stream) + try encode(string: "Content-Type: \(type)\r\n", to: stream) + try encode(string: "Content-Length: \(size)\r\n", to: stream) + } + try encode(string: "\r\n", to: stream) + + // Body + switch part.content { + case let .text(string): + try encode(string: string, to: stream) + + case let .binaryData(data, _, _): + try encode(data: data, to: stream) + + case let .binaryFile(url, _, _, _): + try encode(url: url, to: stream) + } + try encode(string: "\r\n", to: stream) + } + + private func encode(data: Data, to stream: OutputStream) throws { + guard !data.isEmpty else { + throw Error.emptyData + } + try data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in + let uint8Bytes = bytes.baseAddress!.bindMemory(to: UInt8.self, capacity: bytes.count) + let written = stream.write(uint8Bytes, maxLength: bytes.count) + if written < 0 { + throw Error.streamError + } + } + } + + private func encode(string: String, to stream: OutputStream) throws { + try encode(data: Data(string.utf8), to: stream) + } + + private func encode(url: URL, to stream: OutputStream) throws { + guard let inStream = InputStream(url: url) else { + throw Error.streamError + } + let bufferSize = 128 * 1024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + inStream.open() + + defer { + buffer.deallocate() + inStream.close() + } + + while inStream.hasBytesAvailable { + let bytesRead = inStream.read(buffer, maxLength: bufferSize) + guard bytesRead > 0 else { + throw Error.streamError + } + + let bytesWritten = stream.write(buffer, maxLength: bytesRead) + if bytesWritten < 0 { + throw Error.streamError + } + } } } diff --git a/Sources/Osiris/RequestBuilder.swift b/Sources/Osiris/RequestBuilder.swift index 81c508e..2a8eda1 100644 --- a/Sources/Osiris/RequestBuilder.swift +++ b/Sources/Osiris/RequestBuilder.swift @@ -37,7 +37,7 @@ final class RequestBuilder { case .multipart: let encoder = MultipartFormEncoder() - let body = encoder.encode(parts: request.parts) + let body = try encoder.encodeData(parts: request.parts) result.addValue(body.contentType, forHTTPHeaderField: "Content-Type") result.addValue("\(body.contentLength)", forHTTPHeaderField: "Content-Length") result.httpBody = body.data diff --git a/Tests/OsirisTests/MultipartFormEncoderTests.swift b/Tests/OsirisTests/MultipartFormEncoderTests.swift index 1c935e1..795e413 100644 --- a/Tests/OsirisTests/MultipartFormEncoderTests.swift +++ b/Tests/OsirisTests/MultipartFormEncoderTests.swift @@ -1,6 +1,6 @@ // // MultipartFormEncoderTests.swift -// VidjoTests +// OsirisTests // // Created by Sami Samhuri on 2020-10-20. // Copyright © 2020 Guru Logic Inc. All rights reserved. @@ -9,7 +9,7 @@ @testable import Osiris import XCTest -func AssertBodyEqual(_ expression1: @autoclosure () throws -> Data, _ expression2: @autoclosure () throws -> String, _ message: @autoclosure () -> String? = nil, file: StaticString = #filePath, line: UInt = #line) { +func AssertStringDataEqual(_ expression1: @autoclosure () throws -> Data, _ expression2: @autoclosure () throws -> String, _ message: @autoclosure () -> String? = nil, file: StaticString = #filePath, line: UInt = #line) { let data1 = try! expression1() let string1 = String(bytes: data1, encoding: .utf8)! let string2 = try! expression2() @@ -30,14 +30,14 @@ class MultipartFormEncoderTests: XCTestCase { } func testEncodeNothing() throws { - let body = subject.encode(parts: []) + let body = try subject.encodeData(parts: []) XCTAssertEqual(body.contentType, "multipart/form-data; boundary=\"SuperAwesomeBoundary\"") - AssertBodyEqual(body.data, "--SuperAwesomeBoundary--") + AssertStringDataEqual(body.data, "--SuperAwesomeBoundary--") } func testEncodeText() throws { - AssertBodyEqual( - subject.encode(parts: [.text(name: "name", value: "Tina")]).data, + AssertStringDataEqual( + try subject.encodeData(parts: [.text("Tina", name: "name")]).data, [ "--SuperAwesomeBoundary", "Content-Disposition: form-data; name=\"name\"", @@ -50,9 +50,9 @@ class MultipartFormEncoderTests: XCTestCase { func testEncodeData() throws { let data = Data("phony video data".utf8) - AssertBodyEqual( - subject.encode(parts: [ - .binary(name: "video", data: data, type: "video/mp4", filename: "LiesSex&VideoTape.mp4"), + AssertStringDataEqual( + try subject.encodeData(parts: [ + .data(data, name: "video", type: "video/mp4", filename: "LiesSex&VideoTape.mp4"), ]).data, [ "--SuperAwesomeBoundary", @@ -69,12 +69,12 @@ class MultipartFormEncoderTests: XCTestCase { func testEncodeEverything() throws { let imageData = Data("phony image data".utf8) let videoData = Data("phony video data".utf8) - AssertBodyEqual( - subject.encode(parts: [ - .text(name: "name", value: "Queso"), - .binary(name: "image", data: imageData, type: "image/jpeg", filename: "feltcute.jpg"), - .text(name: "spot", value: "top of the bbq"), - .binary(name: "video", data: videoData, type: "video/mp4", filename: "LiesSex&VideoTape.mp4"), + AssertStringDataEqual( + try subject.encodeData(parts: [ + .text("Queso", name: "name"), + .data(imageData, name: "image", type: "image/jpeg", filename: "feltcute.jpg"), + .text("top of the bbq", name: "spot"), + .data(videoData, name: "video", type: "video/mp4", filename: "LiesSex&VideoTape.mp4"), ]).data, [ "--SuperAwesomeBoundary", @@ -105,11 +105,4 @@ class MultipartFormEncoderTests: XCTestCase { ].joined(separator: "\r\n") ) } - - static var allTests = [ - ("testEncodeNothing", testEncodeNothing), - ("testEncodeText", testEncodeText), - ("testEncodeData", testEncodeData), - ("testEncodeEverything", testEncodeEverything), - ] } From 3f17ecb3af0d703dc94ac40c378f2effdf0d8879 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Thu, 7 Jan 2021 13:42:36 -0800 Subject: [PATCH 2/4] Change multipart form boundary string --- Sources/Osiris/MultipartFormEncoder.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/Osiris/MultipartFormEncoder.swift b/Sources/Osiris/MultipartFormEncoder.swift index 4744a5d..f832a32 100644 --- a/Sources/Osiris/MultipartFormEncoder.swift +++ b/Sources/Osiris/MultipartFormEncoder.swift @@ -79,7 +79,7 @@ final class MultipartFormEncoder { let boundary: String init(boundary: String? = nil) { - self.boundary = boundary ?? "VidjoIsCool-\(UUID().uuidString)" + self.boundary = boundary ?? "Osiris-\(UUID().uuidString)" } func encodeData(parts: [Part]) throws -> BodyData { From a0789e95daff7ef4176fb81d3ecbe66375a0295e Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Thu, 7 Jan 2021 13:46:35 -0800 Subject: [PATCH 3/4] Remove property that was only used once --- Tests/OsirisTests/MultipartFormEncoderTests.swift | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Tests/OsirisTests/MultipartFormEncoderTests.swift b/Tests/OsirisTests/MultipartFormEncoderTests.swift index 795e413..25a6ab5 100644 --- a/Tests/OsirisTests/MultipartFormEncoderTests.swift +++ b/Tests/OsirisTests/MultipartFormEncoderTests.swift @@ -18,11 +18,10 @@ func AssertStringDataEqual(_ expression1: @autoclosure () throws -> Data, _ expr } class MultipartFormEncoderTests: XCTestCase { - var boundary = "SuperAwesomeBoundary" var subject: MultipartFormEncoder! override func setUpWithError() throws { - subject = MultipartFormEncoder(boundary: boundary) + subject = MultipartFormEncoder(boundary: "SuperAwesomeBoundary") } override func tearDownWithError() throws { From bb21568d17557d272ab1ad4f16b0379c4ee03fa6 Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Wed, 3 Feb 2021 16:07:03 -0800 Subject: [PATCH 4/4] Fix encoding empty fields and flesh out tests --- Package.swift | 6 +- Sources/Osiris/MultipartFormEncoder.swift | 16 ++-- .../MultipartFormEncoderTests.swift | 86 ++++++++++++++++-- Tests/OsirisTests/Resources/lorem.txt | 9 ++ Tests/OsirisTests/Resources/notbad.jpg | Bin 0 -> 22680 bytes 5 files changed, 98 insertions(+), 19 deletions(-) create mode 100644 Tests/OsirisTests/Resources/lorem.txt create mode 100644 Tests/OsirisTests/Resources/notbad.jpg diff --git a/Package.swift b/Package.swift index f96fe67..6cd2d46 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,10 @@ let package = Package( dependencies: []), .testTarget( name: "OsirisTests", - dependencies: ["Osiris"]), + dependencies: ["Osiris"], + resources: [ + .copy("Resources/lorem.txt"), + .copy("Resources/notbad.jpg"), + ]), ] ) diff --git a/Sources/Osiris/MultipartFormEncoder.swift b/Sources/Osiris/MultipartFormEncoder.swift index f832a32..caf9e5b 100644 --- a/Sources/Osiris/MultipartFormEncoder.swift +++ b/Sources/Osiris/MultipartFormEncoder.swift @@ -39,8 +39,8 @@ extension MultipartFormEncoder { let contentLength: Int64 } - struct Part { - enum Content { + struct Part: Equatable { + enum Content: Equatable { case text(String) case binaryData(Data, type: String, filename: String) case binaryFile(URL, size: Int64, type: String, filename: String) @@ -72,7 +72,6 @@ final class MultipartFormEncoder { case invalidFile(URL) case invalidOutputFile(URL) case streamError - case emptyData case tooMuchDataForMemory } @@ -184,13 +183,12 @@ final class MultipartFormEncoder { } private func encode(data: Data, to stream: OutputStream) throws { - guard !data.isEmpty else { - throw Error.emptyData - } + guard !data.isEmpty else { return } + try data.withUnsafeBytes { (bytes: UnsafeRawBufferPointer) in let uint8Bytes = bytes.baseAddress!.bindMemory(to: UInt8.self, capacity: bytes.count) let written = stream.write(uint8Bytes, maxLength: bytes.count) - if written < 0 { + if written != bytes.count { throw Error.streamError } } @@ -216,11 +214,11 @@ final class MultipartFormEncoder { while inStream.hasBytesAvailable { let bytesRead = inStream.read(buffer, maxLength: bufferSize) guard bytesRead > 0 else { - throw Error.streamError + break } let bytesWritten = stream.write(buffer, maxLength: bytesRead) - if bytesWritten < 0 { + if bytesWritten != bytesRead { throw Error.streamError } } diff --git a/Tests/OsirisTests/MultipartFormEncoderTests.swift b/Tests/OsirisTests/MultipartFormEncoderTests.swift index 25a6ab5..4f7bf67 100644 --- a/Tests/OsirisTests/MultipartFormEncoderTests.swift +++ b/Tests/OsirisTests/MultipartFormEncoderTests.swift @@ -9,10 +9,10 @@ @testable import Osiris import XCTest -func AssertStringDataEqual(_ expression1: @autoclosure () throws -> Data, _ expression2: @autoclosure () throws -> String, _ message: @autoclosure () -> String? = nil, file: StaticString = #filePath, line: UInt = #line) { - let data1 = try! expression1() +func AssertStringDataEqual(_ expression1: @autoclosure () throws -> Data, _ expression2: @autoclosure () throws -> String, _ message: @autoclosure () -> String? = nil, file: StaticString = #filePath, line: UInt = #line) throws { + let data1 = try expression1() let string1 = String(bytes: data1, encoding: .utf8)! - let string2 = try! expression2() + let string2 = try expression2() let message = message() ?? "\"\(string1)\" is not equal to \"\(string2)\"" XCTAssertEqual(data1, Data(string2.utf8), message, file: file, line: line) } @@ -28,14 +28,40 @@ class MultipartFormEncoderTests: XCTestCase { subject = nil } + func testConstructTextPart() { + let part = MultipartFormEncoder.Part.text("value", name: "name") + XCTAssertEqual(part, MultipartFormEncoder.Part(name: "name", content: .text("value"))) + } + + func testConstructDataPart() { + let data = Data("value".utf8) + let part = MultipartFormEncoder.Part.data(data, name: "name", type: "text/plain", filename: "something.txt") + let expected = MultipartFormEncoder.Part(name: "name", content: .binaryData(data, type: "text/plain", filename: "something.txt")) + XCTAssertEqual(part, expected) + } + + func testConstructBinaryFilePart() throws { + let url = Bundle.module.url(forResource: "notbad", withExtension: "jpg")! + let part = try MultipartFormEncoder.Part.file(url, name: "name", type: "image/jpeg") + let expected = MultipartFormEncoder.Part(name: "name", content: .binaryFile(url, size: 22_680, type: "image/jpeg", filename: "notbad.jpg")) + XCTAssertEqual(part, expected) + } + + func testConstructInvalidFilePart() { + let url = Bundle.module.url(forResource: "notbad", withExtension: "jpg")! + .appendingPathComponent("busted") + XCTAssertThrowsError(try MultipartFormEncoder.Part.file(url, name: "name", type: "image/jpeg")) + } + func testEncodeNothing() throws { let body = try subject.encodeData(parts: []) XCTAssertEqual(body.contentType, "multipart/form-data; boundary=\"SuperAwesomeBoundary\"") - AssertStringDataEqual(body.data, "--SuperAwesomeBoundary--") + XCTAssertEqual(body.contentLength, 24) + try AssertStringDataEqual(body.data, "--SuperAwesomeBoundary--") } func testEncodeText() throws { - AssertStringDataEqual( + try AssertStringDataEqual( try subject.encodeData(parts: [.text("Tina", name: "name")]).data, [ "--SuperAwesomeBoundary", @@ -47,9 +73,22 @@ class MultipartFormEncoderTests: XCTestCase { ) } + func testEncodeEmptyText() throws { + try AssertStringDataEqual( + try subject.encodeData(parts: [.text("", name: "name")]).data, + [ + "--SuperAwesomeBoundary", + "Content-Disposition: form-data; name=\"name\"", + "", + "", + "--SuperAwesomeBoundary--", + ].joined(separator: "\r\n") + ) + } + func testEncodeData() throws { let data = Data("phony video data".utf8) - AssertStringDataEqual( + try AssertStringDataEqual( try subject.encodeData(parts: [ .data(data, name: "video", type: "video/mp4", filename: "LiesSex&VideoTape.mp4"), ]).data, @@ -60,20 +99,42 @@ class MultipartFormEncoderTests: XCTestCase { "Content-Length: 16", "", "phony video data", - "--SuperAwesomeBoundary--" + "--SuperAwesomeBoundary--", ].joined(separator: "\r\n") ) } + func testEncodeFile() throws { + let url = Bundle.module.url(forResource: "lorem", withExtension: "txt")! + let body = try subject.encodeFile(parts: [ + .file(url, name: "lorem", type: "text/plain"), + ]) + XCTAssertEqual(body.contentType, "multipart/form-data; boundary=SuperAwesomeBoundary") + XCTAssertEqual(body.contentLength, 3586) + XCTAssertEqual(try FileManager.default.attributesOfItem(atPath: body.url.path)[.size] as! UInt64, 3586) + XCTAssertEqual(try String(contentsOf: body.url), [ + "--SuperAwesomeBoundary", + "Content-Disposition: form-data; name=\"lorem\"; filename=\"lorem.txt\"", + "Content-Type: text/plain", + "Content-Length: 3418", + "", + try! String(contentsOf: url), + + "--SuperAwesomeBoundary--", + ].joined(separator: "\r\n")) + } + func testEncodeEverything() throws { let imageData = Data("phony image data".utf8) let videoData = Data("phony video data".utf8) - AssertStringDataEqual( + let url = Bundle.module.url(forResource: "lorem", withExtension: "txt")! + try AssertStringDataEqual( try subject.encodeData(parts: [ .text("Queso", name: "name"), .data(imageData, name: "image", type: "image/jpeg", filename: "feltcute.jpg"), .text("top of the bbq", name: "spot"), .data(videoData, name: "video", type: "video/mp4", filename: "LiesSex&VideoTape.mp4"), + .file(url, name: "lorem", type: "text/plain"), ]).data, [ "--SuperAwesomeBoundary", @@ -100,7 +161,14 @@ class MultipartFormEncoderTests: XCTestCase { "", "phony video data", - "--SuperAwesomeBoundary--" + "--SuperAwesomeBoundary", + "Content-Disposition: form-data; name=\"lorem\"; filename=\"lorem.txt\"", + "Content-Type: text/plain", + "Content-Length: 3418", + "", + try! String(contentsOf: url), + + "--SuperAwesomeBoundary--", ].joined(separator: "\r\n") ) } diff --git a/Tests/OsirisTests/Resources/lorem.txt b/Tests/OsirisTests/Resources/lorem.txt new file mode 100644 index 0000000..5751953 --- /dev/null +++ b/Tests/OsirisTests/Resources/lorem.txt @@ -0,0 +1,9 @@ +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce vulputate massa eget turpis auctor, bibendum tincidunt purus tempus. Aliquam id risus quis est porttitor mollis a vitae mauris. Nam magna odio, egestas ut viverra vitae, pretium vel sem. Fusce imperdiet enim non orci placerat, quis eleifend nunc malesuada. Suspendisse scelerisque lacinia volutpat. Duis efficitur ut dolor at iaculis. Donec consequat eros nec augue pellentesque, ut dapibus mauris pharetra. Maecenas eget pharetra justo. Proin bibendum a lectus sed rutrum. In faucibus dolor commodo, ornare lacus ac, vulputate quam. + +Nam mollis tortor et elit aliquam suscipit. Maecenas eget erat et justo vehicula scelerisque ac quis erat. Nullam placerat felis sit amet nibh ullamcorper, at volutpat est congue. Fusce in pharetra eros. Phasellus laoreet arcu a libero imperdiet commodo. Suspendisse facilisis, libero in pellentesque aliquam, enim libero pulvinar nibh, ac aliquam nibh ante nec velit. Proin condimentum elit ex, ut dictum tellus volutpat ac. Nunc fermentum nisl id est gravida, nec pellentesque libero blandit. Etiam vitae pellentesque eros. Pellentesque consequat urna ligula, id imperdiet lorem efficitur non. Cras at placerat nulla, vel luctus odio. Phasellus a posuere velit. Ut in magna in augue sagittis elementum ac vitae turpis. Quisque ornare velit nec cursus dignissim. + +In hac habitasse platea dictumst. Morbi lectus leo, rhoncus non scelerisque imperdiet, porttitor non neque. In vehicula posuere convallis. Nullam eu varius nisi. Ut euismod egestas mi. Phasellus eget urna egestas, suscipit ex sed, ultricies leo. Suspendisse consequat in massa quis ullamcorper. Sed eleifend nulla tellus, vitae posuere augue vulputate ac. Curabitur iaculis vulputate dui ornare laoreet. + +Maecenas ac eros cursus, ultricies diam vitae, sollicitudin metus. Suspendisse quis mattis ex. Ut vel ullamcorper sapien. Sed in mauris sed ante imperdiet consectetur vitae sed dui. Aliquam in tempus mi. Phasellus vitae augue volutpat, sodales augue sit amet, pellentesque ante. Integer nec lacinia nunc. Morbi magna eros, placerat vitae eros sed, varius imperdiet sem. Vestibulum ultrices finibus ultrices. Cras condimentum vestibulum nisi mattis posuere. Sed sed nisl nec odio posuere ullamcorper sit amet sed quam. Ut id suscipit turpis. Aliquam et sem quis ligula bibendum aliquam. Praesent efficitur vel nibh in commodo. Donec porta, nunc at aliquam consectetur, ante diam eleifend ante, at elementum est neque in ante. Lorem ipsum dolor sit amet, consectetur adipiscing elit. + +Donec tincidunt massa urna, in ultrices arcu vulputate in. Quisque placerat nisi ipsum, eget aliquet enim dictum vel. Ut sed nulla odio. Etiam in vulputate dui. Nunc elementum sodales neque, quis tristique massa feugiat eget. Donec ultrices erat facilisis ipsum volutpat tincidunt. Etiam efficitur luctus ipsum eu convallis. Ut elementum, nisl id aliquet ultrices, nunc quam gravida lectus, non ullamcorper elit justo non lorem. Sed tincidunt efficitur lectus ac molestie. Sed vel tellus eu tortor finibus fermentum a elementum mi. Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vivamus suscipit sed tortor tempor congue. Proin molestie bibendum metus. Nam ullamcorper, nunc placerat commodo egestas, ante odio faucibus nisi, vel vulputate augue nulla at ex. Duis scelerisque interdum felis, sed fringilla urna. Mauris lacinia felis gravida egestas dictum. diff --git a/Tests/OsirisTests/Resources/notbad.jpg b/Tests/OsirisTests/Resources/notbad.jpg new file mode 100644 index 0000000000000000000000000000000000000000..dfb50dbb515ad6dc1bbdecdd78ef95f8a26cce2f GIT binary patch literal 22680 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 Wz=Eu-11kcg0tg$F08vF00sq;L5BUoK literal 0 HcmV?d00001