Unboxing JSON With Swift and Unbox

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.

Project Setup

Project Setup

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.

Stop Writing Swift That Sucks

Download the Swift Patterns I Swear By

About Bart Jacobs

About bart jacobs

My name is Bart Jacobs and I run a mobile development company, Code Foundry. I've been programming for more than fifteen years, focusing on Cocoa development soon after the introduction of the iPhone in 2007.

Stop Writing Swift That Sucks

In my free book, you learn the four patterns I use in every Swift project I work on. You learn how easy it is to integrate these patterns in any Swift project.