Como converter uma string de data com segundos fracionários opcionais usando Codable no Swift4

Estou substituindo meu antigo código de análise JSON pelo Codificável do Swift e estou correndo em um obstáculo. Eu acho que não é tanto uma questão codificável como é uma questão de DateFormatter.

Comece com uma estrutura

struct JustADate: Codable { var date: Date } 

e uma string json

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

agora permite decodificar

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

Mas se mudarmos a data para que ela tenha frações de segundo, por exemplo:

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

Agora isso quebra. As datas às vezes voltam com segundos fracionários e às vezes não. A maneira que eu usei para resolvê-lo foi no meu código de mapeamento que eu tinha uma function de transformação que tentou ambos os dateFormats com e sem os segundos fracionários. Eu não tenho certeza como abordá-lo usando codable no entanto. Alguma sugestão?

Você pode usar dois formatadores de data diferentes (com e sem frações de segundo) e criar um DateDecodingStrategy personalizado. Em caso de falha ao analisar a data retornada pela API, você pode lançar um DecodingError como sugerido por @PauloMattos nos comentários:

iOS 9, macOS 10.9, tvOS 9, watchOS 2, Xcode 9 ou posterior

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

O Custom DateDecodingStrategy e 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)") } } 

O personalizado DateEncodingStrategy :

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

editar / atualizar :

Xcode 9 • Swift 4 • iOS 11 ou posterior

ISO8601DateFormatter agora suporta formatOptions .withFractionalSeconds no iOS11 ou posterior:

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

Os costumes DateDecodingStrategy e DateEncodingStrategy seriam os mesmos mostrados acima.


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

Alternativamente à resposta de @ Leo, e se você precisar fornecer suporte a sistemas operacionais mais antigos ( ISO8601DateFormatter estará disponível somente a partir do iOS 10, mac OS 10.12), você poderá escrever um formatador personalizado que use os dois formatos ao analisar a sequência:

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

, que você pode usar como estratégia de decodificação de datas:

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

Embora um pouco mais feio na implementação, isso tem a vantagem de ser consistente com os erros de decodificação que o Swift gera no caso de dados malformados, já que não alteramos o mecanismo de relatório de erros).

Por exemplo:

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

vai imprimir TestDate(date: 2017-06-19 18:43:19 +0000)

Adicionando a parte fracionária

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

resultará na mesma saída: TestDate(date: 2017-06-19 18:43:19 +0000)

No entanto, usando uma string incorreta:

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

imprimirá o erro Swift padrão no caso de dados incorretos:

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