Prechádzať zdrojové kódy

Add KeyPathEncoding to URLEncodedFormEncoder (#3689)

### Issue Link :link:
Fixes #3687.

### Goals :soccer:
This PR adds `KeyPathEncoding` to `URLEncodedFormEncoder` to allow the
customization of key path encoding in the form encoding.

### Implementation Details :construction:
Like the recent `NilEncoding`, `KeyPathEncoding` is a `struct` which
takes a closure with some static values predefined.

### Testing Details :mag:
Tests added.
Jon Shier 2 rokov pred
rodič
commit
e68cfdf9c5

+ 15 - 0
Documentation/Usage.md

@@ -330,6 +330,21 @@ You can create your own `URLEncodedFormParameterEncoder` and specify the desired
 let encoder = URLEncodedFormParameterEncoder(encoder: URLEncodedFormEncoder(keyEncoding: .convertToSnakeCase))
 ```
 
+#### Configuring the Encoding of Object Key Paths
+
+Nest object key paths are typically encoded using brackets (e.g. `parent[child][grandchild]`). Alamofire provides the `KeyPathEncoding` to customize that behavior.
+
+- `.brackets` - Wraps each sub-key in the key path in brackets. e.g `parent[child][grandchild]`.
+- `.dots` - Separates each sub-key in the key path with dots. e.g. `parent.child.grandchild`.
+
+Additionally, you can create your own encoding by creating an instance with a custom encoding closure. For example, `KeyPathEncoding { "-\($0)" }` will separate each sub-key path with hyphens. e.g. `parent-child-grandchild`.
+
+You can create your own `URLEncodedFormParameterEncoder` and specify the desired `KeyPathEncoding` in the initializer of the passed `URLEncodedFormEncoder`:
+
+```swift
+let encoder = URLEncodedFormParameterEncoder(encoder: URLEncodedFormEncoder(keyPathEncoding: .brackets))
+```
+
 ##### Configuring the Encoding of Spaces
 
 Older form encoders used `+` to encode spaces and some servers still expect this encoding instead of the modern percent encoding, so Alamofire includes the following methods for encoding spaces:

+ 66 - 19
Source/URLEncodedFormEncoder.swift

@@ -26,19 +26,29 @@ import Foundation
 
 /// An object that encodes instances into URL-encoded query strings.
 ///
-/// There is no published specification for how to encode collection types. By default, the convention of appending
-/// `[]` to the key for array values (`foo[]=1&foo[]=2`), and appending the key surrounded by square brackets for
-/// nested dictionary values (`foo[bar]=baz`) is used. Optionally, `ArrayEncoding` can be used to omit the
-/// square brackets appended to array keys.
+/// `ArrayEncoding` can be used to configure how `Array` values are encoded. By default, the `.brackets` encoding is
+/// used, encoding array values with brackets for each value. e.g `array[]=1&array[]=2`.
 ///
-/// `BoolEncoding` can be used to configure how `Bool` values are encoded. The default behavior is to encode
-/// `true` as 1 and `false` as 0.
+/// `BoolEncoding` can be used to configure how `Bool` values are encoded. By default, the `.numeric` encoding is used,
+/// encoding `true` as `1` and `false` as `0`.
+///
+/// `DataEncoding` can be used to configure how `Data` values are encoded. By default, the `.deferredToData` encoding is
+/// used, which encodes `Data` values using their default `Encodable` implementation.
 ///
 /// `DateEncoding` can be used to configure how `Date` values are encoded. By default, the `.deferredToDate`
-/// strategy is used, which formats dates from their structure.
+/// encoding is used, which encodes `Date`s using their default `Encodable` implementation.
+///
+/// `KeyEncoding` can be used to configure how keys are encoded. By default, the `.useDefaultKeys` encoding is used,
+/// which encodes the keys directly from the `Encodable` implementation.
+///
+/// `KeyPathEncoding` can be used to configure how paths within nested objects are encoded. By default, the `.brackets`
+/// encoding is used, which encodes each sub-key in brackets. e.g. `parent[child][grandchild]=value`.
+///
+/// `NilEncoding` can be used to configure how `nil` `Optional` values are encoded. By default, the `.dropKey` encoding
+/// is used, which drops `nil` key / value pairs from the output entirely.
 ///
-/// `SpaceEncoding` can be used to configure how spaces are encoded. Modern encodings use percent replacement (`%20`),
-/// while older encodings may expect spaces to be replaced with `+`.
+/// `SpaceEncoding` can be used to configure how spaces are encoded. By default, the `.percentEscaped` encoding is used,
+/// replacing spaces with `%20`.
 ///
 /// This type is largely based on Vapor's [`url-encoded-form`](https://github.com/vapor/url-encoded-form) project.
 public final class URLEncodedFormEncoder {
@@ -54,10 +64,10 @@ public final class URLEncodedFormEncoder {
         /// Encodes the key according to the encoding.
         ///
         /// - Parameters:
-        ///     - key:      The `key` to encode.
-        ///     - index:   When this enum instance is `.indexInBrackets`, the `index` to encode.
+        ///     - key:   The `key` to encode.
+        ///     - index: When this enum instance is `.indexInBrackets`, the `index` to encode.
         ///
-        /// - Returns:          The encoded key.
+        /// - Returns:   The encoded key.
         func encode(_ key: String, atIndex index: Int) -> String {
             switch self {
             case .brackets: return "\(key)[]"
@@ -234,13 +244,13 @@ public final class URLEncodedFormEncoder {
             var searchRange = key.index(after: wordStart)..<key.endIndex
 
             // Find next uppercase character
-            while let upperCaseRange = key.rangeOfCharacter(from: CharacterSet.uppercaseLetters, options: [], range: searchRange) {
+            while let upperCaseRange = key.rangeOfCharacter(from: .uppercaseLetters, options: [], range: searchRange) {
                 let untilUpperCase = wordStart..<upperCaseRange.lowerBound
                 words.append(untilUpperCase)
 
                 // Find next lowercase character
                 searchRange = upperCaseRange.lowerBound..<searchRange.upperBound
-                guard let lowerCaseRange = key.rangeOfCharacter(from: CharacterSet.lowercaseLetters, options: [], range: searchRange) else {
+                guard let lowerCaseRange = key.rangeOfCharacter(from: .lowercaseLetters, options: [], range: searchRange) else {
                     // There are no more lower case letters. Just end here.
                     wordStart = searchRange.lowerBound
                     break
@@ -255,7 +265,8 @@ public final class URLEncodedFormEncoder {
                     // Continue searching for the next upper case for the boundary.
                     wordStart = upperCaseRange.lowerBound
                 } else {
-                    // There was a range of >1 capital letters. Turn those into a word, stopping at the capital before the lower case character.
+                    // There was a range of >1 capital letters. Turn those into a word, stopping at the capital before
+                    // the lower case character.
                     let beforeLowerIndex = key.index(before: lowerCaseRange.lowerBound)
                     words.append(upperCaseRange.lowerBound..<beforeLowerIndex)
 
@@ -273,6 +284,34 @@ public final class URLEncodedFormEncoder {
         }
     }
 
+    /// Encoding to use for nested object and `Encodable` value key paths.
+    ///
+    /// ```
+    /// ["parent" : ["child" : ["grandchild": "value"]]]
+    /// ```
+    ///
+    /// This encoding affects how the `parent`, `child`, `grandchild` path is encoded. Brackets are used by default.
+    /// e.g. `parent[child][grandchild]=value`.
+    public struct KeyPathEncoding {
+        /// Encodes key paths by wrapping each component in brackets. e.g. `parent[child][grandchild]`.
+        public static let brackets = KeyPathEncoding { "[\($0)]" }
+        /// Encodes key paths by separating each component with dots. e.g. `parent.child.grandchild`.
+        public static let dots = KeyPathEncoding { ".\($0)" }
+
+        private let encoding: (_ subkey: String) -> String
+
+        /// Creates an instance with the encoding closure called for each sub-key in a key path.
+        ///
+        /// - Parameter encoding: Closure used to perform the encoding.
+        public init(encoding: @escaping (_ subkey: String) -> String) {
+            self.encoding = encoding
+        }
+
+        func encodeKeyPath(_ keyPath: String) -> String {
+            encoding(keyPath)
+        }
+    }
+
     /// Encoding to use for `nil` values.
     public struct NilEncoding {
         /// Encodes `nil` by dropping the entire key / value pair.
@@ -284,7 +323,7 @@ public final class URLEncodedFormEncoder {
 
         private let encoding: () -> String?
 
-        /// Creates an instance with the specified encoding.
+        /// Creates an instance with the encoding closure called for `nil` values.
         ///
         /// - Parameter encoding: Closure used to perform the encoding.
         public init(encoding: @escaping () -> String?) {
@@ -298,9 +337,9 @@ public final class URLEncodedFormEncoder {
 
     /// Encoding to use for spaces.
     public enum SpaceEncoding {
-        /// Encodes spaces according to normal percent escaping rules (%20).
+        /// Encodes spaces using percent escaping (`%20`).
         case percentEscaped
-        /// Encodes spaces as `+`,
+        /// Encodes spaces as `+`.
         case plusReplaced
 
         /// Encodes the string according to the encoding.
@@ -345,6 +384,8 @@ public final class URLEncodedFormEncoder {
     public let dateEncoding: DateEncoding
     /// The `KeyEncoding` to use.
     public let keyEncoding: KeyEncoding
+    /// The `KeyPathEncoding` to use.
+    public let keyPathEncoding: KeyPathEncoding
     /// The `NilEncoding` to use.
     public let nilEncoding: NilEncoding
     /// The `SpaceEncoding` to use.
@@ -371,6 +412,7 @@ public final class URLEncodedFormEncoder {
                 dataEncoding: DataEncoding = .base64,
                 dateEncoding: DateEncoding = .deferredToDate,
                 keyEncoding: KeyEncoding = .useDefaultKeys,
+                keyPathEncoding: KeyPathEncoding = .brackets,
                 nilEncoding: NilEncoding = .dropKey,
                 spaceEncoding: SpaceEncoding = .percentEscaped,
                 allowedCharacters: CharacterSet = .afURLQueryAllowed) {
@@ -380,6 +422,7 @@ public final class URLEncodedFormEncoder {
         self.dataEncoding = dataEncoding
         self.dateEncoding = dateEncoding
         self.keyEncoding = keyEncoding
+        self.keyPathEncoding = keyPathEncoding
         self.nilEncoding = nilEncoding
         self.spaceEncoding = spaceEncoding
         self.allowedCharacters = allowedCharacters
@@ -413,6 +456,7 @@ public final class URLEncodedFormEncoder {
         let serializer = URLEncodedFormSerializer(alphabetizeKeyValuePairs: alphabetizeKeyValuePairs,
                                                   arrayEncoding: arrayEncoding,
                                                   keyEncoding: keyEncoding,
+                                                  keyPathEncoding: keyPathEncoding,
                                                   spaceEncoding: spaceEncoding,
                                                   allowedCharacters: allowedCharacters)
         let query = serializer.serialize(object)
@@ -942,17 +986,20 @@ final class URLEncodedFormSerializer {
     private let alphabetizeKeyValuePairs: Bool
     private let arrayEncoding: URLEncodedFormEncoder.ArrayEncoding
     private let keyEncoding: URLEncodedFormEncoder.KeyEncoding
+    private let keyPathEncoding: URLEncodedFormEncoder.KeyPathEncoding
     private let spaceEncoding: URLEncodedFormEncoder.SpaceEncoding
     private let allowedCharacters: CharacterSet
 
     init(alphabetizeKeyValuePairs: Bool,
          arrayEncoding: URLEncodedFormEncoder.ArrayEncoding,
          keyEncoding: URLEncodedFormEncoder.KeyEncoding,
+         keyPathEncoding: URLEncodedFormEncoder.KeyPathEncoding,
          spaceEncoding: URLEncodedFormEncoder.SpaceEncoding,
          allowedCharacters: CharacterSet) {
         self.alphabetizeKeyValuePairs = alphabetizeKeyValuePairs
         self.arrayEncoding = arrayEncoding
         self.keyEncoding = keyEncoding
+        self.keyPathEncoding = keyPathEncoding
         self.spaceEncoding = spaceEncoding
         self.allowedCharacters = allowedCharacters
     }
@@ -978,7 +1025,7 @@ final class URLEncodedFormSerializer {
 
     func serialize(_ object: URLEncodedFormComponent.Object, forKey key: String) -> String {
         var segments: [String] = object.map { subKey, value in
-            let keyPath = "[\(subKey)]"
+            let keyPath = keyPathEncoding.encodeKeyPath(subKey)
             return serialize(value, forKey: key + keyPath)
         }
         segments = alphabetizeKeyValuePairs ? segments.sorted() : segments

+ 37 - 1
Tests/ParameterEncoderTests.swift

@@ -363,7 +363,7 @@ final class URLEncodedFormEncoderTests: BaseTestCase {
         XCTAssertEqual(result.success, "a=1")
     }
 
-    func testThatNestedDictionariesHaveBracketedKeys() {
+    func testThatNestedDictionariesCanHaveBracketKeyPathsByDefault() {
         // Given
         let encoder = URLEncodedFormEncoder()
         let parameters = ["a": ["b": "b"]]
@@ -375,6 +375,42 @@ final class URLEncodedFormEncoderTests: BaseTestCase {
         XCTAssertEqual(result.success, "a%5Bb%5D=b")
     }
 
+    func testThatNestedDictionariesCanHaveExplicitBracketKeyPaths() {
+        // Given
+        let encoder = URLEncodedFormEncoder(keyPathEncoding: .brackets)
+        let parameters = ["a": ["b": "b"]]
+
+        // When
+        let result = Result<String, Error> { try encoder.encode(parameters) }
+
+        // Then
+        XCTAssertEqual(result.success, "a%5Bb%5D=b")
+    }
+
+    func testThatNestedDictionariesCanHaveDottedKeyPaths() {
+        // Given
+        let encoder = URLEncodedFormEncoder(keyPathEncoding: .dots)
+        let parameters = ["a": ["b": "b"]]
+
+        // When
+        let result = Result<String, Error> { try encoder.encode(parameters) }
+
+        // Then
+        XCTAssertEqual(result.success, "a.b=b")
+    }
+
+    func testThatNestedDictionariesCanHaveCustomKeyPaths() {
+        // Given
+        let encoder = URLEncodedFormEncoder(keyPathEncoding: .init { "-\($0)" })
+        let parameters = ["a": ["b": "b"]]
+
+        // When
+        let result = Result<String, Error> { try encoder.encode(parameters) }
+
+        // Then
+        XCTAssertEqual(result.success, "a-b=b")
+    }
+
     func testThatEncodableStructCanBeEncoded() {
         // Given
         let encoder = URLEncodedFormEncoder()