Search code examples
jsonswift4.1

Swift 4.1 Codable/Decodable Nested Array


Need some help with more complicated json, with the newest swift4.1 encoder/decoder:

struct:

struct LMSRequest: Decodable {
let id : Int?
let method : String?
let params : [String]?
enum CodingKeys: String, CodingKey {
    case id = "id"
    case method = "method"
    case params = "params"
}
init(from decoder: Decoder) throws {
    let values = try decoder.container(keyedBy: CodingKeys.self)
    id = try values.decodeIfPresent(Int.self, forKey: .id)
    method = try values.decodeIfPresent(String.self, forKey: .method)
    params = try values.decodeIfPresent([String].self, forKey: .params)
}}

json:

let json = """
{
  "id": 1,
  "method": "slim.request",
  "params": [
    "b8:27:eb:db:6d:62",
    [
      "serverstatus",
      "-",
      1,
      "tags:GPASIediqtymkovrfijnCYXRTIuwxNlasc"
    ]
  ]
}
""".data(using: .utf8)!

code:

let decoder = JSONDecoder()
let lms = try decoder.decode(LMSRequest.self, from: json)
print(lms)

Error is expected to decode string but found array instead. It's coming from the nested array within the "params" array... really stuck on how to build this out, Thanks!


Solution

  • Given what you've described, you should store params as an enum like this:

    enum Param: CustomStringConvertible {
        case string(String)
        case int(Int)
        case array([Param])
    
        var description: String {
            switch self {
            case let .string(string): return string
            case let .int(int): return "\(int)"
            case let .array(array): return "\(array)"
            }
        }
    }
    

    A param can either be a string, an int, or an array of more params.

    Next, you can make Param Decodable by trying each option in turn:

    extension Param: Decodable {
        init(from decoder: Decoder) throws {
            let container = try decoder.singleValueContainer()
            if let string = try? container.decode(String.self) {
                self = .string(string)
            } else if let int = try? container.decode(Int.self) {
                self = .int(int)
            } else {
                self = .array(try container.decode([Param].self))
            }
        }
    }
    

    Given this, there's no need for custom decoding logic in LMSRequest:

    struct LMSRequest: Decodable {
        let id : Int?
        let method : String?
        let params : [Param]?
    }
    

    As a side note, I would carefully consider whether these fields are all truly optional. It's very surprising that id is optional, and quite surprising that method is optional, and slightly surprising that params are optional. If they're not really optional, don't make them optional in the type.


    From your comments, you're probably misunderstanding how to access enums. params[1] is not a [Param]. It's an .array([Param]). So you have to pattern match it since it might have been a string or an int.

    if case let .array(values) = lms.params[1] { print(values[0]) }
    

    That said, if you're doing this a lot, you can make this simpler with extensions on Param:

    extension Param {
        var stringValue: String? { if case let .string(value) = self { return value } else { return nil } }
        var intValue: Int? { if case let .int(value) = self { return value } else { return nil } }
        var arrayValue: [Param]? { if case let .array(value) = self { return value } else { return nil } }
    
        subscript(_ index: Int) -> Param? {
            return arrayValue?[index]
        }
    }
    

    With that, you can say things like:

    let serverstatus: String? = lms.params[1][0]?.stringValue
    

    Which is probably closer to what you had in mind. (The : String? is just to be clear about the returned type; it's not required.)

    For a more complex and worked-out example of this approach, see my generic JSON Decodable that this is a subset of.