Merge pull request #1 from samsonjs/feature/swiftpm-and-multipart-tests

Convert to Swift package and rewrite the multipart encoder
This commit is contained in:
Sami Samhuri 2020-10-20 20:29:10 -07:00 committed by GitHub
commit 0bcf8fe801
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 294 additions and 305 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.swiftpm

View file

@ -1,4 +1,4 @@
Copyright © 2017 1 Second Everyday. All rights reserved. http://1se.co
Copyright © 2017 1 Second Everyday. All rights reserved. https://1se.co
Released under the terms of the MIT license:

View file

@ -1,263 +0,0 @@
//
// Created by Sami Samhuri on 2017-07-28.
// Copyright © 2017 1 Second Everyday. All rights reserved.
// Released under the terms of the MIT license.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify,
// merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice shall be included in all copies
// or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
// CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
// OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//
import Foundation
struct MultipartEncodingInMemory {
let contentType: String
let contentLength: Int64
let body: Data
}
struct MultipartEncodingOnDisk {
let contentType: String
let contentLength: Int64
let bodyFileURL: URL
}
enum MultipartFormEncodingError: Error {
case invalidText(String)
case invalidPath(String)
case invalidPart(MultipartFormEncoder.Part)
case internalError
case streamError
}
final class MultipartFormEncoder {
struct Part {
let data: Data?
let dataFileURL: URL?
let encoding: String
let filename: String?
let length: Int64
let name: String
let type: String
static func text(name: String, text: String) -> Part? {
guard let data = text.data(using: .utf8) else {
return nil
}
return Part(name: name, type: "text/plain; charset=utf-8", encoding: "8bit", data: data)
}
init(name: String, type: String, encoding: String, data: Data, filename: String? = nil) {
self.dataFileURL = nil
self.name = name
self.type = type
self.encoding = encoding
self.data = data
self.filename = filename
self.length = Int64(data.count)
}
init(name: String, type: String, encoding: String, dataFileURL: URL, filename: String? = nil) {
self.data = nil
self.name = name
self.type = type
self.encoding = encoding
self.dataFileURL = dataFileURL
self.filename = filename
self.length = FileManager.default.sizeOfFile(at: dataFileURL)
}
var isBinary: Bool {
return encoding == "binary"
}
}
let boundary: String
private var parts: [Part] = []
private var contentType: String {
return "multipart/form-data; boundary=\"\(boundary)\""
}
private static let boundaryPrefix = "LifeIsMadeOfSeconds"
class func generateBoundary() -> String {
let timestamp = Int(Date().timeIntervalSince1970)
return "\(boundaryPrefix)-\(timestamp)"
}
init(boundary: String? = nil) {
self.boundary = boundary ?? MultipartFormEncoder.generateBoundary()
}
func addPart(_ part: Part) {
assert(part.data != nil || part.dataFileURL != nil)
parts.append(part)
}
func addText(name: String, text: String, filename: String? = nil) throws {
guard let data = text.data(using: .utf8) else {
throw MultipartFormEncodingError.invalidText(text)
}
let type = "text/plain; charset=utf-8"
let part = Part(name: name, type: type, encoding: "8bit", data: data, filename: filename)
parts.append(part)
}
func addBinary(name: String, contentType: String, data: Data, filename: String? = nil) {
let part = Part(name: name, type: contentType, encoding: "binary", data: data, filename: filename)
parts.append(part)
}
func addBinary(name: String, contentType: String, fileURL: URL, filename: String? = nil) {
assert(FileManager.default.fileExists(atPath: fileURL.path))
let part = Part(name: name, type: contentType, encoding: "binary", dataFileURL: fileURL, filename: filename)
parts.append(part)
}
func encodeToMemory() throws -> MultipartEncodingInMemory {
let stream = OutputStream.toMemory()
stream.open()
do {
try encode(to: stream)
stream.close()
guard let data = stream.property(forKey: .dataWrittenToMemoryStreamKey) as? Data else {
throw MultipartFormEncodingError.internalError
}
return MultipartEncodingInMemory(contentType: contentType, contentLength: Int64(data.count), body: data)
}
catch {
stream.close()
throw error
}
}
func encodeToDisk(path: String) throws -> MultipartEncodingOnDisk {
guard let stream = OutputStream(toFileAtPath: path, append: false) else {
throw MultipartFormEncodingError.invalidPath(path)
}
stream.open()
do {
try encode(to: stream)
stream.close()
let fileURL = URL(fileURLWithPath: path)
let length = FileManager.default.sizeOfFile(at: fileURL)
return MultipartEncodingOnDisk(contentType: contentType, contentLength: length, bodyFileURL: fileURL)
}
catch {
stream.close()
_ = try? FileManager.default.removeItem(atPath: path)
throw error
}
}
// MARK: - Private methods
private func encode(to stream: OutputStream) throws {
for part in parts {
try writeHeader(part, to: stream)
try writeBody(part, to: stream)
try writeFooter(part, to: stream)
}
}
private let lineEnd = "\r\n".data(using: .utf8)!
private func writeHeader(_ part: Part, to stream: OutputStream) throws {
let disposition: String
if let filename = part.filename {
disposition = "Content-Disposition: form-data; name=\"\(part.name)\"; filename=\"\(filename)\""
}
else {
disposition = "Content-Disposition: form-data; name=\"\(part.name)\""
}
let header = [
"--\(boundary)",
disposition,
"Content-Length: \(part.length)",
"Content-Type: \(part.type)",
"", // ends with a newline
].joined(separator: "\r\n")
try writeString(header, to: stream)
try writeData(lineEnd, to: stream)
}
private func writeBody(_ part: Part, to stream: OutputStream) throws {
if let data = part.data {
try writeData(data, to: stream)
}
else if let fileURL = part.dataFileURL {
try writeFile(fileURL, to: stream)
}
else {
throw MultipartFormEncodingError.invalidPart(part)
}
try writeData(lineEnd, to: stream)
}
private func writeFooter(_ part: Part, to stream: OutputStream) throws {
let footer = "--\(boundary)--\r\n\r\n"
try writeString(footer, to: stream)
}
private func writeString(_ string: String, to stream: OutputStream) throws {
guard let data = string.data(using: .utf8) else {
throw MultipartFormEncodingError.invalidText(string)
}
try writeData(data, to: stream)
}
private func writeData(_ data: Data, to stream: OutputStream) throws {
guard !data.isEmpty else {
log.warning("Ignoring request to write 0 bytes of data to stream \(stream)")
return
}
try data.withUnsafeBytes { (bytes: UnsafePointer<UInt8>) throws -> Void in
let written = stream.write(bytes, maxLength: data.count)
if written < 0 {
throw MultipartFormEncodingError.streamError
}
}
}
private func writeFile(_ url: URL, to stream: OutputStream) throws {
guard let inStream = InputStream(fileAtPath: url.path) else {
throw MultipartFormEncodingError.streamError
}
let bufferSize = 128 * 1024
let buffer = UnsafeMutablePointer<UInt8>.allocate(capacity: bufferSize)
inStream.open()
defer {
buffer.deallocate(capacity: bufferSize)
inStream.close()
}
while inStream.hasBytesAvailable {
let bytesRead = inStream.read(buffer, maxLength: bufferSize)
if bytesRead > 0 {
let bytesWritten = stream.write(buffer, maxLength: bytesRead)
if bytesWritten < 0 {
throw MultipartFormEncodingError.streamError
}
}
else {
throw MultipartFormEncodingError.streamError
}
}
}
}

