Ladybug makes it easy to write a model or data-model layer in Swift 4. Full Codable
conformance without the headache.
- Codable vs JSONCodable
- Installation
- Decoding
- Encoding
- Mapping JSON to properties
- Generic Constraints
- Handling Failure
- Class Conformance
- Musings π€
Ladybug provides the JSONCodable
protocol which is a subprotocol of Codable
. Lets compare how we would create an object using Codable
vs. using JSONCodable
.
Lets model a Tree
. I want this object to be Codable
so I can decode from JSON and encode to JSON.
Here is some JSON:
{
"tree_names": {
"colloquial": ["pine", "big green"],
"scientific": ["piniferous scientificus"]
},
"age": 121,
"family": 1,
"planted_at": "7-4-1896",
"leaves": [
{
"size": "large",
"is_attached": true
},
{
"size": "small",
"is_attached": false
}
]
}
struct Tree: Codable {
enum Family: Int, Codable {
case deciduous, coniferous
}
let name: String
let family: Family
let age: Int
let plantedAt: Date
let leaves: [Leaf]
enum CodingKeys: String, CodingKey {
case names = "tree_names"
case family
case age
case plantedAt = "planted_at"
case leaves
}
enum NameKeys: String, CodingKey {
case name = "colloquial"
}
enum DecodingError: Error {
case emptyColloquialNames
}
init(from decoder: Decoder) throws {
let values = try decoder.container(keyedBy: CodingKeys.self)
let namesContainer = try values.nestedContainer(keyedBy: NameKeys.self, forKey: .names)
let names = try namesContainer.decode([String].self, forKey: .name)
guard let firstColloquialName = names.first else {
throw DecodingError.emptyColloquialNames
}
name = firstColloquialName
family = try values.decode(Family.self, forKey: .family)
age = try values.decode(Int.self, forKey: .age)
plantedAt = try values.decode(Date.self, forKey: .plantedAt)
leaves = try values.decode([Leaf].self, forKey: .leaves)
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
var nameContainer = container.nestedContainer(keyedBy: NameKeys.self, forKey: .names)
let colloquialNames = [name]
try nameContainer.encode(colloquialNames, forKey: .name)
try container.encode(family, forKey: .family)
try container.encode(age, forKey: .age)
try container.encode(plantedAt, forKey: .plantedAt)
try container.encode(leaves, forKey: .leaves)
}
struct Leaf: Codable {
enum Size: String, Codable {
case small, medium, large
}
let size: Size
let isAttached: Bool
enum CodingKeys: String, CodingKey {
case isAttached = "is_attached"
case size
}
}
}
Codable
is a great step for Swift, but as you can see here, it can get complicated really fast.
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MM-dd-yyyy"
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(dateFormatter)
let tree = try decoder.decode(Tree_Codable.self, from: jsonData)
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MM-dd-yyyy"
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .formatted(dateFormatter)
let data = try encoder.encode(tree)
By conforming to the JSONCodable
protocol, you can skip all the boilerplate that comes with Codable
while still getting Codable
conformance.
struct Tree: JSONCodable {
enum Family: Int, Codable {
case deciduous, coniferous
}
let name: String
let family: Family
let age: Int
let plantedAt: Date
let leaves: [Leaf]
static let transformersByPropertyKey: [PropertyKey: JSONTransformer] = [
"name": JSONKeyPath("tree_names", "colloquial", 0),
"plantedAt": "planted_at" <- format("MM-dd-yyyy"),
"leaves": [Leaf].transformer,
]
struct Leaf: JSONCodable {
enum Size: String, Codable {
case small, medium, large
}
let size: Size
let isAttached: Bool
static let transformersByPropertyKey: [PropertyKey: JSONTransformer] = [
"isAttached": "is_attached"
]
}
}
As you can see, you only need provide mappings for the JSON keys that don't explicitly map to property names.
let tree = try Tree(data: jsonData)
let data = try tree.toData()
Ladybug will save you time and energy when creating models in Swift by providing Codable
conformance without the headache.
Add the following to your Podfile
pod 'Ladybug', '~> 2.0.0'
Add the following to your Cartfile
github "jhurray/Ladybug" ~> 2.0.0
You can decode any object or array of objects conforming to JSONCodable
from a JSON object, or Data
.
/// Decode the given object from a JSON object
init(json: Any) throws
/// Decode the given object from `Data`
init(data: Data) throws
Example:
let tree = try Tree(json: treeJSON)
let forest = try Array<Tree>(json: [treeJSON, treeJSON, treeJSON])
Both initializers will throw an error if decoding fails.
You can encode any object or array of objects conforming to JSONCodable
to a JSON object or to Data
/// Encode the object into a JSON object
func toJSON() throws -> Any
/// Encode the object into Data
func toData() throws -> Data
Example:
let jsonObject = try tree.toJSON()
let jsonData = try forest.toData()
Both functions will throw an error if encoding fails.
By conforming to the JSONCodable
protocol provided by Ladybug, you can initialize any struct
or final class
with Data
or a JSON object. If your JSON structure diverges form your data model, you can override the static transformersByPropertyKey
property to provide custom mapping.
struct Flight: JSONCodable {
enum Airline: String, Codable {
case delta, united, jetBlue, spirit, other
}
let airline: Airline
let number: Int
static let transformersByPropertyKey: [PropertyKey: JSONTransformer] = [
"number": JSONKeyPath("flight_number")
]
}
...
let flightJSON = [
"airline": "united",
"flight_number": 472,
]
...
let directFlight = try Flight(json: flightJSON)
let flightWithLayover = try Array<Flight>(json: [flightJSON, otherFlightJSON])
directFlight
and flightWithLayover
are fully initialized and can be encoded and decoded. Simple as that.
Note: Any nested enum must conform to Codable
and RawRepresentable
where the RawValue
is Codable
.
Note: PropertyKey
is a String
typealias.
You can associate JSON keys with properties via different objects conforming to JSONTransformer
.
Transformers are provided via a readonly static
property of the JSONCodable
protocol, and are indexed by PropertyKey
.
static var transformersByPropertyKey: [PropertyKey: JSONTransformer] { get }
Ok, it gets a little more complicated, but its easy, I swear.
In the example at the beginning we used JSONKeyPath
to map the value associated with the tree_name
field to the name
property of Tree
.
JSONKeyPath
is used to access values in JSON. It is initialized with a variadic list of json subscripts (Int
or String
).
In the example below:
JSONKeyPath("foo")
maps to{"hello": "world"}
- Similarly,
"foo"
maps to{"hello": "world"}
JSONKeyPath("foo", "hello")
maps to"world"
JSONKeyPath("bar", 0)
maps to"lorem"
{
"foo": {
"hello": "world"
},
"bar": [
"lorem",
"ipsum"
]
}
Note: These key paths are used optionally in objects conforming to JSONTransformer
when the property being mapped to does not match the json structure. If the property name is the same as the key path, you dont need to include the key path.
Note: JSONKeyPath can also be expressed as a string literal.
JSONKeyPath("some_key")
=="some_key"
Note: You can also use Objective-C keypath notation.
JSONKeyPath("foo", "hello")
==JSONKeyPath("foo.hello")
=="foo.hello"
This does not work for Int
subscripts
JSONKeyPath("bar", 1)
!=JSONKeyPath("bar.1")
Lets add a nested class, Passenger
. Flights have passengers. Nice.
You can denote a nested object via the static transformer
property of any object or array of objects conforming to JSONCodable
.
You can combine transformers using the <-
operator. In this case, for the airMarshal
property, both the key path and the nested object need explicit transforms.
{
"airline": "united",
"flight_number": 472,
"air_marshal" {
"name": "50 Cent",
},
"passengers": [
{
"name": "Jennifer Lawrence",
},
{
"name": "Chris Pratt"
},
...
]
}
struct Flight: JSONCodable {
...
let passengers: [Passenger]
let airMarshal: Passenger
...
static let transformersByPropertyKey: [PropertyKey: JSONTransformer] = [
...
"passengers": [Passenger].transformer,
"airMarshal": "air_marshal" <- Passenger.transformer
]
struct Passenger: JSONCodable {
let name: String
}
}
Note: When using the <-
operator, always put the JSONKeyPath
transformer first.
Finally, lets add dates to the mix. Ladybug provides multiple date transformers:
secondsSince1970
: Decode the date as a UNIX timestamp from a JSON number.millisecondsSince1970
: Decode the date as UNIX millisecond timestamp from a JSON number.iso8601
: Decode the date as an ISO-8601-formatted string (in RFC 3339 format).format(_ format: String)
: Decode the date with a custom date format string.custom(_ adapter: @escaping (Any?) -> Date?)
: Return aDate
from the JSON value.
{
"july4th": "7/4/1776",
"y2k": 946684800,
}
struct SomeDates: JSONCodable {
let july4th: Date
let Y2K: Date
let createdAt: Date
static let transformersByPropertyKey: [PropertyKey: JSONTransformer] = [
"july4th": format("MM/dd/yyyy"),
"Y2K": "y2k" <- secondsSince1970,
"createdAt": custom { _ in return Date() }
]
}
Note: If using custom
to map to a non-optional Date
, returning nil
will result in an error being thrown during decoding.
If you need to provide a simple mapping from a JSON value to a property, use MapTransformer
. A great example is using this to convert a string to an integer.
{
"count": "100"
}
...
struct BottlesOfBeerOnTheWall: JSONCodable {
let count: Int
static let transformersByPropertyKey: [PropertyKey: JSONTransformer] = [
"count": Map<Int> { return Int($0 as! String) }
]
}
If you wish to supply a default value for a property, you can use Default
. You supply the default value, and can also control whether or not to override the property if the property key exists in the JSON payload.
init(value: Any, override: Bool = false)
The default transformer is useful when API's change, and can help migration from cached JSON data to JSONCodable
objects with new properties.
Because Array
does not explicitly conform to JSONCodable
, JSONCodable
does not support list types when used as a generic constraint. If you need this support, you can use the List<T: JSONCodable>
wrapper type.
struct Tweet: JSONCodable { ... }
class ContentProvider<T: JSONCodable> { ... }
let tweetDetailProvider = ContentProvider<Tweet>()
let timelineProvider = ContentProvider<List<Tweet>>()
Ladybug is failure driven, and all JSONCodable
initializers and encoding mechanisms throw errors if they fail. There is a JSONCodableError
type that Ladybug will throw if there is a typecasting error, and Ladybug will also throw errors from JSONSerialization
, JSONDecoder
, and JSONEncoder
.
If a value is optional in your JSON payload, it should be optional in your data model. Ladybug will only throw an error if a key is missing and the property it is being mapped to is non-optional. Play it safe kids, use optionals.
There are 2 transformers that can return nil
values: Map<T: Codable>
and custom(_ adapter: @escaping (Any?) -> Date?)
.
If you are decoding from an already encoded JSONCodable
object, returning nil
is fine.
If you are decoding from a URLResponse
, returning nil can lead to an error being thrown.
There are 2 small caveat to keep in mind when you are conforming a class
to JSONCodable
:
- Because classes in swift dont come with baked in default initializers like structs do, you have to make sure properties are initialized. You can do this by supplying default values, or a default initializer that initializes these values.
You can see examples in ClassConformanceTests.swift
.
- Subclassing an object conforming to
Codable
will not work, so it won't work forJSONCodable
either.
Because of these caveats, I would suggest using structs for your data models.
If Swift 4 key paths exposed a string value, we could use PartialKeyPath<Self>
as our PropertyKey
typealias instead of String
. This would be a much safer alternative.
typealias PropertyKey = PartialKeyPath<Self>
...
static var transformersByPropertyKey: [PropertyKey: JSONTransformer] = [
\Tree.name: JSONKeyPath("tree_name")
]
There was no disussion of this in SE-0161.
This would allow us to implicitly map nested objects conforming to JSONCodable
.
As mentioned before, Codable
is a great step towards simplifying JSON parsing in swift, but the O(n) boilerplate that has become a mainstay in swift JSON parsing still exists when using Codable
(e.g. For every property your object has, you need to write 1 or more lines of code to map the json to said property). In Apple's documentation on Encoding and Decoding Custom Types, you can see that as soon as JSON keys diverge from property keys, you have to write a ton of boilerplate code to get Codable
conformance. Ladybug sidesteps this, and does a lot of this for you under the hood.
Its easy to go a little to far with MapTransformer
. In the example below, the map transformer is being used to calculate a sum instead of mapping a JSON value to a Codable
type. To me, this promotes bad data modeling. I'm a firm believer that data models should closely mirror JSON responses. When used in the wrong way, map transformers can give too data models too much responsibility.
{
"values": [1, 1, 2, 3, 5, 8, 13]
}
...
struct FibonacciSequence: JSONCodable {
let values: [Int]
let sum: Int
static let transformersByPropertyKey: [PropertyKey: JSONTransformer] = [
"sum": MapTransformer<Int>(keyPath: "values") { value in
let values = value as! [Int]
return values.reduce(0) { $0 + $1 }
}
]
}
Shoutout to the good folks at Mantle for giving me some inspiration on this project. I'm pretty happy a similar framework is finally possible for Swift without mixing in Obj-C runtime.
Feel free to email me at jhurray33@gmail.com or hit me up on the twitterverse. I'd love to hear your thoughts on this, or see examples where this has been used.