9

Decode and Flatten JSON with Dynamic Keys Using Decodable

 3 years ago
source link: https://swiftsenpai.com/swift/decode-dynamic-keys-json/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

The Decodable protocol was introduced in Swift 4. Since then it has become the standard way for developers to decode JSON received from a remote server.

There are tons of tutorials out there that teach you how to utilize the Decodable protocol to decode various types of JSON structure. However, all these tutorials do not cover one specific type of JSON structure — JSON with dynamic keys .

What do I mean by JSON with dynamic keys? Take a look at the following sample JSON that shows a list of students:

{
  "S001": {
    "firstName": "Tony",
    "lastName": "Stark"
  },
  "S002": {
    "firstName": "Peter",
    "lastName": "Parker"
  },
  "S003": {
    "firstName": "Bruce",
    "lastName": "Wayne"
  }
}

As you can see, the student ID is the key and the student information is the value.

As iOS developers, what we usually need is an array of students, so that the list of students can be easily displayed on a table view. Therefore, aside from decoding the JSON, we also need to flatten the result (make student ID part of the student object) and transform it into an array.

When facing such JSON structure, some developers might fall back to the old decoding approach by using the JSONSerialization class and manually looping through and parsing each and every key-value pair.

However, I do not like the JSONSerialization way because it is more error-prone. On top of that, we will lose all the benefits that come along with using Decodable protocol.

In this article, I will walk you through the decoding approach that utilizes the Decodable protocol. After that, I will make the decoding logic generic so that it can be reused by some other object type.

With all that being said, let’s get right into it.

Extracting the Values

As a recap, this is the JSON that we trying to decode:

{
  "S001": {
    "firstName": "Tony",
    "lastName": "Stark"
  },
  "S002": {
    "firstName": "Peter",
    "lastName": "Parker"
  },
  "S003": {
    "firstName": "Bruce",
    "lastName": "Wayne"
  }
}

For simplicity sake, let’s focus on decoding the firstName and lastName for now. We will get back to the student ID later.

First, let’s define a Student struct that conforms to the Decodable protocol.

struct Student: Decodable {
    let firstName: String
    let lastName: String
}

Next, we will need a Decodable struct that contains a Student array. We will use this struct to hold all the decoded Student objects. Let’s call this struct DecodedArray .

struct DecodedArray: Decodable {
    var array: [Student]
}

In order to access the JSON’s dynamic keys, we must define a custom CodingKey struct. This custom CodingKey struct is needed when we want to create a decoding container from the JSONDecoder .

struct DecodedArray: Decodable {

    var array: [Student]
    
    // Define DynamicCodingKeys type needed for creating 
    // decoding container from JSONDecoder
    private struct DynamicCodingKeys: CodingKey {

        // Use for string-keyed dictionary
        var stringValue: String
        init?(stringValue: String) {
            self.stringValue = stringValue
        }

        // Use for integer-keyed dictionary
        var intValue: Int?
        init?(intValue: Int) {
            // We are not using this, thus just return nil
            return nil
        }
    }
}

Note that we are only interested in the string value initializer because our keys are of type string, therefore we can just return nil in the integer value initializer.

With all that in place, we can now start implementing the DecodedArray initializer.

struct DecodedArray: Decodable {

    var array: [Student]
    
    // Define DynamicCodingKeys type needed for creating
    // decoding container from JSONDecoder
    private struct DynamicCodingKeys: CodingKey {

        // Use for string-keyed dictionary
        var stringValue: String
        init?(stringValue: String) {
            self.stringValue = stringValue
        }

        // Use for integer-keyed dictionary
        var intValue: Int?
        init?(intValue: Int) {
            // We are not using this, thus just return nil
            return nil
        }
    }

    init(from decoder: Decoder) throws {

        // 1
        // Create a decoding container using DynamicCodingKeys
        // The container will contain all the JSON first level key
        let container = try decoder.container(keyedBy: DynamicCodingKeys.self)

        var tempArray = [Student]()

        // 2
        // Loop through each key (student ID) in container
        for key in container.allKeys {

            // Decode Student using key & keep decoded Student object in tempArray
            let decodedObject = try container.decode(Student.self, forKey: DynamicCodingKeys(stringValue: key.stringValue)!)
            tempArray.append(decodedObject)
        }

        // 3
        // Finish decoding all Student objects. Thus assign tempArray to array.
        array = tempArray
    }
}

