Skip to content
This repository has been archived by the owner on Sep 19, 2018. It is now read-only.

Subscripting

mdmathias edited this page Sep 15, 2016 · 20 revisions

Why and What?

Subscripting instances of JSON is accomplished in JSONSubscripting.swift. This file provides an extension on JSON to implement two subscripts: one for an Int index and one for a String key. These allow you to subscript instances of JSON in a way that feels intuitive to working with JSON data.

JSONSubscripting also comprises other functionality that enable the convenient and safe retrieval of data from JSON instances. For example, Freddy provides methods to retrieve data from JSON instances that return optionals or fallback values if the given path into the JSON does not yield the expected data. See the sections on Optional Unpacking and Unpacking with Fallback respectively for more detail.

Subscript

Freddy has two subscripts: one for an Int index, and one for a String key. You can use these to access data within a JSON instance. These subscripts work just like any other Swift subscript.

Recall the JSON described in the README.

let data = getSomeData()
do {
    let json = try JSON(data: data) 
    let jobs = json["jobs"]
} catch {
    // do something with the error
}

The line, let jobs = json["jobs"], uses a String subscript to access the "jobs" key within json. jobs is of type JSON? set to the .array case of the JSON enum. This instance's case has an associated value of [JSON], which will all be of type JSON.string.

Obviously, working with this sort of data can be a little tedious. jobs is an optional, and its associated value encompasses several layers of JSON instances. There has to be a better way.

Using JSONPathType

Freddy uses a protocol called JSONPathType to make it easier to work with instances of JSON. We have already seen an example in the README. let success = try json.getBool(at: "success"), where "success" refers to a path into json where we expect to find a Bool.

Let's revisit the example above to see how we can use this pattern here. Say you want to grab the job "teacher" from the array of "jobs" in json. You could use the subscripts...

let data = getSomeData()
do {
    let json = try JSON(data: data) 
    let teacher = json["jobs"]?[0]
} catch {
    // do something with the error
}

teacher is an optional whose value is the String "teacher". This process looks a little tedious. Let's use a JSONPathType.

First, let's take a look at the JSONPathType protocol.

public protocol JSONPathType {
    func value(in dictionary: [String : JSON]) throws -> JSON
    func value(in array: [JSON]) throws -> JSON
}

A JSONPathType is a protocol with two functions: one for finding a JSON value within a dictionary, and another for finding a JSON value in an array. Both are throwsing functions.

These methods are given default implementations in a protocol extension.

extension JSONPathType {
    public func value(in dictionary: [String : JSON]) throws -> JSON {
        throw JSON.Error.UnexpectedSubscript(type: Self.self)
    }

    public func value(in array: [JSON]) throws -> JSON {
        throw JSON.Error.UnexpectedSubscript(type: Self.self)
    }
}

These default implementations both throw an .UnexpectedSubscript error. The inference to make here is that only types conforming to JSONPathType will be able to retrieve a value from a dictionary or an array. All other calls will simply throw the .UnexpectedSubscript error.

Freddy extends the String and Int types to conform to JSONPathType.

extension String: JSONPathType {
    public func value(in dictionary: [String : JSON]) throws -> JSON {
        guard let next = dictionary[self] else {
            throw JSON.Error.KeyNotFound(key: self)
        }
        return next
    }
}

extension Int: JSONPathType {
    public func value(in array: [JSON]) throws -> JSON {
        guard case array.indices = self else {
            throw JSON.Error.IndexOutOfBounds(index: self)
        }
        return array[self]
    }
}

The method value(in dictionary:) method takes a dictionary of type [String: JSON] and returns the result of attempting to subscript this dictionary with self, which will be an instance of String. If successful, then the method returns an instance of JSON. If unsuccessful, then the method will throw the error .KeyNotFound with self as the associated value.

The method value(in array:) takes an array of type [JSON]. If self is within the range of valid indices for the array, then this array is subscripted by self, which should be an Int, and an instance of JSON is returned. If this is not the case, then the error .IndexOutOfBounds is thrown.

Simple Member Unpacking

To understand how a JSONPathType is used, let's look at the method declaration of getString(at:).

public func getString(at path: JSONPathType...) throws -> String

The getString(at:) method has one variadic parameter that takes a list of zero or more comma separated JSONPathTypes that describe a path to a String within a JSON instance.

Thus, we can replace let teacher = json["jobs"]?[0] with this:

do {
    let json = try JSON(data: data)
    let teacher = try json.getString(at: "jobs", 0)
    // Do something with `teacher`
} catch {
    // Do something with `error`
}

Recall that getString(at:) throws, which means you must call it with try. So, instead of the return being String?, you get a String back if there is no error.

