Browse Source

Date encoding strategies for URLEncodedFormEncoder (#2813)

* Added date encoding strategies to `URLEncodedFormEncoder`

* Documentation fixes and error handling in `URLEncodedFormEncoder.DateEncoding`
Almaz Ibragimov 6 years ago
parent
commit
a516974f78
2 changed files with 210 additions and 23 deletions
  1. 119 23
      Source/ParameterEncoder.swift
  2. 91 0
      Tests/ParameterEncoderTests.swift

+ 119 - 23
Source/ParameterEncoder.swift

@@ -197,6 +197,9 @@ open class URLEncodedFormParameterEncoder: ParameterEncoder {
 /// `BoolEncoding` can be used to configure how `Bool` values are encoded. The default behavior is to encode
 /// `true` as 1 and `false` as 0.
 ///
+/// `DateEncoding` can be used to configure how `Date` values are encoded. By default, the `.deferredToDate`
+/// strategy is used, which formats dates from their structure.
+///
 /// `SpaceEncoding` can be used to configure how spaces are encoded. Modern encodings use percent replacement (%20),
 /// while older encoding may expect spaces to be replaced with +.
 ///
@@ -221,6 +224,49 @@ public final class URLEncodedFormEncoder {
         }
     }
 
+    /// Configures how `Date` parameters are encoded.
+    public enum DateEncoding {
+        /// Defers encoding to the `Date` type.
+        case deferredToDate
+        /// Encodes dates as seconds since midnight UTC on January 1, 1970.
+        case secondsSince1970
+        /// Encodes dates as milliseconds since midnight UTC on January 1, 1970.
+        case millisecondsSince1970
+        /// Encodes dates according to the ISO 8601 and RFC3339 standards.
+        case iso8601
+        /// Encodes dates using the given `DateFormatter`.
+        case formatted(DateFormatter)
+        /// Encodes dates using the given closure.
+        case custom((Date) throws -> String)
+
+        private static let iso8601Formatter: ISO8601DateFormatter = {
+            let formatter = ISO8601DateFormatter()
+            formatter.formatOptions = .withInternetDateTime
+            return formatter
+        }()
+
+        /// Encodes the date according to the strategy.
+        ///
+        /// - Parameter string: The `Date` to encode.
+        /// - Returns:          The encoded `String` or `nil` if the date should be encoded as `Encodable` structure.
+        func encode(_ value: Date) throws -> String? {
+            switch self {
+            case .deferredToDate:
+                return nil
+            case .secondsSince1970:
+                return String(value.timeIntervalSince1970)
+            case .millisecondsSince1970:
+                return String(value.timeIntervalSince1970 * 1000.0)
+            case .iso8601:
+                return DateEncoding.iso8601Formatter.string(from: value)
+            case let .formatted(formatter):
+                return formatter.string(from: value)
+            case let .custom(closure):
+                return try closure(value)
+            }
+        }
+    }
+
     /// Configures how `Array` parameters are encoded.
     public enum ArrayEncoding {
         /// An empty set of square brackets ("[]") are sppended to the key for every value.
@@ -271,6 +317,8 @@ public final class URLEncodedFormEncoder {
     public let arrayEncoding: ArrayEncoding
     /// The `BoolEncoding` to use.
     public let boolEncoding: BoolEncoding
+    /// The `DateEncoding` to use.
+    public let dateEncoding: DateEncoding
     /// The `SpaceEncoding` to use.
     public let spaceEncoding: SpaceEncoding
     /// The `CharacterSet` of allowed characters.
@@ -281,21 +329,26 @@ public final class URLEncodedFormEncoder {
     /// - Parameters:
     ///   - arrayEncoding: The `ArrayEncoding` instance. Defaults to `.brackets`.
     ///   - boolEncoding:  The `BoolEncoding` instance. Defaults to `.numeric`.
+    ///   - dateEncoding:  The `DateEncoding` instance. Defaults to `.deferredToDate`.
     ///   - spaceEncoding: The `SpaceEncoding` instance. Defaults to `.percentEscaped`.
     ///   - allowedCharacters: The `CharacterSet` of allowed (non-escaped) characters. Defaults to `.afURLQueryAllowed`.
     public init(arrayEncoding: ArrayEncoding = .brackets,
                 boolEncoding: BoolEncoding = .numeric,
+                dateEncoding: DateEncoding = .deferredToDate,
                 spaceEncoding: SpaceEncoding = .percentEscaped,
                 allowedCharacters: CharacterSet = .afURLQueryAllowed) {
         self.arrayEncoding = arrayEncoding
         self.boolEncoding = boolEncoding
+        self.dateEncoding = dateEncoding
         self.spaceEncoding = spaceEncoding
         self.allowedCharacters = allowedCharacters
     }
 
     func encode(_ value: Encodable) throws -> URLEncodedFormComponent {
         let context = URLEncodedFormContext(.object([:]))
-        let encoder = _URLEncodedFormEncoder(context: context, boolEncoding: boolEncoding)
+        let encoder = _URLEncodedFormEncoder(context: context,
+                                             boolEncoding: boolEncoding,
+                                             dateEncoding: dateEncoding)
         try value.encode(to: encoder)
 
         return context.component
@@ -342,13 +395,16 @@ final class _URLEncodedFormEncoder {
     let context: URLEncodedFormContext
 
     private let boolEncoding: URLEncodedFormEncoder.BoolEncoding
+    private let dateEncoding: URLEncodedFormEncoder.DateEncoding
 
     public init(context: URLEncodedFormContext,
                 codingPath: [CodingKey] = [],
-                boolEncoding: URLEncodedFormEncoder.BoolEncoding) {
+                boolEncoding: URLEncodedFormEncoder.BoolEncoding,
+                dateEncoding: URLEncodedFormEncoder.DateEncoding) {
         self.context = context
         self.codingPath = codingPath
         self.boolEncoding = boolEncoding
+        self.dateEncoding = dateEncoding
     }
 }
 
@@ -356,20 +412,23 @@ extension _URLEncodedFormEncoder: Encoder {
     func container<Key>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> where Key : CodingKey {
         let container = _URLEncodedFormEncoder.KeyedContainer<Key>(context: context,
                                                                    codingPath: codingPath,
-                                                                   boolEncoding: boolEncoding)
+                                                                   boolEncoding: boolEncoding,
+                                                                   dateEncoding: dateEncoding)
         return KeyedEncodingContainer(container)
     }
 
     func unkeyedContainer() -> UnkeyedEncodingContainer {
         return _URLEncodedFormEncoder.UnkeyedContainer(context: context,
                                                        codingPath: codingPath,
-                                                       boolEncoding: boolEncoding)
+                                                       boolEncoding: boolEncoding,
+                                                       dateEncoding: dateEncoding)
     }
 
     func singleValueContainer() -> SingleValueEncodingContainer {
         return _URLEncodedFormEncoder.SingleValueContainer(context: context,
                                                            codingPath: codingPath,
-                                                           boolEncoding: boolEncoding)
+                                                           boolEncoding: boolEncoding,
+                                                           dateEncoding: dateEncoding)
     }
 }
 
@@ -492,13 +551,16 @@ extension _URLEncodedFormEncoder {
 
         private let context: URLEncodedFormContext
         private let boolEncoding: URLEncodedFormEncoder.BoolEncoding
+        private let dateEncoding: URLEncodedFormEncoder.DateEncoding
 
         init(context: URLEncodedFormContext,
              codingPath: [CodingKey],
-             boolEncoding: URLEncodedFormEncoder.BoolEncoding) {
+             boolEncoding: URLEncodedFormEncoder.BoolEncoding,
+             dateEncoding: URLEncodedFormEncoder.DateEncoding) {
             self.context = context
             self.codingPath = codingPath
             self.boolEncoding = boolEncoding
+            self.dateEncoding = dateEncoding
         }
 
         private func nestedCodingPath(for key: CodingKey) -> [CodingKey] {
@@ -522,7 +584,8 @@ extension _URLEncodedFormEncoder.KeyedContainer: KeyedEncodingContainerProtocol
     func nestedSingleValueEncoder(for key: Key) -> SingleValueEncodingContainer {
         let container = _URLEncodedFormEncoder.SingleValueContainer(context: context,
                                                                     codingPath: nestedCodingPath(for: key),
-                                                                    boolEncoding: boolEncoding)
+                                                                    boolEncoding: boolEncoding,
+                                                                    dateEncoding: dateEncoding)
 
         return container
     }
@@ -530,7 +593,8 @@ extension _URLEncodedFormEncoder.KeyedContainer: KeyedEncodingContainerProtocol
     func nestedUnkeyedContainer(forKey key: Key) -> UnkeyedEncodingContainer {
         let container = _URLEncodedFormEncoder.UnkeyedContainer(context: context,
                                                                 codingPath: nestedCodingPath(for: key),
-                                                                boolEncoding: boolEncoding)
+                                                                boolEncoding: boolEncoding,
+                                                                dateEncoding: dateEncoding)
 
         return container
     }
@@ -538,17 +602,24 @@ extension _URLEncodedFormEncoder.KeyedContainer: KeyedEncodingContainerProtocol
     func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer<NestedKey> where NestedKey : CodingKey {
         let container = _URLEncodedFormEncoder.KeyedContainer<NestedKey>(context: context,
                                                                          codingPath: nestedCodingPath(for: key),
-                                                                         boolEncoding: boolEncoding)
+                                                                         boolEncoding: boolEncoding,
+                                                                         dateEncoding: dateEncoding)
 
         return KeyedEncodingContainer(container)
     }
 
     func superEncoder() -> Encoder {
-        return _URLEncodedFormEncoder(context: context, codingPath: codingPath, boolEncoding: boolEncoding)
+        return _URLEncodedFormEncoder(context: context,
+                                      codingPath: codingPath,
+                                      boolEncoding: boolEncoding,
+                                      dateEncoding: dateEncoding)
     }
 
     func superEncoder(forKey key: Key) -> Encoder {
-        return _URLEncodedFormEncoder(context: context, codingPath: nestedCodingPath(for: key), boolEncoding: boolEncoding)
+        return _URLEncodedFormEncoder(context: context,
+                                      codingPath: nestedCodingPath(for: key),
+                                      boolEncoding: boolEncoding,
+                                      dateEncoding: dateEncoding)
     }
 }
 
