Jelajahi Sumber

Add NilEncoding to URLEncodedFormParameterEncoder (#3686)

### Issue Link :link:
#3631

### Goals :soccer:
This PR adds a `NilEncoding` type for `URLEncodedFormParameterEncoder`
so that it can handle optional values.

### Implementation Details :construction:
Like the other encodings, it allows a variety of representations. Unlike
the others, it's implemented as a `struct` so it can more easily be used
with custom encodings.

### Testing Details :mag:
Tests added for each encoding.
Jon Shier 2 tahun lalu
induk
melakukan
ce0fb463a8
3 mengubah file dengan 150 tambahan dan 65 penghapusan
  1. 22 0
      Documentation/Usage.md
  2. 80 29
      Source/URLEncodedFormEncoder.swift
  3. 48 36
      Tests/ParameterEncoderTests.swift

+ 22 - 0
Documentation/Usage.md

@@ -343,6 +343,28 @@ You can create your own `URLEncodedFormParameterEncoder` and specify the desired
 let encoder = URLEncodedFormParameterEncoder(encoder: URLEncodedFormEncoder(spaceEncoding: .plusReplaced))
 ```
 
+##### Configuring the Encoding of Optionals
+
+There is no standard for encoding `Optional` values as part of form data. Nonetheless, Alamofire provides `NilEncoding` with the following methods for encoding optionals:
+
+- `.dropKey` - Encodes `nil` values by dropping them from the output entirely. This matches other Swift encoders. e.g. `otherValue=2`.
+- `.dropValue` - Encodes `nil` values by dropping the value from the output. e.g. `nilValue=&otherValue=2`.
+- `.null` - Encodes `nil` values as the string `null`. e.g. `nilValue=null&otherValue=2`.
+
+Additionally, custom encodings can be created by specifying an encoding closure that provides the `nil` replacement value.
+
+```swift
+extension URLEncodedFormEncoder.NilEncoding {
+  static let customEncoding = NilEncoding { "customNilValue" }
+}
+```
+
+You can create your own `URLEncodedFormParameterEncoder` and specify the desired `NilEncoding` in the initializer of the passed `URLEncodedFormEncoder`:
+
+```swift
+let encoder = URLEncodedFormParameterEncoder(encoder: URLEncodedFormEncoder(nilEncoding: .dropKey))
+```
+
 #### `JSONParameterEncoder`
 
 `JSONParameterEncoder` encodes `Encodable` values using Swift's `JSONEncoder` and sets the result as the `httpBody` of the `URLRequest`. The `Content-Type` HTTP header field of an encoded request is set to `application/json` if not already set.

+ 80 - 29
Source/URLEncodedFormEncoder.swift

@@ -273,6 +273,29 @@ public final class URLEncodedFormEncoder {
         }
     }
 
+    /// Encoding to use for `nil` values.
+    public struct NilEncoding {
+        /// Encodes `nil` by dropping the entire key / value pair.
+        public static let dropKey = NilEncoding { nil }
+        /// Encodes `nil` by dropping only the value. e.g. `value1=one&nilValue=&value2=two`.
+        public static let dropValue = NilEncoding { "" }
+        /// Encodes `nil` as `null`.
+        public static let null = NilEncoding { "null" }
+
+        private let encoding: () -> String?
+
+        /// Creates an instance with the specified encoding.
+        ///
+        /// - Parameter encoding: Closure used to perform the encoding.
+        public init(encoding: @escaping () -> String?) {
+            self.encoding = encoding
+        }
+
+        func encodeNil() -> String? {
+            encoding()
+        }
+    }
+
     /// Encoding to use for spaces.
     public enum SpaceEncoding {
         /// Encodes spaces according to normal percent escaping rules (%20).
@@ -322,6 +345,8 @@ public final class URLEncodedFormEncoder {
     public let dateEncoding: DateEncoding
     /// The `KeyEncoding` to use.
     public let keyEncoding: KeyEncoding
+    /// The `NilEncoding` to use.
+    public let nilEncoding: NilEncoding
     /// The `SpaceEncoding` to use.
     public let spaceEncoding: SpaceEncoding
     /// The `CharacterSet` of allowed (non-escaped) characters.
@@ -336,6 +361,7 @@ public final class URLEncodedFormEncoder {
     ///   - dataEncoding:             The `DataEncoding` to use. `.base64` by default.
     ///   - dateEncoding:             The `DateEncoding` to use. `.deferredToDate` by default.
     ///   - keyEncoding:              The `KeyEncoding` to use. `.useDefaultKeys` by default.
+    ///   - nilEncoding:              The `NilEncoding` to use. `.drop` by default.
     ///   - spaceEncoding:            The `SpaceEncoding` to use. `.percentEscaped` by default.
     ///   - allowedCharacters:        The `CharacterSet` of allowed (non-escaped) characters. `.afURLQueryAllowed` by
     ///                               default.
@@ -345,6 +371,7 @@ public final class URLEncodedFormEncoder {
                 dataEncoding: DataEncoding = .base64,
                 dateEncoding: DateEncoding = .deferredToDate,
                 keyEncoding: KeyEncoding = .useDefaultKeys,
+                nilEncoding: NilEncoding = .dropKey,
                 spaceEncoding: SpaceEncoding = .percentEscaped,
                 allowedCharacters: CharacterSet = .afURLQueryAllowed) {
         self.alphabetizeKeyValuePairs = alphabetizeKeyValuePairs
@@ -353,6 +380,7 @@ public final class URLEncodedFormEncoder {
         self.dataEncoding = dataEncoding
         self.dateEncoding = dateEncoding
         self.keyEncoding = keyEncoding
+        self.nilEncoding = nilEncoding
         self.spaceEncoding = spaceEncoding
         self.allowedCharacters = allowedCharacters
     }
@@ -362,7 +390,8 @@ public final class URLEncodedFormEncoder {
         let encoder = _URLEncodedFormEncoder(context: context,
                                              boolEncoding: boolEncoding,
                                              dataEncoding: dataEncoding,
-                                             dateEncoding: dateEncoding)
+                                             dateEncoding: dateEncoding,
+                                             nilEncoding: nilEncoding)
         try value.encode(to: encoder)
 
         return context.component
@@ -416,17 +445,20 @@ final class _URLEncodedFormEncoder {
     private let boolEncoding: URLEncodedFormEncoder.BoolEncoding
     private let dataEncoding: URLEncodedFormEncoder.DataEncoding
     private let dateEncoding: URLEncodedFormEncoder.DateEncoding
+    private let nilEncoding: URLEncodedFormEncoder.NilEncoding
 
     init(context: URLEncodedFormContext,
          codingPath: [CodingKey] = [],
          boolEncoding: URLEncodedFormEncoder.BoolEncoding,
          dataEncoding: URLEncodedFormEncoder.DataEncoding,
-         dateEncoding: URLEncodedFormEncoder.DateEncoding) {
+         dateEncoding: URLEncodedFormEncoder.DateEncoding,
+         nilEncoding: URLEncodedFormEncoder.NilEncoding) {
         self.context = context
         self.codingPath = codingPath
         self.boolEncoding = boolEncoding
         self.dataEncoding = dataEncoding
         self.dateEncoding = dateEncoding
+        self.nilEncoding = nilEncoding
     }
 }
 
@@ -436,7 +468,8 @@ extension _URLEncodedFormEncoder: Encoder {
                                                                    codingPath: codingPath,
                                                                    boolEncoding: boolEncoding,
                                                                    dataEncoding: dataEncoding,
-                                                                   dateEncoding: dateEncoding)
+                                                                   dateEncoding: dateEncoding,
+                                                                   nilEncoding: nilEncoding)
         return KeyedEncodingContainer(container)
     }
 
@@ -445,7 +478,8 @@ extension _URLEncodedFormEncoder: Encoder {
                                                 codingPath: codingPath,
                                                 boolEncoding: boolEncoding,
                                                 dataEncoding: dataEncoding,
-                                                dateEncoding: dateEncoding)
+                                                dateEncoding: dateEncoding,
+                                                nilEncoding: nilEncoding)
     }
 
     func singleValueContainer() -> SingleValueEncodingContainer {
@@ -453,7 +487,8 @@ extension _URLEncodedFormEncoder: Encoder {
                                                     codingPath: codingPath,
                                                     boolEncoding: boolEncoding,
                                                     dataEncoding: dataEncoding,
-                                                    dateEncoding: dateEncoding)
+                                                    dateEncoding: dateEncoding,
+                                                    nilEncoding: nilEncoding)
     }
 }
 
@@ -584,17 +619,20 @@ extension _URLEncodedFormEncoder {
         private let boolEncoding: URLEncodedFormEncoder.BoolEncoding
         private let dataEncoding: URLEncodedFormEncoder.DataEncoding
         private let dateEncoding: URLEncodedFormEncoder.DateEncoding
+        private let nilEncoding: URLEncodedFormEncoder.NilEncoding
 
         init(context: URLEncodedFormContext,
              codingPath: [CodingKey],
              boolEncoding: URLEncodedFormEncoder.BoolEncoding,
              dataEncoding: URLEncodedFormEncoder.DataEncoding,
-             dateEncoding: URLEncodedFormEncoder.DateEncoding) {
+             dateEncoding: URLEncodedFormEncoder.DateEncoding,
+             nilEncoding: URLEncodedFormEncoder.NilEncoding) {
             self.context = context
             self.codingPath = codingPath
             self.boolEncoding = boolEncoding
             self.dataEncoding = dataEncoding
             self.dateEncoding = dateEncoding
+            self.nilEncoding = nilEncoding
         }
 
         private func nestedCodingPath(for key: CodingKey) -> [CodingKey] {
@@ -605,9 +643,9 @@ extension _URLEncodedFormEncoder {
 
 extension _URLEncodedFormEncoder.KeyedContainer: KeyedEncodingContainerProtocol {
     func encodeNil(forKey key: Key) throws {
-        let context = EncodingError.Context(codingPath: codingPath,
-                                            debugDescription: "URLEncodedFormEncoder cannot encode nil values.")
-        throw EncodingError.invalidValue("\(key): nil", context)
+        guard let nilValue = nilEncoding.encodeNil() else { return }
+
+        try encode(nilValue, forKey: key)
     }
 
     func encode<T>(_ value: T, forKey key: Key) throws where T: Encodable {
@@ -620,7 +658,8 @@ extension _URLEncodedFormEncoder.KeyedContainer: KeyedEncodingContainerProtocol
                                                                     codingPath: nestedCodingPath(for: key),
                                                                     boolEncoding: boolEncoding,
                                                                     dataEncoding: dataEncoding,
-                                                                    dateEncoding: dateEncoding)
+                                                                    dateEncoding: dateEncoding,
+                                                                    nilEncoding: nilEncoding)
 
         return container
     }
@@ -630,7 +669,8 @@ extension _URLEncodedFormEncoder.KeyedContainer: KeyedEncodingContainerProtocol
                                                                 codingPath: nestedCodingPath(for: key),
                                                                 boolEncoding: boolEncoding,
                                                                 dataEncoding: dataEncoding,
-                                                                dateEncoding: dateEncoding)
+                                                                dateEncoding: dateEncoding,
+                                                                nilEncoding: nilEncoding)
 
         return container
     }
@@ -640,7 +680,8 @@ extension _URLEncodedFormEncoder.KeyedContainer: KeyedEncodingContainerProtocol
                                                                          codingPath: nestedCodingPath(for: key),
                                                                          boolEncoding: boolEncoding,
                                                                          dataEncoding: dataEncoding,
-                                                                         dateEncoding: dateEncoding)
+                                                                         dateEncoding: dateEncoding,
+                                                                         nilEncoding: nilEncoding)
 
         return KeyedEncodingContainer(container)
     }
@@ -650,7 +691,8 @@ extension _URLEncodedFormEncoder.KeyedContainer: KeyedEncodingContainerProtocol
                                codingPath: codingPath,
                                boolEncoding: boolEncoding,
                                dataEncoding: dataEncoding,
-                               dateEncoding: dateEncoding)
+                               dateEncoding: dateEncoding,
+                               nilEncoding: nilEncoding)
     }
 
     func superEncoder(forKey key: Key) -> Encoder {
@@ -658,7 +700,8 @@ extension _URLEncodedFormEncoder.KeyedContainer: KeyedEncodingContainerProtocol
                                codingPath: nestedCodingPath(for: key),
                                boolEncoding: boolEncoding,
                                dataEncoding: dataEncoding,
-                               dateEncoding: dateEncoding)
+                               dateEncoding: dateEncoding,
+                               nilEncoding: nilEncoding)
     }
 }
 
@@ -672,17 +715,20 @@ extension _URLEncodedFormEncoder {
         private let boolEncoding: URLEncodedFormEncoder.BoolEncoding
         private let dataEncoding: URLEncodedFormEncoder.DataEncoding
         private let dateEncoding: URLEncodedFormEncoder.DateEncoding
+        private let nilEncoding: URLEncodedFormEncoder.NilEncoding
 
         init(context: URLEncodedFormContext,
              codingPath: [CodingKey],
              boolEncoding: URLEncodedFormEncoder.BoolEncoding,
              dataEncoding: URLEncodedFormEncoder.DataEncoding,
-             dateEncoding: URLEncodedFormEncoder.DateEncoding) {
+             dateEncoding: URLEncodedFormEncoder.DateEncoding,
+             nilEncoding: URLEncodedFormEncoder.NilEncoding) {
             self.context = context
             self.codingPath = codingPath
             self.boolEncoding = boolEncoding
             self.dataEncoding = dataEncoding
             self.dateEncoding = dateEncoding
+            self.nilEncoding = nilEncoding
         }
 
         private func checkCanEncode(value: Any?) throws {
@@ -697,12 +743,9 @@ extension _URLEncodedFormEncoder {
 
 extension _URLEncodedFormEncoder.SingleValueContainer: SingleValueEncodingContainer {
     func encodeNil() throws {
-        try checkCanEncode(value: nil)
-        defer { canEncodeNewValue = false }
+        guard let nilValue = nilEncoding.encodeNil() else { return }
 
-        let context = EncodingError.Context(codingPath: codingPath,
-                                            debugDescription: "URLEncodedFormEncoder cannot encode nil values.")
-        throw EncodingError.invalidValue("nil", context)
+        try encode(nilValue)
     }
 
     func encode(_ value: Bool) throws {
@@ -800,7 +843,8 @@ extension _URLEncodedFormEncoder.SingleValueContainer: SingleValueEncodingContai
                                              codingPath: codingPath,
                                              boolEncoding: boolEncoding,
                                              dataEncoding: dataEncoding,
-                                             dateEncoding: dateEncoding)
+                                             dateEncoding: dateEncoding,
+                                             nilEncoding: nilEncoding)
         try value.encode(to: encoder)
     }
 }
@@ -818,26 +862,29 @@ extension _URLEncodedFormEncoder {
         private let boolEncoding: URLEncodedFormEncoder.BoolEncoding
         private let dataEncoding: URLEncodedFormEncoder.DataEncoding
         private let dateEncoding: URLEncodedFormEncoder.DateEncoding
+        private let nilEncoding: URLEncodedFormEncoder.NilEncoding
 
         init(context: URLEncodedFormContext,
              codingPath: [CodingKey],
              boolEncoding: URLEncodedFormEncoder.BoolEncoding,
              dataEncoding: URLEncodedFormEncoder.DataEncoding,
-             dateEncoding: URLEncodedFormEncoder.DateEncoding) {
+             dateEncoding: URLEncodedFormEncoder.DateEncoding,
+             nilEncoding: URLEncodedFormEncoder.NilEncoding) {
             self.context = context
             self.codingPath = codingPath
             self.boolEncoding = boolEncoding
             self.dataEncoding = dataEncoding
             self.dateEncoding = dateEncoding
+            self.nilEncoding = nilEncoding
         }
     }
 }
 
 extension _URLEncodedFormEncoder.UnkeyedContainer: UnkeyedEncodingContainer {
     func encodeNil() throws {
-        let context = EncodingError.Context(codingPath: codingPath,
-                                            debugDescription: "URLEncodedFormEncoder cannot encode nil values.")
-        throw EncodingError.invalidValue("nil", context)
+        guard let nilValue = nilEncoding.encodeNil() else { return }
+
+        try encode(nilValue)
     }
 
     func encode<T>(_ value: T) throws where T: Encodable {
@@ -852,7 +899,8 @@ extension _URLEncodedFormEncoder.UnkeyedContainer: UnkeyedEncodingContainer {
                                                            codingPath: nestedCodingPath,
                                                            boolEncoding: boolEncoding,
                                                            dataEncoding: dataEncoding,
-                                                           dateEncoding: dateEncoding)
+                                                           dateEncoding: dateEncoding,
+                                                           nilEncoding: nilEncoding)
     }
 
     func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer<NestedKey> where NestedKey: CodingKey {
@@ -861,7 +909,8 @@ extension _URLEncodedFormEncoder.UnkeyedContainer: UnkeyedEncodingContainer {
                                                                          codingPath: nestedCodingPath,
                                                                          boolEncoding: boolEncoding,
                                                                          dataEncoding: dataEncoding,
-                                                                         dateEncoding: dateEncoding)
+                                                                         dateEncoding: dateEncoding,
+                                                                         nilEncoding: nilEncoding)
 
         return KeyedEncodingContainer(container)
     }
@@ -873,7 +922,8 @@ extension _URLEncodedFormEncoder.UnkeyedContainer: UnkeyedEncodingContainer {
                                                        codingPath: nestedCodingPath,
                                                        boolEncoding: boolEncoding,
                                                        dataEncoding: dataEncoding,
-                                                       dateEncoding: dateEncoding)
+                                                       dateEncoding: dateEncoding,
+                                                       nilEncoding: nilEncoding)
     }
 
     func superEncoder() -> Encoder {
@@ -883,7 +933,8 @@ extension _URLEncodedFormEncoder.UnkeyedContainer: UnkeyedEncodingContainer {
                                       codingPath: codingPath,
                                       boolEncoding: boolEncoding,
                                       dataEncoding: dataEncoding,
-                                      dateEncoding: dateEncoding)
+                                      dateEncoding: dateEncoding,
+                                      nilEncoding: nilEncoding)
     }
 }
 

+ 48 - 36
Tests/ParameterEncoderTests.swift

@@ -193,30 +193,6 @@ final class URLEncodedFormParameterEncoderTests: BaseTestCase {
 }
 
 final class URLEncodedFormEncoderTests: BaseTestCase {
-    func testEncoderThrowsErrorWhenAttemptingToEncodeNilInKeyedContainer() {
-        // Given
-        let encoder = URLEncodedFormEncoder()
-        let parameters = FailingOptionalStruct(testedContainer: .keyed)
-
-        // When
-        let result = Result<String, Error> { try encoder.encode(parameters) }
-
-        // Then
-        XCTAssertTrue(result.isFailure)
-    }
-
-    func testEncoderThrowsErrorWhenAttemptingToEncodeNilInUnkeyedContainer() {
-        // Given
-        let encoder = URLEncodedFormEncoder()
-        let parameters = FailingOptionalStruct(testedContainer: .unkeyed)
-
-        // When
-        let result = Result<String, Error> { try encoder.encode(parameters) }
-
-        // Then
-        XCTAssertTrue(result.isFailure)
-    }
-
     func testEncoderCanEncodeDictionary() {
         // Given
         let encoder = URLEncodedFormEncoder()
@@ -500,18 +476,6 @@ final class URLEncodedFormEncoderTests: BaseTestCase {
         XCTAssertFalse(result.isSuccess)
     }
 
-    func testThatOptionalValuesCannotBeEncoded() {
-        // Given
-        let encoder = URLEncodedFormEncoder()
-        let parameters: [String: String?] = ["string": nil]
-
-        // When
-        let result = Result<String, Error> { try encoder.encode(parameters) }
-
-        // Then
-        XCTAssertFalse(result.isSuccess)
-    }
-
     func testThatEncodableSuperclassCanBeEncodedWithIndexInBrackets() {
         // Given
         let encoder = URLEncodedFormEncoder(arrayEncoding: .indexInBrackets)
@@ -826,6 +790,54 @@ final class URLEncodedFormEncoderTests: BaseTestCase {
         XCTAssertEqual(result.success, "A=oneTwoThree")
     }
 
+    func testThatNilCanBeEncodedByDroppingTheKeyByDefault() {
+        // Given
+        let encoder = URLEncodedFormEncoder()
+        let parameters: [String: String?] = ["a": nil]
+
+        // When
+        let result = Result<String, Error> { try encoder.encode(parameters) }
+
+        // Then
+        XCTAssertEqual(result.success, "")
+    }
+
+    func testThatNilCanBeEncodedAsNull() {
+        // Given
+        let encoder = URLEncodedFormEncoder(nilEncoding: .null)
+        let parameters: [String: String?] = ["a": nil]
+
+        // When
+        let result = Result<String, Error> { try encoder.encode(parameters) }
+
+        // Then
+        XCTAssertEqual(result.success, "a=null")
+    }
+
+    func testThatNilCanBeEncodedByDroppingTheKey() {
+        // Given
+        let encoder = URLEncodedFormEncoder(nilEncoding: .dropKey)
+        let parameters: [String: String?] = ["a": nil]
+
+        // When
+        let result = Result<String, Error> { try encoder.encode(parameters) }
+
+        // Then
+        XCTAssertEqual(result.success, "")
+    }
+
+    func testThatNilCanBeEncodedByDroppingTheValue() {
+        // Given
+        let encoder = URLEncodedFormEncoder(nilEncoding: .dropValue)
+        let parameters: [String: String?] = ["a": nil]
+
+        // When
+        let result = Result<String, Error> { try encoder.encode(parameters) }
+
+        // Then
+        XCTAssertEqual(result.success, "a=")
+    }
+
     func testThatSpacesCanBeEncodedAsPluses() {
         // Given
         let encoder = URLEncodedFormEncoder(spaceEncoding: .plusReplaced)