Many developers new to Swift seem to be struggling with JSON. Despite the speed of Foundation's JSONSerialization
class, it hands you an object of type Any
, leaving it up to you to unwrap the object you received.
In Objective-C, that's less of a problem. But Swift's inherent safety measures make this less trivial. This is what you end up with if you decide to choose for JSONSerialization
.
if let JSON = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: AnyObject],
let currently = JSON?["currently"] as? [String: AnyObject],
let summary = currently["summary"] {
print(summary)
}
That doesn't look pretty. It looked even worse in earlier versions of Swift. Once the Swift Core Libraries project starts to take shape, this becomes much easier. The goal of this project is to add functionality to the Swift programming language on top of the Swift standard library.
Fortunately, there are a bunch of open source libraries available for handling JSON in Swift. One of the most popular solutions is SwiftyJSON. SwiftyJSON is quickly gaining in popularity for the same reasons AFNetworking was a few years ago, a lack of a robust, native solution.
Another popular solution is, Argo, created and maintained by none other than ThoughtBot.
For many years, I've been using JSONModel. I love JSONModel because it makes consuming APIs so much easier. The most important downside of JSONModel is that every model class needs to inherit from JSONModel
. That's a trade-off many developers are not willing to make.
I've been looking for a nice solution to replace JSONModel in Swift. Ideally, it's a solution that doesn't rely on inheritance but that still gives me access to a nice API for parsing JSON and pouring raw data into model objects. Unbox, created by John Sundell, looks very promising to me.
Unbox doesn't rely on inheritance. It defines a protocol that any class or structure can conform to. It provides support for:
- key paths
- nested models
- failable initializers
- native error handling
- custom transformations
I'd like to show you how Unbox works by consuming the Dark Sky API. Dark Sky's API returns a large blob of data that would be a pain to consume if all you had was the JSONSerialization
class. Unbox makes this much, much easier.
Project Setup
Fire up Xcode and create a new project based on the Single View Application template. Name the project Weather and set Language to Swift. For this tutorial, I'll be using Xcode 9 and Swift 4.
Unbox supports CocoaPods and Carthage. Because I use CocoaPods for most of my projects, I'm going to stick with CocoaPods for this example.
Open a Terminal window, navigate to the root of the project, and execute pod init
to create a Podfile for your project. Open the Podfile in a text editor and add Unbox as a dependency. This is what the project's Podfile looks like.
target 'Weather' do
platform :ios, '10.0'
use_frameworks!
pod 'Unbox'
end
Run pod install
to install the project's dependencies and open the workspace CocoaPods has created for you. At the time of writing, 2.5.0 is the latest version of Unbox.
Fetching Weather Data
The Dark Sky API is very easy to use. Open ViewController.swift and add a method, fetchWeatherData()
, to fetch weather data from Dark Sky. If you want to follow along, take a moment to create a free Dark Sky account and insert your Dark Sky API key.
private func fetchWeatherData() {
// Helpers
let sharedSession = URLSession.shared
let latitude = 51.525598
let longitude = -0.118750
let apiKey = "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
if let url = URL(string: "https://api.forecast.io/forecast/\(apiKey)/\(latitude),\(longitude)") {
// Create Data Task
let dataTask = sharedSession.dataTask(with: url, completionHandler: { (data, response, error) in
if let requestError = error {
print("Unable to Fetch Weather Data")
print("\(requestError), \(requestError.localizedDescription)")
} else if let weatherData = data {
self.processWeatherData(weatherData)
}
})
dataTask.resume()
}
}
To test the application, implement the processWeatherData(_:)
method as shown below.
private func processWeatherData(_ data: Data) {
if let dataAsString = String(data: data, encoding: String.Encoding.utf8) {
print(dataAsString)
}
}
Invoke the fetchWeatherData()
method in the view controller's viewDidLoad()
method to see if everything is working as it should.
override func viewDidLoad() {
super.viewDidLoad()
// Fetch Weather Data
fetchWeatherData()
}
The output in Xcode's console should look something like this.
{"latitude":51.525598,"longitude":-0.11875,"timezone":"Europe/London","offset":1,...,"units":"us"}}
Dark Sky returns a lot of data. Unbox can help us pick the data we need and ignore the rest. First, we need to create a model to store the data in.
Creating a Model
Create a new file, name it WeatherData.swift, and add an import statement for Unbox at the top. Because Unbox doesn't require model objects to inherit from a magical superclass, we can use a struct for storing the weather data.
To make sure Unbox can do its magic, the structure needs to conform to the Unboxable
protocol. To conform to the protocol, we implement an initializer Unbox uses to populate instances of the struct. Notice that the initializer is throwing. We find out why that is in a moment.
import Unbox
struct WeatherData: Unboxable {
// MARK: - Initialization
init(unboxer: Unboxer) throws {
}
}
The first piece of information I'd like to extract from the Dark Sky response is the location, that is, latitude and longitude. Take a look at the implementation below.
import Unbox
struct WeatherData: Unboxable {
// MARK: - Properties
let lat: Double
let long: Double
// MARK: - Initialization
init(unboxer: Unboxer) throws {
self.lat = try unboxer.unbox(key: "latitude")
self.long = try unboxer.unbox(key: "longitude")
}
}
The initializer Unbox uses to instantiate an instance of the WeatherData
struct is init(unboxer:)
. The unboxer
object is used to extract information from the JSON object. Notice that we use the keys from the Dark Sky response to extract the data we're interested in.
It should now be clear why init(unboxer:)
is a throwing method. Parsing JSON is a failable operation. What does that mean? If the unbox(key:)
method is unable to extract the value for a particular key or key path from the JSON object, unbox(key:)
throws an error. That's why the initializer is throwing. Any errors thrown by the unbox(key:)
method are propagated to the call site of the initializer. Let me show you how this works.
Return to ViewController.swift and revisit processWeatherData(_:)
. To instantiate a WeatherData
instance from the Dark Sky response, we invoke the unbox(_:)
function, passing in the Data
object that is passed to the processWeatherData(_:)
method. Because the unbox(_:)
function is throwing, we wrap it in a do-catch
statement.
import UIKit
import Unbox
class ViewController: UIViewController {
...
private func processWeatherData(_ data: Data) {
do {
// Create Weather Data
let weatherData: WeatherData = try unbox(data: data)
print(weatherData.lat)
print(weatherData.long)
} catch {
print("Unable to Unbox Response Due to Error (\(error))")
}
}
}
If the Unbox(_:)
function doesn't throw an error, an object of type WeatherData
is returned and we print the latitude and longitude to the console. If the data we pass to the unbox(_:)
function doesn't meet the requirements we defined in the WeatherData
structure, the operation fails and an error is thrown.
private func processWeatherData(_ data: Data) {
do {
// Create Weather Data
let weatherData: WeatherData = try unbox(data: data)
print(weatherData.lat)
print(weatherData.long)
} catch {
print("Unable to Unbox Response Due to Error (\(error))")
}
}
Key Paths
Support for key paths is essential for me. It makes it easy to flatten complex, nested JSON objects. Let's update the WeatherData
struct to extract the current temperature and wind speed.
import Unbox
struct WeatherData: Unboxable {
// MARK: - Properties
let lat: Double
let long: Double
let windSpeed: Double
let fahrenheit: Double
// MARK: - Initialization
init(unboxer: Unboxer) throws {
self.lat = try unboxer.unbox(key: "latitude")
self.long = try unboxer.unbox(key: "longitude")
self.windSpeed = try unboxer.unbox(keyPath: "currently.windSpeed")
self.fahrenheit = try unboxer.unbox(keyPath: "currently.temperature")
}
}
If we're working with a key path, we invoke the unbox(keyPath:)
method. As the name of the argument implies, unbox(keyPath:)
expects a key path. Having a separate method for key paths may seem inconvenient or inelegant, but it clearly communicates that we're passing in a key path, not a key that happens to include one or more periods.
Add a print statement for windSpeed
and fahrenheit
in the ViewController
class and run the application again. Inspect the output in the console to make sure Unbox was able to extract the wind speed from the Dark Sky response.
private func processWeatherData(_ data: Data) {
do {
// Create Weather Data
let weatherData: WeatherData = try unbox(data: data)
print(weatherData.lat)
print(weatherData.long)
print(weatherData.windSpeed)
print(weatherData.fahrenheit)
} catch {
print("Unable to Unbox Response Due to Error (\(error))")
}
}
Nested Models
You may have noticed that the Dark Sky data contains several arrays of data points. The hourly data set, for example, is an array of data points that forecast the weather conditions for the next few hours. How do we deal with that? That's easy with Unbox.
Create a new file and name it WeatherDataPoint.swift. Define another struct, WeatherDataPoint
, that conforms to the Unboxable
protocol. This is what the implementation should look like.
import Unbox
struct WeatherDataPoint: Unboxable {
let time: Int
let windSpeed: Double
let fahrenheit: Double
init(unboxer: Unboxer) throws {
self.time = try unboxer.unbox(key: "time")
self.windSpeed = try unboxer.unbox(key: "windSpeed")
self.fahrenheit = try unboxer.unbox(key: "temperature")
}
}
This isn't new. What's new is how we use the WeatherDataPoint
struct in the WeatherData
model. Take a look at the updated implementation of the WeatherData
structure.
import Unbox
struct WeatherData: Unboxable {
// MARK: - Properties
let lat: Double
let long: Double
let windSpeed: Double
let fahrenheit: Double
let hourlyDataPoints: [WeatherDataPoint]
// MARK: - Initialization
init(unboxer: Unboxer) throws {
self.lat = try unboxer.unbox(key: "latitude")
self.long = try unboxer.unbox(key: "longitude")
self.windSpeed = try unboxer.unbox(keyPath: "currently.windSpeed")
self.fahrenheit = try unboxer.unbox(keyPath: "currently.temperature")
self.hourlyDataPoints = try unboxer.unbox(keyPath: "hourly.data")
}
}
We tell Unbox that the hourlyDataPoints
property is of type [WeatherDataPoint]
, an array of WeatherDataPoint
instances. In the initializer, we instruct the Unboxer
object to use the data found at the hourly.data
key path.
Unbox is clever enough to handle nested model structures. We can prove this by updating the processWeatherData(_:)
method in the ViewController
class.
private func processWeatherData(_ data: Data) {
do {
// Create Weather Data
let weatherData: WeatherData = try unbox(data: data)
print(weatherData.lat)
print(weatherData.long)
print(weatherData.windSpeed)
print(weatherData.fahrenheit)
for dataPoint in weatherData.hourlyDataPoints {
print(dataPoint.fahrenheit)
}
} catch {
print("Unable to Unbox Response Due to Error (\(error))")
}
}
Run the application in the simulator to see the result. This is very nice if you ask me.
What's Next?
The Unbox library has a lot more to offer, including custom transformations. This is something I plan to cover in a future tutorial about Unbox. Unbox is a promising library and, at 1000 lines of code, easy enough to understand and learn from.
You can download the source files of the tutorial from GitHub.