Skip to content

Commit

Permalink
URLEncodedForm date encoding strategy (#2273)
Browse files Browse the repository at this point in the history
* Added ability to configure date coding/decoding for UrlEncodedForm

* Added documentation to the `DateFormat` enum

* renamed internetDateTime to iso8601

* Added custom date formatter

* Added comment about `ISO8601DateFormatter` performance

* Added ISO8601DateFormatter.threadSpecific so a new ISO8601DateFormatter isn't created for every encode/decode.

* Use typealias instead of declaring DateFormat 2x

* Added `ThreadSpecificDateFormatter` to ensure thread safety when using a `custom` DateFormat for `URLEncodedFormEncoder` or `URLEncodedFormDecoder`

* Fixed comment

* Changed custom interface to mimic `JSONDecoder.DateDecodingStrategy.custom(_:)` and `JSONEncoder.DateEncodingStrategy.custom(_:)` interfaces

* Removed unused ThreadSpecificDateFormatter

* `ISO8601DateFormatter` seems to be thread safe. This bug was filed: https://bugs.swift.org/browse/SR-7745?page=com.atlassian.jira.plugin.system.issuetabpanels%3Aall-tabpanel
I tried running the sample program with `10000000` iterations in the `vapor/swift:5.2` docker image without any issue.

* Default the date format for URLEncodedFrom coding/decoding to `timeIntervalSinceReferenceDate`

* Default to `timeIntervalSince1970`

* Removed `timeIntervalSinceReferenceDate` option

* Made `Date: URLQueryFragmentConvertible`

* Date decoding uses an array of DateFormats and tries in order

* Comments

* Removed unnecessary import

* Comments

* Reintroduced threadSpecific `ISO8601DateFormatter`

* Renamed unixTimestamp to secondsSince1970 to match Apple's naming convention

* Throw last error when decodingDate using multiple decoding approaches

* Use `DateDecodingStrategy` and `DateEncodingStrategy` naming convention

* Updated test cases

* Updated documentation

* Added additional default test and updated documentation

* Improved error handling

* Removed unnecessary comment

* Replaced dateDecodingStrategies with dateDecodingStrategy
  • Loading branch information
thecheatah committed Apr 21, 2020
1 parent 9b4ebf7 commit 7f6c827
Show file tree
Hide file tree
Showing 4 changed files with 173 additions and 8 deletions.
17 changes: 17 additions & 0 deletions Sources/Vapor/URLEncodedForm/DateFormatter+threadSpecific.swift
@@ -0,0 +1,17 @@
import NIO

fileprivate final class ISO8601 {
fileprivate static let threadSpecific: ThreadSpecificVariable<ISO8601DateFormatter> = .init()
}

extension ISO8601DateFormatter {
static var threadSpecific: ISO8601DateFormatter {
if let existing = ISO8601.threadSpecific.currentValue {
return existing
} else {
let new = ISO8601DateFormatter()
ISO8601.threadSpecific.currentValue = new
return new
}
}
}
51 changes: 49 additions & 2 deletions Sources/Vapor/URLEncodedForm/URLEncodedFormDecoder.swift
Expand Up @@ -13,9 +13,19 @@
public struct URLEncodedFormDecoder: ContentDecoder, URLQueryDecoder {
/// Used to capture URLForm Coding Configuration used for decoding
public struct Configuration {
/// Supported date formats
public enum DateDecodingStrategy {
/// Seconds since 1 January 1970 00:00:00 UTC (Unix Timestamp)
case secondsSince1970
/// ISO 8601 formatted date
case iso8601
/// Using custom callback
case custom((Decoder) throws -> Date)
}

let boolFlags: Bool
let arraySeparators: [Character]

let dateDecodingStrategy: DateDecodingStrategy
/// Creates a new `URLEncodedFormCodingConfiguration`.
/// - parameters:
/// - boolFlags: Set to `true` allows you to parse `flag1&flag2` as boolean variables
Expand All @@ -24,12 +34,15 @@ public struct URLEncodedFormDecoder: ContentDecoder, URLQueryDecoder {
/// true, it will always resolve for an optional `Bool`.
/// - arraySeparators: Uses these characters to decode arrays. If set to `,`, `arr=v1,v2` would
/// populate a key named `arr` of type `Array` to be decoded as `["v1", "v2"]`
/// - dateDecodingStrategy: Date format used to decode a date. Date formats are tried in the order provided
public init(
boolFlags: Bool = true,
arraySeparators: [Character] = [",", "|"]
arraySeparators: [Character] = [",", "|"],
dateDecodingStrategy: DateDecodingStrategy = .secondsSince1970
) {
self.boolFlags = boolFlags
self.arraySeparators = arraySeparators
self.dateDecodingStrategy = dateDecodingStrategy
}
}

Expand Down Expand Up @@ -155,7 +168,41 @@ private struct _Decoder: Decoder {
return self.data.children[key.stringValue] == nil
}

private func decodeDate(forKey key: Key) throws -> Date {
return try decodeDate(forKey: key, as: configuration.dateDecodingStrategy)
}

private func decodeDate(forKey key: Key, as dateFormat: URLEncodedFormDecoder.Configuration.DateDecodingStrategy) throws -> Date {
//If we are trying to decode a required array, we might not have decoded a child, but we should still try to decode an empty array
let child = self.data.children[key.stringValue] ?? []
switch dateFormat {
case .secondsSince1970:
guard let value = child.values.last else {
throw DecodingError.valueNotFound(Date.self, at: self.codingPath + [key])
}
if let result = Date.init(urlQueryFragmentValue: value) {
return result
} else {
throw DecodingError.typeMismatch(Date.self, at: self.codingPath + [key])
}
case .iso8601:
let decoder = _Decoder(data: child, codingPath: self.codingPath + [key], configuration: configuration)
if let date = ISO8601DateFormatter.threadSpecific.date(from: try String(from: decoder)) {
return date
} else {
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Unable to decode date. Expecting ISO8601 formatted date"))
}
case .custom(let callback):
let decoder = _Decoder(data: child, codingPath: self.codingPath + [key], configuration: configuration)
return try callback(decoder)
}
}

func decode<T>(_ type: T.Type, forKey key: Key) throws -> T where T: Decodable {
//Check if we received a date. We need the decode with the appropriate format
guard !(T.self is Date.Type) else {
return try decodeDate(forKey: key) as! T
}
//If we are trying to decode a required array, we might not have decoded a child, but we should still try to decode an empty array
let child = self.data.children[key.stringValue] ?? []
if let convertible = T.self as? URLQueryFragmentConvertible.Type {
Expand Down
36 changes: 34 additions & 2 deletions Sources/Vapor/URLEncodedForm/URLEncodedFormEncoder.swift
Expand Up @@ -27,15 +27,30 @@ public struct URLEncodedFormEncoder: ContentEncoder, URLQueryEncoder {
case values
}

/// Supported date formats
public enum DateEncodingStrategy {
/// Seconds since 1 January 1970 00:00:00 UTC (Unix Timestamp)
case secondsSince1970
/// ISO 8601 formatted date
case iso8601
/// Using custom callback
case custom((Date, Encoder) throws -> Void)
}
/// Specified array encoding.
public var arrayEncoding: ArrayEncoding
public var dateEncodingStrategy: DateEncodingStrategy

/// Creates a new `Configuration`.
///
/// - parameters:
/// - arrayEncoding: Specified array encoding. Defaults to `.bracket`.
public init(arrayEncoding: ArrayEncoding = .bracket) {
/// - dateFormat: Format to encode date format too. Defaults to `secondsSince1970`
public init(
arrayEncoding: ArrayEncoding = .bracket,
dateEncodingStrategy: DateEncodingStrategy = .secondsSince1970
) {
self.arrayEncoding = arrayEncoding
self.dateEncodingStrategy = dateEncodingStrategy
}
}

Expand Down Expand Up @@ -163,11 +178,28 @@ private class _Encoder: Encoder {
// skip
}

private func encodeDate(_ date: Date, forKey key: Key) throws {
switch configuration.dateEncodingStrategy {
case .secondsSince1970:
internalData.children[key.stringValue] = URLEncodedFormData(values: [date.urlQueryFragmentValue])
case .iso8601:
internalData.children[key.stringValue] = URLEncodedFormData(values: [
ISO8601DateFormatter.threadSpecific.string(from: date).urlQueryFragmentValue
])
case .custom(let callback):
let encoder = _Encoder(codingPath: self.codingPath + [key], configuration: self.configuration)
try callback(date, encoder)
self.internalData.children[key.stringValue] = try encoder.getData()
}
}

/// See `KeyedEncodingContainerProtocol`
func encode<T>(_ value: T, forKey key: Key) throws
where T : Encodable
{
if let convertible = value as? URLQueryFragmentConvertible {
if let date = value as? Date {
try encodeDate(date, forKey: key)
} else if let convertible = value as? URLQueryFragmentConvertible {
internalData.children[key.stringValue] = URLEncodedFormData(values: [convertible.urlQueryFragmentValue])
} else {
let encoder = _Encoder(codingPath: self.codingPath + [key], configuration: self.configuration)
Expand Down
77 changes: 73 additions & 4 deletions Tests/VaporTests/URLEncodedFormTests.swift
@@ -1,5 +1,6 @@
@testable import Vapor
import XCTest
import NIO

final class URLEncodedFormTests: XCTestCase {
// MARK: Codable
Expand Down Expand Up @@ -116,13 +117,81 @@ final class URLEncodedFormTests: XCTestCase {

func testDateCoding() throws {
let toEncode = DateCoding(date: Date(timeIntervalSince1970: 0))
let resultForTimeIntervalSince1970 = try URLEncodedFormEncoder()
.encode(toEncode)

let decodedDefaultFromUnixTimestamp = try URLEncodedFormDecoder().decode(DateCoding.self, from: "date=0")
XCTAssertEqual(decodedDefaultFromUnixTimestamp, toEncode)

let resultForDefault = try URLEncodedFormEncoder().encode(toEncode)
XCTAssertEqual("date=0.0", resultForDefault)

let decodedDefault = try URLEncodedFormDecoder().decode(DateCoding.self, from: resultForDefault)
XCTAssertEqual(decodedDefault, toEncode)

let resultForTimeIntervalSince1970 = try URLEncodedFormEncoder(
configuration: .init(dateEncodingStrategy: .secondsSince1970)
).encode(toEncode)
XCTAssertEqual("date=0.0", resultForTimeIntervalSince1970)

let decodedTimeIntervalSince1970 = try URLEncodedFormDecoder()
.decode(DateCoding.self, from: resultForTimeIntervalSince1970)
let decodedTimeIntervalSince1970 = try URLEncodedFormDecoder(
configuration: .init(dateDecodingStrategy: .secondsSince1970)
).decode(DateCoding.self, from: resultForTimeIntervalSince1970)
XCTAssertEqual(decodedTimeIntervalSince1970, toEncode)

let resultForInternetDateTime = try URLEncodedFormEncoder(
configuration: .init(dateEncodingStrategy: .iso8601)
).encode(toEncode)
XCTAssertEqual("date=1970-01-01T00:00:00Z", resultForInternetDateTime)

let decodedInternetDateTime = try URLEncodedFormDecoder(
configuration: .init(dateDecodingStrategy: .iso8601)
).decode(DateCoding.self, from: resultForInternetDateTime)
XCTAssertEqual(decodedInternetDateTime, toEncode)

XCTAssertThrowsError(try URLEncodedFormDecoder(
configuration: .init(dateDecodingStrategy: .iso8601)
).decode(DateCoding.self, from: "date=bad-date"))

class DateFormatterFactory {
private var threadSpecificValue = ThreadSpecificVariable<DateFormatter>()
var currentValue: DateFormatter {
get {
guard let dateFormatter = threadSpecificValue.currentValue else {
let threadSpecificDateFormatter = self.newDateFormatter
threadSpecificValue.currentValue = threadSpecificDateFormatter
return threadSpecificDateFormatter
}
return dateFormatter
}
}

private var newDateFormatter: DateFormatter {
let dateFormatter = DateFormatter()
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.dateFormat = "'Date:' yyyy-MM-dd 'Time:' HH:mm:ss 'Timezone:' ZZZZZ"
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0)
return dateFormatter
}
}
let factory = DateFormatterFactory()
let resultCustom = try URLEncodedFormEncoder(
configuration: .init(dateEncodingStrategy: .custom({ (date, encoder) in
var container = encoder.singleValueContainer()
try container.encode(factory.currentValue.string(from: date))
}))
).encode(toEncode)
XCTAssertEqual("date=Date:%201970-01-01%20Time:%2000:00:00%20Timezone:%20Z", resultCustom)

let decodedCustom = try URLEncodedFormDecoder(
configuration: .init(dateDecodingStrategy: .custom({ (decoder) -> Date in
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
guard let date = factory.currentValue.date(from: string) else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Unable to decode date from string '\(string)'")
}
return date
}))
).decode(DateCoding.self, from: resultCustom)
XCTAssertEqual(decodedCustom, toEncode)
}

func testEncodedArrayValues() throws {
Expand Down

0 comments on commit 7f6c827

Please sign in to comment.