@@ -560,11 +631,16 @@ extension _URLEncodedFormEncoder {
 
         private let context: URLEncodedFormContext
         private let boolEncoding: URLEncodedFormEncoder.BoolEncoding
+        private let dateEncoding: URLEncodedFormEncoder.DateEncoding
 
-        init(context: URLEncodedFormContext, codingPath: [CodingKey], boolEncoding: URLEncodedFormEncoder.BoolEncoding) {
+        init(context: URLEncodedFormContext,
+             codingPath: [CodingKey],
+             boolEncoding: URLEncodedFormEncoder.BoolEncoding,
+             dateEncoding: URLEncodedFormEncoder.DateEncoding) {
             self.context = context
             self.codingPath = codingPath
             self.boolEncoding = boolEncoding
+            self.dateEncoding = dateEncoding
         }
 
         private func checkCanEncode(value: Any?) throws {
@@ -651,13 +727,24 @@ extension _URLEncodedFormEncoder.SingleValueContainer: SingleValueEncodingContai
     }
 
     func encode<T>(_ value: T) throws where T : Encodable {
-        try checkCanEncode(value: value)
-        defer { canEncodeNewValue = false }
+        switch value {
+        case let date as Date:
+            guard let string = try dateEncoding.encode(date) else {
+                fallthrough
+            }
 
-        let encoder = _URLEncodedFormEncoder(context: context,
-                                             codingPath: codingPath,
-                                             boolEncoding: boolEncoding)
-        try value.encode(to: encoder)
+            try encode(value, as: string)
+
+        default:
+            try checkCanEncode(value: value)
+            defer { canEncodeNewValue = false }
+
+            let encoder = _URLEncodedFormEncoder(context: context,
+                                                 codingPath: codingPath,
+                                                 boolEncoding: boolEncoding,
+                                                 dateEncoding: dateEncoding)
+            try value.encode(to: encoder)
+        }
     }
 }
 
@@ -672,13 +759,16 @@ extension _URLEncodedFormEncoder {
 
         private let context: URLEncodedFormContext
         private let boolEncoding: URLEncodedFormEncoder.BoolEncoding
+        private let dateEncoding: URLEncodedFormEncoder.DateEncoding
 
         init(context: URLEncodedFormContext,
              codingPath: [CodingKey],
-             boolEncoding: URLEncodedFormEncoder.BoolEncoding) {
+             boolEncoding: URLEncodedFormEncoder.BoolEncoding,
+             dateEncoding: URLEncodedFormEncoder.DateEncoding) {
             self.context = context
             self.codingPath = codingPath
             self.boolEncoding = boolEncoding
+            self.dateEncoding = dateEncoding
         }
     }
 }
