Skip to content

Commit

Permalink
Support Date for DictionaryDecoder
Browse files Browse the repository at this point in the history
  • Loading branch information
tattn committed Nov 30, 2023
1 parent 2e1285c commit 0a83524
Show file tree
Hide file tree
Showing 5 changed files with 186 additions and 3 deletions.
49 changes: 49 additions & 0 deletions Sources/DictionaryDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,19 @@ import Foundation

open class DictionaryDecoder: Decoder {
open var codingPath: [CodingKey]
open var dateDecodingStrategy: DateDecodingStrategy = .deferredToDate
open var userInfo: [CodingUserInfoKey: Any] = [:]
var storage = Storage()

public enum DateDecodingStrategy {
case deferredToDate
case secondsSince1970
case millisecondsSince1970
case iso8601
case formatted(DateFormatter)
case custom((_ decoder: Decoder) throws -> Date)
}

public init() {
codingPath = []
}
Expand Down Expand Up @@ -49,10 +59,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<T>(forType type: T.Type) throws -> Any {
guard let value = storage.last else {
let description = "Expected \(type) but found nil value instead."
Expand Down
43 changes: 41 additions & 2 deletions Sources/DictionaryEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,19 @@ import Foundation

open class DictionaryEncoder: Encoder {
open var codingPath: [CodingKey] = []
open var dateEncodingStrategy: DateEncodingStrategy = .deferredToDate
open var userInfo: [CodingUserInfoKey: Any] = [:]
private(set) var storage = Storage()

public enum DateEncodingStrategy {
case deferredToDate
case secondsSince1970
case millisecondsSince1970
case iso8601
case formatted(DateFormatter)
case custom((Date, Encoder) throws -> Void)
}

public init() {}

open func container<Key: CodingKey>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key> {
Expand All @@ -28,8 +38,37 @@ open class DictionaryEncoder: Encoder {
}

func box<T: Encodable>(_ 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()
}
}
}

Expand Down
37 changes: 37 additions & 0 deletions Tests/DictionaryDecoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,4 +63,41 @@ 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(iOS 15.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))
}
}
58 changes: 58 additions & 0 deletions Tests/DictionaryEncoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -134,4 +134,62 @@ 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(iOS 15.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)
}
}
}
2 changes: 1 addition & 1 deletion Tests/FailableTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down

0 comments on commit 0a83524

Please sign in to comment.