In the previous tutorial, we implemented two solutions to parse the JSON data we receive from the Dark Sky API using Swift 3. The solutions we implemented work, but they are far from perfect.
Even though we won't be writing a fully fledged JSON library, I do want to improve the second solution we created in the previous tutorial. To follow along, clone or download the playground from GitHub.
What We Want to Accomplish
Before we start making modifications to the playground, I would like to show you what result we are aiming for. This is what the extension of the WeatherData
structure currently looks like.
extension WeatherData: JSONDecodable {
public init?(JSON: Any) {
guard let JSON = JSON as? [String: AnyObject] else { return nil }
guard let lat = JSON["latitude"] as? Double else { return nil }
guard let long = JSON["longitude"] as? Double else { return nil }
guard let hourlyData = JSON["hourly"]?["data"] as? [[String: AnyObject]] else { return nil }
self.lat = lat
self.long = long
var buffer = [WeatherHourData]()
for hourlyDataPoint in hourlyData {
if let weatherHourData = WeatherHourData(JSON: hourlyDataPoint) {
buffer.append(weatherHourData)
}
}
self.hourData = buffer
}
}
There are several issues I would like to resolve:
- the model is burdened with extracting the data from the JSON data
- the initializer is failable and includes several
guard
statements - initializing the array of
WeatherHourData
instances is too complex and not elegant
This is the result we are aiming for.
extension WeatherData: JSONDecodable {
public init(decoder: JSONDecoder) throws {
self.lat = try decoder.decode(key: "latitude")
self.long = try decoder.decode(key: "longitude")
self.hourData = try decoder.decode(key: "hourly.data")
}
}
This looks much better. The initializer is throwing, which means that we receive an error when something goes wrong during initialization. The most important change is that the initializer is only responsible for mapping values of the JSON data to properties of the model. That is what we are after.
Refactoring the JSONDecodable
Protocol
The first change we need to make to the JSONDecodable
protocol is obvious, updating the required initializer.
protocol JSONDecodable {
init(decoder: JSONDecoder) throws
}
Instead of passing in the JSON data to the initializer, we pass in an object that is specialized in decoding the JSON data, an instance of JSONDecoder
. As I showed you a moment ago, in the initializer of the JSONDecodable
protocol, the model object is expected to ask the JSON decoder for the values of the keys it is interested in.
And remember that the initializer is throwing, which brings us to the second change, adding error handling.
Error Handling
Error handling in Swift if great. To add error handling, we only need to create an enumeration that conforms to the Error
protocol. The Error
protocol is empty. It simply tells the compiler that the conforming type can be used for error handling.
public enum JSONDecoderError: Error {
case invalidData
case keyNotFound(String)
case keyPathNotFound(String)
}
The JSONDecoderError
enum currently supports three cases:
- invalid data
- a missing key
- a missing key path
Note that the keyNotFound
and keyPathNotFound
cases have an associated value, which we will use to store the key or key path that wasn't found by the JSON decoder. Associated values are fantastic.
Implementing the JSONDecoder
Structure
Initialization
This brings us to the JSONDecoder
structure. This is the entity that does the heavy lifting of decoding the JSON data. The public initializer is pretty simple.
import Foundation
public struct JSONDecoder {
typealias JSON = [String: AnyObject]
// MARK: - Properties
private let JSONData: JSON
// MARK: - Initialization
public init(data: Data) throws {
if let JSONData = try JSONSerialization.jsonObject(with: data, options: []) as? JSON {
self.JSONData = JSONData
} else {
throw JSONDecoderError.invalidData
}
}
}
The initializer requires an explanation. Because we make use of the JSONSerialization
class, we need to import the Foundation framework.
import Foundation
We also declare a type alias, JSON
, for the type [String: AnyObject]
. Why? This makes the code cleaner and more readable. You will start to see the benefits of this addition in a few moments.
typealias JSON = [String: AnyObject]
We declare a private constant property that stores the deserialized JSON data. This property is set in the initializer.
// MARK: - Properties
private let JSONData: JSON
The initializer of the JSONDecoder
structure deserializes the Data
object, which is passed in as an argument. If the Data
object cannot be deserialized by the JSONSerialization
class or is not of type [String: AnyObject]
, we throw an error, JSONDecoderError.invalidData
.
// MARK: - Initialization
public init(data: Data) throws {
if let JSONData = try JSONSerialization.jsonObject(with: data, options: []) as? JSON {
self.JSONData = JSONData
} else {
throw JSONDecoderError.invalidData
}
}
Decoding
Earlier in this tutorial, I showed you an example of what we are aiming for. In that example, we asked the JSONDecoder
instance for the value for a particular key by invoking the decode(key:)
method. As you can see below, this method is throwing.
extension WeatherData: JSONDecodable {
public init(decoder: JSONDecoder) throws {
self.lat = try decoder.decode(key: "latitude")
self.long = try decoder.decode(key: "longitude")
self.hourData = try decoder.decode(key: "hourly.data")
}
}
As you probably know, Swift is very strict about types. Based on the above initializer, you would think we need to implement a decode(key:)
method for every possible return type. Fortunately, generics can help us with this problem. This is what the signature of the decode(key:)
method looks like.
func decode<T>(key: String) throws -> T {
}
T
is a placeholder type that we use in the body of the function. Swift's type inference is what does the magic and heavy lifting. If you look at the implementation of the decode(key:)
method, the puzzle starts to come together. The implementation is surprisingly simple.
func decode<T>(key: String) throws -> T {
guard let value: T = try? value(forKey: key) else { throw JSONDecoderError.keyNotFound(key) }
return value
}
In a guard
statement, we invoke a helper method, value(forKey:)
, and assign the result to the value
constant, which is of type T
. If this operation is unsuccessful, we throw an error, JSONDecoderError.keyNotFound
.
Let us continue with exploring the implementation of the value(forKey:)
method. This is very similar to the decode(key:)
method. The reason for moving the extraction of the value from the JSON data into a separate method becomes clear in a few moments.
private func value<T>(forKey key: String) throws -> T {
guard let value = JSONData[key] as? T else { throw JSONDecoderError.keyNotFound(key) }
return value
}
We have two more problems to solve. How does the JSONDecoder
handle an array of model objects that is nested in another model object? And how can we add support for key paths?
Supporting Key Paths
Adding support for key paths is easier than you would think. We first need to update the decode(key:)
method. If the key
parameter contains a dot, we can assume we are dealing with a key path. In that case, we invoke another helper method, value(forKeyPath:)
.
func decode<T>(key: String) throws -> T {
if key.contains(".") {
return try value(forKeyPath: key)
}
guard let value: T = try? value(forKey: key) else { throw JSONDecoderError.keyNotFound(key) }
return value
}
The implementation of value(forKeyPath:)
can look a bit daunting at first. Let me walk you through it.
private func value<T>(forKeyPath keyPath: String) throws -> T {
var partial = JSONData
let keys = keyPath.components(separatedBy: ".")
for i in 0..<keys.count {
if i < keys.count - 1 {
if let partialJSONData = JSONData[keys[i]] as? JSON {
partial = partialJSONData
} else {
throw JSONDecoderError.invalidData
}
} else {
return try JSONDecoder(JSONData: partial).value(forKey: keys[i])
}
}
throw JSONDecoderError.keyPathNotFound(keyPath)
}
We store the value of JSONData
in a temporary variable, partial
, and we extract the keys from the key path.
var partial = JSONData
let keys = keyPath.components(separatedBy: ".")
Next, we loop through the array of keys and, as long as the current key is not the last key, we extract the JSON data that corresponds with the current key, storing the value in partial
.
The moment we have a reference to the last key in keys
, we instantiate an instance of JSONDecoder
and use the value(forKey:)
method to ask it for the value for that key.
for i in 0..<keys.count {
if i < keys.count - 1 {
if let partialJSONData = JSONData[keys[i]] as? JSON {
partial = partialJSONData
} else {
throw JSONDecoderError.invalidData
}
} else {
return try JSONDecoder(JSONData: partial).value(forKey: keys[i])
}
}
Notice that we use a different initializer to initialize the JSONDecoder
instance.
private init(JSONData: JSON) {
self.JSONData = JSONData
}
The value(forKeyPath:)
is also throwing, which means we throw an error whenever something goes haywire.
Arrays of Model Objects
To add support for arrays of model objects, we need to implement a variation of the decode(key:)
method. The signature and implementation of this method are almost identical. The difference is the return type, [T]
, and the type of the value
constant in the guard
statement. Note that the placeholder type, T
, is required to conform to the JSONDecodable
protocol. Why that is becomes clear in a moment.
func decode<T: JSONDecodable>(key: String) throws -> [T] {
if key.contains(".") {
return try value(forKeyPath: key)
}
guard let value: [T] = try? value(forKey: key) else { throw JSONDecoderError.keyNotFound(key) }
return value
}
The implementation of the second decode(key:)
method implies that we also need to implement a second value(forKey:)
method and a second value(forKeyPath)
method.
In the value(forKey:)
method, we first extract the array of JSON data we are interested in from the JSONData
property. Next, we use the map(_:)
method to create an instance of T
for every element in the array of JSON data. That is why the placeholder type, T
, needs to conform to the JSONDecodable
protocol.
private func value<T: JSONDecodable>(forKey key: String) throws -> [T] {
if let value = JSONData[key] as? [JSON] {
return try value.map({ (partial) -> T in
let decoder = JSONDecoder(JSONData: partial)
return try T(decoder: decoder)
})
}
throw JSONDecoderError.keyNotFound(key)
}
The implementation of value(forKeyPath:)
is almost identical to that of the one we implemented earlier. The only difference is the method's return type.
private func value<T: JSONDecodable>(forKeyPath keyPath: String) throws -> [T] {
var partial = JSONData
let keys = keyPath.components(separatedBy: ".")
for i in 0..<keys.count {
if i < keys.count - 1 {
if let partialJSONData = JSONData[keys[i]] as? JSON {
partial = partialJSONData
} else {
throw JSONDecoderError.invalidData
}
} else {
return try JSONDecoder(JSONData: partial).value(forKey: keys[i])
}
}
throw JSONDecoderError.keyPathNotFound(keyPath)
}
Refactoring WeatherData
and WeatherHourData
We can now refactor WeatherData
and WeatherHourData
. This is what their implementations look like. Note that only the extension for conforming to the JSONDecodable
protocol is shown.
extension WeatherData: JSONDecodable {
public init(decoder: JSONDecoder) throws {
self.lat = try decoder.decode(key: "latitude")
self.long = try decoder.decode(key: "longitude")
self.hourData = try decoder.decode(key: "hourly.data")
}
}
extension WeatherHourData: JSONDecodable {
public init(decoder: JSONDecoder) throws {
self.windSpeed = try decoder.decode(key: "windSpeed")
self.temperature = try decoder.decode(key: "temperature")
self.precipitation = try decoder.decode(key: "precipIntensity")
let time: Double = try decoder.decode(key: "time")
self.time = Date(timeIntervalSince1970: time)
}
}
We currently don't have support for extracting and transforming values to Date
objects, which is why this is handled in the initializer of the WeatherHourData
structure. This is a limitation of the current implementation of the JSONDecoder
structure.
Updating the Playground
The playground can be simplified as well. Take a look at what we have thanks to the JSONDecodable
protocol.
import Foundation
// Fetch URL
let url = Bundle.main.url(forResource: "response", withExtension: "json")!
// Load Data
let data = try! Data(contentsOf: url)
do {
let decoder = try JSONDecoder(data: data)
let weatherData = try WeatherData(decoder: decoder)
print(weatherData)
} catch {
print(error)
}
Cherry on the Cake
We can make the syntax even better by adding a static method to the JSONDecoder
class and, once again, leveraging Swift's type inference. This is what we want to accomplish.
let weatherData: WeatherData = try JSONDecoder.decode(data: data)
We can do this by implementing a static method that does the decoding for us. Add the following method to the JSONDecoder
structure.
// MARK: - Static Methods
public static func decode<T: JSONDecodable>(data: Data) throws -> T {
let decoder = try JSONDecoder(data: data)
return try T(decoder: decoder)
}
The implementation is simple thanks to generics and type inference. This should look familiar by now.
Because we are working in a playground, we need to make the JSONDecodable
protocol public. Remember that additional source files of a playground are put in a separate module and not directly accessible by the playground.
public protocol JSONDecodable {
init(decoder: JSONDecoder) throws
}
This addition was inspired by the Unbox library, developed by John Sundell. Unbox is a fantastic, lightweight library for parsing JSON data.
Are We Done Yet
Is this the next JSON library that everyone is going to use. I doubt it. It has several shortcomings and its feature set is quite limited compared to other options.
But we have accomplished a significant goal. We have parsed JSON data without using a third party solution. You learned how to parse JSON data and you also learned how protocols and generics can help you with that.
You can download the playground from GitHub.