@@ -700,14 +790,16 @@ extension _URLEncodedFormEncoder.UnkeyedContainer: UnkeyedEncodingContainer {
 
         return _URLEncodedFormEncoder.SingleValueContainer(context: context,
                                                            codingPath: nestedCodingPath,
-                                                           boolEncoding: boolEncoding)
+                                                           boolEncoding: boolEncoding,
+                                                           dateEncoding: dateEncoding)
     }
 
     func nestedContainer<NestedKey>(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer<NestedKey> where NestedKey : CodingKey {
         defer { count += 1 }
         let container = _URLEncodedFormEncoder.KeyedContainer<NestedKey>(context: context,
                                                                          codingPath: nestedCodingPath,
-                                                                         boolEncoding: boolEncoding)
+                                                                         boolEncoding: boolEncoding,
+                                                                         dateEncoding: dateEncoding)
 
         return KeyedEncodingContainer(container)
     }
@@ -717,13 +809,17 @@ extension _URLEncodedFormEncoder.UnkeyedContainer: UnkeyedEncodingContainer {
 
         return _URLEncodedFormEncoder.UnkeyedContainer(context: context,
                                                        codingPath: nestedCodingPath,
-                                                       boolEncoding: boolEncoding)
+                                                       boolEncoding: boolEncoding,
+                                                       dateEncoding: dateEncoding)
     }
 
     func superEncoder() -> Encoder {
         defer { count += 1 }
 
-        return _URLEncodedFormEncoder(context: context, codingPath: codingPath, boolEncoding: boolEncoding)
+        return _URLEncodedFormEncoder(context: context,
+                                      codingPath: codingPath,
+                                      boolEncoding: boolEncoding,
+                                      dateEncoding: dateEncoding)
     }
 }
 

