Swift's JSONdecoder with Multiple Date Formats in a JSON String

Swift's JSONDecoder with multiple date formats in a JSON string?

There are a few ways to deal with this:

  • You can create a DateFormatter subclass which first attempts the date-time string format, then if it fails, attempts the plain date format
  • You can give a .custom Date decoding strategy wherein you ask the Decoder for a singleValueContainer(), decode a string, and pass it through whatever formatters you want before passing the parsed date out
  • You can create a wrapper around the Date type which provides a custom init(from:) and encode(to:) which does this (but this isn't really any better than a .custom strategy)
  • You can use plain strings, as you suggest
  • You can provide a custom init(from:) on all types which use these dates and attempt different things in there

All in all, the first two methods are likely going to be the easiest and cleanest — you'll keep the default synthesized implementation of Codable everywhere without sacrificing type safety.

JSONDecoder with multiple date formats?

Another approach is a custom dateDecodingStrategy with ISO8601DateFormatter which is able to specify the date format depending on the given string

func run<T: Decodable>(_ request: URLRequest, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher<Response<T>, Error> {
return URLSession.shared
.dataTaskPublisher(for: request)
.receive(on: DispatchQueue.main)
.tryMap { result -> Response<T> in
decoder.dateDecodingStrategy = .custom { decoder -> Date in
let container = try decoder.singleValueContainer()
let dateFormatter = ISO8601DateFormatter()
let dateString = try container.decode(String.self)
if !dateString.hasSuffix("Z") {
dateFormatter.formatOptions.remove(.withTimeZone)
}
if let isoDate = dateFormatter.date(from: dateString) {
return isoDate
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Wrong Date Format")
}
}
let value = try decoder.decode(T.self, from: result.data)
return Response(value: value, response: result.response)
}
.eraseToAnyPublisher()
}

How to JSON encode multiple date formats within the same struct

In a custom encode(to:), just encode each one as a string using the desired formatter. There's no "date" type in JSON; it's just a string. Something along these lines:

enum CodingKeys: CodingKey {
case biometricId, amount, source, day, time, unitId
}

func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
try container.encode(biometricId, forKey: .biometricId)
try container.encode(unitId, forKey: .unitId)
try container.encode(source, forKey: .source)
try container.encode(amount, forKey: .amount)

let formatter = DateFormatter()
formatter.timeZone = TimeZone.current
formatter.dateFormat = "H:m:s"
let timeString = formatter.string(from: time)
try container.encode(timeString, forKey: .time)

formatter.dateFormat = "yyyy-M-d"
let dayString = formatter.string(from: day)
try container.encode(dayString, forKey: .day)
}

But note that you can't test for equivalent strings. JSON dictionaries aren't order-preserving, so there's no way to guarantee a character-by-character match.

Note that if you really want to have days and times, you should consider DateComponents rather than a Date. A date is a specific instance in time; it's not in any time zone, and it can't be just an hour, minute, and second.

Also, your use of Double is going to cause rounding differences. So 2.1 will be encoded as 2.1000000000000001. If that's a problem, you should use Decimal for amount rather than Double.

Decoding Date values with different JSON formats in Swift?

As shown in the possible duplicate post I have already mentioned in comments, you need to create a custom date decoding strategy:

First create your date formatter for parsing the date string (note that this assumes your date is local time, if you need UTC time or server time you need to set the formatter timezone property accordingly):

extension Formatter {
static let yyyyMMdd: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
}

Then create a custom decoding strategy to try all possible date decoding strategy you might need:

extension JSONDecoder.DateDecodingStrategy {
static let deferredORyyyyMMdd = custom {
let container = try $0.singleValueContainer()
do {
return try Date(timeIntervalSinceReferenceDate: container.decode(Double.self))
} catch {
let string = try container.decode(String.self)
if let date = Formatter.yyyyMMdd.date(from: string) {
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)")
}
}
}

Playground testing:

struct TestCod: Codable {
let txt: String
let date: Date
}

let jsonA = """
{
"txt":"stack",
"date":589331953.61679399
}
"""
let jsonB = """
{
"txt":"overflow",
"date":"2019-09-05"
}
"""

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .deferredORyyyyMMdd
let decodedJSONA = try! decoder.decode(TestCod.self, from: Data(jsonA.utf8))
decodedJSONA.date // "Sep 4, 2019 at 8:19 PM"
let decodedJSONB = try! decoder.decode(TestCod.self, from: Data(jsonB.utf8))
decodedJSONB.date // "Sep 5, 2019 at 12:00 AM"

Decoding oddball date format with JSONDecoder

You can't parse such a date string using a standard DateFormatter because there is no standard format specifier representing "seconds (or milliseconds) since epoch". The A format specifier is for "seconds in day".

One solution is to use the .custom date decoding strategy with a custom method that can parse such a string.

Here is some test code that works:

func customDateParser(_ decoder: Decoder) throws -> Date {
let dateString = try decoder.singleValueContainer().decode(String.self)
let scanner = Scanner(string: dateString)
var millis: Int64 = 0
if scanner.scanString("/Date(", into: nil) &&
scanner.scanInt64(&millis) &&
scanner.scanInt(nil) &&
scanner.scanString(")/", into: nil) &&
scanner.isAtEnd {
return Date(timeIntervalSince1970: TimeInterval(millis) / 1000)
} else {
return Date() // TODO - unexpected format, throw error
}
}

let json = "{ \"date\": \"/Date(965620800000-0400)/\" }".data(using: .utf8)!

struct Test: Decodable {
var date: Date
}

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom(customDateParser)
let test = try! decoder.decode(Test.self, from: json)
print(test)

Note that the timezone offset in the date string is irrelevant. It's not needed to generate the correct Date instance.

I've left the error handling as an exercise for the reader.

Decodable multiple DateDecodingStrategies

You can use the DateDecodingStrategy.custom option for that. A short example:

let shortFormatter = DateFormatter()
let longFormatter = DateFormatter()
shortFormatter.dateFormat = "yyyy-MM-dd"
longFormatter.dateFormat = "yyyy-MM-dd hh:mm:ss"

func customDateFormatter(_ decoder: Decoder) throws -> Date {
let dateString = try decoder.singleValueContainer().decode(String.self)
let dateKey = decoder.codingPath.last as! Movie.CodingKeys
switch dateKey {
case .shortDate :
return shortFormatter.date(from: dateString)!
case .longDate :
return longFormatter.date(from: dateString)!
default:
fatalError("Unexpected date coding key: \(dateKey)")
}
}

I created both DateFormatter instances outside the function merely as an optimization. As such, each call won’t need to recreate/configure them for each decoded date.

Finally, set the dateDecodingStrategy using the function we created above:

let json =
"""
{
"name": "A Clockwork Orange",
"short_date": "2017-10-10",
"long_date": "2017-10-10 12:21:02"
}
""".data(using: .utf8)!

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom(customDateFormatter)
let movie = try! decoder.decode(Movie.self, from: json)

Decode date/time from String or TimeInterval in Swift

extension Formatter {
static let iso8601withFractionalSeconds: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
return formatter
}()
static let iso8601: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
static let ddMMyyyy: DateFormatter = {
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "dd-MM-yyyy"
return formatter
}()
}


