diff --git a/Sources/Vapor/URLEncodedForm/DateFormatter+threadSpecific.swift b/Sources/Vapor/URLEncodedForm/DateFormatter+threadSpecific.swift new file mode 100644 index 0000000000..c29db8201d --- /dev/null +++ b/Sources/Vapor/URLEncodedForm/DateFormatter+threadSpecific.swift @@ -0,0 +1,17 @@ +import NIO + +fileprivate final class ISO8601 { + fileprivate static let threadSpecific: ThreadSpecificVariable = .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 + } + } +} diff --git a/Sources/Vapor/URLEncodedForm/URLEncodedFormDecoder.swift b/Sources/Vapor/URLEncodedForm/URLEncodedFormDecoder.swift index 5fb0df864c..197eb8f505 100644 --- a/Sources/Vapor/URLEncodedForm/URLEncodedFormDecoder.swift +++ b/Sources/Vapor/URLEncodedForm/URLEncodedFormDecoder.swift @@ -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 @@ -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 } } @@ -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(_ 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 { diff --git a/Sources/Vapor/URLEncodedForm/URLEncodedFormEncoder.swift b/Sources/Vapor/URLEncodedForm/URLEncodedFormEncoder.swift index 423ddfb753..339010aad8 100644 --- a/Sources/Vapor/URLEncodedForm/URLEncodedFormEncoder.swift +++ b/Sources/Vapor/URLEncodedForm/URLEncodedFormEncoder.swift @@ -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 } } @@ -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(_ 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) diff --git a/Tests/VaporTests/URLEncodedFormTests.swift b/Tests/VaporTests/URLEncodedFormTests.swift index 4c1ddc147b..8abcade36a 100644 --- a/Tests/VaporTests/URLEncodedFormTests.swift +++ b/Tests/VaporTests/URLEncodedFormTests.swift @@ -1,5 +1,6 @@ @testable import Vapor import XCTest +import NIO final class URLEncodedFormTests: XCTestCase { // MARK: Codable @@ -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() + 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 {