+ 91 - 0
Tests/ParameterEncoderTests.swift

@@ -485,6 +485,97 @@ final class URLEncodedFormEncoderTests: BaseTestCase {
         XCTAssertEqual(result.value, "bool=true")
     }
 
+    func testThatDatesCanBeEncoded() {
+        // Given
+        let encoder = URLEncodedFormEncoder(dateEncoding: .deferredToDate)
+        let parameters = ["date": Date(timeIntervalSinceReferenceDate: 123.456)]
+
+        // When
+        let result = AFResult<String> { try encoder.encode(parameters) }
+
+        // Then
+        XCTAssertEqual(result.value, "date=123.456")
+    }
+
+    func testThatDatesCanBeEncodedAsSecondsSince1970() {
+        // Given
+        let encoder = URLEncodedFormEncoder(dateEncoding: .secondsSince1970)
+        let parameters = ["date": Date(timeIntervalSinceReferenceDate: 123.456)]
+
+        // When
+        let result = AFResult<String> { try encoder.encode(parameters) }
+
+        // Then
+        XCTAssertEqual(result.value, "date=978307323.456")
+    }
+
+    func testThatDatesCanBeEncodedAsMillisecondsSince1970() {
+        // Given
+        let encoder = URLEncodedFormEncoder(dateEncoding: .millisecondsSince1970)
+        let parameters = ["date": Date(timeIntervalSinceReferenceDate: 123.456)]
+
+        // When
+        let result = AFResult<String> { try encoder.encode(parameters) }
+
+        // Then
+        XCTAssertEqual(result.value, "date=978307323456.0")
+    }
+
+    func testThatDatesCanBeEncodedAsISO8601Formatted() {
+        // Given
+        let encoder = URLEncodedFormEncoder(dateEncoding: .iso8601)
+        let parameters = ["date": Date(timeIntervalSinceReferenceDate: 123.456)]
+
+        // When
+        let result = AFResult<String> { try encoder.encode(parameters) }
+
+        // Then
+        XCTAssertEqual(result.value, "date=2001-01-01T00%3A02%3A03Z")
+    }
+
+    func testThatDatesCanBeEncodedAsFormatted() {
+        // Given
+        let dateFormatter = DateFormatter()
+        dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSSS"
+        dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
+
+        let encoder = URLEncodedFormEncoder(dateEncoding: .formatted(dateFormatter))
+        let parameters = ["date": Date(timeIntervalSinceReferenceDate: 123.456)]
+
+        // When
+        let result = AFResult<String> { try encoder.encode(parameters) }
+
+        // Then
+        XCTAssertEqual(result.value, "date=2001-01-01%2000%3A02%3A03.4560")
+    }
+
+    func testThatDatesCanBeEncodedAsCustomFormatted() {
+        // Given
+        let encoder = URLEncodedFormEncoder(dateEncoding: .custom({ "\($0.timeIntervalSinceReferenceDate)" }))
+        let parameters = ["date": Date(timeIntervalSinceReferenceDate: 123.456)]
+
+        // When
+        let result = AFResult<String> { try encoder.encode(parameters) }
+
+        // Then
+        XCTAssertEqual(result.value, "date=123.456")
+    }
+
+    func testEncoderThrowsErrorWhenCustomDateEncodingFails() {
+        // Given
+        struct DateEncodingError: Error {}
+
+        let encoder = URLEncodedFormEncoder(dateEncoding: .custom({ _ in throw DateEncodingError() }))
+        let parameters = ["date": Date(timeIntervalSinceReferenceDate: 123.456)]
+
+        // When
+        let result = AFResult<String> { try encoder.encode(parameters) }
+
+        // Then
+        XCTAssertTrue(result.isFailure)
+        XCTAssertTrue(result.error is DateEncodingError)
+    }
+
     func testThatArraysCanBeEncodedWithoutBrackets() {
         // Given
         let encoder = URLEncodedFormEncoder(arrayEncoding: .noBrackets)