Reading JSON from a Rails API in Swift

Swift’s Codable interface allows a Swift type to be converted to and from, among other things, JSON. Besides casing disagreements which can be handled with custom CodingKey enums, converting from Rails-generated JSON to a Swift type is pretty straightforward, but one issue is that Rails’ encoding of dates is not compatible with Swift’s JSONDecoder’s default format.

Because of this, if you try to decode Rails’ created_at or updated_at columns without handling the format difference, you’ll get the error “The data couldn’t be read because it isn’t in the correct format.” with a debug description of “Date string does not match format expected by formatter.”

To avoid (or resolve) this, we can create an instance of JSONDecoder and pass a DateFormatter that uses Rails’ format for DateTime strings:

let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX") //1
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(formatter)

If you only need the date and not a time (such as for a published_on field) you can instead use "yyyy-MM-dd" as the dateFormat in the code above. You may end up using a mix of both in your application.

I like to wrap up an instance of this date formatter as an extension to DateFormatter so that I can use it more easily:

// DateFormatter+Rails.swift
extension DateFormatter {
  static let rails: DateFormatter = {
    let formatter = DateFormatter()
    formatter.locale = Locale(identifier: "en_US_POSIX")
    formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
    return formatter
  }()
}

// In my API layer
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(.rails)

Swift does provide the built-in ISO8601 / RFC 3339 option JSONDecoder.DateDecodingStrategy.iso8601 which should provide a way to do this without needing to specify a custom DateFormatter, but it doesn’t handle fractional seconds which leads to your decoder raising DecodingError.dataCorrupted with a debugDescription of “Expected date string to be ISO8601-formatted.”

In theory you could resolve this by creating an instance of ISO8601DateFormatter and setting formatOptions. Using this leads to what I consider a more verbose solution but which may be preferable over the format string:

let formatter = ISO8601DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(formatter)

I have some version of this DateFormatter in just about every iOS app I work on that uses a Rails API backend. Having the shared instance is handy so that the code isn’t repeated and so that your Swift Encodable and Decodable types are compatible with Rails APIs.

  1. A quick note here - setting the locale helps to make this behavior more consistent across user devices regardless of region settings. See this tech note from Apple for more on why.