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

17,235

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 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.

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 DateFormatters 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)
Share:
17,235
RamwiseMatt
Author by

RamwiseMatt

Updated on June 06, 2022

Comments

  • RamwiseMatt
    RamwiseMatt about 2 years

    Swift's JSONDecoder offers a dateDecodingStrategy property, which allows us to define how to interpret incoming date strings in accordance with a DateFormatter 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 the JSONDecoder handle this, since the provided DateFormatter object can only deal with a single dateFormat at a time?

    One ham-handed solution is to rewrite the accompanying Decodable models to just accept strings as their properties and to provide public Date getter/setter variables, but that seems like a poor solution to me. Any thoughts?

  • RamwiseMatt
    RamwiseMatt about 7 years
    The first approach is the one I was looking for. Thanks!
  • fabb
    fabb almost 7 years
    With 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 via CodingKeys), but the date formatting is configured via JSONDecoder 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
    Leo Dabus over 5 years
    this will create a new date formatter and a new decoder every time you can this property
  • Leo Dabus
    Leo Dabus over 5 years
  • Daniel T.
    Daniel T. about 4 years
    I 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
    alexandru.gaidei about 2 years
    Testing in swift 5, works great with multiple formats. Thanks!