Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix issue with @Multipart & add body encoding tests #40

Merged
merged 2 commits into from
Dec 9, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)
}
}