Swift Codable: How to encode top-level data into nested container
Asked Answered
F

2

6

My app uses a server that returns JSON that looks like this:

{
    "result":"OK",
    "data":{

        // Common to all URLs
        "user": {
            "name":"John Smith" // ETC...
        },

        // Different for each URL
        "data_for_this_url":0
    }
}

As you can see, the URL-specific info exists in the same dictionary as the common user dictionary.

GOAL:

  1. Decode this JSON into classes/structs.
    • Because user is common, I want this to be in the top-level class/struct.
  2. Encode to new format (e.g. plist).
    • I need to preserve the original structure. (i.e. recreate the data dictionary from top-level user info and child object's info)

PROBLEM:

When re-encoding the data, I cannot write both the user dictionary (from top-level object) and URL-specific data (from child object) to the encoder.

Either user overwrites the other data, or the other data overwrites user. I don't know how to combine them.

Here's what I have so far:

// MARK: - Common User
struct User: Codable {
    var name: String?
}

// MARK: - Abstract Response
struct ApiResponse<DataType: Codable>: Codable {
    // MARK: Properties
    var result: String
    var user: User?
    var data: DataType?

    // MARK: Coding Keys
    enum CodingKeys: String, CodingKey {
        case result, data
    }
    enum DataDictKeys: String, CodingKey {
        case user
    }

    // MARK: Decodable
    init(from decoder: Decoder) throws {
        let baseContainer = try decoder.container(keyedBy: CodingKeys.self)
        self.result = try baseContainer.decode(String.self, forKey: .result)
        self.data = try baseContainer.decodeIfPresent(DataType.self, forKey: .data)

        let dataContainer = try baseContainer.nestedContainer(keyedBy: DataDictKeys.self, forKey: .data)
        self.user = try dataContainer.decodeIfPresent(User.self, forKey: .user)
    }

    // MARK: Encodable
    func encode(to encoder: Encoder) throws {
        var baseContainer = encoder.container(keyedBy: CodingKeys.self)
        try baseContainer.encode(self.result, forKey: .result)

        // MARK: - PROBLEM!!

        // This is overwritten
        try baseContainer.encodeIfPresent(self.data, forKey: .data)

        // This overwrites the previous statement
        var dataContainer = baseContainer.nestedContainer(keyedBy: DataDictKeys.self, forKey: .data)
        try dataContainer.encodeIfPresent(self.user, forKey: .user)
    }
}

EXAMPLE:

In the example below, the re-encoded plist does not include order_count, because it was overwritten by the dictionary containing user.

// MARK: - Concrete Response
typealias OrderDataResponse = ApiResponse<OrderData>

struct OrderData: Codable {
    var orderCount: Int = 0
    enum CodingKeys: String, CodingKey {
        case orderCount = "order_count"
    }
}


let orderDataResponseJson = """
{
    "result":"OK",
    "data":{
        "user":{
            "name":"John"
        },
        "order_count":10
    }
}
"""

// MARK: - Decode from JSON
let jsonData = orderDataResponseJson.data(using: .utf8)!
let response = try JSONDecoder().decode(OrderDataResponse.self, from: jsonData)

// MARK: - Encode to PropertyList
let plistEncoder = PropertyListEncoder()
plistEncoder.outputFormat = .xml

let plistData = try plistEncoder.encode(response)
let plistString = String(data: plistData, encoding: .utf8)!

print(plistString)

// 'order_count' is not included in 'data'!

/*
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>data</key>
    <dict>
        <key>user</key>
        <dict>
            <key>name</key>
            <string>John</string>
        </dict>
    </dict>
    <key>result</key>
    <string>OK</string>
</dict>
</plist>
*/
Footsie answered 22/5, 2018 at 7:8 Comment(3)
You shouldn't change the structure of the data in the first place. It should not be a problem that all of your response contain similar results. Define your structs according the JSON structure. You will be able to encode-decode them quite easily.Voyageur
I even agree with you. I'll probably end up doing that anyway. But for now, I simply want to know if this can be done.Footsie
Besides, practicing strange cases like this can help to deepen my understanding of the technology, which is always my goal.Footsie
F
8

I just now had an epiphany while looking through the encoder protocols.

KeyedEncodingContainerProtocol.superEncoder(forKey:) method is for exactly this type of situation.

This method returns a separate Encoder that can collect several items and/or nested containers and then encode them into a single key.

For this specific case, the top-level user data can be encoded by simply calling its own encode(to:) method, with the new superEncoder. Then, nested containers can also be created with the encoder, to be used as normal.

Solution to Question

// MARK: - Encodable
func encode(to encoder: Encoder) throws {

    var baseContainer = encoder.container(keyedBy: CodingKeys.self)
    try baseContainer.encode(self.result, forKey: .result)

    // MARK: - PROBLEM!!
//    // This is overwritten
//    try baseContainer.encodeIfPresent(self.data, forKey: .data)
//
//    // This overwrites the previous statement
//    var dataContainer = baseContainer.nestedContainer(keyedBy: DataDictKeys.self, forKey: .data)
//    try dataContainer.encodeIfPresent(self.user, forKey: .user)

    // MARK: - Solution
    // Create a new Encoder instance to combine data from separate sources.
    let dataEncoder = baseContainer.superEncoder(forKey: .data)

    // Use the Encoder directly:
    try self.data?.encode(to: dataEncoder)

    // Create containers for manually encoding, as usual:
    var userContainer = dataEncoder.container(keyedBy: DataDictKeys.self)
    try userContainer.encodeIfPresent(self.user, forKey: .user)
}

Output:

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
    <key>data</key>
    <dict>
        <key>order_count</key>
        <integer>10</integer>
        <key>user</key>
        <dict>
            <key>name</key>
            <string>John</string>
        </dict>
    </dict>
    <key>result</key>
    <string>OK</string>
</dict>
</plist>
Footsie answered 24/5, 2018 at 6:40 Comment(0)
A
1

Great question and solution but if you would like to simplify it you may use KeyedCodable I wrote. Whole implementation of your Codable's will look like that (OrderData and User remain the same of course):

struct ApiResponse<DataType: Codable>: Codable {
  // MARK: Properties
  var result: String!
  var user: User?
  var data: DataType?

  enum CodingKeys: String, KeyedKey {
    case result
    case user = "data.user"
    case data
  }

}

Atal answered 27/5, 2018 at 14:16 Comment(4)
You've demonstrated the decoding ability of your library. But the question was specifically about reversing this process. Can your library re-encode the data to its original structure? If so, can you provide an example?Footsie
That's it :) It's Codable implementation and it works for both decoding and encoding. For the proof that is working correctly you can look into UnitTests especially to file InnerTest. Your example is a second test. It works like that: 1) decode json, check properties 2) encode obj (1) back to string 3) decode json( 2) check properties 4) encode obj (3) to second string 5) check strings from 2 and 4 are equal Of course whole test ends with success. Let me know what do you think about it.Atal
I see. You've implemented Encodable via protocol extension, which you can't do for init methods. The question was mainly concerned with the standard Codable way, but I'll keep this in mind. Definitely seems like a useable library. Good work.Footsie
I also think Apples .nestedContainer methods are way too complicated to use, thank you for your library - specifically what I was looking for! I don't understand why someone downvoted you, I gave you my thumbs up! Keep up the good work!Impression

© 2022 - 2025 — McMap. All rights reserved.