Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Decoding arbitrary json field dynamically in Swift

TL;DR

Is there a way that I can use JSONDecoder and write a function which will just read out from given json given field value of specified decodable type?


Imaging I have the following json:

{
   "product":{
      "name":"PR1",
      "price":20
   },
   "employee":{
      "lastName":"Smith",
      "department":"IT",
      "manager":"Anderson"
   }
}

And I have 2 Decodable structs:

struct Product: Decodable {
    var name: String
    var price: Int
}

struct Employee: Decodable {
    var lastName: String
    var department: String
    var manager: String
}

I want to write a function

func getValue<T:Decodable>(from json: Data, field: String) -> T { ... }

so that I can call it so:

let product: Product = getValue(from: myJson, field: "product")
let employee: Employee = getValue(from: myJson, field: "employee")

Is this possible with JSONDecoder or should I mess with JSONSerialization, first read out the "subtree" of the given json and then pass it to the decoder? Defining structs inside generic functions seems not to be allowed in swift.

like image 527
frangulyan Avatar asked Dec 09 '18 22:12

frangulyan


2 Answers

Decodable assumes that you know everything you want at design time to enable static typing. The more dynamic you want, the more creative you have to become. Defining a generic coding keys struct comes really handy in these kind of situations:

/// A structure that holds no fixed key but can generate dynamic keys at run time
struct GenericCodingKeys: CodingKey {
    var stringValue: String
    var intValue: Int?

    init?(stringValue: String) { self.stringValue = stringValue }
    init?(intValue: Int) { self.intValue = intValue; self.stringValue = "\(intValue)" }
    static func makeKey(_ stringValue: String) -> GenericCodingKeys { return self.init(stringValue: stringValue)! }
    static func makeKey(_ intValue: Int) -> GenericCodingKeys { return self.init(intValue: intValue)! }
}

/// A structure that retains just the decoder object so we can decode dynamically later
fileprivate struct JSONHelper: Decodable {
    let decoder: Decoder

    init(from decoder: Decoder) throws {
        self.decoder = decoder
    }
}

func getValue<T: Decodable>(from json: Data, field: String) throws -> T {
    let helper = try JSONDecoder().decode(JSONHelper.self, from: json)
    let container = try helper.decoder.container(keyedBy: GenericCodingKeys.self)
    return try container.decode(T.self, forKey: .makeKey(field))
}

let product: Product = try getValue(from: json, field: "product")
let employee: Employee = try getValue(from: json, field: "employee")
like image 55
Code Different Avatar answered Oct 23 '22 11:10

Code Different


I would start by saying that Code Different's answer is a viable and good answer, but if you seek a different way of doing it, tho working mostly the same way underneath the surface, I have an alternative solution, using the main components of Code Different's answer, resulting in the code below. One of the main differences, is the fact that one JSONDecoder is being reused on the same JSON, for each struct you're extracting, using this.

I would also recommend these:

  • How to use Any in Codable Type

  • Swift 4 Codable; How to decode object with single root-level key


/// Conforming to this protocol, makes the type decodable using the JSONContainer class
/// You can use `Decodable` instead.
protocol JSONContainerCodable: Codable {

    /// Returns the name that the type is recognized with, in the JSON.
    /// This is overridable in types conforming to the protocol.
    static var containerIdentifier: String { get }

    /// Defines whether or not the type's container identifier is lowercased.
    /// Defaults to `true`
    static var isLowerCased: Bool { get }
}

extension JSONContainerCodable {

    static var containerIdentifier: String {
        let identifier = String(describing: self)
        return !isLowerCased ? identifier : identifier.lowercased()
    }

    static var isLowerCased: Bool {
        return true
    }
}

struct Product: JSONContainerCodable {

    var name:  String
    var price: Int
}

struct Employee: JSONContainerCodable {

    var lastName:   String
    var department: String
    var manager:    String
}

/// This class is simply a wrapper around JSONDecoder
class JSONContainerDecoder: Decodable {

    private struct AnyCodingKeys: CodingKey {

        var stringValue: String
        var intValue: Int?

        init?(intValue: Int) {
            self.intValue = intValue
            self.stringValue = "\(intValue)"
        }

        init?(stringValue: String) {
            self.stringValue = stringValue
        }

        init(_ string: String) {
            stringValue = string
        }
    }

    private let decoder: JSONDecoder
    private let container: KeyedDecodingContainer<AnyCodingKeys>

    /// Overrides the initializer as specified in `Decodable`.
    required init(from decoder: Decoder) throws {
        self.decoder = JSONDecoder()
        self.container = try decoder.container(keyedBy: AnyCodingKeys.self)
    }

    /// Factory initializer. Swift (4.2) currently doesn't support overriding the parentheses operator.
    static func decoding(_ data: Data, with decoder: JSONDecoder = JSONDecoder()) throws -> JSONContainerDecoder {
        return try decoder.decode(JSONContainerDecoder.self, from: myJSON)
    }

    /// Gets the given type from the JSON, based on its field/container identifier, and decodes it. Assumes there exists only one type with the given field/container identifier, in the JSON.
    func get<T: JSONContainerCodable>(_ type: T.Type, field: String? = nil) throws -> T {
        return try container.decode(T.self, forKey: AnyCodingKeys(field ?? T.containerIdentifier))
    }

    /// Short version of the decode getter above; assumes the variable written to already has its type defined.
    func get<T: JSONContainerCodable>(field: String? = nil) throws -> T {
        return try get(T.self, field: field)
    }
}

let myJSON = """
{
    "product": {
        "name": "PR1",
        "price": 20
    },
    "employee": {
        "lastName": "Smith",
        "department": "IT",
        "manager": "Anderson"
    }
}
""".data(using: .utf8)!

let container = try! JSONContainer.decoding(myJSON)

print(try! container.get( Product.self))
print(try! container.get(Employee.self))

Product(name: "PR1", price: 20)
Employee(lastName: "Smith", department: "IT", manager: "Anderson")
like image 29
Andreas detests censorship Avatar answered Oct 23 '22 11:10

Andreas detests censorship