extension JSONDecoder.DateDecodingStrategy {
static let multiple = custom {
let container = try $0.singleValueContainer()
do {
return try Date(timeIntervalSince1970: container.decode(Double.self))
} catch DecodingError.typeMismatch {
let string = try container.decode(String.self)
if let date = Formatter.iso8601withFractionalSeconds.date(from: string) ??
Formatter.iso8601.date(from: string) ??
Formatter.ddMMyyyy.date(from: string) {
return date
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)")
}
}
}

Playground testing:

struct Root: Codable {
let articles: Articles
}
struct Articles: Codable {
let authors: Authors
let relevantUntil: Date
let publicationDate: Date
let lastComment: Date
}

struct Authors: Codable {
let birthday: Date
}


let json = """
{"articles": {
"authors": {"birthday": "01-01-1970"},
"relevant_until": "2020-11-19 01:23:45",
"publication_date": 1605705003.0019,
"last_comment": "2020-11-19 01:23:45.678"}
}
"""

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .multiple
decoder.keyDecodingStrategy = .convertFromSnakeCase

do {
let root = try decoder.decode(Root.self, from: .init(json.utf8))
print(root.articles) // Articles(authors: __lldb_expr_107.Authors(birthday: 1970-01-01 03:00:00 +0000), relevantUntil: 2020-11-19 04:23:45 +0000, publicationDate: 2020-11-18 13:10:03 +0000, lastComment: 2020-11-19 04:23:45 +0000)

} catch {
print(error)
}


Related Topics



Leave a reply



Submit