Swift's JSONDecoder with multiple date formats in a JSON string?
Solution 1
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 theDecoder
for asingleValueContainer()
, 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 custominit(from:)
andencode(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.
Solution 2
Please try decoder configurated similarly to this:
lazy var decoder: JSONDecoder = {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
let container = try decoder.singleValueContainer()
let dateStr = try container.decode(String.self)
// possible date strings: "2016-05-01", "2016-07-04T17:37:21.119229Z", "2018-05-20T15:00:00Z"
let len = dateStr.count
var date: Date? = nil
if len == 10 {
date = dateNoTimeFormatter.date(from: dateStr)
} else if len == 20 {
date = isoDateFormatter.date(from: dateStr)
} else {
date = self.serverFullDateFormatter.date(from: dateStr)
}
guard let date_ = date else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateStr)")
}
print("DATE DECODER \(dateStr) to \(date_)")
return date_
})
return decoder
}()
Solution 3
try this. (swift 4)
let formatter = DateFormatter()
var decoder: JSONDecoder {
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
if let date = formatter.date(from: dateString) {
return date
}
formatter.dateFormat = "yyyy-MM-dd"
if let date = formatter.date(from: dateString) {
return date
}
throw DecodingError.dataCorruptedError(in: container,
debugDescription: "Cannot decode date string \(dateString)")
}
return decoder
}
Solution 4
Swift 5
Actually based on @BrownsooHan version using a JSONDecoder
extension
JSONDecoder+dateDecodingStrategyFormatters.swift
extension JSONDecoder {
/// Assign multiple DateFormatter to dateDecodingStrategy
///
/// Usage :
///
/// decoder.dateDecodingStrategyFormatters = [ DateFormatter.standard, DateFormatter.yearMonthDay ]
///
/// The decoder will now be able to decode two DateFormat, the 'standard' one and the 'yearMonthDay'
///
/// Throws a 'DecodingError.dataCorruptedError' if an unsupported date format is found while parsing the document
var dateDecodingStrategyFormatters: [DateFormatter]? {
@available(*, unavailable, message: "This variable is meant to be set only")
get { return nil }
set {
guard let formatters = newValue else { return }
self.dateDecodingStrategy = .custom { decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
for formatter in formatters {
if let date = formatter.date(from: dateString) {
return date
}
}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)")
}
}
}
}
It is a bit of a hacky way to add a variable that can only be set, but you can easily transform var dateDecodingStrategyFormatters
by func setDateDecodingStrategyFormatters(_ formatters: [DateFormatter]? )
Usage
lets say that you have already defined several DateFormatter
s in your code like so :
extension DateFormatter {
static let standardT: DateFormatter = {
var dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
return dateFormatter
}()
static let standard: DateFormatter = {
var dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return dateFormatter
}()
static let yearMonthDay: DateFormatter = {
var dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
return dateFormatter
}()
}
you can now just assign these to the decoder straight away by setting dateDecodingStrategyFormatters
:
// Data structure
struct Dates: Codable {
var date1: Date
var date2: Date
var date3: Date
}
// The Json to decode
let jsonData = """
{
"date1": "2019-05-30 15:18:00",
"date2": "2019-05-30T05:18:00",
"date3": "2019-04-17"
}
""".data(using: .utf8)!
// Assigning mutliple DateFormatters
let decoder = JSONDecoder()
decoder.dateDecodingStrategyFormatters = [ DateFormatter.standardT,
DateFormatter.standard,
DateFormatter.yearMonthDay ]
do {
let dates = try decoder.decode(Dates.self, from: jsonData)
print(dates)
} catch let err as DecodingError {
print(err.localizedDescription)
}
Sidenotes
Once again I am aware that setting the dateDecodingStrategyFormatters
as a var
is a bit hacky, and I dont recommend it, you should define a function instead. However it is a personal preference to do so.
Solution 5
Facing this same issue, I wrote the following extension:
extension JSONDecoder.DateDecodingStrategy {
static func custom(_ formatterForKey: @escaping (CodingKey) throws -> DateFormatter?) -> JSONDecoder.DateDecodingStrategy {
return .custom({ (decoder) -> Date in
guard let codingKey = decoder.codingPath.last else {
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "No Coding Path Found"))
}
guard let container = try? decoder.singleValueContainer(),
let text = try? container.decode(String.self) else {
throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: decoder.codingPath, debugDescription: "Could not decode date text"))
}
guard let dateFormatter = try formatterForKey(codingKey) else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "No date formatter for date text")
}
if let date = dateFormatter.date(from: text) {
return date
} else {
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(text)")
}
})
}
}
This extension allows you to create a DateDecodingStrategy for the JSONDecoder that handles multiple different date formats within the same JSON String. The extension contains a function that requires the implementation of a closure that gives you a CodingKey, and it is up to you to provide the correct DateFormatter for the provided key.
Lets say that you have the following JSON:
{
"publication_date": "2017-11-02",
"opening_date": "2017-11-03",
"date_updated": "2017-11-08 17:45:14"
}
The following Struct:
struct ResponseDate: Codable {
var publicationDate: Date
var openingDate: Date?
var dateUpdated: Date
enum CodingKeys: String, CodingKey {
case publicationDate = "publication_date"
case openingDate = "opening_date"
case dateUpdated = "date_updated"
}
}
Then to decode the JSON, you would use the following code:
let dateFormatterWithTime: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
let dateFormatterWithoutTime: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd"
return formatter
}()
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom({ (key) -> DateFormatter? in
switch key {
case ResponseDate.CodingKeys.publicationDate, ResponseDate.CodingKeys.openingDate:
return dateFormatterWithoutTime
default:
return dateFormatterWithTime
}
})
let results = try? decoder.decode(ResponseDate.self, from: data)
RamwiseMatt
Updated on June 06, 2022Comments
-
RamwiseMatt about 2 years
Swift's
JSONDecoder
offers adateDecodingStrategy
property, which allows us to define how to interpret incoming date strings in accordance with aDateFormatter
object.However, I am currently working with an API that returns both date strings (
yyyy-MM-dd
) and datetime strings (yyyy-MM-dd HH:mm:ss
), depending on the property. Is there a way to have theJSONDecoder
handle this, since the providedDateFormatter
object can only deal with a singledateFormat
at a time?One ham-handed solution is to rewrite the accompanying
Decodable
models to just accept strings as their properties and to provide publicDate
getter/setter variables, but that seems like a poor solution to me. Any thoughts? -
RamwiseMatt about 7 yearsThe first approach is the one I was looking for. Thanks!
-
fabb almost 7 yearsWith
Codable
it seems strange that all the other json mapping information is provided directly from the according objects (e.g. the mapping to json keys viaCodingKeys
), but the date formatting is configured viaJSONDecoder
for the whole DTO tree. Having used Mantle in the past, the last one of your proposed solutions feels like the most appropriate one, even though it means to repeat a lot of mapping code for the other fields that could be autogenerated otherwise. -
Leo Dabus over 5 yearsthis will create a new date formatter and a new decoder every time you can this property
-
Leo Dabus over 5 years
-
Daniel T. about 4 yearsI used the second approach
.dateDecodingStrategy = .custom { decoder in var container = try decoder.singleValueContainer(); let text = try container.decode(String.self); guard let date = serverDateFormatter1.date(from: text) ?? serverDateFormatter2.date(from: text) else { throw BadDate(text) }; return date }
-
alexandru.gaidei about 2 yearsTesting in swift 5, works great with multiple formats. Thanks!