Search code examples
iosjsonswiftcodabledecoder

Swift: Decode JSON response and store nested JSON as String or JSON


Given the following JSON from a network request; If you wanted to decode this into a Swift object that coforms to Codable, but you wanted to retain the nested JSON that is the value for the key configuration_payload, how could you do it?

{
    "registration": {
        "id": "0000-0000-0000-0000-000",
        "device_type": "device",
        "state": "provisioning",
        "thing_uuid": 999999999,
        "discovery_timeout": 10,
        "installation_timeout": 90,
        "configuration_payload":
            {
                "title": "Some Title",
                "url": "https://www.someurl.com/",
                "category": "test",
                "views": 9999
            }
      }
}

Using the following Swift struct, I want to be able to grab the configuration_payload as a String.

public struct Registration: Codable {
    public enum State: String, Codable {
        case provisioning, provisioned
    }
    
    public let id, deviceType: String
    public let state: State
    public let error: String?
    public let thingUUID: Int?
    public let discoveryTimeout, installationTimeout: Int
    public let configurationPayload: String?
}

As far as I can tell, the JSONDecoder in Swift, sees the value for configuration_payload as nested JSON and wants to decode it into it's own object. To add to confusion, configuration_payload is not always going to return the same JSON structure, it will vary, so I can not create a Swift struct that I can expect and simply JSON encode it again when needed. I need to be able to store the value as a String to account for variations in the JSON under the configuration_payload key.


Solution

  • As others have already said, you cannot just keep a part without decoding. However, decoding unknown data is trivial:

    enum RawJsonValue {
        case boolean(Bool)
        case number(Double)
        case string(String)
        case array([RawJsonValue?])
        case object([String: RawJsonValue])
    }
    
    extension RawJsonValue: Codable {
        init(from decoder: Decoder) throws {
            let container = try decoder.singleValueContainer()
    
            if let boolValue = try? container.decode(Bool.self) {
                self = .boolean(boolValue)
            } else if let numberValue = try? container.decode(Double.self) {
                self = .number(numberValue)
            } else if let stringValue = try? container.decode(String.self) {
                self = .string(stringValue)
            } else if let arrayValue = try? container.decode([RawJsonValue?].self) {
                self = .array(arrayValue)
            } else {
                let objectValue = try container.decode([String: RawJsonValue].self)
                self = .object(objectValue)
            }
        }
    
        func encode(to encoder: Encoder) throws {
            var container = encoder.singleValueContainer()
    
            switch self {
            case .boolean(let boolValue):
                try container.encode(boolValue)
            case .number(let numberValue):
                try container.encode(numberValue)
            case .string(let stringValue):
                try container.encode(stringValue)
            case .array(let arrayValue):
                try container.encode(arrayValue)
            case .object(let objectValue):
                try container.encode(objectValue)
            }
        }
    }
    

    Now we can safely decode and convert to JSON string if needed:

    struct Registration: Codable {
        public enum State: String, Codable {
            case provisioning, provisioned
        }
    
        let id, deviceType: String
        let state: State
        let error: String?
        let thingUUID: Int?
        let discoveryTimeout, installationTimeout: Int
        let configurationPayload: RawJsonValue?
    }
    
    let jsonData = """
    {
        "id": "0000-0000-0000-0000-000",
        "device_type": "device",
        "state": "provisioning",
        "thing_uuid": 999999999,
        "discovery_timeout": 10,
        "installation_timeout": 90,
        "configuration_payload":
            {
                "title": "Some Title",
                "url": "https://www.someurl.com/",
                "category": "test",
                "views": 9999
            }
    }
    """.data(using: .utf8)!
    
    let decoder = JSONDecoder()
    decoder.keyDecodingStrategy = .convertFromSnakeCase
    let registration = try! decoder.decode(Registration.self, from: jsonData)
    
    let encoder = JSONEncoder()
    encoder.keyEncodingStrategy = .convertToSnakeCase
    
    let payloadString = String(data: try! encoder.encode(registration.configurationPayload), encoding: .utf8)!
    print(payloadString) // {"title":"Some Title","views":9999,"url":"https:\/\/www.someurl.com\/","category":"test"}
    

    The only problem I can see is potential loss of precision when decoding decimal numbers, which is a known problem with Foundation JSON decoder. Also, some null values could be also removed. This could be fixed by decoding object manually by iterating keys and having a special null type.