iso8601 date json decoding using swift4 [duplicate] - swift

This question already has answers here:
How to convert a date string with optional fractional seconds using Codable in Swift?
(4 answers)
Closed 5 years ago.
So, I've got iso8601 dates in my json which look like "2016-06-07T17:20:00.000+02:00"
Is there a way to parse these iso8601 dates using swift4? Am I missing something obvious?
I tried the following, but only the dateString "2016-06-07T17:20:00Z" from jsonShipA is parsable....
import Foundation
struct Spaceship : Codable {
var name: String
var createdAt: Date
}
let jsonShipA = """
{
"name": "Skyhopper",
"createdAt": "2016-06-07T17:20:00Z"
}
"""
let jsonShipB = """
{
"name": "Skyhopper",
"createdAt": "2016-06-07T17:20:00.000+02:00"
}
"""
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let dataA = jsonShipA.data(using: .utf8)!
if let decodedShip = try? decoder.decode(Spaceship.self, from: dataA) {
print("jsonShipA date = \(decodedShip.createdAt)")
} else {
print("Failed to decode iso8601 date format from jsonShipA")
}
let dataB = jsonShipB.data(using: .utf8)!
if let decodedShip = try? decoder.decode(Spaceship.self, from: dataB) {
print("jsonShipA date = \(decodedShip.createdAt)")
} else {
print("Failed to decode iso8601 date format from jsonShipB")
}
The output of the playground is:
jsonShipA date = 2016-06-07 17:20:00 +0000
Failed to decode iso8601 date format from jsonShipB
The error being thrown is "Expected date string to be ISO8601-formatted."
But to my knowledge, the date "2016-06-07T17:20:00.000+02:00" is a valid ISO8601 date

You can use like this :
enum DateError: String, Error {
case invalidDate
}
let decoder = JSONDecoder()
let formatter = DateFormatter()
formatter.calendar = Calendar(identifier: .iso8601)
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(secondsFromGMT: 0)
decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
let container = try decoder.singleValueContainer()
let dateStr = try container.decode(String.self)
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSXXXXX"
if let date = formatter.date(from: dateStr) {
return date
}
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssXXXXX"
if let date = formatter.date(from: dateStr) {
return date
}
throw DateError.invalidDate
})

TL;DR version: it only parses the withInternetDateTime format of the ISO8601DateFormatter described here. This means that your string should not have milliseconds.
More info:
Looking at the Swift source on line 787, the comment says:
/// Decode the `Date` as an ISO-8601-formatted string (in RFC 3339 format).
Looking at that RFC, it gives a couple of (admittedly tricky) examples in section 5.8:
1985-04-12T23:20:50.52Z
1996-12-19T16:39:57-08:00
1996-12-20T00:39:57Z
1990-12-31T23:59:60Z
1990-12-31T15:59:60-08:00
1937-01-01T12:00:27.87+00:20
Only the second and the third example are actually decoded by Swift, the rest fails. It seems to me that either the comment is incorrect, or the implementation is not complete. As for the real implementation, that's outside the Swift source, it simply seems to use the ISO8601DateFormatter class in Foundation.
The Swift unittest is also very limited, see line 180. It simply encodes a single date, and then decodes it back. So in other words, the only thing that's tested, is the format that the ISO8601DateFormatter outputs by default, which is hardcoded to the option .withInternetDateTime, described here.

Related

Can MKGeoJSONDecoder be extended to parse ISO8601 dates like JSONDecoder?

I have GeoJSON data from an API and it contains dates that are in ISO8601 format. I can decode them in SwiftUI as a string and manipulate them through a calculated field to get a version that is type Date but it's clumsy.
I know the JSONDecoder supports date en/decoding options and I'd like the same behaviour.. similar to this:
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
I was thinking of maybe an extension to MKGeoJSONDecoder but I can't figure out how to even get started because of the need to be in the parsing flow.
Thoughts? Thanks
Thanks vadian, all of the data I wanted was in the properties attribute so there was really no value in using MKGeoJSONDecoder. I just switched to normal JSONDecoder and put a custom ISO8601 formatter to match my data and all works great.
In case anyone wants to reference, here is the playground code. You'd want to add a bunch of error checking of course. And thanks to Sarunw for the great info on the custom date decoding (https://sarunw.com/posts/how-to-parse-iso8601-date-in-swift/)
import Foundation
import MapKit
struct Response: Codable {
let features: [Feature]
}
struct Feature: Codable {
let properties: Properties
}
struct Properties: Codable {
let stnNamValue: String?
let dateTmValue: Date?
enum CodingKeys: String, CodingKey {
case stnNamValue = "stn_nam-value"
case dateTmValue = "date_tm-value"
}
}
Task {
// get the data from API
let url = URL(string: "https://api.weather.gc.ca/collections/swob-realtime/items?f=json&sortby=-date_tm-value&clim_id-value=5050919&limit=3")
let (data, _) = try await URLSession.shared.data(from: url!)
// create the decoder with custom ISO8601 formatter
let decoder = JSONDecoder()
let formatter = ISO8601DateFormatter()
formatter.formatOptions = [.withFullDate, .withFullTime,.withFractionalSeconds, .withTimeZone]
decoder.dateDecodingStrategy = .custom({ decoder in
let container = try decoder.singleValueContainer()
let dateString = try container.decode(String.self)
if let date = formatter.date(from: dateString) {return date}
throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)")
})
// decode & print the results
let decodedResponse = try decoder.decode(Response.self, from: data)
decodedResponse.features.forEach {(feature) in
print("Station: \(feature.properties.stnNamValue ?? "<no value>"), Date: \(feature.properties.dateTmValue)")
}
}
Output is the date/times of the most recent surface weather observations for Flin Flon, Manitoba:
Station: FLIN FLON, Date: Optional(2022-02-16 12:22:00 +0000)
Station: FLIN FLON, Date: Optional(2022-02-16 12:21:00 +0000)
Station: FLIN FLON, Date: Optional(2022-02-16 12:20:00 +0000)

