diff --git a/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift b/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift new file mode 100644 index 0000000..3799809 --- /dev/null +++ b/Sources/SimpleHTTP/DataCoder/FormURL/FormURLEncoder.swift @@ -0,0 +1,90 @@ +import Foundation + +public struct FormURLEncoder: ContentDataEncoder { + public static let contentType: HTTPContentType = .formURLEncoded + + public init() { } + + public func encode(_ value: some Encodable) 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" +} 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 +}