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>
*/
ABeard89
  • 911
  • 9
  • 17
  • 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. – nayem May 22 '18 at 07:22
  • 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. – ABeard89 May 22 '18 at 07:25
  • Besides, practicing strange cases like this can help to deepen my understanding of the technology, which is always my goal. – ABeard89 May 22 '18 at 07:26

2 Answers2

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>
ABeard89
  • 911
  • 9
  • 17
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
  }

}

decybel
  • 61
  • 3
  • 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? – ABeard89 May 28 '18 at 01:25
  • 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](https://github.com/dgrzeszczak/KeyedCodable/blob/develop/KeyedCodableTests/InnerTests.swift). 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. – decybel May 28 '18 at 05:10
  • 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. – ABeard89 May 28 '18 at 06:25
  • 1
    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! – smat88dd Jul 08 '21 at 09:11