web-dev-qa-db-fra.com

Comment convertir une chaîne de date avec des fractions de secondes optionnelles avec Codable dans Swift4

Je remplace mon ancien code d'analyse JSON par Swift's Codable et je rencontre un problème. Je suppose que ce n'est pas autant une question Codable que c'est une question DateFormatter.

Commencez avec une structure

 struct JustADate: Codable {
    var date: Date
 }

et une chaîne json

let json = """
  { "date": "2017-06-19T18:43:19Z" }
"""

laisse maintenant décoder

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

let data = json.data(using: .utf8)!
let justADate = try! decoder.decode(JustADate.self, from: data) //all good

Mais si nous changeons la date pour qu’elle ait une fraction de seconde, par exemple:

let json = """
  { "date": "2017-06-19T18:43:19.532Z" }
"""

Maintenant ça casse. Les dates reviennent parfois avec des fractions de secondes et parfois pas. Auparavant, dans mon code de mappage, j'avais une fonction de transformation qui testait les deux formats de date avec et sans les fractions de seconde. Je ne sais pas trop comment l'aborder avec Codable cependant. Aucune suggestion?

17
Guillermo Alvarez

Vous pouvez utiliser deux formateurs de date différents (avec et sans fraction de seconde) et créer un DateDecodingStrategy personnalisé. En cas d'échec lors de l'analyse de la date renvoyée par l'API, vous pouvez émettre une DecodingError comme suggéré par @PauloMattos dans les commentaires:

iOS 9, macOS 10.9, tvOS 9, watchOS 2, Xcode 9 ou version ultérieure

La coutume 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
    }()
}

La coutume DateDecodingStrategy et Error:

extension JSONDecoder.DateDecodingStrategy {
    static let customISO8601 = custom { decoder throws -> Date in
        let container = try decoder.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)")
    }
}

La coutume DateEncodingStrategy:

extension JSONEncoder.DateEncodingStrategy {
    static let customISO8601 = custom { date, encoder throws in
        var container = encoder.singleValueContainer()
        try container.encode(Formatter.iso8601.string(from: date))
    }
}

edit/update:

Xcode 9 • Swift 4 • iOS 11 ou version ultérieure

ISO8601DateFormatter prend désormais en charge formatOptions.withFractionalSeconds sous iOS11 ou version ultérieure:

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

Les coutumes DateDecodingStrategy et DateEncodingStrategy seraient les mêmes que celles indiquées ci-dessus.


// 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)
}
25
Leo Dabus

À la place de la réponse de @ Leo et si vous devez prendre en charge les anciens systèmes d’exploitation (ISO8601DateFormatter est disponible uniquement à partir de iOS 10, mac OS 10.12), vous pouvez écrire un formateur personnalisé qui utilise les deux formats lors de l’analyse de la chaîne:

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

, que vous pouvez utiliser comme stratégie de décodage de date:

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

Même si sa mise en œuvre est un peu plus laide, cela présente l’avantage d’être cohérent avec les erreurs de décodage générées par Swift en cas de données malformées, car nous ne modifions pas le mécanisme de rapport d’erreurs).

Par exemple:

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)")
}

va imprimer TestDate(date: 2017-06-19 18:43:19 +0000)

Ajout de la partie décimale

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

donnera le même résultat: TestDate(date: 2017-06-19 18:43:19 +0000)

Cependant, en utilisant une chaîne incorrecte: 

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

affichera l'erreur Swift par défaut en cas de données incorrectes:

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))
0
Cristik