Thus, this syntax is safe and convenient. As you might expect, Freddy provides getInt(at:) and getDouble(at:) methods in addition to the getString(at:) and getBool(at:) methods that you have already seen.

Complex Member Unpacking

But what if a JSONPathType describes a path into a JSON instance that leads to an Array or Dictionary? Freddy has you covered with:

public func getArray(at path: JSONPathType...) throws -> [JSON]
public func getDictionary(at path: JSONPathType...) throws -> [String: JSON]

Here is how you would use getArray(at:) with the "jobs" key.

do {
    let json = try JSON(data: data)
    let jobs = try json.getArray(at: "jobs")
    // Do something with `jobs`
} catch {
    // Do something with `error`
}

Here, jobs is of type [JSON], where each element will be a JSON.String.

This is nice, but it would be better if we could get the elements within "jobs" as the String instances we know them to be.

You can!

do {
    let json = try JSON(data: data)
    let jobs = try json.decodedArray(at: "jobs", type: String)
    // Do something with `jobs`
} catch {
    // Do something with `error`
}

The method decodedArray(at:type:) has two parameters. The first takes a variable list of JSONPathTypes, and the second takes a type that conforms to JSONDecodable. decodedArray(at:type:) returns an Array of whose elements also conform to JSONDecodable. Thus, the second parameter supplies the type that the elements within the json are decoded into for the return.

Freddy provides extensions on Swift's basic data types to conform to JSONDecodable, which means jobs is of type [String] in the above example.

As you might expect, you can have any type that you create to conform to JSONDecodable and it will work beautifully with decodedArray(at:type:). See the wiki page for JSONDecodable for more information.

Decoding

The section above shows how you can use decodedArray(at:type:) to find an array of data within a JSON instance and return the anticipated Array of a given type. But what if you just want one element within an array within the JSON? Use decode(at:type:)?

Consider trying to get an instance of Person from the example we have been working with from the README.

do {
    let json = try JSON(data: data)
    let matt = try json.decode(at: "people", 0, type: Person.self)
    // Do something with `matt`
} catch {
    // Do something with `error`
}

As with decodedArray(at:type:) above, decode(at:type:) takes two parameters. The first allows you to describe the path to the data of interest within a JSON, and the second parameter specifies the type you would like to decode the JSON into. The argument passed to this parameter must conform to JSONDecodable.

Thus, the call above to decode(at:type:) yields an instance of Person, which is assigned to the constant matt.

Optional Unpacking

Freddy provides a number of methods to create instances of JSON or models that conform to JSONDecodable, but what if what you are looking for in your JSON is not present? For example, what if you need to subscript the JSON for a key that you are not absolutely sure will be present at runtime? Typically, this would lead to a crash if the key is not present. Freddy has a solution for this problem: use optional unpacking.

Let's refer back to our decode(at:type:) example above. Freddy has another version of this method that produces an optional if anything goes awry: decode(at:alongPath:type:).

The first parameter takes a variable list of JSONPathTypes. The second parameter, alongPath, is a SubscriptingOptions OptionSet that dictates whether or not missing keys or index out of bounds errors are treated as nil. The last parameter lists the type that is expected at the path within the JSON.

Let's look at an example of using this version of decode when the supplied path is incorrect.

do {
    let json = try JSON(data: data)
    let matt = try json.decode(at: "peope", 0, alongPath: .MissingKeyBecomesNil, type: Person.self)
    // Do something with `matt`
} catch {
    // Do something with `error`
}

We pass MissingKeyBecomesNil as the argument to the parameter alongPath, which means that path errors will be treated as optionals. Since the supplied key -- "peope" -- will not be found within the JSON, matt is of type Person? set to the value of nil.

Optional unpacking versions are available for all of the methods reviewed above.

Unpacking with Fallback

Sometimes you will want to provide a fallback value when querying JSON for a given path. That is, if there is no value at the path you provide, you would rather fallback to a default value than throw an error or deal with an optional.

Freddy supplies fallback versions of all of the methods we have covered thus far. As an example, we can use decode(at:or:) to default to a provided value if the path does not yield what we expect.

do {
    let json = try JSON(data: data)
    let matt = try json.decode(at: "peope", 0, or: Person(name: "Matt", age: 32, spouse: true))
    matt.name
} catch {
    // Do something with `error`
}

As above, the key "peope" is not found within the JSON. In this case, the argument supplied to the or parameter is used to construct the fallback value that will be assigned to matt. or takes an @autoclsoure that creates an instance of some type that conforms to JSONDecodable. Thus, we are able to create an instance of Person using its memberwise initializer.