Skip to content

Commit

Permalink
Fix issue with @multipart & add body encoding tests (#40)
Browse files Browse the repository at this point in the history
Fixed an issue where using @multipart was causing a `preconditionFailure`.
Adds basic tests around JSON, URLFormEncoder, Multipart encoding.
  • Loading branch information
joshuawright11 authored Dec 9, 2023
1 parent cf0a4a1 commit 56150a4
Show file tree
Hide file tree
Showing 5 changed files with 124 additions and 7 deletions.
3 changes: 2 additions & 1 deletion Example/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ let provider = Provider(baseURL: "http://127.0.0.1:3000")
let start = Date()
let res = try await next(req)
let elapsedTime = String(format: "%.2fs", Date().timeIntervalSince(start))
print("Got a \(res.statusCode!) for \(req.method) \(req.url!) after \(elapsedTime)")
let statusCode = res.statusCode.map { "\($0)" } ?? "N/A"
print("Got a \(statusCode) for \(req.method) \(req.url!) after \(elapsedTime)")
return res
}

Expand Down
6 changes: 3 additions & 3 deletions PapyrusCore/Sources/Multipart/MultipartEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,15 +16,15 @@ public struct MultipartEncoder: RequestEncoder {

public func encode(_ value: some Encodable) throws -> Data {
guard let parts = value as? [String: Part] else {
preconditionFailure("Can only encode `[String: Part]` with `MultipartEncoder`.")
preconditionFailure("Can only encode `[String: Part]` with `MultipartEncoder`. Got \(type(of: value)) instead")
}

let initialBoundary = Data("--\(boundary)\(crlf)".utf8)
let middleBoundary = Data("\(crlf)--\(boundary)\(crlf)".utf8)
let finalBoundary = Data("\(crlf)--\(boundary)--\(crlf)".utf8)

var body = Data()
for (key, part) in parts {
for (key, part) in parts.sorted(by: { $0.key < $1.key }) {
body += body.isEmpty ? initialBoundary : middleBoundary
body += partHeaderData(part, key: key)
body += part.data
Expand All @@ -44,7 +44,7 @@ public struct MultipartEncoder: RequestEncoder {
headers["Content-Type"] = mimeType
}

let string = headers.map { "\($0): \($1)\(crlf)" }.joined() + crlf
let string = headers.map { "\($0): \($1)\(crlf)" }.sorted().joined() + crlf
return Data(string.utf8)
}

Expand Down
22 changes: 21 additions & 1 deletion PapyrusCore/Sources/RequestBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ public struct RequestBuilder {
public enum Content {
case value(ContentValue)
case fields([ContentKey: ContentValue])
case multipart([ContentKey: Part])
}

public static var defaultQueryEncoder: URLEncodedFormEncoder = URLEncodedFormEncoder()
Expand Down Expand Up @@ -153,11 +154,26 @@ public struct RequestBuilder {
body = .value(ContentValue(value))
}

public mutating func addField(_ key: String, value: Part, mapKey: Bool = true) {
var parts: [ContentKey: Part] = [:]
if let body = body {
guard case .multipart(let existingParts) = body else {
preconditionFailure("Tried to add a multipart Part, \(key), to a request but it already had non multipart fields added to it. If you use @Multipart, all fields on the request must be of type Part.")
}

parts = existingParts
}

let key: ContentKey = mapKey ? .implicit(key) : .explicit(key)
parts[key] = value
body = .multipart(parts)
}

public mutating func addField<E: Encodable>(_ key: String, value: E, mapKey: Bool = true) {
var fields: [ContentKey: ContentValue] = [:]
if let body = body {
guard case .fields(let existingFields) = body else {
preconditionFailure("Tried to add a @Field, \(key): \(E.self), to a request, but it already had a @Body, \(body). @Body and @Field are mutually exclusive.")
preconditionFailure("Tried to add a Field, \(key): \(E.self), to a request, but it already had Body or Multipart parameters, \(body). Body, Field, and Multipart are mutually exclusive.")
}

fields = existingFields
Expand Down Expand Up @@ -204,6 +220,10 @@ public struct RequestBuilder {
return nil
case .value(let value):
return try requestEncoder.encode(value)
case .multipart(let fields):
let pairs = fields.map { ($0.mapped(keyMapping), $1) }
let dict = Dictionary(uniqueKeysWithValues: pairs)
return try requestEncoder.encode(dict)
case .fields(let fields):
let pairs = fields.map { ($0.mapped(keyMapping), $1) }
let dict = Dictionary(uniqueKeysWithValues: pairs)
Expand Down
4 changes: 2 additions & 2 deletions PapyrusCore/Sources/ResponseDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ public protocol ResponseDecoder: KeyMappable {
// MARK: application/json

extension ResponseDecoder where Self == JSONDecoder {
public static func json(_ decoder: JSONDecoder) -> Self {
decoder
public static var json: Self {
JSONDecoder()
}
}

Expand Down
96 changes: 96 additions & 0 deletions PapyrusCore/Tests/RequestBuilderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,4 +16,100 @@ final class RequestBuilderTests: XCTestCase {
let req = RequestBuilder(baseURL: "foo/", method: "bar", path: "/baz")
XCTAssertEqual(try req.fullURL().absoluteString, "foo/baz")
}

func testMultipart() throws {
var req = RequestBuilder(baseURL: "foo/", method: "bar", path: "/baz")
let encoder = MultipartEncoder(boundary: UUID().uuidString)
req.requestEncoder = encoder
req.addField("a", value: Part(data: Data("one".utf8), fileName: "one.txt", mimeType: "text/plain"))
req.addField("b", value: Part(data: Data("two".utf8)))
let (body, headers) = try req.bodyAndHeaders()
guard let body else {
XCTFail()
return
}

// 0. Assert Headers

XCTAssertEqual(headers, [
"Content-Type": "multipart/form-data; boundary=\(encoder.boundary)",
"Content-Length": "266"
])

// 1. Assert Body

XCTAssertEqual(body.string, """
--\(encoder.boundary)\r
Content-Disposition: form-data; name="a"; filename="one.txt"\r
Content-Type: text/plain\r
\r
one\r
--\(encoder.boundary)\r
Content-Disposition: form-data; name="b"\r
\r
two\r
--\(encoder.boundary)--\r
"""
)
}

func testJSON() async throws {
var req = RequestBuilder(baseURL: "foo/", method: "bar", path: "/baz")
let encoder = JSONEncoder()
encoder.outputFormatting = [.sortedKeys, .prettyPrinted]
req.requestEncoder = encoder
req.addField("a", value: "one")
req.addField("b", value: "two")
let (body, headers) = try req.bodyAndHeaders()
guard let body else {
XCTFail()
return
}

// 0. Assert Headers

XCTAssertEqual(headers, [
"Content-Type": "application/json",
"Content-Length": "32"
])

// 1. Assert Body

XCTAssertEqual(body.string, """
{
"a" : "one",
"b" : "two"
}
"""
)
}

func testURLForm() async throws {
var req = RequestBuilder(baseURL: "foo/", method: "bar", path: "/baz")
req.requestEncoder = URLEncodedFormEncoder()
req.addField("a", value: "one")
req.addField("b", value: "two")
let (body, headers) = try req.bodyAndHeaders()
guard let body else {
XCTFail()
return
}

// 0. Assert Headers

XCTAssertEqual(headers, [
"Content-Type": "application/x-www-form-urlencoded",
"Content-Length": "11"
])

// 1. Assert Body
XCTAssertTrue(["a=one&b=two", "b=two&a=one"].contains(body.string))
}
}

extension Data {
fileprivate var string: String {
String(decoding: self, as: UTF8.self)
}
}

0 comments on commit 56150a4

Please sign in to comment.