28
Package.swift Normal file
View file

@ -0,0 +1,28 @@
// swift-tools-version:5.3
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
let package = Package(
name: "Osiris",
products: [
// Products define the executables and libraries a package produces, and make them visible to other packages.
.library(
name: "Osiris",
targets: ["Osiris"]),
],
dependencies: [
// Dependencies declare other packages that this package depends on.
// .package(url: /* package url */, from: "1.0.0"),
],
targets: [
// Targets are the basic building blocks of a package. A target can define a module or a test suite.
// Targets can depend on other targets in this package, and on products in packages this package depends on.
.target(
name: "Osiris",
dependencies: []),
.testTarget(
name: "OsirisTests",
dependencies: ["Osiris"]),
]
)

View file

@ -13,42 +13,24 @@ Create an encoder and then add parts to it as needed:
```Swift
let encoder = MultipartFormEncoder()
try! encoder.addText(name: "email", text: "somebody@example.com")
try! encoder.addText(name: "password", text: "secret")
let avatarData = UIImageJPEGRepresentation(avatar, 1)!
encoder.addBinary(name: "avatar.jpg", contentType: "image/jpeg", data: avatarData)
encoder.addPart(.text(name: "email", text: "somebody@example.com"))
encoder.addPart(.text(name: "password", text: "secret"))
let avatarData = UIImage(from: somewhere).jpegData(compressionQuality: 1)
encoder.addPart(.binary(name: "avatar", type: "image/jpeg", data: avatarData, filename: "avatar.jpg"))
```
You can encode the entire form as `Data` in memory if it's not very big:
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:
```Swift
let encoded = try encoder.encodeToMemory()
let body = encoder.encode()
var request = URLRequest(url: URL(string: "https://example.com/accounts")!)
request.httpMethod = "POST"
request.httpBody = encoded.body
request.addValue(encoded.contentType, forHTTPHeaderField: "Content-Type")
request.addValue("\(encoded.contentLength)", forHTTPHeaderField: "Content-Length")
request.httpBody = body.data
request.addValue(body.contentType, forHTTPHeaderField: "Content-Type")
request.addValue("\(body.contentLength)", forHTTPHeaderField: "Content-Length")
// ... whatever you normally do with requests
```
For larger forms you can also stream the encoded form data directly to disk:
```Swift
let path = NSTemporaryDirectory().appending("/form.data")
let encoded = try encoder.encodeToDisk(path: path)
var request = URLRequest(url: URL(string: "https://example.com/accounts")!)
request.httpMethod = "POST"
request.addValue(encoded.contentType, forHTTPHeaderField: "Content-Type")
request.addValue("\(encoded.contentLength)", forHTTPHeaderField: "Content-Length")
let task = URLSession.shared.uploadTask(with: request, fromFile: encoded.bodyFileURL) { maybeData, maybeResponse, maybeError in
}
task.resume()
```
You can create and add your own parts using the `MultipartFormEncoder.Part` struct and `MultipartFormEncoder.addPart(_ part: Part)`.
# HTTPRequest
Basic usage:
@ -131,7 +113,7 @@ I don't recommend you use `Service` as shown here, but maybe use it as a jumping
Mostly created by Sami Samhuri for [1SE][]. `FormEncoder.swift` was lifted from [Alamofire][].
[1SE]: http://1se.co
[1SE]: https://1se.co
# License

