Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

How to get the nondecoded attributes from a Decoder container in Swift 4?

I'm using the Decodable protocol in order to parse JSON received from an external source. After decoding the attributes that I do know about there still may be some attributes in the JSON that are unknown and have not yet been decoded. For example, if the external source added a new attribute to the JSON at some future point in time I would like to hold onto these unknown attributes by storing them in a [String: Any] dictionary (or an alternative) so the values do not get ignored.

The issue is that after decoding the attributes that I do know about there isn't any accessors on the container to retrieve the attributes that have not yet been decoded. I'm aware of the decoder.unkeyedContainer() which I could use to iterate over each value however this would not work in my case because in order for that to work you need to know what value type you're iterating over but the value types in the JSON are not always identical.

Here is an example in playground for what I'm trying to achieve:

// Playground
import Foundation

let jsonData = """
{
    "name": "Foo",
    "age": 21
}
""".data(using: .utf8)!

struct Person: Decodable {
    enum CodingKeys: CodingKey {
        case name
    }

    let name: String
    let unknownAttributes: [String: Any]

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.name = try container.decode(String.self, forKey: .name)

        // I would like to store the `age` attribute in this dictionary
        // but it would not be known at the time this code was written.
        self.unknownAttributes = [:]
    }
}

let decoder = JSONDecoder()
let person = try! decoder.decode(Person.self, from: jsonData)

// The `person.unknownAttributes` dictionary should
// contain the "age" attribute with a value of 21.

I would like for the unknownAttributes dictionary to store the age attribute and value in this case and any other possible value types if they get added to the JSON from the external source in the future.

The reason I am wanting to do something like this is so that I can persist the unknown attributes present in the JSON so that in a future update of the code I will be able to handle them appropriately once the attribute keys are known.

I've done plenty of searching on StackOverflow and Google but haven't yet encountered this unique case. Thanks in advance!

like image 309
tshaw Avatar asked Jan 04 '23 02:01

tshaw


1 Answers

You guys keep coming up with novel ways to stress the Swift 4 coding APIs... ;)

A general solution, supporting all value types, might not be possible. But, for primitive types, you can try this:

Create a simple CodingKey type with string-based keys:

struct UnknownCodingKey: CodingKey {
    init?(stringValue: String) { self.stringValue = stringValue }
    let stringValue: String

    init?(intValue: Int) { return nil }
    var intValue: Int? { return nil }
}

Then write a general decoding function using the standard KeyedDecodingContainer keyed by the UnknownCodingKey above:

func decodeUnknownKeys(from decoder: Decoder, with knownKeys: Set<String>) throws -> [String: Any] {
    let container = try decoder.container(keyedBy: UnknownCodingKey.self)
    var unknownKeyValues = [String: Any]()

    for key in container.allKeys {
        guard !knownKeys.contains(key.stringValue) else { continue }

        func decodeUnknownValue<T: Decodable>(_ type: T.Type) -> Bool {
            guard let value = try? container.decode(type, forKey: key) else {
                return false
            }
            unknownKeyValues[key.stringValue] = value
            return true
        }
        if decodeUnknownValue(String.self) { continue }
        if decodeUnknownValue(Int.self)    { continue }
        if decodeUnknownValue(Double.self) { continue }
        // ...
    }
    return unknownKeyValues
}

Finally, use the decodeUnknownKeys function to fill your unknownAttributes dictionary:

struct Person: Decodable {
    enum CodingKeys: CodingKey {
        case name
    }

    let name: String
    let unknownAttributes: [String: Any]

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.name = try container.decode(String.self, forKey: .name)

        let knownKeys = Set(container.allKeys.map { $0.stringValue })
        self.unknownAttributes = try decodeUnknownKeys(from: decoder, with: knownKeys)
    }
}

A simple test:

let jsonData = """
{
    "name": "Foo",
    "age": 21,
    "token": "ABC",
    "rate": 1.234
}
""".data(using: .utf8)!

let decoder = JSONDecoder()
let person = try! decoder.decode(Person.self, from: jsonData)
print(person.name)
print(person.unknownAttributes)

prints:

Foo
["age": 21, "token": "ABC", "rate": 1.234]

like image 185
Paulo Mattos Avatar answered Jan 26 '23 01:01

Paulo Mattos