Search code examples
jsonswiftjsondecoder

How to map a single value from a JSON dictionary to a property when using Swift Decodable


I have a JSON object that looks something like this:

{
    "name": "Acid Arrow",
    "school": {
        "name": "Evocation",
        "url": "http://www.dnd5eapi.co/api/magic-schools/5"
    }
}

that I would like to model in Swift as the following:

struct Spell: Decodeable {
    let name: String
    let school: MagicSchool
}

enum MagicSchool: String {
    case abjuration = "Abjuration"
    case abjuration = "Abjuration" 
    case conjuration = "Conjuration" 
    case divination = "Divination"   
    case enchantment = "Enchantment"    
    case evocation = "Evocation"   
    case illusion = "Illusion"   
    case necromancy = "Necromancy"  
    case transmutation = "Transmutation"   
}

The only ways I can find to reduce the JSON school dictionary down to a single enumeration value is to implement the entire Decodeable by providing a custom init(from decoder: Decoder) initializer that would look something like this:

extension Spell: Decodeable {
    init(from decoder: Decoder) {
        let values = try decoder.container(keyedBy: CodingKeys.self)

        // manually map to the spell name
        name = try values.decode(String.self, forKey: .name)

        // manually decode **school** into a dictionary
        let jsonSchool = try values.decode(Dictionary<String,String>.self, forKey: .school)

        // extract the "name" property from the dict and assign it as `MagicSchool` enum
        school = MagicSchool(rawValue: jsonSchool["name"])

    }
}

But it doesn't like it because of a type conflict on the key type for Spell.school

Am I trying to do this the wrong way? Is there a simpler way to transform a complex type into a basic type or to specify a path in the mapping?


Solution

  • Using init(from decoder: Decoder) throws { initializer is the appropriate way of dealing with this scenario.

    There are probably different ways of dealing with the school entity. I prefer decoding it into a struct to get type safety.

    And as pointed out in the comments the code you provided is full of typos. After cleaning those up it was just a case of an optional beeing assigned to a non optional field.

    struct Spell: Decodable {
        let name: String
        let school: MagicSchool
    }
    
    enum MagicSchool: String {
        case abjuration = "Abjuration"
        case conjuration = "Conjuration"
        case divination = "Divination"
        case enchantment = "Enchantment"
        case evocation = "Evocation"
        case illusion = "Illusion"
        case necromancy = "Necromancy"
        case transmutation = "Transmutation"
    }
    
    extension Spell {
        
        struct InternalSchool: Decodable{
            let name: String
            let url: String
        }
        
        enum CodingKeys: CodingKey{
            case name, school
        }
        
        init(from decoder: Decoder) throws {
            let values = try decoder.container(keyedBy: CodingKeys.self)
    
            // manually map to the spell name
            name = try values.decode(String.self, forKey: .name)
    
            // manually decode **school** into the custom type
            let school = try values.decode(InternalSchool.self, forKey: .school)
    
            // check if you can create an enum from the given string and throw appropriate error
            guard let schoolEnum = MagicSchool(rawValue: school.name) else{
                throw DecodingError.dataCorrupted(.init(codingPath: [CodingKeys.school], debugDescription: "school has unknown value"))
            }
            // assign enum
            self.school = schoolEnum
        }
    }