Let’s break down in detail what’s happening inside the initializer.

  1. Create a decoding container using the DynamicCodingKeys struct. This container will contain all the JSON’s first level dynamic keys.
  2. Loop through each key to decode its respective Student object.
  3. Store all the decoded Student objects into the Student array.

That’s it for extracting firstName and lastName . Let’s run all these in the Xcode playground to see them in action.

let jsonString = """
{
  "S001": {
    "firstName": "Tony",
    "lastName": "Stark"
  },
  "S002": {
    "firstName": "Peter",
    "lastName": "Parker"
  },
  "S003": {
    "firstName": "Bruce",
    "lastName": "Wayne"
  }
}
"""

let jsonData = Data(jsonString.utf8)

// Ask JSONDecoder to decode the JSON data as DecodedArray
let decodedResult = try! JSONDecoder().decode(DecodedArray.self, from: jsonData)

dump(decodedResult.array)

// Output:
//▿ 3 elements
//▿ __lldb_expr_21.Student
//  - firstName: "Bruce"
//  - lastName: "Wayne"
//▿ __lldb_expr_21.Student
//  - firstName: "Peter"
//  - lastName: "Parker"
//▿ __lldb_expr_21.Student
//  - firstName: "Tony"
//  - lastName: "Stark"

Here we convert the JSON string to data and ask the JSONDecoder to decode the JSON data as DecodedArray type. With that, we will be able to access all the decoded Student objects via DecodedArray.array .

Congratulations! You have successfully decoded all the Student objects. However, there are still works to do. In the next section, we will look into adding the student ID into the Student struct.

Extracting the Keys

With what we currently have, adding the student ID into the Student struct is pretty straightforward. What we need to do is implement our own Student initializer and manually decode lastName , firstName and studentId .

Take a look at the following updated Student struct:

struct Student: Decodable {

    let firstName: String
    let lastName: String

    // 1
    // Define student ID
    let studentId: String

    // 2
    // Define coding key for decoding use
    enum CodingKeys: CodingKey {
        case firstName
        case lastName
    }

    init(from decoder: Decoder) throws {

        let container = try decoder.container(keyedBy: CodingKeys.self)

        // 3
        // Decode firstName & lastName
        firstName = try container.decode(String.self, forKey: CodingKeys.firstName)
        lastName = try container.decode(String.self, forKey: CodingKeys.lastName)

        // 4
        // Extract studentId from coding path
        studentId = container.codingPath.first!.stringValue
    }
}

Let’s go through the changes we made on Student struct one by one:

  1. Define studentId to hold the extracted key (student ID).
  2. Define coding keys that are needed for manual decoding.
  3. Manually decode firstName and lastName .
  4. This is where the magic happens. The decoding container codingPath is an array of CodingKey that contains the path of coding keys taken to get to this point in decoding . For our case, it should contain the key we obtained from DynamicCodingKeys in DecodedArray , which is the student ID.

Let’s run this again in Xcode playground to see the final result.

let jsonData = Data(jsonString.utf8)
let decodedResult = try! JSONDecoder().decode(DecodedArray.self, from: jsonData)

dump(decodedResult.array)
// Output:
//▿ 3 elements
//▿ __lldb_expr_37.Student
//  - firstName: "Peter"
//  - lastName: "Parker"
//  - studentId: "S002"
//▿ __lldb_expr_37.Student
//  - firstName: "Tony"
//  - lastName: "Stark"
//  - studentId: "S001"
//▿ __lldb_expr_37.Student
//  - firstName: "Bruce"
//  - lastName: "Wayne"
//  - studentId: "S003"

With that, we have successfully decoded and flattened a JSON with dynamic keys using the Decodable protocol.

In the next section, let’s go one step further by improving the DecodedArray struct functionality and reusability.

Adding Custom Collection Support

If you take a closer look into the DecodedArray struct, it is basically just a wrapper of the Student array. This makes it the perfect candidate to transform into a custom collection.

By transforming into a custom collection, the DecodedArray struct can take advantage of the array literal, as well as all the standard collection functionalities such as filtering and mapping.