How can I parse an ISO 8601 date string in Swift 5.3?

I've tried using ISO8601DateFormatter, but that doesn't seem to be working as expected:
let formatter = ISO8601DateFormatter()
formatter.date(from: "2020-11-16T12:31:43Z") // returns nil
In addition to that, formatting a Date as an ISO 8601 string and parsing it back doesn't seem to work:
formatter.date(from: formatter.string(from: Date())) // returns nil
This is using the Swift REPL — Apple Swift version 5.3 (swiftlang-1200.0.29.2 clang-1200.0.30.1) on macOS 10.15.6.
I could use DateFormatter with a handwritten format, but I'd prefer to avoid that if possible.
Is there a standard way to parse ISO 8601 strings? And how come formatting a string and parsing it back doesn't work?
EDIT: For clarity, here is my complete REPL session with the exact code that I entered, and with unchanged output.
Welcome to Apple Swift version 5.3 (swiftlang-1200.0.29.2 clang-1200.0.30.1).
Type :help for assistance.
1> import Foundation
2> let formatter = ISO8601DateFormatter()
formatter: ISO8601DateFormatter = {
baseNSFormatter#0 = {
baseNSObject#0 = {
isa = NSISO8601DateFormatter
}
}
_formatter = {}
_timeZone = "GMT"
_formatOptions = 1907
}
3> formatter.formatOptions = .withInternetDateTime
4> formatter.date(from: "2020-11-16T12:31:43Z")
$R0: Date? = nil
5> formatter.date(from: formatter.string(from: Date()))
$R1: Date? = nil
I've also tried formatter.formatOptions = [.withInternetDateTime] and formatter.formatOptions.insert(.withInternetDateTime) without success.

Vapor pass date parameter

In my Vapor 3 app, I have an Event model, which has the properties startDate: Date and endDate: Date.
Now, I'm wondering how to pass those date values in a POST request. In Postman, I tried the following in x-www-form-urlencoded:
startDate -> 2019-03-14
This returns the error below:
Could not convert to Double: str(\"2019-03-14\")
Apparently, Date turns into Double.
So, instead, what value do I need to pass?
Note
I know, that, in Postman, I can insert {{$timestamp}}, but 1) this doesn't answer my question when using the API outside Postman and 2) this doesn't allow me to enter a date other than now.
So the issue here is that by default, a Date instance is decoded using the time interval since Jan 1, 2001. The URL form decoder that Vapor uses doesn't support different date strategies like the JSONDecoder does at the moment, so you'll have to do the decoding a different way. Here are a couple of ideas I could come up with:
Just send the timestamp in the request. For testing different dates in Postman, you can set an environment variable in the pre-request script and access that in the request body.
Manually implement the Event.init(from:) and .encode(to:) methods. Just to make sure you don't break the Fluent coding, you will probably have to add some extra logic, but it should work. Here's an example:
final class Event: Model {
static let formDateFormatter: 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"
return formatter
}()
var startDate: Date
var endDate: Date
init(from decoder: Decoder)throws {
let container = try decoder.container(keyedby: CodingKeys.self)
if let start = try? container.decode(String.self, keyedBy: .startDate), let date = Event.formDateFormatter.string(from: start) {
self.startDate = date
} else {
self.startDate = try container.decode(Date.self, keyedBy: .startDate)
}
if let end = try? container.decode(String.self, keyedBy: .endDate), let date = Event.formDateFormatter.string(from: end) {
self.endDate = date
} else {
self.endDate = try container.decode(Date.self, keyedBy: .endDate)
}
}
}
I'm not sure about x-www-form-urlencoded cause I tested it and if I send a date as 0 it decodes it as 2001-01-01 00:00:00 +0000 thought it definitely should be 1970-01-01 00:00:00 +0000.
But with JSON payload you have a flexibility cause you could provide a JSONDecoder configured as needed for you.
struct Payload: Content {
var date: Date
}
If you'd like to send dates as UNIX-timestamp
router.post("check") { req throws -> Future<String> in
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .secondsSince1970 // choose it for unix-timestamp
return try req.content.decode(json: Payload.self, using: decoder).map { p in
return String(describing: p.date)
}
}
If you'd like to send dates in your own format
router.post("check") { req throws -> Future<String> in
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.timeZone = TimeZone(secondsFromGMT: 0)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(formatter) // custom date formatter
return try req.content.decode(json: Payload.self, using: decoder).map { p in
return String(describing: p.date)
}
}
So for unix-timestamp you should send seconds from 1970 and e.g. 0 will be decoded to 1970-01-01 00:00:00 +0000.
And for custom format described above you should send dates like 2018-01-01 00:00:00 to decode it as 2018-01-01 00:00:00 +0000
UPD: you could write an extension to decode it beautifully
extension ContentContainer where M: Request {
func decodeJson<D>(_ payload: D.Type) throws -> Future<D> where D: Decodable {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
formatter.timeZone = TimeZone(secondsFromGMT: 0)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(formatter)
return try decode(json: payload, using: decoder)
}
}
so then you'll be able to decode your payload like this
router.post("check") { (req) throws -> Future<String> in
return try req.content.decodeJson(Payload.self).map { p in
return String(describing: p.date)
}
}
I realized that the Date object is returned in the following format when queried:
2021-12-31T14:29:00Z
So this is what I tried to pass and that worked! No need for any custom decoding.

