Simon
Simon

Reputation: 2981

Swift/JSONEncoder: Encoding class containing a nested raw JSON object literal

I have a class in Swift whose structure resembles this:

class MyClass {
  var name: String
  var data: String
}

Which could be initialised where data contains a JSON object encoded as a String.

var instance = MyClass()
instance.name = "foo"
instance.data = "{\"bar\": \"baz\"}"

I'd now like to serialise this instance using JSONEncoder, I'd get an output similar to this:

{
  "name": "foo",
  "data": "{\"bar\": \"baz\"}"
}

However, what I'd really like

{
  "name": "foo",
  "data": {
    "bar": "baz"
  }
}

Can I achieve this with JSONEncoder? (without changing the data type away from String)

Upvotes: 3

Views: 1787

Answers (2)

Rob Napier
Rob Napier

Reputation: 299305

You'll first need to decode data as generic JSON. That's a bit tedious, but not too difficult. See RNJSON for a version I wrote, or here's a stripped-down version that handles your issues.

enum JSON: Codable {
    struct Key: CodingKey, Hashable {
        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 decode the JSON and then re-encode it:

extension MyClass: Encodable {
    enum CodingKeys: CodingKey {
        case name, data
    }
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name)

        let json = try JSONDecoder().decode(JSON.self, from: Data(data.utf8))
        try container.encode(json, forKey: .data)
    }
}

Upvotes: 3

Jobert
Jobert

Reputation: 1652

You could use something like this:

extension MyClass {
    func jsonFormatted() throws -> String? {
        guard let data = data.data(using: .utf8) else {
            return nil
        }
        let anyData = try JSONSerialization.jsonObject(with: data, options: [])
        let dictionary = ["name": name, "data": anyData] as [String : Any]
        let jsonData = try JSONSerialization.data(withJSONObject: dictionary, options: .prettyPrinted)
        let jsonString = String(data: jsonData, encoding: .utf8)
        return jsonString
    }
}

So basically, you leave the structure of data intact, but the rest is wrapped in a dictionary that can be converted to that json string you want to achieve.
Note you'll need to handle the optional and errors that could be thrown. You can use this to test:

if let jsonString = try? instance.jsonFormatted() {
    print(jsonString)
}

Upvotes: 1

Related Questions