diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 14fbcfa..38159d7 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -9,15 +9,15 @@ jobs: strategy: matrix: os: - - ubuntu-latest - macOS-latest + - ubuntu-latest runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 - run: rm .swift-version - name: Install Swift - uses: YOCKOW/Action-setup-swift@master + uses: YOCKOW/Action-setup-swift@v1 with: - swift-version: '5.1' + swift-version: '5.9' - name: Test run: swift test --enable-test-discovery diff --git a/Sources/DictionaryDecoder.swift b/Sources/DictionaryDecoder.swift index b5b812a..87e25d4 100644 --- a/Sources/DictionaryDecoder.swift +++ b/Sources/DictionaryDecoder.swift @@ -11,6 +11,7 @@ import Foundation open class DictionaryDecoder: Decoder { open var codingPath: [CodingKey] + open var dateDecodingStrategy = JSONDecoder.DateDecodingStrategy.deferredToDate open var userInfo: [CodingUserInfoKey: Any] = [:] var storage = Storage() @@ -49,10 +50,49 @@ open class DictionaryDecoder: Decoder { } catch { storage.push(container: value) defer { _ = storage.popContainer() } + if type == Date.self { + return try unwrapDate() as! T + } return try T(from: self) } } + private func unwrapDate() throws -> Date { + switch dateDecodingStrategy { + case .deferredToDate: + return try Date(from: self) + + case .secondsSince1970: + let container = SingleValueContainer(decoder: self) + let double = try container.decode(Double.self) + return Date(timeIntervalSince1970: double) + + case .millisecondsSince1970: + let container = SingleValueContainer(decoder: self) + let double = try container.decode(Double.self) + return Date(timeIntervalSince1970: double / 1000.0) + + case .iso8601: + let container = SingleValueContainer(decoder: self) + let string = try container.decode(String.self) + guard let date = _iso8601Formatter.date(from: string) else { + throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Expected date string to be ISO8601-formatted.")) + } + return date + + case .formatted(let formatter): + let container = SingleValueContainer(decoder: self) + let string = try container.decode(String.self) + guard let date = formatter.date(from: string) else { + throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: self.codingPath, debugDescription: "Date string does not match format expected by formatter.")) + } + return date + + case .custom(let closure): + return try closure(self) + } + } + private func lastContainer(forType type: T.Type) throws -> Any { guard let value = storage.last else { let description = "Expected \(type) but found nil value instead." diff --git a/Sources/DictionaryEncoder.swift b/Sources/DictionaryEncoder.swift index c0c1de0..42428b1 100644 --- a/Sources/DictionaryEncoder.swift +++ b/Sources/DictionaryEncoder.swift @@ -10,6 +10,7 @@ import Foundation open class DictionaryEncoder: Encoder { open var codingPath: [CodingKey] = [] + open var dateEncodingStrategy = JSONEncoder.DateEncodingStrategy.deferredToDate open var userInfo: [CodingUserInfoKey: Any] = [:] private(set) var storage = Storage() @@ -28,8 +29,37 @@ open class DictionaryEncoder: Encoder { } func box(_ value: T) throws -> Any { - try value.encode(to: self) - return storage.popContainer() + switch value { + case let date as Date: + return try wrapDate(date) + default: + try value.encode(to: self) + return storage.popContainer() + } + } + + func wrapDate(_ date: Date) throws -> Any { + switch dateEncodingStrategy { + case .deferredToDate: + try date.encode(to: self) + return storage.popContainer() + + case .secondsSince1970: + return TimeInterval(date.timeIntervalSince1970.description) as Any + + case .millisecondsSince1970: + return TimeInterval((date.timeIntervalSince1970 * 1000).description) as Any + + case .iso8601: + return _iso8601Formatter.string(from: date) + + case .formatted(let formatter): + return formatter.string(from: date) + + case .custom(let closure): + try closure(date, self) + return storage.popContainer() + } } } diff --git a/Tests/DictionaryDecoderTests.swift b/Tests/DictionaryDecoderTests.swift index 3f8eccf..a4cf4a2 100644 --- a/Tests/DictionaryDecoderTests.swift +++ b/Tests/DictionaryDecoderTests.swift @@ -63,4 +63,50 @@ class DictionaryDecoderTests: XCTestCase { XCTAssertEqual(try decoder.decode(Model.self, from: ["int": 0, "string": "test"]), Model(int: 0, string: "test", double: nil)) XCTAssertEqual(try decoder.decode(Model.self, from: ["double": 0.5, "string": "test"]), Model(int: nil, string: "test", double: 0.5)) } + + func testDate() throws { + struct Model: Codable, Equatable { + let date: Date + let optionalDate: Date? + } + + let date = Date(timeIntervalSince1970: 1234567890) + decoder.dateDecodingStrategy = .deferredToDate + XCTAssertEqual(try decoder.decode(Model.self, from: ["date": date.timeIntervalSinceReferenceDate]), Model(date: date, optionalDate: nil)) + + decoder.dateDecodingStrategy = .secondsSince1970 + XCTAssertEqual(try decoder.decode(Model.self, from: ["date": date.timeIntervalSince1970]), Model(date: date, optionalDate: nil)) + + decoder.dateDecodingStrategy = .millisecondsSince1970 + XCTAssertEqual(try decoder.decode(Model.self, from: ["date": date.timeIntervalSince1970 * 1000]), Model(date: date, optionalDate: nil)) + + if #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) { + decoder.dateDecodingStrategy = .iso8601 + XCTAssertEqual(try decoder.decode(Model.self, from: ["date": date.ISO8601Format()]), Model(date: date, optionalDate: nil)) + } + + do { + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" + decoder.dateDecodingStrategy = .formatted(dateFormatter) + XCTAssertEqual(try decoder.decode(Model.self, from: ["date": dateFormatter.string(from: date)]), Model(date: date, optionalDate: nil)) + } + + decoder.dateDecodingStrategy = .custom { decoder in + let contaienr = try decoder.singleValueContainer() + return try contaienr.decode(Int.self) == 13 ? date : date.addingTimeInterval(.infinity) + } + XCTAssertEqual(try decoder.decode(Model.self, from: ["date": 13]), Model(date: date, optionalDate: nil)) + } +} + +#if os(Linux) +private extension Date { + func ISO8601Format() -> String { + let formatter = ISO8601DateFormatter() + return formatter.string(from: self) + } } +#endif diff --git a/Tests/DictionaryEncoderTests.swift b/Tests/DictionaryEncoderTests.swift index 7cbf695..c83b9d8 100644 --- a/Tests/DictionaryEncoderTests.swift +++ b/Tests/DictionaryEncoderTests.swift @@ -134,4 +134,71 @@ class DictionaryEncoderTests: XCTestCase { let result = try encoder.encode(object) XCTAssertEqual(result as? [String: String], expected) } + + func testEncodeDate() throws { + struct Model: Codable { + let date: Date + let optionalDate: Date? + } + let date = Date(timeIntervalSince1970: 1234567890) + let seeds: [(model: Model, count: Int)] = [ + (model: Model(date: date, optionalDate: nil), count: 1), + (model: Model(date: date, optionalDate: date), count: 2), + ] + for seed in seeds { + encoder.dateEncodingStrategy = .deferredToDate + var dictionary = try encoder.encode(seed.model) + XCTAssertEqual(dictionary["date"] as? Double, seed.model.date.timeIntervalSinceReferenceDate) + XCTAssertEqual(dictionary["optionalDate"] as? Double, seed.model.optionalDate?.timeIntervalSinceReferenceDate) + XCTAssertEqual(dictionary.keys.count, seed.count) + + encoder.dateEncodingStrategy = .secondsSince1970 + dictionary = try encoder.encode(seed.model) + XCTAssertEqual(dictionary["date"] as? TimeInterval, seed.model.date.timeIntervalSince1970) + XCTAssertEqual(dictionary["optionalDate"] as? TimeInterval, seed.model.optionalDate?.timeIntervalSince1970) + XCTAssertEqual(dictionary.keys.count, seed.count) + + encoder.dateEncodingStrategy = .millisecondsSince1970 + dictionary = try encoder.encode(seed.model) + XCTAssertEqual(dictionary["date"] as? TimeInterval, seed.model.date.timeIntervalSince1970 * 1000) + XCTAssertEqual(dictionary["optionalDate"] as? TimeInterval, seed.model.optionalDate.map { $0.timeIntervalSince1970 * 1000 }) + XCTAssertEqual(dictionary.keys.count, seed.count) + + if #available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) { + encoder.dateEncodingStrategy = .iso8601 + dictionary = try encoder.encode(seed.model) + XCTAssertEqual(dictionary["date"] as? String, seed.model.date.ISO8601Format()) + XCTAssertEqual(dictionary["optionalDate"] as? String, seed.model.optionalDate?.ISO8601Format()) + XCTAssertEqual(dictionary.keys.count, seed.count) + } + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ" + encoder.dateEncodingStrategy = .formatted(dateFormatter) + dictionary = try encoder.encode(seed.model) + XCTAssertEqual(dictionary["date"] as? String, dateFormatter.string(from: seed.model.date)) + XCTAssertEqual(dictionary["optionalDate"] as? String, seed.model.optionalDate.map(dateFormatter.string)) + XCTAssertEqual(dictionary.keys.count, seed.count) + + encoder.dateEncodingStrategy = .custom { date, encoder in + var container = encoder.singleValueContainer() + try container.encode(13) + } + dictionary = try encoder.encode(seed.model) + XCTAssertEqual(dictionary["date"] as? Int, 13) + XCTAssertEqual(dictionary["optionalDate"] as? Int, seed.model.optionalDate == nil ? nil : 13) + XCTAssertEqual(dictionary.keys.count, seed.count) + } + } +} + +#if os(Linux) +private extension Date { + func ISO8601Format() -> String { + let formatter = ISO8601DateFormatter() + return formatter.string(from: self) + } } +#endif diff --git a/Tests/FailableTests.swift b/Tests/FailableTests.swift index 1e52d4d..55cff3f 100644 --- a/Tests/FailableTests.swift +++ b/Tests/FailableTests.swift @@ -42,7 +42,7 @@ class FailableTests: XCTestCase { func testFailableURL() { let json = """ -{"url": "https://foo.com", "url2": "invalid url string"} +{"url": "https://foo.com", "url2": "a://invalid url string"} """.data(using: .utf8)! struct Model: Codable {