Skip to content

Commit a5bb4fc

Browse files
authored
feat(coder) Add Form URL encoder (#28)
1 parent 2136d2b commit a5bb4fc

3 files changed

Lines changed: 217 additions & 0 deletions

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import Foundation
2+
3+
public struct FormURLEncoder: ContentDataEncoder {
4+
public static let contentType: HTTPContentType = .formURLEncoded
5+
6+
public init() { }
7+
8+
public func encode(_ value: some Encodable) throws -> Data {
9+
let encoder = FormKeyValueEncoder()
10+
try value.encode(to: encoder)
11+
12+
let encoded = encoder.pairs
13+
.map { key, value in
14+
let encodedKey = key.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? key
15+
let encodedValue = value.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? value
16+
return "\(encodedKey)=\(encodedValue)"
17+
}
18+
.joined(separator: "&")
19+
20+
guard let data = encoded.data(using: .utf8) else {
21+
throw EncodingError.invalidValue(value, .init(codingPath: [], debugDescription: "UTF-8 encoding failed"))
22+
}
23+
24+
return data
25+
}
26+
}
27+
28+
// MARK: - Encoder
29+
30+
private final class FormKeyValueEncoder: Encoder {
31+
var codingPath: [CodingKey] = []
32+
var userInfo: [CodingUserInfoKey: Any] = [:]
33+
var pairs: [(key: String, value: String)] = []
34+
35+
func container<Key: CodingKey>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> {
36+
KeyedEncodingContainer(FormKeyedContainer(encoder: self))
37+
}
38+
39+
func unkeyedContainer() -> UnkeyedEncodingContainer {
40+
fatalError("Form URL encoding does not support unkeyed containers")
41+
}
42+
43+
func singleValueContainer() -> SingleValueEncodingContainer {
44+
fatalError("Form URL encoding does not support single value containers")
45+
}
46+
}
47+
48+
// MARK: - Keyed container
49+
50+
private struct FormKeyedContainer<Key: CodingKey>: KeyedEncodingContainerProtocol {
51+
let encoder: FormKeyValueEncoder
52+
var codingPath: [CodingKey] = []
53+
54+
mutating func encodeNil(forKey key: Key) throws {}
55+
56+
mutating func encode(_ value: String, forKey key: Key) throws {
57+
append(key, value)
58+
}
59+
60+
mutating func encode(_ value: Bool, forKey key: Key) throws {
61+
append(key, "\(value)")
62+
}
63+
64+
mutating func encode(_ value: Int, forKey key: Key) throws {
65+
append(key, "\(value)")
66+
}
67+
68+
mutating func encode(_ value: Double, forKey key: Key) throws {
69+
append(key, "\(value)")
70+
}
71+
72+
mutating func encode<T: Encodable>(_ value: T, forKey key: Key) throws {
73+
append(key, "\(value)")
74+
}
75+
76+
mutating func nestedContainer<NestedKey: CodingKey>(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer<NestedKey> {
77+
fatalError("Nested containers not supported")
78+
}
79+
80+
mutating func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer {
81+
fatalError("Nested containers not supported")
82+
}
83+
84+
mutating func superEncoder() -> Encoder { encoder }
85+
mutating func superEncoder(forKey key: Key) -> Encoder { encoder }
86+
87+
private func append(_ key: Key, _ value: String) {
88+
encoder.pairs.append((key: key.stringValue, value: value))
89+
}
90+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import SimpleHTTPFoundation
2+
3+
extension HTTPContentType {
4+
public static let formURLEncoded: HTTPContentType = "application/x-www-form-urlencoded"
5+
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import Testing
2+
import Foundation
3+
import SimpleHTTP
4+
5+
struct FormURLEncoderTests {
6+
let encoder = FormURLEncoder()
7+
8+
struct Encode {
9+
let encoder = FormURLEncoder()
10+
11+
@Test("single string field returns key=value pair")
12+
func singleStringField_returnsKeyValuePair() throws {
13+
let input = SingleField(name: "John")
14+
15+
let data = try encoder.encode(input)
16+
17+
#expect(String(data: data, encoding: .utf8) == "name=John")
18+
}
19+
20+
@Test("multiple fields returns ampersand-separated pairs")
21+
func multipleFields_returnsAmpersandSeparatedPairs() throws {
22+
let input = MultipleFields(name: "John", age: 30)
23+
24+
let data = try encoder.encode(input)
25+
26+
#expect(String(data: data, encoding: .utf8) == "name=John&age=30")
27+
}
28+
29+
@Test("boolean field returns true or false")
30+
func booleanField_returnsTrueOrFalse() throws {
31+
let input = BoolField(active: true)
32+
33+
let data = try encoder.encode(input)
34+
35+
#expect(String(data: data, encoding: .utf8) == "active=true")
36+
}
37+
38+
@Test("double field returns decimal value")
39+
func doubleField_returnsDecimalValue() throws {
40+
let input = DoubleField(score: 9.5)
41+
42+
let data = try encoder.encode(input)
43+
44+
#expect(String(data: data, encoding: .utf8) == "score=9.5")
45+
}
46+
47+
@Test("spaces in value are percent-encoded")
48+
func spacesInValue_arePercentEncoded() throws {
49+
let input = SingleField(name: "John Doe")
50+
51+
let data = try encoder.encode(input)
52+
let result = String(data: data, encoding: .utf8)
53+
54+
#expect(result == "name=John%20Doe")
55+
}
56+
57+
@Test("special characters in key are percent-encoded")
58+
func specialCharactersInKey_arePercentEncoded() throws {
59+
let input = SpecialKeyField(value: "hello")
60+
61+
let data = try encoder.encode(input)
62+
let result = String(data: data, encoding: .utf8)
63+
64+
#expect(result?.contains("my%20key=hello") == true)
65+
}
66+
67+
@Test("nil optional field is omitted")
68+
func nilOptionalField_isOmitted() throws {
69+
let input = OptionalField(name: "John", nickname: nil)
70+
71+
let data = try encoder.encode(input)
72+
73+
#expect(String(data: data, encoding: .utf8) == "name=John")
74+
}
75+
76+
@Test("present optional field is included")
77+
func presentOptionalField_isIncluded() throws {
78+
let input = OptionalField(name: "John", nickname: "JD")
79+
80+
let data = try encoder.encode(input)
81+
82+
#expect(String(data: data, encoding: .utf8) == "name=John&nickname=JD")
83+
}
84+
85+
@Test("content type is form URL encoded")
86+
func contentType_returnsFormURLEncoded() {
87+
#expect(FormURLEncoder.contentType == .formURLEncoded)
88+
}
89+
}
90+
}
91+
92+
// MARK: - Fixtures
93+
94+
private struct SingleField: Encodable {
95+
let name: String
96+
}
97+
98+
private struct MultipleFields: Encodable {
99+
let name: String
100+
let age: Int
101+
}
102+
103+
private struct BoolField: Encodable {
104+
let active: Bool
105+
}
106+
107+
private struct DoubleField: Encodable {
108+
let score: Double
109+
}
110+
111+
private struct OptionalField: Encodable {
112+
let name: String
113+
let nickname: String?
114+
}
115+
116+
private struct SpecialKeyField: Encodable {
117+
enum CodingKeys: String, CodingKey {
118+
case value = "my key"
119+
}
120+
121+
let value: String
122+
}

0 commit comments

Comments
 (0)