diff --git a/README.md b/README.md index ed6a273..ebcfb0a 100644 --- a/README.md +++ b/README.md @@ -141,7 +141,7 @@ let user = try User.decode(from: json) #### CodableDate This wrapper allows decoding dates on per-property strategy basis. By default, `CodableDate` uses the `iso8601` strategy. The built-in strategies are: -`iso8601, rfc2822, rfc3339 and timestamp`. There is also the option of using a custom format by providing a valid string format to the option `.format()`. +`iso8601`, `iso8601WithMillisecondPrecision`, `rfc2822`, `rfc3339`, and `timestamp`. There is also the option of using a custom format by providing a valid string format to the option `.format()`. ```Swift struct Dates: Kodable { @@ -170,6 +170,7 @@ print(dates.rfc3339Date.description) // Prints "1996-12-20 00:39:57 +0000" print(dates.timestamp.description) // Prints "2001-01-01 00:00:00 +0000" ```` +Note that there's no built-in support for ISO8601 dates with precision greater than millisecond (e.g. microsecond or nanosecond), because Apple doesn't officially supports such precision natively, yet. Should you feel the necessity to have those, PRs are always welcome! ## Advanced Usage diff --git a/Sources/Wrappers/CodableDate.swift b/Sources/Wrappers/CodableDate.swift index 7911e80..3c58f68 100644 --- a/Sources/Wrappers/CodableDate.swift +++ b/Sources/Wrappers/CodableDate.swift @@ -69,16 +69,27 @@ extension Optional: DateProtocol where Wrapped == Date {} // MARK: Coding Strategy public enum DateCodingStrategy { + /// Custom date formatter. case format(String) + /// Uses the iOS native `ISO8601DateFormatter`. case iso8601 + /// Uses the iOS native `ISO8601DateFormatter` with `DateFormatter.Options.withFractionalSeconds`. + /// - Note: If you pass a date with greater precision than milliseconds, it will truncate and parse up to milliseconds only. So "2021-08-30T18:35:19.209999Z" would end up being parsed as "2021-08-30T18:35:19.209Z". + case iso8601WithMillisecondPrecision + /// Implements the RFC2822 date format: "EEE, d MMM y HH:mm:ss zzz" + /// - SeeAlso: https://datatracker.ietf.org/doc/html/rfc2822 case rfc2822 + /// Implements the RFC3339 date format: "yyyy-MM-dd'T'HH:mm:ssZ" + /// - SeeAlso: https://datatracker.ietf.org/doc/html/rfc3339 case rfc3339 + /// Time interval since 1970. case timestamp public func date(from value: String) -> Date? { switch self { case let .format(format): return DateCodingStrategy.getFormatter(format).date(from: value) case .iso8601: return DateCodingStrategy.iso8601Formatter.date(from: value) + case .iso8601WithMillisecondPrecision: return DateCodingStrategy.iso8601WithFractionalSecondsFormatter.date(from: value) case .rfc2822: return DateCodingStrategy.rfc2822Formatter.date(from: value) case .rfc3339: return DateCodingStrategy.rfc3339Formatter.date(from: value) case .timestamp: @@ -91,6 +102,7 @@ public enum DateCodingStrategy { switch self { case let .format(format): return DateCodingStrategy.getFormatter(format).string(from: date) case .iso8601: return DateCodingStrategy.iso8601Formatter.string(from: date) + case .iso8601WithMillisecondPrecision: return DateCodingStrategy.iso8601WithFractionalSecondsFormatter.string(from: date) case .rfc2822: return DateCodingStrategy.rfc2822Formatter.string(from: date) case .rfc3339: return DateCodingStrategy.rfc3339Formatter.string(from: date) case .timestamp: return "\(date.timeIntervalSince1970)" @@ -98,6 +110,12 @@ public enum DateCodingStrategy { } private static let iso8601Formatter = ISO8601DateFormatter() + private static let iso8601WithFractionalSecondsFormatter: ISO8601DateFormatter = { + let result = ISO8601DateFormatter() + result.formatOptions.insert(.withFractionalSeconds) + return result + }() + private static let rfc2822Formatter: DateFormatter = getFormatter("EEE, d MMM y HH:mm:ss zzz") private static let rfc3339Formatter: DateFormatter = getFormatter("yyyy-MM-dd'T'HH:mm:ssZ") private static var formatters: [String: Formatter] = [:] diff --git a/Tests/KodableTests.swift b/Tests/KodableTests.swift index e0c93d3..f443099 100644 --- a/Tests/KodableTests.swift +++ b/Tests/KodableTests.swift @@ -601,10 +601,11 @@ final class KodableTests: XCTestCase { XCTAssertEqual(try optionalTransformer.transformFromJSON(value: nil), nil) } - func testCodableDate() { + func testCodableDate() throws { struct Dates: Kodable { @CodableDate(decoding: .enforceType) var iso8601: Date @CodableDate("iso8601") var isoDate: Date? + @CodableDate(.iso8601WithMillisecondPrecision, "iso8601_millisecond_date") var isoNanosecondDate: Date? @CodableDate(.format("y-MM-dd"), "simple_date") var simpleDate: Date @CodableDate(.rfc2822, "rfc2822") var rfc2822Date: Date @CodableDate(.rfc3339, "rfc3339") var rfc3339Date: Date @@ -624,46 +625,59 @@ final class KodableTests: XCTestCase { @CodableDate(.rfc2822, "rfc3339") var duplicateIso: Date } - do { - // Regular codable - let codableDecoded = try CodableDates.decodeJSON(from: KodableTests.json) - XCTAssertEqual(codableDecoded.iso8601.description, "1996-12-20 00:39:57 +0000") - XCTAssertEqual(codableDecoded.duplicateIso.description, "1996-12-20 00:39:57 +0000") - - let encodableJson = try codableDecoded.encodeJSON() - let newCodableObject = try CodableDates.decodeJSON(from: encodableJson) - XCTAssertEqual(newCodableObject.iso8601.description, "1996-12-20 00:39:57 +0000") - XCTAssertEqual(newCodableObject.duplicateIso.description, "1996-12-20 00:39:57 +0000") - - // ExtendedCodable - let decoded = try Dates.decodeJSON(from: KodableTests.json) - XCTAssertEqual(decoded.iso8601.description, "1996-12-20 00:39:57 +0000") - XCTAssertEqual(decoded.isoDate?.description, "1996-12-20 00:39:57 +0000") - XCTAssertEqual(decoded.simpleDate.description, "2001-01-01 00:00:00 +0000") - XCTAssertEqual(decoded.rfc2822Date.description, "1996-12-19 16:39:57 +0000") - XCTAssertEqual(decoded.rfc3339Date.description, "1996-12-20 00:39:57 +0000") - XCTAssertEqual(decoded.nonOptionalTimestamp.description, "2001-01-01 00:00:00 +0000") - XCTAssertEqual(decoded.timestamp?.description, "2001-01-01 00:00:00 +0000") - XCTAssertEqual(decoded.defaultTimestamp.description, KodableTests.testDate.description) - XCTAssertEqual(decoded.optionalDate, nil) - XCTAssertEqual(decoded.ignoredDate, nil) - - let json = try decoded.encodeJSON() - let newObject = try Dates.decodeJSON(from: json) - - XCTAssertEqual(decoded.iso8601.description, "1996-12-20 00:39:57 +0000") - XCTAssertEqual(newObject.isoDate?.description, "1996-12-20 00:39:57 +0000") - XCTAssertEqual(newObject.simpleDate.description, "2001-01-01 00:00:00 +0000") - XCTAssertEqual(newObject.rfc2822Date.description, "1996-12-19 16:39:57 +0000") - XCTAssertEqual(newObject.rfc3339Date.description, "1996-12-20 00:39:57 +0000") - XCTAssertEqual(newObject.nonOptionalTimestamp.description, "2001-01-01 00:00:00 +0000") - XCTAssertEqual(newObject.timestamp?.description, "2001-01-01 00:00:00 +0000") - XCTAssertEqual(newObject.defaultTimestamp.description, KodableTests.testDate.description) - XCTAssertEqual(newObject.optionalDate, nil) - XCTAssertEqual(newObject.ignoredDate, nil) - } catch { - XCTFail(error.localizedDescription) + // Regular codable + let codableDecoded = try CodableDates.decodeJSON(from: KodableTests.json) + XCTAssertEqual(codableDecoded.iso8601.description, "1996-12-20 00:39:57 +0000") + XCTAssertEqual(codableDecoded.duplicateIso.description, "1996-12-20 00:39:57 +0000") + + let encodableJson = try codableDecoded.encodeJSON() + let newCodableObject = try CodableDates.decodeJSON(from: encodableJson) + XCTAssertEqual(newCodableObject.iso8601.description, "1996-12-20 00:39:57 +0000") + XCTAssertEqual(newCodableObject.duplicateIso.description, "1996-12-20 00:39:57 +0000") + + // ExtendedCodable + let decoded = try Dates.decodeJSON(from: KodableTests.json) + XCTAssertEqual(decoded.iso8601.description, "1996-12-20 00:39:57 +0000") + XCTAssertEqual(decoded.isoDate?.description, "1996-12-20 00:39:57 +0000") + XCTAssertEqual(decoded.isoNanosecondDate?.description, "2021-08-30 18:35:19 +0000") + XCTAssertEqual(decoded.simpleDate.description, "2001-01-01 00:00:00 +0000") + XCTAssertEqual(decoded.rfc2822Date.description, "1996-12-19 16:39:57 +0000") + XCTAssertEqual(decoded.rfc3339Date.description, "1996-12-20 00:39:57 +0000") + XCTAssertEqual(decoded.nonOptionalTimestamp.description, "2001-01-01 00:00:00 +0000") + XCTAssertEqual(decoded.timestamp?.description, "2001-01-01 00:00:00 +0000") + XCTAssertEqual(decoded.defaultTimestamp.description, KodableTests.testDate.description) + XCTAssertEqual(decoded.optionalDate, nil) + XCTAssertEqual(decoded.ignoredDate, nil) + + let json = try decoded.encodeJSON() + let newObject = try Dates.decodeJSON(from: json) + + XCTAssertEqual(newObject.isoDate?.description, "1996-12-20 00:39:57 +0000") + XCTAssertEqual(newObject.isoNanosecondDate?.description, "2021-08-30 18:35:19 +0000") + XCTAssertEqual(newObject.simpleDate.description, "2001-01-01 00:00:00 +0000") + XCTAssertEqual(newObject.rfc2822Date.description, "1996-12-19 16:39:57 +0000") + XCTAssertEqual(newObject.rfc3339Date.description, "1996-12-20 00:39:57 +0000") + XCTAssertEqual(newObject.nonOptionalTimestamp.description, "2001-01-01 00:00:00 +0000") + XCTAssertEqual(newObject.timestamp?.description, "2001-01-01 00:00:00 +0000") + XCTAssertEqual(newObject.defaultTimestamp.description, KodableTests.testDate.description) + XCTAssertEqual(newObject.optionalDate, nil) + XCTAssertEqual(newObject.ignoredDate, nil) + } + + func testISO8601DateCodingStrategy() { + // Our tests don't cover microsecond and nanosecond precision dates because our date formatters don't actually support them yet. + let iso8601 = "2021-08-30T18:35:19Z" + let iso8601WithMillisecondPrecision = "2021-08-30T18:35:19.999Z" + + func assert(_ dateString: String, against formatter: DateCodingStrategy) { + if let date = formatter.date(from: dateString) { + XCTAssertEqual(formatter.string(from: date), dateString) + } else { + XCTFail("Failed to initialize date from \(dateString) using \(formatter)") + } } + assert(iso8601, against: .iso8601) + assert(iso8601WithMillisecondPrecision, against: .iso8601WithMillisecondPrecision) } func testFailedCodableDate() { @@ -764,6 +778,7 @@ final class KodableTests: XCTestCase { "parts": ["first": "random address"], ], "iso8601": "1996-12-19T16:39:57-08:00", + "iso8601_millisecond_date": "2021-08-30T18:35:19.001Z", "duplicateIso": "1996-12-19T16:39:57-08:00", "simple_date": "2001-01-01", "rfc2822": "Thu, 19 Dec 1996 16:39:57 GMT",