jsonswiftcodable

A better approach to decode same JSON with different structure in Swift?


I was dealing with a request that need to modify our iOS model structure, the modification was moving the EncodingOption parameter to the EditingOptions class from OutputOptions struct.

Hierarchy of the config

AppConfig {
profile: ProfileSetting
}

ProfileSetting {
editSetting:EditingOptions
outputSetting: OutputOptions
}

Previous data structure:

class EditingOptions: NSObject, Codable {
//..other parameters
}

struct OutputOptions: Codable {
var barcodeEncoding: EncodingOption = .utf8
//..other parameters
}

Modified data structure:

class EditSetting: NSObject, Codable {
var encodingOption: EncodingOption
//..other parameters
}

struct OutputSetting: Codable {
//..other parameters
}

The modified structure was not compatible with previous config file with the JSON error keyNotFound:

keyNotFound(CodingKeys(stringValue: "encodingOption", intValue: nil), 
Swift.DecodingError.Context(codingPath: [
CodingKeys(stringValue: "profileManage", intValue: nil), CodingKeys(stringValue: "deviceName",
 intValue: nil), CodingKeys(stringValue: "profiles", intValue: nil), 
_JSONKey(stringValue: "Index 0", intValue: 0), 
CodingKeys(stringValue: "outputOptions", intValue: nil)
],
 debugDescription: "No value associated with key CodingKeys(stringValue: \"encodingOption\", intValue: nil) (\"encodingOption\").", underlyingError: nil))

The solution from ChatGPT was add a init(from decoder: Decoder) method for ProfileSetting class:

required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        //...other parameters
        
        if let editingOptions = try? container.decode(EditingOptions.self, forKey: .editingOptions) {
            self.editingOptions = editingOptions
        } else {
            self.editingOptions = EditingOptions()
        }
    }

The barcodeEncoding was a hidden parameter with a default setting, and not been release to public yet.

The solution did resolved the keyNotFound error, however, I'm not sured that the solution was the RIGHT way to solve the issue.


Solution

  • If I understood correctly, your issue can be simplified into:

     let json = """
     {
        "a": { "keyA": "Value for KeyA" },
        "b": { "keyB": "Value for KeyB",
                "otherKey": "My value To Pass to a now "}
     }
    """
    

    Where otherKey is your encodingOption. It's present on B, but now you want it to pass it to A.

    So the old code is:

    func oldCode(with json: String) {
        struct OldParent: Codable {
            let a: OldA
            let b: OldB
        }
        
        class OldA: NSObject, Codable {
            let keyA: String
        }
        
        struct OldB: Codable {
            let keyB: String
            let otherKey: String
        }
        
        let decoder = JSONDecoder()
        
        do {
            let parent = try decoder.decode(OldParent.self, from: Data(json.utf8))
            print(parent)
            print("--")
        } catch {
            print("Error: \(error)")
        }
    }
    

    It works as designed, your iOS model reflects the API model.

    Now you want to modify your iOS model, but the API one won't change.

    A possible solution is to keep otherKey in B, and inside the decoding of the Parent (which knows both A and B), pass that value into A:

    func newCode(with json: String) {
        struct NewParent: Codable {
            let a: NewA
            let b: NewB
            
            init(from decoder: any Decoder) throws {
                let container = try decoder.container(keyedBy: CodingKeys.self)
                self.a = try container.decode(NewA.self, forKey: .a)
                self.b = try container.decode(NewB.self, forKey: .b)
                self.a.otherKey = self.b.otherKey //Pass the value
            }
        }
        
        class NewA: NSObject, Codable {
            let keyA: String
            var otherKey: String = "defaultValue"
            
            enum CodingKeys: CodingKey {
                case keyA //We skip here otherKey to tell the decoder to decode only keyA which is present in the JSON
            }
        }
        
        struct NewB: Codable {
            let keyB: String
            let otherKey: String //Only because it's in JSON
        }
        
        let decoder = JSONDecoder()
        
        do {
            let parent = try decoder.decode(NewParent.self, from: Data(json.utf8))
            print(parent)
            print("--")
        } catch {
            print("Error: \(error)")
        }
    }
    

    Edit: adding another alternative:

    If you don't like having let otherKey: String //Only because it's in JSON in B and want it only in A, you can indeed decode it yourself. I didn't do test on efficiency, but I guess it takes a little more time, because it needs to decode again the container for B (once for otherKey and the other one for B, there might be cache, and other optimizations, I haven't fully tested).

    func newCode2(with json: String) {
        struct NewParent: Codable {
            let a: NewA
            let b: NewB
            
            init(from decoder: any Decoder) throws {
                let container = try decoder.container(keyedBy: CodingKeys.self)
                self.a = try container.decode(NewA.self, forKey: .a)
                self.b = try container.decode(NewB.self, forKey: .b)
                // Here decode again b and look for the value of otherKey
                let containerForOtherKeyInB = try container.nestedContainer(keyedBy: CustomKey.self, forKey: .b)
                self.a.otherKey = try containerForOtherKeyInB.decode(String.self, forKey: .otherKey)
            }
        }
        
        class NewA: NSObject, Codable {
            let keyA: String
            var otherKey: String = "defaultValue"
            
            enum CodingKeys: CodingKey {
                case keyA //We skip here otherKey
            }
        }
        enum CustomKey: CodingKey {
            case otherKey
        }
        struct NewB: Codable {
            let keyB: String
        }
        
        let decoder = JSONDecoder()
        
        do {
            let parent = try decoder.decode(NewParent.self, from: Data(json.utf8))
            print(parent)
            print("--")
        } catch {
            print("Error: \(error)")
        }
    }