From a516974f78d8c357e6ae47a92c6d7d8e98518b71 Mon Sep 17 00:00:00 2001 From: Almaz Ibragimov Date: Tue, 7 May 2019 20:59:38 +0300 Subject: [PATCH] Date encoding strategies for URLEncodedFormEncoder (#2813) * Added date encoding strategies to `URLEncodedFormEncoder` * Documentation fixes and error handling in `URLEncodedFormEncoder.DateEncoding` --- Source/ParameterEncoder.swift | 142 +++++++++++++++++++++++++----- Tests/ParameterEncoderTests.swift | 91 +++++++++++++++++++ 2 files changed, 210 insertions(+), 23 deletions(-) diff --git a/Source/ParameterEncoder.swift b/Source/ParameterEncoder.swift index d79995d15..b1e840099 100644 --- a/Source/ParameterEncoder.swift +++ b/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(keyedBy type: Key.Type) -> KeyedEncodingContainer where Key : CodingKey { let container = _URLEncodedFormEncoder.KeyedContainer(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(keyedBy keyType: NestedKey.Type, forKey key: Key) -> KeyedEncodingContainer where NestedKey : CodingKey { let container = _URLEncodedFormEncoder.KeyedContainer(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(_ 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(keyedBy keyType: NestedKey.Type) -> KeyedEncodingContainer where NestedKey : CodingKey { defer { count += 1 } let container = _URLEncodedFormEncoder.KeyedContainer(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) } } diff --git a/Tests/ParameterEncoderTests.swift b/Tests/ParameterEncoderTests.swift index 1182f9f3b..a9a4ce8eb 100644 --- a/Tests/ParameterEncoderTests.swift +++ b/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 { 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 { 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 { 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 { 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 { 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 { 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 { try encoder.encode(parameters) } + + // Then + XCTAssertTrue(result.isFailure) + XCTAssertTrue(result.error is DateEncodingError) + } + func testThatArraysCanBeEncodedWithoutBrackets() { // Given let encoder = URLEncodedFormEncoder(arrayEncoding: .noBrackets)