First, let’s define a typealias to represent the Student array and update the other part of DecodedArray accordingly. The typealias is required when we conform the DecodedArray to the Collection protocol later.

Here’s the updated DecodedArray where I have marked the changes made with *** .

struct DecodedArray: Decodable {

    // ***
    // Define typealias required for Collection protocl conformance
    typealias DecodedArrayType = [Student]

    // ***
    private var array: DecodedArrayType

    // Define DynamicCodingKeys type needed for creating decoding container from JSONDecoder
    private struct DynamicCodingKeys: CodingKey {

        // Use for string-keyed dictionary
        var stringValue: String
        init?(stringValue: String) {
            self.stringValue = stringValue
        }

        // Use for integer-keyed dictionary
        var intValue: Int?
        init?(intValue: Int) {
            // We are not using this, thus just return nil
            return nil
        }
    }

    init(from decoder: Decoder) throws {

        // Create decoding container using DynamicCodingKeys
        // The container will contain all the JSON first level key
        let container = try decoder.container(keyedBy: DynamicCodingKeys.self)

        // ***
        var tempArray = DecodedArrayType()

        // Loop through each keys in container
        for key in container.allKeys {

            // Decode Student using key & keep decoded Student object in tempArray
            let decodedObject = try container.decode(Student.self, forKey: DynamicCodingKeys(stringValue: key.stringValue)!)
            tempArray.append(decodedObject)
        }

        // Finish decoding all Student objects. Thus assign tempArray to array.
        array = tempArray
    }
}

Next up, let’s extend the DecodedArray and conform to the Collection protocol.

extension DecodedArray: Collection {

    // Required nested types, that tell Swift what our collection contains
    typealias Index = DecodedArrayType.Index
    typealias Element = DecodedArrayType.Element

    // The upper and lower bounds of the collection, used in iterations
    var startIndex: Index { return array.startIndex }
    var endIndex: Index { return array.endIndex }

    // Required subscript, based on a dictionary index
    subscript(index: Index) -> Iterator.Element {
        get { return array[index] }
    }

    // Method that returns the next index when iterating
    func index(after i: Index) -> Index {
        return array.index(after: i)
    }
}

The details of conforming to the Collection protocol are beyond the scope of this article. If you want to know more, I highly recommend this great article .

That’s about it, we have fully transformed the DecodedArray struct into a custom collection.

Once again, let’s test out our changes in the Xcode playground. But this time with cool functionalities that we gain from the Collection protocol conformance — array literal, map , and filter .

let jsonData = Data(jsonString.utf8)
let decodedResult = try! JSONDecoder().decode(DecodedArray.self, from: jsonData)

// Array literal
dump(decodedResult[2])
//▿ __lldb_expr_5.Student
//- firstName: "Bruce"
//- lastName: "Wayne"
//- studentId: "S003"

// Map
dump(decodedResult.map({ $0.firstName }))
// Output:
//▿ 3 elements
//- "Tony"
//- "Peter"
//- "Bruce"

// Filter
dump(decodedResult.filter({ $0.studentId == "S002" }))
// Output:
//▿ __lldb_expr_1.Student
//- firstName: "Peter"
//- lastName: "Parker"
//- studentId: "S002"

Pretty cool isn’t it? With a little bit of extra effort, let’s make it even cooler by making the DecodedArray generic so that we can use it on other object types.

Make It Generic, Increase Reusability

To make our DecodedArray generic, we just need to add a generic parameter clause and replace all the Student type with a placeholder type T .

Once again, I have marked all the changes with *** .

// ***
// Add generic parameter clause
struct DecodedArray<T: Decodable>: Decodable {

    // ***
    typealias DecodedArrayType = [T]

    private var array: DecodedArrayType

    // Define DynamicCodingKeys type needed for creating decoding container from JSONDecoder
    private struct DynamicCodingKeys: CodingKey {

        // Use for string-keyed dictionary
        var stringValue: String
        init?(stringValue: String) {
            self.stringValue = stringValue
        }

        // Use for integer-keyed dictionary
        var intValue: Int?
        init?(intValue: Int) {
            // We are not using this, thus just return nil
            return nil
        }
    }