View file

@ -4,6 +4,15 @@
import Foundation
extension NSNumber {
/// [From Argo](https://github.com/thoughtbot/Argo/blob/3da833411e2633bc01ce89542ac16803a163e0f0/Argo/Extensions/NSNumber.swift)
///
/// - Returns: `true` if this instance represent a `CFBoolean` under the hood, as opposed to say a double or integer.
var isBool: Bool {
return CFBooleanGetTypeID() == CFGetTypeID(self)
}
}
final class FormEncoder {
class func encode(_ parameters: [String: Any]) -> String {
var components: [(String, String)] = []

View file

@ -6,6 +6,10 @@
import Foundation
#if canImport(UIKit)
import UIKit
#endif
enum HTTPMethod: String {
case delete
case get
@ -44,14 +48,16 @@ final class HTTPRequest {
headers[name] = value
}
#if canImport(UIKit)
func addMultipartJPEG(name: String, image: UIImage, quality: CGFloat, filename: String? = nil) {
guard let data = UIImageJPEGRepresentation(image, quality) else {
guard let data = image.jpegData(compressionQuality: quality) else {
assertionFailure()
return
}
let part = MultipartFormEncoder.Part(name: name, type: "image/jpeg", encoding: "binary", data: data, filename: filename)
let part = MultipartFormEncoder.Part(name: name, content: .binary(data, type: "image/jpeg", filename: filename ?? "image.jpeg"))
addPart(part)
}
#endif
private func addPart(_ part: MultipartFormEncoder.Part) {
// Convert this request to multipart
@ -112,11 +118,11 @@ enum HTTPResponse {
var bodyString: String {
guard let data = self.data else {
log.warning("No data found on response: \(self)")
NSLog("[WARN] No data found on response: \(self)")
return ""
}
guard let string = String(data: data, encoding: .utf8) else {
log.warning("Data is not UTF8: \(data)")
NSLog("[WARN] Data is not UTF8: \(data)")
return ""
}
return string
@ -124,13 +130,13 @@ enum HTTPResponse {
var dictionaryFromJSON: [String : Any] {
guard let data = self.data else {
log.warning("No data found on response: \(self)")
NSLog("[WARN] No data found on response: \(self)")
return [:]
}
do {
guard let dictionary = try JSONSerialization.jsonObject(with: data, options: []) as? [String : Any] else {
if let parsed = try? JSONSerialization.jsonObject(with: data, options: []) {
log.error("Failed to parse JSON as dictionary: \(parsed)")
NSLog("[ERROR] Failed to parse JSON as dictionary: \(parsed)")
}
return [:]
}
@ -138,7 +144,7 @@ enum HTTPResponse {
}
catch {
let json = String(data: data, encoding: .utf8) ?? "<invalid data>"
log.error("Failed to parse JSON \(json): \(error)")
NSLog("[ERROR] Failed to parse JSON \(json): \(error)")
return [:]
}
}

View file

@ -0,0 +1,100 @@
//
// Created by Sami Samhuri on 2017-07-28.
// Copyright © 2017 1 Second Everyday. All rights reserved.
// Released under the terms of the MIT license.
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this
// software and associated documentation files (the "Software"), to deal in the Software
// without restriction, including without limitation the rights to use, copy, modify,
// merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice shall be included in all copies
// or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
// INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
// PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
// CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
// OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//
import Foundation
extension MultipartFormEncoder {
struct Body {
let contentType: String
let data: Data
var contentLength: Int {
data.count
}
}
struct Part {
enum Content {
case text(String)
case binary(Data, type: String, filename: String)
}
let name: String
let content: Content
static func text(name: String, value: 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))
}
}
}
final class MultipartFormEncoder {
let boundary: String
private var parts: [Part] = []
init(boundary: String? = nil) {
self.boundary = boundary ?? "LifeIsMadeOfSeconds-\(UUID().uuidString)"
}
func addPart(_ part: Part) {
parts.append(part)
}
func encode() -> 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
switch part.content {
case let .text(string):
bodyData.append(Data(string.utf8))
case let .binary(data, _, _):
bodyData.append(data)
}
bodyData.append(Data("\r\n".utf8))
}
// Footer
bodyData.append(Data("--\(boundary)--".utf8))
return Body(contentType: "multipart/form-data; boundary=\"\(boundary)\"", data: bodyData)
}
}

View file

@ -19,11 +19,10 @@ final class RequestBuilder {
result.addValue(value, forHTTPHeaderField: name)
}
if let params = request.parameters {
let data: Data
switch request.contentType {
case .json:
result.addValue("application/json", forHTTPHeaderField: "Content-Type")
data = try JSONSerialization.data(withJSONObject: params, options: [])
result.httpBody = try JSONSerialization.data(withJSONObject: params, options: [])
case .none:
// Fall back to form encoding for maximum compatibility.
@ -41,10 +40,10 @@ final class RequestBuilder {
for part in request.parts {
encoder.addPart(part)
}
let encoded = try encoder.encodeToMemory()
result.addValue(encoded.contentType, forHTTPHeaderField: "Content-Type")
result.addValue("\(encoded.contentLength)", forHTTPHeaderField: "Content-Length")
result.httpBody = encoded.body
let body = encoder.encode()
result.addValue(body.contentType, forHTTPHeaderField: "Content-Type")
result.addValue("\(body.contentLength)", forHTTPHeaderField: "Content-Length")
result.httpBody = body.data
}
}
return result

7
Tests/LinuxMain.swift Normal file
View file

@ -0,0 +1,7 @@
import XCTest
import OsirisTests
var tests = [XCTestCaseEntry]()
tests += OsirisTests.allTests()
XCTMain(tests)

View file

@ -0,0 +1,111 @@
//
// MultipartFormEncoderTests.swift
// VidjoTests
//
// Created by Sami Samhuri on 2020-10-20.
// Copyright © 2020 Guru Logic Inc. All rights reserved.
//
@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) {
let data1 = try! expression1()
let string1 = String(bytes: data1, encoding: .utf8)!
let string2 = try! expression2()
let message = message() ?? "\"\(string1)\" is not equal to \"\(string2)\""
XCTAssertEqual(data1, Data(string2.utf8), message, file: file, line: line)
}
class MultipartFormEncoderTests: XCTestCase {
var boundary = "SuperAwesomeBoundary"
var subject: MultipartFormEncoder!
override func setUpWithError() throws {
subject = MultipartFormEncoder(boundary: boundary)
}
override func tearDownWithError() throws {
subject = nil
}
func testEncodeNothing() throws {
let body = subject.encode()
XCTAssertEqual(body.contentType, "multipart/form-data; boundary=\"SuperAwesomeBoundary\"")
AssertBodyEqual(body.data, "--SuperAwesomeBoundary--")
}
func testEncodeText() throws {
subject.addPart(.text(name: "name", value: "Tina"))
AssertBodyEqual(
subject.encode().data,
[
"--SuperAwesomeBoundary",
"Content-Disposition: form-data; name=\"name\"",
"",
"Tina",
"--SuperAwesomeBoundary--",
].joined(separator: "\r\n")
)
}
func testEncodeData() throws {
subject.addPart(.binary(name: "video", data: Data("phony video data".utf8), type: "video/mp4", filename: "LiesSex&VideoTape.mp4"))
AssertBodyEqual(
subject.encode().data,
[
"--SuperAwesomeBoundary",
"Content-Disposition: form-data; name=\"video\"; filename=\"LiesSex&VideoTape.mp4\"",
"Content-Type: video/mp4",
"Content-Length: 16",
"",
"phony video data",
"--SuperAwesomeBoundary--"
].joined(separator: "\r\n")
)
}
func testEncodeEverything() throws {
subject.addPart(.text(name: "name", value: "Queso"))
subject.addPart(.binary(name: "image", data: Data("phony image data".utf8), type: "image/jpeg", filename: "feltcute.jpg"))
subject.addPart(.text(name: "spot", value: "top of the bbq"))
subject.addPart(.binary(name: "video", data: Data("phony video data".utf8), type: "video/mp4", filename: "LiesSex&VideoTape.mp4"))
AssertBodyEqual(
subject.encode().data,
[
"--SuperAwesomeBoundary",
"Content-Disposition: form-data; name=\"name\"",
"",
"Queso",
"--SuperAwesomeBoundary",
"Content-Disposition: form-data; name=\"image\"; filename=\"feltcute.jpg\"",
"Content-Type: image/jpeg",
"Content-Length: 16",
"",
"phony image data",
"--SuperAwesomeBoundary",
"Content-Disposition: form-data; name=\"spot\"",
"",
"top of the bbq",
"--SuperAwesomeBoundary",
"Content-Disposition: form-data; name=\"video\"; filename=\"LiesSex&VideoTape.mp4\"",
"Content-Type: video/mp4",
"Content-Length: 16",
"",
"phony video data",
"--SuperAwesomeBoundary--"
].joined(separator: "\r\n")
)
}
static var allTests = [
("testEncodeNothing", testEncodeNothing),
("testEncodeText", testEncodeText),
("testEncodeData", testEncodeData),
("testEncodeEverything", testEncodeEverything),
]
}

View file

@ -0,0 +1,9 @@
import XCTest
#if !canImport(ObjectiveC)
public func allTests() -> [XCTestCaseEntry] {
return [
testCase(MultipartFormEncoderTests.allTests),
]
}
#endif