Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

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

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>
*/
like image 616
ABeard89 Avatar asked May 22 '18 07:05

ABeard89


People also ask

What is drawback of Codable protocol?

It only automatically works for one type of formatted date. If the JSON provides a date with a non-default format, the codable protocol will fail.

What is Codable and Codable in Swift?

Codable; the data-parsing dream come true!Codable is the combined protocol of Swift's Decodable and Encodable protocols. Together they provide standard methods of decoding data for custom types and encoding data to be saved or transferred.

What is Type Codable in Swift?

Codable is a type alias for the Encodable and Decodable protocols. When you use Codable as a type or a generic constraint, it matches any type that conforms to both protocols.


2 Answers

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>
like image 138
ABeard89 Avatar answered Oct 23 '22 03:10

ABeard89


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
  }

}

like image 26
decybel Avatar answered Oct 23 '22 05:10

decybel