8

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?

klotz
  • 1,951
  • 2
  • 22
  • 27

3 Answers3

3

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.

Rob Napier
  • 286,113
  • 34
  • 456
  • 610
  • I see, thank you. This is conceptually similar to [`json.RawMessage`](https://golang.org/pkg/encoding/json/#RawMessage) in Go, which is just a byte array and assumes the JSON _is_ correct. This is basically what I was looking to replicate in Swift. – klotz Sep 30 '19 at 22:35
  • Which makes me wonder if a custom would be possible that already represents `Data` (`typealias`?) and makes the assumption its value _is_ correct? – klotz Sep 30 '19 at 22:48
  • Certainly, but not with JSONEncoder. You'd have to build something yourself. You could probably use JSONEncoder to do various parts, and then glue together bits, but there's no direct access to the output bytes. – Rob Napier Oct 01 '19 at 02:20
2

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}}

Ahmad F
  • 30,560
  • 17
  • 97
  • 143
  • I'd like to use `partial` "as is" since decoding would more or less double the memory usage and take extra time. In my case this would be problematic because I'm dealing with a fairly large JSON data structure. – klotz Sep 30 '19 at 22:18
  • That's not going to be possible using JSONEncoder. You'd have to build the whole thing from scratch in this case. There's no mechanism to just inject unverified characters into the stream. Doing what you're suggesting, you could wind up with invalid JSON, and JSONEncoder doesn't permit that. I suggest encoding the "rest" and then gluing the two together using standard string manipulation if that's what you require. – Rob Napier Sep 30 '19 at 22:26
  • Thanks @RobNapier. Could you please clarify why it's not going to be possible using JSONEncoder? – Ahmad F Sep 30 '19 at 22:31
  • Because JSONEncoder doesn't give you any direct access to the output byte stream, so there's nowhere to write the raw bytes. – Rob Napier Oct 01 '19 at 02:19
0

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
Dharman
  • 30,962
  • 25
  • 85
  • 135
maticzav
  • 880
  • 9
  • 17