Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

URLEncodedForm date encoding strategy #2273

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
4c6301f
Added ability to configure date coding/decoding for UrlEncodedForm
thecheatah Mar 29, 2020
a318cac
Added documentation to the `DateFormat` enum
thecheatah Mar 29, 2020
569205e
renamed internetDateTime to iso8601
thecheatah Mar 29, 2020
47d275c
Added custom date formatter
thecheatah Mar 29, 2020
ad50caa
Added comment about `ISO8601DateFormatter` performance
thecheatah Mar 29, 2020
4f8165f
Added ISO8601DateFormatter.threadSpecific so a new ISO8601DateFormatt…
thecheatah Mar 29, 2020
f212661
Use typealias instead of declaring DateFormat 2x
thecheatah Mar 29, 2020
78908d1
Added `ThreadSpecificDateFormatter` to ensure thread safety when usin…
thecheatah Mar 29, 2020
b9bdedb
Fixed comment
thecheatah Mar 29, 2020
287dff9
Changed custom interface to mimic `JSONDecoder.DateDecodingStrategy.c…
thecheatah Mar 29, 2020
bbaf3e8
Removed unused ThreadSpecificDateFormatter
thecheatah Mar 29, 2020
73617bf
`ISO8601DateFormatter` seems to be thread safe. This bug was filed: h…
thecheatah Mar 30, 2020
2cdccb4
Default the date format for URLEncodedFrom coding/decoding to `timeIn…
thecheatah Mar 30, 2020
ab98e1e
Default to `timeIntervalSince1970`
thecheatah Mar 30, 2020
8b96414
Removed `timeIntervalSinceReferenceDate` option
thecheatah Mar 31, 2020
860d81a
Made `Date: URLQueryFragmentConvertible`
thecheatah Mar 31, 2020
70d35a6
Date decoding uses an array of DateFormats and tries in order
thecheatah Mar 31, 2020
be68616
Comments
thecheatah Mar 31, 2020
76ef4fe
Removed unnecessary import
thecheatah Mar 31, 2020
4750b51
Comments
thecheatah Mar 31, 2020
8c99199
Merged with Vapor 4.0.1
thecheatah Apr 11, 2020
d5cade4
Reintroduced threadSpecific `ISO8601DateFormatter`
thecheatah Apr 17, 2020
0138d63
Renamed unixTimestamp to secondsSince1970 to match Apple's naming con…
thecheatah Apr 17, 2020
d0fcd9a
Throw last error when decodingDate using multiple decoding approaches
thecheatah Apr 17, 2020
b62257b
Use `DateDecodingStrategy` and `DateEncodingStrategy` naming convention
thecheatah Apr 17, 2020
567f686
Updated test cases
thecheatah Apr 17, 2020
57144c5
Merge branch 'master' into Feature/2272-URL-Coding-Internet-Dates
thecheatah Apr 17, 2020
2a80df9
Updated documentation
thecheatah Apr 17, 2020
fe655cb
Added additional default test and updated documentation
thecheatah Apr 19, 2020
914b8b7
Improved error handling
thecheatah Apr 20, 2020
0c173b3
Removed unnecessary comment
thecheatah Apr 20, 2020
bca712a
Replaced dateDecodingStrategies with dateDecodingStrategy
thecheatah Apr 21, 2020
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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
thecheatah marked this conversation as resolved.
Show resolved Hide resolved
) {
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