Как преобразовать строку даты с необязательными дробными секундами, используя Codable в Swift4

Я заменяю свой старый синтаксический код JSON на Swift Codable и немного запутался. Я предполагаю, что это не такой вопрос, как Codable, поскольку это вопрос DateFormatter.

Начать со структуры

 struct JustADate: Codable {
    var date: Date
 }

и строка json

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

теперь позволяет декодировать

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

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

Но если мы изменим дату так, чтобы она имела дробные секунды, например:

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

Теперь он ломается. Даты иногда возвращаются с дробными секундами, а иногда и нет. То, как я его решал, было в моем картографическом коде, у меня была функция преобразования, которая пробовала оба dateFormats с дробными секундами и без них. Я не совсем уверен, как подойти к нему, используя Codable. Любые предложения?

Ответ 1

Вы можете использовать два разных средства форматирования даты (с долями секунды и без) и создать пользовательский DateDecodingStrategy. В случае сбоя при разборе даты, возвращаемой API, вы можете выбросить DecodingError, как предложено @PauloMattos в комментариях:

iOS 9, macOS 10.9, tvOS 9, watchOS 2, Xcode 9 или более поздняя версия

Пользовательский 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
    }()
}

Пользовательские DateDecodingStrategy и 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)")
    }
}

Кастом DateEncodingStrategy:

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

редактировать/обновить:

Xcode 10 • Swift 4.2 или более поздняя версия • iOS 11.2.1 или более поздняя версия

ISO8601DateFormatter теперь поддерживает formatOptions .withFractionalSeconds в iOS11 или более поздней версии:

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

Обычаи DateDecodingStrategy и DateEncodingStrategy будут такими же, как показано выше.


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

Ответ 2

В качестве альтернативы для ответа @Leo, и если вам нужно предоставить поддержку для старых ОС (ISO8601DateFormatter доступен только с iOS 10, Mac OS 10.12), вы можете написать собственный форматировщик, который использует оба формата при разборе строка:

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

который вы можете использовать в качестве стратегии декодирования даты:

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

Хотя это немного уродливее в реализации, это имеет то преимущество, что оно согласуется с ошибками декодирования, которые Swift бросает в случае искаженных данных, поскольку мы не изменяем механизм сообщений об ошибках).

Например:

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

напечатает TestDate(date: 2017-06-19 18:43:19 +0000)

Добавление дробной части

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

приведет к тому же выводу: TestDate(date: 2017-06-19 18:43:19 +0000)

Однако используя неправильную строку:

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

напечатает ошибку Swift по умолчанию в случае неправильных данных:

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