Using Decodable protocol with multiples keys

2019-05-29 07:27发布

Say I have the following code:

import Foundation

let jsonData = """
[
    {"firstname": "Tom", "lastname": "Smith", "age": {"realage": "28"}},
    {"firstname": "Bob", "lastname": "Smith", "age": {"fakeage": "31"}}
]
""".data(using: .utf8)!

struct Person: Codable {
    let firstName, lastName: String
    let age: String?

    enum CodingKeys : String, CodingKey {
        case firstName = "firstname"
        case lastName = "lastname"
        case age
    }
}

let decoded = try JSONDecoder().decode([Person].self, from: jsonData)
print(decoded)

Everything is working except age is always nil. Which makes sense. My question is how can I set the Person's age = realage or 28 in the first example, and nil in the second example. Instead of age being nil in both cases I want it to be 28 in the first case.

Is there a way to achieve this only using CodingKeys and not having to add another struct or class? If not how can I use another struct or class to achieve what I want in the simplest way possible?

5条回答
老娘就宠你
2楼-- · 2019-05-29 08:03

Lots of great answers here. I have certain reasons for not wanting to make it into it's own data model. Specially in my case it comes with a lot of data I don't need and this specific thing I need corresponds more to a person than an age model.

I'm sure others will find this post useful tho which is amazing. Just to add to that I will post my solution for how I decided to do this.

After looking at the Encoding and Decoding Custom Types Apple Documentation, I found it was possible to build a custom decoder and encoder to achieve this (Encode and Decode Manually).

struct Coordinate: Codable {
    var latitude: Double
    var longitude: Double
    var elevation: Double

    enum CodingKeys: String, CodingKey {
        case latitude
        case longitude
        case additionalInfo
    }

    enum AdditionalInfoKeys: String, CodingKey {
        case elevation
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        latitude = try values.decode(Double.self, forKey: .latitude)
        longitude = try values.decode(Double.self, forKey: .longitude)

        let additionalInfo = try values.nestedContainer(keyedBy: AdditionalInfoKeys.self, forKey: .additionalInfo)
        elevation = try additionalInfo.decode(Double.self, forKey: .elevation)
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(latitude, forKey: .latitude)
        try container.encode(longitude, forKey: .longitude)

        var additionalInfo = container.nestedContainer(keyedBy: AdditionalInfoKeys.self, forKey: .additionalInfo)
        try additionalInfo.encode(elevation, forKey: .elevation)
    }

}

The one change that is included in the code above that Apple doesn't mention is the fact that you can't use extensions like in their documentation example. So you have to embed it right within the struct or class.

Hopefully this helps someone, along with the other amazing answers here.

查看更多
我欲成王,谁敢阻挡
3楼-- · 2019-05-29 08:04

There are times to trick the API to get the interface you want.

let jsonData = """
[
    {"firstname": "Tom", "lastname": "Smith", "age": {"realage": "28"}},
    {"firstname": "Bob", "lastname": "Smith", "age": {"fakeage": "31"}}
]
""".data(using: .utf8)!

struct Person: Codable {
    let firstName: String
    let lastName: String
    var age: String? { return _age["realage"] }

    enum CodingKeys: String, CodingKey {
        case firstName = "firstname"
        case lastName = "lastname"
        case _age = "age"
    }

    private let _age: [String: String]
}

do {
    let decoded = try JSONDecoder().decode([Person].self, from: jsonData)
    print(decoded)

    let encoded = try JSONEncoder().encode(decoded)
    if let encoded = String(data: encoded, encoding: .utf8) { print(encoded) }
} catch {
    print(error)
}

Here the API (firstName, lastName, age) is kept and the JSON is preserved in both directions.

查看更多
等我变得足够好
4楼-- · 2019-05-29 08:06

My favorite approach when it comes to decoding nested JSON data is to define a "raw" model that stays very close to the JSON, even using snake_case if needed. It help bringing JSON data into Swift really quickly, then you can use Swift to do the manipulations you need:

struct Person: Decodable {
    let firstName, lastName: String
    let age: String?

    // This matches the keys in the JSON so we don't have to write custom CodingKeys    
    private struct RawPerson: Decodable {
        struct RawAge: Decodable {
            let realage: String?
            let fakeage: String?
        }

        let firstname: String
        let lastname: String
        let age: RawAge
    }

    init(from decoder: Decoder) throws {
        let rawPerson  = try RawPerson(from: decoder)
        self.firstName = rawPerson.firstname
        self.lastName  = rawPerson.lastname
        self.age       = rawPerson.age.realage
    }
}

Also, I recommend you to be judicious with the use of Codable, as it implies both Encodable and Decodable. It seems like you only need Decodable so conform your model to that protocol only.

查看更多
狗以群分
5楼-- · 2019-05-29 08:15

For greater flexibility and robustness, you could implement an Age enumeration to fully support your data model head-on ;) For instance:

enum Age: Decodable {
    case realAge(String)
    case fakeAge(String)

    private enum CodingKeys: String, CodingKey {
        case realAge = "realage", fakeAge = "fakeage"
    }

    init(from decoder: Decoder) throws {
        let dict = try decoder.container(keyedBy: CodingKeys.self)
        if let age = try dict.decodeIfPresent(String.self, forKey: .realAge) {
            self = .realAge(age)
            return
        }
        if let age = try dict.decodeIfPresent(String.self, forKey: .fakeAge) {
            self = .fakeAge(age)
            return
        }
        let errorContext = DecodingError.Context(
            codingPath: dict.codingPath,
            debugDescription: "Age decoding failed"
        )
        throw DecodingError.keyNotFound(CodingKeys.realAge, errorContext)
    }
}

and then use it in your Person type:

struct Person: Decodable {
    let firstName, lastName: String
    let age: Age

    enum CodingKeys: String, CodingKey {
        case firstName = "firstname"
        case lastName = "lastname"
        case age
    }

    var realAge: String? {
        switch age {
        case .realAge(let age): return age
        case .fakeAge: return nil
        }
    }
}

Decode as before:

let jsonData = """
[
    {"firstname": "Tom", "lastname": "Smith", "age": {"realage": "28"}},
    {"firstname": "Bob", "lastname": "Smith", "age": {"fakeage": "31"}}
]
""".data(using: .utf8)!

let decoded = try! JSONDecoder().decode([Person].self, from: jsonData)
for person in decoded { print(person) }

prints:

Person(firstName: "Tom", lastName: "Smith", age: Age.realAge("28"))
Person(firstName: "Bob", lastName: "Smith", age: Age.fakeAge("31"))


Finally, the new realAge computed property provides the behavior you were after initially (i.e., non-nil only for real ages):

for person in decoded { print(person.firstName, person.realAge) }

Tom Optional("28")
Bob nil

查看更多
狗以群分
6楼-- · 2019-05-29 08:21

You can use like this :

struct Person: Decodable {
    let firstName, lastName: String
    var age: Age?

    enum CodingKeys: String, CodingKey {
        case firstName = "firstname"
        case lastName = "lastname"
        case age
    }
}

struct Age: Decodable {
    let realage: String?
}

You can call like this :

do {
    let decoded = try JSONDecoder().decode([Person].self, from: jsonData)
    print(decoded[0].age?.realage) // Optional("28")
    print(decoded[1].age?.realage) // nil
} catch {
    print("error")
}
查看更多
登录 后发表回答