From 76d701abbe185f9d13b9fe8058701026e60e0b51 Mon Sep 17 00:00:00 2001 From: pjechris Date: Sat, 4 Apr 2026 12:18:39 +0200 Subject: [PATCH 1/3] added FormURLEncoder --- .../DataCoder/FormURL/FormURLEncoder.swift | 88 +++++++++++++++++++ .../FormURL/HTTPContentType+FormURL.swift | 5 ++ 2 files changed, 93 insertions(+) create mode 100644 Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift create mode 100644 Sources/SimpleHTTP/DataCoder/FormURL/HTTPContentType+FormURL.swift diff --git a/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift b/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift new file mode 100644 index 0000000..dd70334 --- /dev/null +++ b/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift @@ -0,0 +1,88 @@ +import Foundation + +struct FormURLEncoder: ContentDataEncoder { + static let contentType: HTTPContentType = .formURLEncoded + + func encode(_ value: T) throws -> Data { + let encoder = FormKeyValueEncoder() + try value.encode(to: encoder) + + let encoded = encoder.pairs + .map { key, value in + let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key + let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value + return "\(encodedKey)=\(encodedValue)" + } + .joined(separator: "&") + + guard let data = encoded.data(using: .utf8) else { + throw EncodingError.invalidValue(value, .init(codingPath: [], debugDescription: "UTF-8 encoding failed")) + } + + return data + } +} + +// MARK: - Encoder + +private final class FormKeyValueEncoder: Encoder { + var codingPath: [CodingKey] = [] + var userInfo: [CodingUserInfoKey: Any] = [:] + var pairs: [(key: String, value: String)] = [] + + func container(keyedBy type: Key.Type) -> KeyedEncodingContainer { + KeyedEncodingContainer(FormKeyedContainer(encoder: self)) + } + + func unkeyedContainer() -> UnkeyedEncodingContainer { + fatalError("Form URL encoding does not support unkeyed containers") + } + + func singleValueContainer() -> SingleValueEncodingContainer { + fatalError("Form URL encoding does not support single value containers") + } +} + +// MARK: - Keyed container + +private struct FormKeyedContainer: KeyedEncodingContainerProtocol { + let encoder: FormKeyValueEncoder + var codingPath: [CodingKey] = [] + + mutating func encodeNil(forKey key: Key) throws {} + + mutating func encode(_ value: String, forKey key: Key) throws { + append(key, value) + } + + mutating func encode(_ value: Bool, forKey key: Key) throws { + append(key, "\(value)") + } + + mutating func encode(_ value: Int, forKey key: Key) throws { + append(key, "\(value)") + } + + mutating func encode(_ value: Double, forKey key: Key) throws { + append(key, "\(value)") + } + + mutating func encode(_ value: T, forKey key: Key) throws { + append(key, "\(value)") + } + + mutating func nestedContainer(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer { + fatalError("Nested containers not supported") + } + + mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer { + fatalError("Nested containers not supported") + } + + mutating func superEncoder() -> Encoder { encoder } + mutating func superEncoder(forKey key: Key) -> Encoder { encoder } + + private func append(_ key: Key, _ value: String) { + encoder.pairs.append((key: key.stringValue, value: value)) + } +} diff --git a/Sources/SimpleHTTP/DataCoder/FormURL/HTTPContentType+FormURL.swift b/Sources/SimpleHTTP/DataCoder/FormURL/HTTPContentType+FormURL.swift new file mode 100644 index 0000000..5d94cb4 --- /dev/null +++ b/Sources/SimpleHTTP/DataCoder/FormURL/HTTPContentType+FormURL.swift @@ -0,0 +1,5 @@ +import SimpleHTTPFoundation + +extension HTTPContentType { + public static let formURLEncoded: HTTPContentType = "application/x-www-form-urlencoded" +} From 3c6c63c3e32c9f20f48d228ccefc4a4c0b06c333 Mon Sep 17 00:00:00 2001 From: pjechris Date: Sat, 4 Apr 2026 13:30:48 +0200 Subject: [PATCH 2/3] added tests --- .../DataCoder/FormURL/FormURLEncoder.swift | 10 +- .../FormURL/FormURLEncoderTests.swift | 122 ++++++++++++++++++ 2 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 Tests/SimpleHTTPTests/DataCoder/FormURL/FormURLEncoderTests.swift diff --git a/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift b/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift index dd70334..d79bb76 100644 --- a/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift +++ b/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift @@ -1,9 +1,13 @@ import Foundation -struct FormURLEncoder: ContentDataEncoder { - static let contentType: HTTPContentType = .formURLEncoded +public struct FormURLEncoder: ContentDataEncoder { + public static let contentType: HTTPContentType = .formURLEncoded - func encode(_ value: T) throws -> Data { + public init() { + + } + + public func encode(_ value: T) throws -> Data { let encoder = FormKeyValueEncoder() try value.encode(to: encoder) diff --git a/Tests/SimpleHTTPTests/DataCoder/FormURL/FormURLEncoderTests.swift b/Tests/SimpleHTTPTests/DataCoder/FormURL/FormURLEncoderTests.swift new file mode 100644 index 0000000..92be3c3 --- /dev/null +++ b/Tests/SimpleHTTPTests/DataCoder/FormURL/FormURLEncoderTests.swift @@ -0,0 +1,122 @@ +import Testing +import Foundation +import SimpleHTTP + +struct FormURLEncoderTests { + let encoder = FormURLEncoder() + + struct Encode { + let encoder = FormURLEncoder() + + @Test("single string field returns key=value pair") + func singleStringField_returnsKeyValuePair() throws { + let input = SingleField(name: "John") + + let data = try encoder.encode(input) + + #expect(String(data: data, encoding: .utf8) == "name=John") + } + + @Test("multiple fields returns ampersand-separated pairs") + func multipleFields_returnsAmpersandSeparatedPairs() throws { + let input = MultipleFields(name: "John", age: 30) + + let data = try encoder.encode(input) + + #expect(String(data: data, encoding: .utf8) == "name=John&age=30") + } + + @Test("boolean field returns true or false") + func booleanField_returnsTrueOrFalse() throws { + let input = BoolField(active: true) + + let data = try encoder.encode(input) + + #expect(String(data: data, encoding: .utf8) == "active=true") + } + + @Test("double field returns decimal value") + func doubleField_returnsDecimalValue() throws { + let input = DoubleField(score: 9.5) + + let data = try encoder.encode(input) + + #expect(String(data: data, encoding: .utf8) == "score=9.5") + } + + @Test("spaces in value are percent-encoded") + func spacesInValue_arePercentEncoded() throws { + let input = SingleField(name: "John Doe") + + let data = try encoder.encode(input) + let result = String(data: data, encoding: .utf8) + + #expect(result == "name=John%20Doe") + } + + @Test("special characters in key are percent-encoded") + func specialCharactersInKey_arePercentEncoded() throws { + let input = SpecialKeyField(value: "hello") + + let data = try encoder.encode(input) + let result = String(data: data, encoding: .utf8) + + #expect(result?.contains("my%20key=hello") == true) + } + + @Test("nil optional field is omitted") + func nilOptionalField_isOmitted() throws { + let input = OptionalField(name: "John", nickname: nil) + + let data = try encoder.encode(input) + + #expect(String(data: data, encoding: .utf8) == "name=John") + } + + @Test("present optional field is included") + func presentOptionalField_isIncluded() throws { + let input = OptionalField(name: "John", nickname: "JD") + + let data = try encoder.encode(input) + + #expect(String(data: data, encoding: .utf8) == "name=John&nickname=JD") + } + + @Test("content type is form URL encoded") + func contentType_returnsFormURLEncoded() { + #expect(FormURLEncoder.contentType == .formURLEncoded) + } + } +} + +// MARK: - Fixtures + +private struct SingleField: Encodable { + let name: String +} + +private struct MultipleFields: Encodable { + let name: String + let age: Int +} + +private struct BoolField: Encodable { + let active: Bool +} + +private struct DoubleField: Encodable { + let score: Double +} + +private struct OptionalField: Encodable { + let name: String + let nickname: String? +} + +private struct SpecialKeyField: Encodable { + enum CodingKeys: String, CodingKey { + case value = "my key" + } + + let value: String +} From 07e1dae3cefa04c83828a56dc7a0a5ba16790479 Mon Sep 17 00:00:00 2001 From: pjechris Date: Sat, 4 Apr 2026 13:32:51 +0200 Subject: [PATCH 3/3] syntax --- Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift b/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift index d79bb76..3799809 100644 --- a/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift +++ b/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift @@ -3,11 +3,9 @@ import Foundation public struct FormURLEncoder: ContentDataEncoder { public static let contentType: HTTPContentType = .formURLEncoded - public init() { + public init() { } - } - - public func encode(_ value: T) throws -> Data { + public func encode(_ value: some Encodable) throws -> Data { let encoder = FormKeyValueEncoder() try value.encode(to: encoder)