Skip to content

Commit

Permalink
Merge pull request #20 from tattn/feature/support-date-for-dictionary…
Browse files Browse the repository at this point in the history
…decoder

Support Date for DictionaryDecoder
  • Loading branch information
tattn authored Nov 30, 2023
2 parents 2e1285c + 766f2f5 commit 42a2cfb
Show file tree
Hide file tree
Showing 6 changed files with 190 additions and 7 deletions.
8 changes: 4 additions & 4 deletions .github/workflows/continuous-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
40 changes: 40 additions & 0 deletions Sources/DictionaryDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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<T>(forType type: T.Type) throws -> Any {
guard let value = storage.last else {
let description = "Expected \(type) but found nil value instead."
Expand Down
34 changes: 32 additions & 2 deletions Sources/DictionaryEncoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -28,8 +29,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
46 changes: 46 additions & 0 deletions Tests/DictionaryDecoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
67 changes: 67 additions & 0 deletions Tests/DictionaryEncoderTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
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 42a2cfb

Please sign in to comment.