Swift and iso8601 try parse "HH:mm:ss"

We used Decodable, and have a time: Date field, but from server came only time with "HH:mm:ss" format. Another date parsers with DateInRegion.
But this field crash app
I try do smth in decoder, but cant see any properties (.count)
and I dont now what need do
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom({ decoder -> Date in
do {
let value = try decoder.singleValueContainer()
let string = try value.decode(String.self)
if string.conut == 8 {
if let date = DateInRegion(
string: string,
format: .iso8601(options: .withTime),
fromRegion: Region.Local()
) {
return date.absoluteDate
} else {
throw DecodingError.nil
}
} else if let date = DateInRegion(
string: string,
format: .iso8601(options: .withInternetDateTime),
fromRegion: Region.Local()
) {
return date.absoluteDate
} else {
throw DecodingError.nil
}
} catch let error {
Not sure what you are trying to achieve, but it seems you want to handle multiple date format in a single JSON decoder.
Given the response struct is Decodable:
struct Response: Decodable {
var time: Date
var time2: Date
}
You can configure your decoder to handle all the date format you want:
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
// Extract string date from the container
let container = try decoder.singleValueContainer()
let stringDate = try container.decode(String.self)
// Trying to parse the "HH:mm:ss" format
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "HH:mm:ss"
if let date = dateFormatter.date(from: stringDate) {
return date // successfully parsed "HH:mm:ss" format
}
// Trying to parse the iso8601 format
if let date = ISO8601DateFormatter().date(from: stringDate) {
return date // successfully parsed iso8601 format.
}
// Unknown date format, throw an error.
let context = DecodingError.Context(codingPath: [], debugDescription: "Unable to parse a date from '\(stringDate)'")
throw DecodingError.dataCorrupted(context)
})
Note: This is an example code and it's not very optimized (DateFormatter init each time you want to parse a date, using the brute force parsing to determine whether the date is in "HH:mm:ss" format or not, ...).
I think, the best way to optimize this, is to use multiple decoders, each configured for a specific date format, and use the right one when needed. For example, if you are trying to parse a JSON from an API, you should be able to determine whether you need to parse an iso8601 format or something else, and use the right decoder accordingly.
Here is an example of the decoder working in a playground:
let json = """
{"time": "11:05:45", "time2": "2018-09-28T16:02:55+00:00"}
"""
let response = try! decoder.decode(Response.self, from: json.data(using: .utf8)!)
response.time // Jan 1, 2000 at 11:05 AM
response.time2 // Sep 28, 2018 at 6:02 PM

How to handle two possible date formats?

My app calls a web api that sometimes returns json dates in this format:
"2017-01-18T10:49:00Z"
and sometimes in this format:
"2017-02-14T19:53:38.1173228Z"
I can use the following dateformat to convert the 2nd one to a Date object:
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
But of course it doesn't work for the 1st one.
I've tried utilities like https://github.com/melvitax/DateHelper to see if it will work, but I haven't found a way to convert a json date (in any format) into a Date object.
Any recommendations?
Try both formats:
let parser1 = DateFormatter()
parser1.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
let parser2 = DateFormatter()
parser2.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
func parse(_ dateString: String) -> Date? {
let parsers = [parser1, parser2]
for parser in parsers {
if let result = parser.date(from: dateString) {
return result
}
}
return nil
}
print(parse("2017-01-18T10:49:00Z"))
print(parse("2017-02-14T19:53:38.1173228Z"))
Also note that the Z in the format is not a literal value.