Come convertire una stringa di data con secondi frazionari facoltativi usando Codable in Swift4

Sto sostituendo il mio vecchio codice di analisi JSON con Swift’s Codable e sto andando incontro a un piccolo inconveniente. Immagino che non sia tanto una domanda codificabile quanto una domanda di DateFormatter.

Inizia con una struttura

struct JustADate: Codable { var date: Date } 

e una stringa json

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

ora consente la decodifica

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

Ma se cambiamo la data in modo che abbia secondi frazionari, ad esempio:

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

Ora si rompe. Le date a volte tornano con secondi frazionari e a volte no. Il modo in cui l’ho usato per risolverlo era nel mio codice di mapping. Avevo una funzione di trasformazione che provava entrambi i dateFormats con e senza i secondi frazionari. Non sono abbastanza sicuro di come affrontarlo usando comunque Codable. Eventuali suggerimenti?

È ansible utilizzare due diversi programmi di formattazione della data (con e senza secondi frazione) e creare una Custom DateDecodingStrategy. In caso di errore durante l’analisi della data restituita dall’API, è ansible generare un DecodingError come suggerito da @PauloMattos nei commenti:

iOS 9, macOS 10.9, tvOS 9, watchOS 2, Xcode 9 o successive

Il DateFormatter personalizzato ISO8601 :

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

DateDecodingStrategy ed 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 Custom DateEncodingStrategy :

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

modifica / aggiornamento :

Xcode 9 • Swift 4 • iOS 11 o successivo

ISO8601DateFormatter ora supporta formatOptions .withFractionalSeconds in iOS11 o versioni successive:

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

La dogana DateDecodingStrategy e DateEncodingStrategy sarebbero le stesse mostrate sopra.


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

In alternativa alla risposta di @ Leo e se è necessario fornire supporto per i sistemi operativi meno recenti ( ISO8601DateFormatter è disponibile solo a partire da iOS 10, mac OS 10.12), è ansible scrivere un formattatore personalizzato che utilizza entrambi i formati durante l’analisi della stringa:

 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?, for string: String, errorDescription error: AutoreleasingUnsafeMutablePointer?) -> 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 } } 

, che puoi usare come strategia di decodifica della data:

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

Anche se un po ‘più brutto in fase di implementazione, questo ha il vantaggio di essere coerente con gli errori di decodifica che Swift genera in caso di dati non corretti, in quanto non alteriamo il meccanismo di segnalazione degli errori).

Per esempio:

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

stamperà TestDate(date: 2017-06-19 18:43:19 +0000)

Aggiunta della parte frazionaria

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

risulterà nella stessa uscita: TestDate(date: 2017-06-19 18:43:19 +0000)

Tuttavia utilizzando una stringa errata:

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

stamperà l’errore Swift predefinito in caso di dati errati:

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