    init(from decoder: Decoder) throws {

        // Create decoding container using DynamicCodingKeys
        // The container will contain all the JSON first level key
        let container = try decoder.container(keyedBy: DynamicCodingKeys.self)

        var tempArray = DecodedArrayType()

        // Loop through each keys in container
        for key in container.allKeys {

            // ***
            // Decode T using key & keep decoded T object in tempArray
            let decodedObject = try container.decode(T.self, forKey: DynamicCodingKeys(stringValue: key.stringValue)!)
            tempArray.append(decodedObject)
        }

        // Finish decoding all T objects. Thus assign tempArray to array.
        array = tempArray
    }
}

With all this in place, we can now use it to decode any object types. To see that in action, let’s use our generic DecodedArray to decode the following JSON.

{
  "Vegetable": [
    { "name": "Carrots" },
    { "name": "Mushrooms" }
  ],
  "Spice": [
    { "name": "Salt" },
    { "name": "Paper" },
    { "name": "Sugar" }
  ],
  "Fruit": [
    { "name": "Apple" },
    { "name": "Orange" },
    { "name": "Banana" },
    { "name": "Papaya" }
  ]
}

The above JSON represents an array of Food objects grouped by category. Thus, we must first define the Food struct.

struct Food: Decodable {

    let name: String
    let category: String

    enum CodingKeys: CodingKey {
        case name
    }

    init(from decoder: Decoder) throws {

        let container = try decoder.container(keyedBy: CodingKeys.self)

        // Decode name
        name = try container.decode(String.self, forKey: CodingKeys.name)

        // Extract category from coding path
        category = container.codingPath.first!.stringValue
    }
}

After defining the Food struct, we are now ready to decode the given JSON.

let jsonString = """
{
  "Vegetable": [
    { "name": "Carrots" },
    { "name": "Mushrooms" }
  ],
  "Spice": [
    { "name": "Salt" },
    { "name": "Paper" },
    { "name": "Sugar" }
  ],
  "Fruit": [
    { "name": "Apple" },
    { "name": "Orange" },
    { "name": "Banana" },
    { "name": "Papaya" }
  ]
}
"""

let jsonData = Data(jsonString.utf8)

// Define DecodedArray type using the angle brackets (<>)
let decodedResult = try! JSONDecoder().decode(DecodedArray<[Food]>.self, from: jsonData)

// Perform flatmap on decodedResult to convert [[Food]] to [Food]
let allFood = decodedResult.flatMap{ $0 }

dump(allFood)
// Ouput:
//▿ 9 elements
//▿ __lldb_expr_11.Food
//  - name: "Apple"
//  - category: "Fruit"
//▿ __lldb_expr_11.Food
//  - name: "Orange"
//  - category: "Fruit"
//▿ __lldb_expr_11.Food
//  - name: "Banana"
//  - category: "Fruit"
//▿ __lldb_expr_11.Food
//  - name: "Papaya"
//  - category: "Fruit"
//▿ __lldb_expr_11.Food
//  - name: "Salt"
//  - category: "Spice"
//▿ __lldb_expr_11.Food
//  - name: "Paper"
//  - category: "Spice"
//▿ __lldb_expr_11.Food
//  - name: "Sugar"
//  - category: "Spice"
//▿ __lldb_expr_11.Food
//  - name: "Carrots"
//  - category: "Vegetable"
//▿ __lldb_expr_11.Food
//  - name: "Mushrooms"
//  - category: "Vegetable"

Do note that decodedResult is an array of Food arrays ( [[Food]] ). Therefore, to get an array of Food objects ( [Food] ), we will apply flatmap on decodedResult to convert [[Food]] to [Food] .

Wrapping Up

This article only demonstrates decoding and flattening JSON with 2 layers, you can definitely apply the same concept on JSON with 3 or more layers. I’ll leave that as an exercise for you!

Last but not least, here’s the full sample code that you can run on Xcode playground.

What do you think about this decoding approach? Feel free to leave your comments or thoughts in the comment section below.

If you like this article, make sure to check out my otherarticles related to Swift.

You can also follow me on Twitter for more articles related to iOS development.

Thanks for reading. ‍:computer:

Further Readings


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK