swift

Extending Marshal (JSON parsing) library to handle Date

Even with Codable available in Swift 4, I still prefer using Marshal library. Here’s how to extend it to properly handle JSON representations of Date

Codable is pretty good solution for handling JSONs in Swift. Swift’s core team approach to JSON parsing is well known thus it’s no wonder they avoided all sorts of magic and went for the most obvious and straight-forward solution. Which has its price when you have to deal with complex JSON forms and non-optimal structure.

There’s another library that follows the same guiding principles but allows you to avoid creating unnecessary model objects: Marshal. Marshal is “infested” with protocols and generics and it seemed really daunting at first…20 or so viewings. 😏 But once I realized how easy it is to extend, I fell in love.

Out of the box, Marshal handles simple scalar values but it does not offer handling of Dates. No wonder, since there’s not one common way to do this.

Date ⇆ String

When I can, I insist that APIs convert dates into String using ISO-8601 format under UTC time zone. Then it’s fairly easy to support Date to String and vice-versa:

extension Date : ValueType {
	public static func value(from object: Any) throws -> Date {
		guard let dateString = object as? String else {
			throw MarshalError.typeMismatch(expected: String.self, actual: type(of: object))
		}
		if let date = DateFormatter.iso8601Formatter.date(from: dateString) {
			return date
		}
		throw MarshalError.typeMismatch(expected: "ISO8601 date string", actual: dateString)
	}
}

Ah, if only the world would be this perfect. I often encounter few more formats: one where fractional seconds are added at the end and another where time zone information is missing. It’s very easy to extend the code above to handle these formats as well:

extension Date : ValueType {
	public static func value(from object: Any) throws -> Date {
		guard let dateString = object as? String else {
			throw MarshalError.typeMismatch(expected: String.self, actual: type(of: object))
		}
		if let date = DateFormatter.iso8601Formatter.date(from: dateString) {
			return date
		} else if let date = DateFormatter.iso8601FractionalSecondsFormatter.date(from: dateString) {
			return date
		} else if let date = DateFormatter.iso8601NoTimeZoneFormatter.date(from: dateString) {
			return date
		}
		throw MarshalError.typeMismatch(expected: "ISO8601 date string", actual: dateString)
	}
}

In case you need the declaration of these formatters…

extension DateFormatter {
	static let iso8601Formatter: DateFormatter = {
		let df = DateFormatter()
		df.locale = Locale(identifier: "en_US_POSIX")
		df.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
		return df
	}()

	static let iso8601NoTimeZoneFormatter: DateFormatter = {
		let df = DateFormatter()
		df.locale = Locale(identifier: "en_US_POSIX")
		df.dateFormat = "yyyy-MM-dd'T'HH:mm:ss"
		return df
	}()

	static let iso8601FractionalSecondsFormatter: DateFormatter = {
		let df = DateFormatter()
		df.locale = Locale(identifier: "en_US_POSIX")
		df.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSZ"
		return df
	}()
}

Date ⇆ Number

Another popular format is to convert the Date into a numerical figure representing the number of seconds since Jan 1st 1970, so called Unix timestamp.

Handling this is simple but it gives us a chance to re-factor our extension so it can handle just about any form:

extension Date : ValueType {
	public static func value(from object: Any) throws -> Date {
		switch object {
		case let date as Date:
			return date

		case let dateString as String:
			if let date = DateFormatter.iso8601Formatter.date(from: dateString) {
				return date
			} else if let date = DateFormatter.iso8601FractionalSecondsFormatter.date(from: dateString) {
				return date
			} else if let date = DateFormatter.iso8601NoTimeZoneFormatter.date(from: dateString) {
				return date
			}
			throw MarshalError.typeMismatch(expected: "ISO8601 date string", actual: dateString)

		case let dateNum as Int64:
			return Date(timeIntervalSince1970: TimeInterval(integerLiteral: dateNum) )

		case let dateNum as Double:
			return Date(timeIntervalSince1970: dateNum)

		default:
			throw MarshalError.typeMismatch(expected: "Date", actual: type(of: object))
		}
	}
}

I hope it’s obvious now how you can extend Marshal for any other type you need.