Decoding to different models based on json key?

codable

(Gal Cohen) #1

I'm receiving a different responses from a websocket. They all have different payloads but share some { "event": "EVENT_NAME" } in the json.

so let's say

{
"event": "connected"
... connected event related fields
}

{
"event": "message"
message event related fields
}

{
"event": "disconnected"
disconnected event fields
}

I could decode with into some a generic response model just to get out the event value, and based on the value decode it again. Seems like i'd be doing some extra work here. Having trouble coming up with a better solution to this. Any ideas?


(David Sweeris) #2

Model the payload as an enum with associated values? I’m not sure if that reduces the work you’d have to do or just moves it around.


(Kaitlin Mahar) #3

I think that just moves the work around. Enums with associated values don't get automatic conformance to Codable, so you'd still have to pull out the event name and then do some kind of conditional decoding behavior based on that. You could organize the work a lot of different ways, but all of them are going to boil down to the same thing.


(Gal Cohen) #4

Yeah, I actually tried a variation of the associated enum solution in one of my attempts. I like it for modeling the data. But I think it does end up doing the decoding twice. That's what I'm trying to figure out how to avoid. Right now it just switches on the event value and decodes again. I'm not even sure if there's any benefit to reusing container from let container = try decoder.container(keyedBy: CodingKeys.self), if i should be creating a new one for the specific model, or try something else all together.

Code Example
let connectedJSON = """
{
    "event": "connected",
    "string": "abc"
}
""".data(using: .utf8)!


let disconnectedJSON = """
{
"event": "disconnected",
"number": 123,
"trueValue": true
}
""".data(using: .utf8)!

enum EventData: Decodable {

    private enum EventType: String, Decodable {
        case connected, disconnected
    }
    
    struct Connected: Decodable {
        let string: String
    }
    
    struct Disconnected: Decodable {
        let number: Int
        let trueValue: Bool
    }

    case connected(Connected)
    case disconnected(Disconnected)
    
    enum CodingKeys: String, CodingKey {
        case event
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        let event = try container.decode(EventType.self, forKey: .event)
        switch event {
        case .connected:
            let connectedData = try Connected(from: decoder)
            self = .connected(connectedData)

        case .disconnected:
            let disconnectedData = try Disconnected(from: decoder)
            self = .disconnected(disconnectedData)
        }
    }
}

let decoder = JSONDecoder()
let event1 = try! decoder.decode(EventData.self, from: connectedJSON)
let event2 = try! decoder.decode(EventData.self, from: disconnectedJSON)

#5

A sheer fact that event is on the same layer as the rest of data makes it really hard to decode properly in one pass. The only way I can think of is to create an umbrella Patch type, and make ConnectedData and DisconnectedData from it:

struct Patch: Decodable {
    let string: String?
    let number: Int?
    let trueValue: Bool?

Though logically it isn’t all that different. Anyhow, I don’t think decoding twice will incur much of overhead.

P.S.
I tend to use your algorithm too, the only difference is I use init(from: decoder) on event rather than doing the actual decoding like create container and such myself:

private struct Metadata: Decodable {
    enum EventType: String, Decodable {
        case connected, disconnected
    }
    var event: EventType
}
    
init(from decoder: Decoder) throws {
    let metadata = try Metadata(from: decoder)
        
    switch metadata.event {
    case .connected: self = try .connected(Connected(from: decoder))
    case .disconnected: self = try .disconnected(Disconnected(from: decoder))
    }
}

It somehow feels much more proper when most of the Decoder handling are the synthisized one.


(Kaitlin Mahar) #6

I don't think reusing the initially requested container is possible, as you can't know until after you request it and know the event type what all of the coding keys will be. I suppose you could request a container with all possible coding keys over all event types, knowing that some fields won't actually be present.

However, that would lead to a lot of extra work, as you have to a) write out all of those keys, and b) manually decode all the data for each event from the top container rather than utilizing the compiler-generated inits for each event type.

I agree with @Lantua there isn't that much overhead in requesting a keyed container again. Under the hood, JSONEncoder will just cast its top container to a [String: Any] and return a struct that wraps it. The JSON Data won't be deserialized twice or anything like that.


(John Scott) #7

I've come up with an alternative based on trying all the possible concrete types. Herw's what it looks like in practice:

let connectedJSON = """
{
        "event": "connected",
        "string": "abc"
}
""".data(using: .utf8)!

let disconnectedJSON = """
{
    "event": "disconnected",
    "number": 123,
    "trueValue": true
}
""".data(using: .utf8)!

protocol EventType: Decodable {}

struct Connected: Decodable, EventType {
    let string: String
}

struct Disconnected: Decodable, EventType {
    let number: Int
    let trueValue: Bool
}

typealias EventData = Decoded<EventType>.with2Types<Connected, Disconnected>

let decoder = JSONDecoder()
let event1 = try! decoder.decode(EventData.self, from: connectedJSON)
let event2 = try! decoder.decode(EventData.self, from: disconnectedJSON)

dump(event1.value)
dump(event2.value)

Here's the implementation:

enum Decoded<P> {
    static func build(from decoder: Decoder, types: Decodable.Type...) -> P {
        for type in types {
            if let value = try? type.init(from: decoder) as! P {
                return value
            }
        }
        fatalError()
    }
    
    struct with1Type<T1>: Decodable where T1:Decodable {
        let value: P
        init(from decoder: Decoder) throws {
            value = build(from: decoder, types: T1.self)
        }
    }
    
    struct with2Types<T1,T2>: Decodable where T1:Decodable, T2:Decodable {
        let value: P
        init(from decoder: Decoder) throws {
            value = build(from: decoder, types: T1.self, T2.self)
        }
    }
    
    // with3Types, ...
}

#8

Be mindful that this approach:

  1. Doesn’t scale very well. If you need to have, say, 5 types, you’ll need to implement separate with5Types struct, though you could get away with gyb. More importantly,
  2. Because it’s not consulting type’s metadata(event), it’s possible for one type to shadow another. If Disconnected has all the data Message has, and Disconnected is checked before, Message will be shadowed.
    It’s also possible for 2 types to shadow one another if they have the same set of variables, so reordering won’t be help.

(John Scott) #9

In addition to those two, it also has the issue that we can't explicitly set T1: P and P: Decodable.


(John Scott) #10

wrt to #2, adding let event with a single enum type solves the issue:

struct Connected: Decodable, EventType {
    let string: String
    
    let event: Event
    enum Event: String, Codable {
        case connected
    }
}

struct Disconnected: Decodable, EventType {
    let event: Event
    enum Event: String, Codable {
        case disconnected
    }
    let number: Int
    let trueValue: Bool
}

(but the error is rubbish when it fails :frowning:)