Merge Encodable in Swift
Asked Answered
F

4

10

I have the following Swift structs

struct Session: Encodable {
    let sessionId: String
}
struct Person: Encodable {
    let name: String
    let age: Int
}

let person = Person(name: "Jan", age: 36)
let session = Session(sessionId: "xyz")

that I need to encode to a json object that has this format:

{
  "name": "Jan",
  "age": 36,
  "sessionId": "xyz"
}

where all keys of the Session are merged into the keys of the Person

I thought about using a container struct with a custom Encodable implementation where I use a SingleValueEncodingContainer but it can obviously encode only one value

struct RequestModel: Encodable {
    let session: Session
    let person: Person

    public func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        try container.encode(person)
        // crash
        try container.encode(session)
    }
}

let person = Person(name: "Jan", age: 36)
let session = Session(sessionId: "xyz")
let requestModel =  RequestModel(session: session, person: person)

let encoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted

let data = try encoder.encode(requestModel)
let json = String(data: data, encoding: .utf8)!

print(json)

I cannot change the json format as it's a fixed network API. I could have the sessionId as property of the Person but I'd like to avoid that as they are unrelated models.

Another way could be to have the RequestModel copy all the properties from the Session and Person as follows but it's not very nice as my real structs have much more properties.

struct RequestModel: Encodable {
    let sessionId: String
    let name: String
    let age: Int

    init(session: Session, person: Person) {
        sessionId = session.sessionId
        name = person.name
        age = person.age
    }
}
Frymire answered 25/10, 2018 at 7:55 Comment(0)
T
4

Use encoder.container(keyedBy: CodingKeys.self) instead of singleValueContainer() and add the key-value pairs separately, i.e.

struct RequestModel: Encodable
{
    let session: Session
    let person: Person

    enum CodingKeys: String, CodingKey {
        case sessionId, name, age
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(person.age, forKey: RequestModel.CodingKeys.age)
        try container.encode(person.name, forKey: RequestModel.CodingKeys.name)
        try container.encode(session.sessionId, forKey: RequestModel.CodingKeys.sessionId)
    }
}

Output:

{
  "age" : 36,
  "name" : "Jan",
  "sessionId" : "xyz"
}

Let me know in case you still face any issues.

Tridactyl answered 25/10, 2018 at 8:17 Comment(0)
S
14

Call encode(to:) of each encodable objects, instead of singleValueContainer(). It makes possible to join multi encodable objects into one encodable object without defining extra CodingKeys.

struct RequestModel: Encodable {
    let session: Session
    let person: Person

    public func encode(to encoder: Encoder) throws {
        try session.encode(to: encoder)
        try person.encode(to: encoder)
    }
}
Sufferable answered 26/12, 2018 at 7:6 Comment(3)
Thanks a million! So simple yet so elegant.Postnatal
It's only encoding the last item in the encode function i.e. person. Any idea why?Larue
@HudiIlfeld sure. In your implementation of encode(to:) you state that you want to use a single value container. As such, the output will only ever be able to contain one value, so later encodings overwrite it. If you need to combine multiple objects into one JSON output, ensure that all of them use the same kind of container, and that container must be either a keyed or unkeyed value container, depending on whether you want your JSON to be an array or a dictionary. Don't mix and match, and don't use single value containers.Horatia
T
4

Use encoder.container(keyedBy: CodingKeys.self) instead of singleValueContainer() and add the key-value pairs separately, i.e.

struct RequestModel: Encodable
{
    let session: Session
    let person: Person

    enum CodingKeys: String, CodingKey {
        case sessionId, name, age
    }

    public func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(person.age, forKey: RequestModel.CodingKeys.age)
        try container.encode(person.name, forKey: RequestModel.CodingKeys.name)
        try container.encode(session.sessionId, forKey: RequestModel.CodingKeys.sessionId)
    }
}

Output:

{
  "age" : 36,
  "name" : "Jan",
  "sessionId" : "xyz"
}

Let me know in case you still face any issues.

Tridactyl answered 25/10, 2018 at 8:17 Comment(0)
H
4

I'd like to expand on @marty-suzuki's answer here, because there are a few nuances that may be missed if you're not careful. Here's my version of the code:

struct EncodableCombiner: Encodable {
    let subelements: [Encodable]
    func encode(to encoder: Encoder) throws {
        for element in subelements {
            try element.encode(to: encoder)
        }
    }
}

Simply instantiate with an array of codable objects and treat the resulting object as a codable object in its own right. Now, there are a couple of important notes to remember when using this method:

  1. You can only have one type of root object in your JSON, which will either be a single value, an array, or a dictionary. So when you implement encode(to:) in your various codable objects, never create your container using encoder.singleValueContainer.
  2. Every object you wish to combine must operate on the same kind of container, so if one of them uses unkeyedContainer(), so must they all. Similarly, if one uses container(keyedBy:) then the others must too.
  3. If you're using keyed containers, then no two variables across all the combined objects can share the same key name! Otherwise, you're going to find that they overwrite each other since they're being parsed into the same dictionary.

An alternative that would alleviate these problems, but does not result in the same JSON structure, would be this:

struct EncodableCombiner: Encodable {
    let elementA: MyEncodableA
    let elementB: MyEncodableB
    func encode(to encoder: Encoder) throws {
        var container = encoder.unkeyedContainer()
        try container.encode(elementA)
        try container.encode(elementB)
    }
}

Now, this is slightly less convenient because we can't simply supply an array of objects that conform to Encodable; it needs to know exactly what they are to call container.encode(). The result is a JSON object with an array as its root object, and each subelement expressed as an element in that array. In fact, you could simplify it further like this:

struct EncodableCombiner: Encodable {
    let elementA: MyEncodableA
    let elementB: MyEncodableB
}

... which would of course result in a dictionary root object with the encoded form of MyEncodableA keyed as elementA, and MyEncodableB as elementB.

It all depends on what structure you want.

Horatia answered 26/2, 2021 at 14:31 Comment(0)
A
0

I am personally find mine version the most convenient

struct BaseParams: Encodable {
    let platform: String = "ios"
    let deviceId: String = "deviceId"
}

struct RequestModel<PayloadType: Encodable>: Encodable {
    let session = BaseParams()
    let payload: PayloadType

    public func encode(to encoder: Encoder) throws {
        try session.encode(to: encoder)
        try payload.encode(to: encoder)
    }
}

Usage

struct TransactionParams: Encodable {
    let transation: [String]
}

let transactionParams = TransactionParams(transation: ["1", "2"])
let requestModel = RequestModel(payload: transactionParams)

let data = try JSONEncoder().encode(requestModel)
try JSONSerialization.jsonObject(with: data)

Result

["platform": "ios", "transation": ["1", "2"], "deviceId": "deviceId"]

Aswarm answered 30/7, 2023 at 21:24 Comment(0)

© 2022 - 2025 — McMap. All rights reserved.