From 7cd43c21febcbceea9aee095647560a625441223 Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 9 Dec 2023 10:16:11 -0800 Subject: [PATCH 1/2] Fix & add test --- Example/main.swift | 3 +- .../Sources/Multipart/MultipartEncoder.swift | 6 +-- PapyrusCore/Sources/RequestBuilder.swift | 22 ++++++++++- PapyrusCore/Tests/RequestBuilderTests.swift | 37 +++++++++++++++++++ 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/Example/main.swift b/Example/main.swift index 6d5749f..65a9a52 100644 --- a/Example/main.swift +++ b/Example/main.swift @@ -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 } diff --git a/PapyrusCore/Sources/Multipart/MultipartEncoder.swift b/PapyrusCore/Sources/Multipart/MultipartEncoder.swift index 897e167..d72b9d3 100644 --- a/PapyrusCore/Sources/Multipart/MultipartEncoder.swift +++ b/PapyrusCore/Sources/Multipart/MultipartEncoder.swift @@ -16,7 +16,7 @@ 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) @@ -24,7 +24,7 @@ public struct MultipartEncoder: RequestEncoder { 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 @@ -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) } diff --git a/PapyrusCore/Sources/RequestBuilder.swift b/PapyrusCore/Sources/RequestBuilder.swift index 0e29794..c7fa4e3 100644 --- a/PapyrusCore/Sources/RequestBuilder.swift +++ b/PapyrusCore/Sources/RequestBuilder.swift @@ -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() @@ -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 in the body of 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(_ 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 a Body or Multipart parameters, \(body). @Body and @Field are mutually exclusive.") } fields = existingFields @@ -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) diff --git a/PapyrusCore/Tests/RequestBuilderTests.swift b/PapyrusCore/Tests/RequestBuilderTests.swift index 40a2ebd..ebcdc43 100644 --- a/PapyrusCore/Tests/RequestBuilderTests.swift +++ b/PapyrusCore/Tests/RequestBuilderTests.swift @@ -16,4 +16,41 @@ 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, """ + --\(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 + + """.data(using: .utf8) + ) + } } From 091f8f5f63cce60586e6499a83675525637e1c3b Mon Sep 17 00:00:00 2001 From: Josh Wright Date: Sat, 9 Dec 2023 11:00:54 -0800 Subject: [PATCH 2/2] More tests --- PapyrusCore/Sources/RequestBuilder.swift | 4 +- PapyrusCore/Sources/ResponseDecoder.swift | 4 +- PapyrusCore/Tests/RequestBuilderTests.swift | 63 ++++++++++++++++++++- 3 files changed, 65 insertions(+), 6 deletions(-) diff --git a/PapyrusCore/Sources/RequestBuilder.swift b/PapyrusCore/Sources/RequestBuilder.swift index c7fa4e3..ea0c657 100644 --- a/PapyrusCore/Sources/RequestBuilder.swift +++ b/PapyrusCore/Sources/RequestBuilder.swift @@ -158,7 +158,7 @@ public struct RequestBuilder { 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 in the body of the request must be of type Part.") + 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 @@ -173,7 +173,7 @@ public struct RequestBuilder { 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 or Multipart parameters, \(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 diff --git a/PapyrusCore/Sources/ResponseDecoder.swift b/PapyrusCore/Sources/ResponseDecoder.swift index 4d879cf..ced4417 100644 --- a/PapyrusCore/Sources/ResponseDecoder.swift +++ b/PapyrusCore/Sources/ResponseDecoder.swift @@ -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() } } diff --git a/PapyrusCore/Tests/RequestBuilderTests.swift b/PapyrusCore/Tests/RequestBuilderTests.swift index ebcdc43..a20f643 100644 --- a/PapyrusCore/Tests/RequestBuilderTests.swift +++ b/PapyrusCore/Tests/RequestBuilderTests.swift @@ -38,7 +38,7 @@ final class RequestBuilderTests: XCTestCase { // 1. Assert Body - XCTAssertEqual(body, """ + XCTAssertEqual(body.string, """ --\(encoder.boundary)\r Content-Disposition: form-data; name="a"; filename="one.txt"\r Content-Type: text/plain\r @@ -50,7 +50,66 @@ final class RequestBuilderTests: XCTestCase { two\r --\(encoder.boundary)--\r - """.data(using: .utf8) + """ ) } + + 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) + } }