How to convert a date string with optional fractional seconds using Codable in Swift4

白昼怎懂夜的黑 提交于 2019-11-26 05:16:09

You can use two different date formatters (with and without fraction seconds) and create a custom DateDecodingStrategy. In case of failure when parsing the date returned by the API you can throw a DecodingError as suggested by @PauloMattos in comments:

iOS 9, macOS 10.9, tvOS 9, watchOS 2, Xcode 9 or later

The custom ISO8601 DateFormatter:

extension Formatter {
    static let iso8601: DateFormatter = {
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .iso8601)
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"
        return formatter
    }()
    static let iso8601noFS: DateFormatter = {
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .iso8601)
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX"
        return formatter
    }()
}

The custom DateDecodingStrategy and Error:

extension JSONDecoder.DateDecodingStrategy {
    static let customISO8601 = custom {
        let container = try $0.singleValueContainer()
        let string = try container.decode(String.self)
        if let date = Formatter.iso8601.date(from: string) ?? Formatter.iso8601noFS.date(from: string) {
            return date
        }
        throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid date: \(string)")
    }
}

The custom DateEncodingStrategy:

extension JSONEncoder.DateEncodingStrategy {
    static let customISO8601 = custom {
        var container = $1.singleValueContainer()
        try container.encode(Formatter.iso8601.string(from: $0))
    }
}

edit/update:

Xcode 9 • Swift 4 • iOS 11 or later

ISO8601DateFormatter now supports formatOptions .withFractionalSeconds in iOS11 or later:

extension Formatter {
    static let iso8601: ISO8601DateFormatter = {
        let formatter = ISO8601DateFormatter()
        formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
        return formatter
    }()
    static let iso8601noFS = ISO8601DateFormatter()
}

The customs DateDecodingStrategy and DateEncodingStrategy would be the same as shown above.


// Playground testing
struct ISODates: Codable {
    let dateWith9FS: Date
    let dateWith3FS: Date
    let dateWith2FS: Date
    let dateWithoutFS: Date
}
let isoDatesJSON = """
{
"dateWith9FS": "2017-06-19T18:43:19.532123456Z",
"dateWith3FS": "2017-06-19T18:43:19.532Z",
"dateWith2FS": "2017-06-19T18:43:19.53Z",
"dateWithoutFS": "2017-06-19T18:43:19Z",
}
"""
let isoDatesData = Data(isoDatesJSON.utf8)

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .customISO8601

do {
    let isoDates = try decoder.decode(ISODates.self, from: isoDatesData)
    print(Formatter.iso8601.string(from: isoDates.dateWith9FS))   // 2017-06-19T18:43:19.532Z
    print(Formatter.iso8601.string(from: isoDates.dateWith3FS))   // 2017-06-19T18:43:19.532Z
    print(Formatter.iso8601.string(from: isoDates.dateWith2FS))   // 2017-06-19T18:43:19.530Z
    print(Formatter.iso8601.string(from: isoDates.dateWithoutFS)) // 2017-06-19T18:43:19.000Z
} catch {
    print(error)
}

Alternatively to @Leo's answer, and if you need to provide support for older OS'es (ISO8601DateFormatter is available only starting with iOS 10, mac OS 10.12), you can write a custom formatter that uses both formats when parsing the string:

class MyISO8601Formatter: DateFormatter {

    static let formatters: [DateFormatter] = [
        iso8601Formatter(withFractional: true),
        iso8601Formatter(withFractional: false)
        ]

    static func iso8601Formatter(withFractional fractional: Bool) -> DateFormatter {
        let formatter = DateFormatter()
        formatter.calendar = Calendar(identifier: .iso8601)
        formatter.locale = Locale(identifier: "en_US_POSIX")
        formatter.timeZone = TimeZone(secondsFromGMT: 0)
        formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss\(fractional ? ".SSS" : "")XXXXX"
        return formatter
    }

    override public func getObjectValue(_ obj: AutoreleasingUnsafeMutablePointer<AnyObject?>?,
                                 for string: String,
                                 errorDescription error: AutoreleasingUnsafeMutablePointer<NSString?>?) -> Bool {
        guard let date = (type(of: self).formatters.flatMap { $0.date(from: string) }).first else {
            error?.pointee = "Invalid ISO8601 date: \(string)" as NSString
            return false
        }
        obj?.pointee = date as NSDate
        return true
    }

    override public func string(for obj: Any?) -> String? {
        guard let date = obj as? Date else { return nil }
        return type(of: self).formatters.flatMap { $0.string(from: date) }.first
    }
}

, which you can use it as date decoding strategy:

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(MyISO8601Formatter())

Although a little bit uglier in implementation, this has the advantage of being consistent with the decoding errors that Swift throws in case of malformed data, as we don't alter the error reporting mechanism).

For example:

struct TestDate: Codable {
    let date: Date
}

// I don't advocate the forced unwrap, this is for demo purposes only
let jsonString = "{\"date\":\"2017-06-19T18:43:19Z\"}"
let jsonData = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(MyISO8601Formatter())
do {
    print(try decoder.decode(TestDate.self, from: jsonData))
} catch {
    print("Encountered error while decoding: \(error)")
}

will print TestDate(date: 2017-06-19 18:43:19 +0000)

Adding the fractional part

let jsonString = "{\"date\":\"2017-06-19T18:43:19.123Z\"}"

will result in the same output: TestDate(date: 2017-06-19 18:43:19 +0000)

However using an incorrect string:

let jsonString = "{\"date\":\"2017-06-19T18:43:19.123AAA\"}"

will print the default Swift error in case of incorrect data:

Encountered error while decoding: dataCorrupted(Swift.DecodingError.Context(codingPath: [__lldb_expr_84.TestDate.(CodingKeys in _B178608BE4B4E04ECDB8BE2F689B7F4C).date], debugDescription: "Date string does not match format expected by formatter.", underlyingError: nil))
易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!