Date
Nov. 21st, 2024
 
2024年 10月 21日

Post: Swift: Codable With Custom Dates

Swift: Codable With Custom Dates

Published 12:12 Dec 28, 2017.

Created by @ezra. Categorized in #Programming, and tagged as #iOS.

Source format: Markdown

Table of Content

How do you decode a JSON feed that has several custom date formats? If you are lucky using a dateDecodingStrategy might be enough. Unfortunately it has limited support for the .iso8601 format and you can only set one strategy at a time so it does not help when you have two or more different date formats.

An Example From The iTunes RSS Generator

I am using the iTunes RSS Feed Generator as an example. Here is what the JSON looks like for the top 3 UK podcasts, omitting many fields for brevity, written using the Swift 4 multi-line string syntax:

// https://rss.itunes.apple.com/api/v1/gb/podcasts/top-podcasts/all/3/explicit.json
let json = """
{
  "feed": {
    "title":"Top Audio Podcasts",
    "country":"gb",
    "updated":"2017-11-16T02:02:55.000-08:00",
    "results":[
      {
      "artistName":"BBC Radio",
      "name":"Blue Planet II: The Podcast",
      "releaseDate":"2017-11-12",
      "url":"https://itunes.apple.com/gb/podcast/blue-planet-ii-the-podcast/id1296222557?mt=2"
    },
    {
      "artistName":"Audible",
      "name":"The Butterfly Effect with Jon Ronson",
      "releaseDate":"2017-11-03",
      "url":"https://itunes.apple.com/gb/podcast/the-butterfly-effect-with-jon-ronson/id1258779354?mt=2"
    },
    {
      "artistName":"TED",
      "name":"TED Talks Daily",
      "releaseDate":"2017-11-16",
      "url":"https://itunes.apple.com/gb/podcast/ted-talks-daily/id160904630?mt=2"
    }
    ]
  }
}
"""

Note that there are two different date formats in use. The first is the timestamp for when the chart was last updated. This is an iso8601format (but note the fractional seconds):

"updated":"2017-11-16T02:02:55.000-08:00",

The second is the podcast release date and is a simple yyyy-MM-dd format:

"releaseDate":"2017-11-12",

Swift Codable - A Recap

Swift 4 brought us a standardized way to encode/decode JSON using our own custom types by adopting the Codable protocol. Standard types like String, URL and Date are already Codable so we can use them to build Codable types for the podcast and feed. We can also nest Swift structs to directly model the RSS Feed structure:

import Foundation

struct RSSFeed: Codable {
  struct Feed: Codable {
    struct Podcast: Codable {
      let name: String
      let artistName: String
      let url: URL
      let releaseDate: Date
    }

    let title: String
    let country: String
    let updated: Date
    let podcasts: [Podcast]

    private enum CodingKeys: String, CodingKey {
      case title
      case country
      case updated
      case podcasts = "results"
    }
  }

  let feed: Feed
}

typealias Feed = RSSFeed.Feed
typealias Podcast = Feed.Podcast

Note: I am switching the name of the generic “results” field to “podcasts” by providing a CodingKeys enum with the alternate key name in the Feed struct.

To decode our JSON string we convert it to Data and feed it to a JSONDecoder:

let data = Data(json.utf8)
let decoder = JSONDecoder()
let rssFeed = try! decoder.decode(RSSFeed.self, from: data)

Unfortunately this gives us decoding errors for the two dates.

Date Decoding Strategy

You can change the way the JSON decoder handles dates by setting the date decoding strategy. The updated date for the Feed looks a lot like an iso8601 date which is one of the supported strategies so we could try that:

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

Unfortunately it still does not work as it seems the Foundation library does not support iso8601 times that include fractional seconds (02:55.000). Luckily it is no big deal to use a custom date formatter that does handle the full iso8601 format (see also this Stack Overflow answer):

extension DateFormatter {
  static let iso8601Full: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZZZZZ"
    formatter.calendar = Calendar(identifier: .iso8601)
    formatter.timeZone = TimeZone(secondsFromGMT: 0)
    formatter.locale = Locale(identifier: "en_US_POSIX")
    return formatter
  }()
}

Note the .SSS in the date format. To use this custom data formatter when decoding our JSON data:

let data = Data(json.utf8)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full)
let rssFeed = try! decoder.decode(RSSFeed.self, from: data)

This gets us closer but we also need to handle the custom releaseDate date format.

Custom Transformation

Using the dateDecodingStrategy works well when your feed has one custom date format. Let’s see how to manually decode the Podcast type so we can also handle the custom releaseDate format. First we need a date formatter to handle the yyyy-MM-dd format of the releaseDate:

extension DateFormatter {
  static let yyyyMMdd: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd"
    formatter.calendar = Calendar(identifier: .iso8601)
    formatter.timeZone = TimeZone(secondsFromGMT: 0)
    formatter.locale = Locale(identifier: "en_US_POSIX")
    return formatter
  }()
}

Now we can extend the Podcast type with our own required initializer init(from: Decoder) that handles the custom date format:

extension Podcast {
  init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    name = try container.decode(String.self, forKey: .name)
    artistName = try container.decode(String.self, forKey: .artistName)
    url = try container.decode(URL.self, forKey: .url)

    let dateString = try container.decode(String.self, forKey: .releaseDate)
    let formatter = DateFormatter.yyyyMMdd
    if let date = formatter.date(from: dateString) {
        releaseDate = date
    } else {
        throw DecodingError.dataCorruptedError(forKey: .releaseDate,
              in: container,
              debugDescription: "Date string does not match format expected by formatter.")
    }
  }
}

This is boilerplate code to decode a keyed container (a dictionary) using the CodingKeys enum that has keys for each member of the Podcast struct. To handle the custom date we first try to decode the .releaseDate field to a String and then use our data formatter to parse it into a Date. If we do not get a valid Date back from the formatter we throw a .dataCorruptedError.

We should now be able to decode the full feed:

let data = Data(json.utf8)
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(DateFormatter.iso8601Full)
let rssFeed = try! decoder.decode(RSSFeed.self, from: data)

let feed = rssFeed.feed
print(feed.title, feed.country, feed.updated)

feed.podcasts.forEach {
  print($0.name)
}
Top Audio Podcasts gb 2017-11-16 10:02:55 +0000
Blue Planet II: The Podcast
The Butterfly Effect with Jon Ronson
TED Talks Daily
Pinned Message
HOTODOGO
The Founder and CEO of Infeca Technology.
Developer, Designer, Blogger.
Big fan of Apple, Love of colour.
Feel free to contact me.
反曲点科技创始人和首席执行官。
开发、设计与写作皆为所长。
热爱苹果、钟情色彩。
随时恭候 垂询