alex17
alex17

Reputation: 21

How to parse JSON that is not defined in CodingKeys

Consider the following JSON:

{
  "jsonName": "fluffy",
  "color1": "Blue",
  "color2": "Red",
  "color3": "Green",
  "color4": "Yellow",
  "color5": "Purple"
}

And the model object:

struct Cat: Decodable {
    let name: String
    let colors: [String]

    private enum CodingKeys: String, CodingKey {
        case name = "jsonName"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
    
        // How to parse all the items with colorX into the colors property???
    }   
}

There can be up to 10 colors, but some of them may be empty strings.

I've tried several variations of the decode method but I can't figure out how to use an identifier like color\(i).

Upvotes: 2

Views: 125

Answers (4)

vadian
vadian

Reputation: 285220

An alternative to Joakim's, Rob's and workingdog's answer is to decode the JSON as [String:String] dictionary in a singleValueContainer and map the data accordingly. The solution doesn't need any CodingKey. localizedStandardCompare sorts the keys in the proper numeric order if this matters. If it doesn't matter at all omit sorted. Further the "empty-string" colors are filtered out.

struct Cat: Decodable {
    let name: String
    let colors: [String]
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let dictionary = try container.decode([String:String].self)
        guard let name = dictionary["jsonName"] else { throw DecodingError.dataCorruptedError(in: container, debugDescription: "Key `name` not found") }
        self.name = name
        colors = dictionary
            .keys
            .sorted{$0.localizedStandardCompare($1) == .orderedAscending}
            .filter{$0.hasPrefix("color")}
            .compactMap {
                guard let color = dictionary[$0], !color.isEmpty else { return nil }
                return color
            }
    }
}

I know there is a keyNotFound decoding error but in a singleValueContainer it's pretty complicated to create one.

Upvotes: 1

try this simple approach using a while loop inside the init(from decoder: Decoder)

struct Cat: Decodable {
    let name: String
    var colors: [String] // <-- here var
    
    private enum CodingKeys: String, CodingKey {
        case name = "jsonName"
    }
    
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        self.name = try values.decode(String.self, forKey: .name)
        
        // -- here
        self.colors = []
        let container = try decoder.singleValueContainer()
        let colours = try container.decode([String: String?].self)
        var index = 1
        while
            let col = colours["color\(index)"] as? String,
            !col.isEmpty
        {
            self.colors.append(col)
            index += 1
        }

        // or alternatively, if you have gaps in the color indices, eg no color3
//        for i in 1..<11 {
//            if let col = colours["color\(i)"] as? String, !col.isEmpty {
//                self.colors.append(col)
//            }
//        }

    }
}

Upvotes: 1

Sweeper
Sweeper

Reputation: 273540

Similar to Rob Napier's answer, I would also implement a custom CodingKey type to decode the colour keys, in addition to a regular enum CodingKey representing the non-colour keys.

struct Cat: Decodable {
    let name: String
    let colors: [String]
    
    enum GeneralCodingKeys: String, CodingKey {
        case name = "jsonName"
        // other non-color coding keys...
    }
    
    struct ColorKey: CodingKey {
        let number: Int
        init(_ number: Int) { self.number = number }
        
        init?(intValue: Int) { nil }
        var intValue: Int? { nil }
        var stringValue: String { "color\(number)" }
        init?(stringValue: String) {
            if let (_, match) = stringValue.wholeMatch(of: /color(\d+)/)?.output, 
               let number = Int(match) {
                self = .init(number)
            } else {
                return nil
            }
        }
    }
    
    init(from decoder: any Decoder) throws {
        let container = try decoder.container(keyedBy: GeneralCodingKeys.self)
        self.name = try container.decode(String.self, forKey: .name)
        let colorContainer = try decoder.container(keyedBy: ColorKey.self)
        var colors: [String] = []
        for key in colorContainer.allKeys {
            colors.append(try colorContainer.decode(String.self, forKey: key))
        }
        self.colors = colors
    }
}

In ColorKey, I used a regex to do a much stricter validation (compared to Rob Napier's answer) on the key. If you don't need that, you can also just check hasPrefix("color"):

struct ColorKey: CodingKey {
    let key: String
    init(_ key: String) { self.key = key }
    
    init?(intValue: Int) { nil }
    var intValue: Int? { nil }
    var stringValue: String { key }
    init?(stringValue: String) {
        if stringValue.hasPrefix("color") {
            self = .init(stringValue)
        } else {
            return nil
        }
    }
}

Upvotes: 2

Rob Napier
Rob Napier

Reputation: 299565

As with so many of these problems, the tool to start with is AnyCodingKey:

public struct AnyCodingKey: CodingKey, CustomStringConvertible, ExpressibleByStringLiteral,
                            ExpressibleByIntegerLiteral, Hashable, Comparable {
    public var description: String { stringValue }
    public let stringValue: String
    public init(_ string: String) { self.stringValue = string }
    public init?(stringValue: String) { self.init(stringValue) }
    public var intValue: Int?
    public init(intValue: Int) {
        self.stringValue = "\(intValue)"
        self.intValue = intValue
    }
    public init(stringLiteral value: String) { self.init(value) }
    public init(integerLiteral value: Int) { self.init(intValue: value) }
    public static func < (lhs: AnyCodingKey, rhs: AnyCodingKey) -> Bool {
        lhs.stringValue < rhs.stringValue
    }
}

You can build this more simply, but this implementation is kind of nice.

With that, one possible decoder looks like this:

init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: AnyCodingKey.self)
    name = try container.decode(String.self, forKey: "jsonName")

    var colors: [String] = []
    for key in container.allKeys where key.stringValue.hasPrefix("color") {
        colors.append(try container.decode(String.self, forKey: key))
    }
    self.colors = colors
}

This decodes "jsonName" as name, and then puts anything starting with "color" into colors. You can adapt to a wide variety of specific use cases.

Upvotes: 1

Related Questions