How to encode struct in Swift using Encodable that contains an already encoded value

Viewed 8925

Imagine a data structure as follows, containing a value in contents that is an already encoded JSON fragment.

let partial = """
{ "foo": "Foo", "bar": 1 }
"""

struct Document {
  let contents: String
  let other: [String: Int]
}

let doc = Document(contents: partial, other: ["foo": 1])

Desired output

The combined data structure should use contents as is and encode other.

{
  "contents": { "foo": "Foo", "bar": 1 },
  "other": { "foo": 1 }
}

Using Encodable

The following implementation of Encodable encodes Document as JSON, however it also re-encodes contents into a string, meaning it is wrapped in quotes and has all " quotes escaped into \".

extension Document : Encodable {
    enum CodingKeys : String, CodingKey {
        case contents
        case other
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)

        try container.encode(contents, forKey: .contents)
        try container.encode(other, forKey: .other)
    }
}

Output

{
  "contents": "{\"foo\": \"Foo\", \"bar\": 1}",
  "other": { "foo": 1 }
}

How can encode just pass through contents as is?

3 Answers

I agree with Ahmad's basic approach, but I'm assuming you need something more dynamic. In that case, you should make clear that content is not a "String." It's JSON. And so you can store it as JSON using a JSON type (simplified here, see the gist for a more feature-rich version):

enum JSON: Codable {
    struct Key: CodingKey, Hashable, CustomStringConvertible {
        var description: String {
            return stringValue
        }

        let stringValue: String
        init(_ string: String) { self.stringValue = string }
        init?(stringValue: String) { self.init(stringValue) }
        var intValue: Int? { return nil }
        init?(intValue: Int) { return nil }
    }

    case string(String)
    case number(Double) // FIXME: Split Int and Double
    case object([Key: JSON])
    case array([JSON])
    case bool(Bool)
    case null

    init(from decoder: Decoder) throws {
        if let string = try? decoder.singleValueContainer().decode(String.self) { self = .string(string) }
        else if let number = try? decoder.singleValueContainer().decode(Double.self) { self = .number(number) }
        else if let object = try? decoder.container(keyedBy: Key.self) {
            var result: [Key: JSON] = [:]
            for key in object.allKeys {
                result[key] = (try? object.decode(JSON.self, forKey: key)) ?? .null
            }
            self = .object(result)
        }
        else if var array = try? decoder.unkeyedContainer() {
            var result: [JSON] = []
            for _ in 0..<(array.count ?? 0) {
                result.append(try array.decode(JSON.self))
            }
            self = .array(result)
        }
        else if let bool = try? decoder.singleValueContainer().decode(Bool.self) { self = .bool(bool) }
        else if let isNull = try? decoder.singleValueContainer().decodeNil(), isNull { self = .null }
        else { throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: [],
                                                                       debugDescription: "Unknown JSON type")) }
    }

    func encode(to encoder: Encoder) throws {
        switch self {
        case .string(let string):
            var container = encoder.singleValueContainer()
            try container.encode(string)
        case .number(let number):
            var container = encoder.singleValueContainer()
            try container.encode(number)
        case .bool(let bool):
            var container = encoder.singleValueContainer()
            try container.encode(bool)
        case .object(let object):
            var container = encoder.container(keyedBy: Key.self)
            for (key, value) in object {
                try container.encode(value, forKey: key)
            }
        case .array(let array):
            var container = encoder.unkeyedContainer()
            for value in array {
                try container.encode(value)
            }
        case .null:
            var container = encoder.singleValueContainer()
            try container.encodeNil()
        }
    }
}

With that you can redefine your document to hold JSON:

struct Document: Codable {
  let contents: JSON
  let other: [String: Int]
}

And decode that JSON from a String if you like:

let doc = Document(contents:
    try! JSONDecoder().decode(JSON.self, from: Data(partial.utf8)),
                   other: ["foo": 1])

With that in place, the default JSONEncoder() is all you need to get the output you're describing.

You could achieve it by doing this:

let partial = """
{
"foo": "Foo",
"bar": 1
}
"""

// declare a new type for `content` to deal with it as an object instead of a string
struct Document {
    let contents: Contents
    let other: [String: Int]

    struct Contents: Codable {
        let foo: String
        let bar: Int
    }
}

extension Document : Encodable {
    enum CodingKeys: String, CodingKey {
        case contents
        case other
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)

        try container.encode(contents, forKey: .contents)
        try container.encode(other, forKey: .other)
    }
}

let decoder = JSONDecoder()
let contents = try decoder.decode(Document.Contents.self, from: partial.data(using: .utf8)!)

let encoder = JSONEncoder()
let doc = Document(contents: contents, other: ["foo": 1])
let result = try encoder.encode(doc)
print(String(data: result, encoding: .utf8)!)

Basically, you could deal with partial first by decoding it, and then you pass the decoded result of it to Document.

The output should be:

{"other":{"foo":1},"contents":{"foo":"Foo","bar":1}}

I might be a bit late, but I hope it helps someone in the future. I've had a similar problem where I had some pre-encoded variables and wanted to nest them inside some parent structure that was encodable.

struct Request: Encodable {
    let variables: [String: Data] // I'd encode data to JSON someplace else.
}

Unfortunately, the type of each keyed value varied (e.g. you could have an integer in one key and an object in the other) and I couldn't pass information upwards from where I was encoding it the first time. Here's what I have in mind:

{ 
    "variables": {
        "one": { "hello": "world" },
        "two": 2
    }
}

Enum and Generics were also not an option as this was a highly flexible part that only required types to conform to Encodable.

All in all, I ended up copying over most of Swift's JSONEncoder implementation that you can find here. (I recommend cleaning out the JSONDecoder implementation since it's useless in our case.)

The part that needs changing is inside the encode function in JSONEncoder class. Basically, you want to split the parts that get topLevel value (i.e. NSObject) and the part that serializes it. The new encode should also return a NSObject-type instead of Data.

open func encode<T : Encodable>(_ value: T) throws -> NSObject {
    let encoder = __JSONEncoder(options: self.options)

    guard let topLevel = try encoder.box_(value) else {
        throw EncodingError.invalidValue(value, EncodingError.Context(codingPath: [], debugDescription: "Top-level \(T.self) did not encode any values."))
    }

    return topLevel
}

Once you have that, you may pass NSObject anywhere as a type, the important chunk left is that you run JSONSerialization.data function to get the actual JSON. What JSONEncoder does internally is that it reduces an Encodable struct to Foundation types. JSONSerialization can then handle those types and you get a valid JSON.

Here's how I used it:

let body: Any = [
    "query": query, // String
    "variables": variables // NSObject dictionary
]

let httpBody = try! JSONSerialization.data(
    withJSONObject: body,
    options: JSONSerialization.WritingOptions()
)
request.httpBody = httpBody
Related