Swift: Codable - extract a single coding key

帅比萌擦擦* 提交于 2020-05-10 14:20:33

问题


I have the following code to extract a JSON contained within a coding key:

let value = try! decoder.decode([String:Applmusic].self, from: $0["applmusic"])

This successfully handles the following JSONs:

{
  "applmusic":{
    "code":"AAPL",
    "quality":"good",
    "line":"She told me don't worry",
}

However, fails to extract a JSON with the coding key of applmusic from the following one:

{
  "applmusic":{
    "code":"AAPL",
    "quality":"good",
    "line":"She told me don't worry",
  },
  "spotify":{
    "differentcode":"SPOT",
    "music_quality":"good",
    "spotify_specific_code":"absent in apple"
  },
  "amazon":{
    "amzncode":"SPOT",
    "music_quality":"good",
    "stanley":"absent in apple"
  }
}

The data models for applmusic,spotify and amazon are different. However, I need only to extract applmusic and omit other coding keys.

My Swift data model is the following:

public struct Applmusic: Codable {
    public let code: String
    public let quality: String
    public let line: String
}

The API responds with the full JSON and I cannot ask it to give me only the needed fields.

How to decode only the specific part of the json? It seems, that Decodable requires me to deserialize the whole json first, so I have to know the full data model for it.

Obviously, one of the solutions would be to create a separate Response model just to contain the applmusicparameter, but it looks like a hack:

public struct Response: Codable {
    public struct Applmusic: Codable {
        public let code: String
        public let quality: String
        public let line: String
    }
    // The only parameter is `applmusic`, ignoring the other parts - works fine
    public let applmusic: Applmusic
}

Could you propose a better way to deal with such JSON structures?

A little bit more insight

I use it the following technique in the generic extension that automatically decodes the API responses for me. Therefore, I'd prefer to generalize a way for handling such cases, without the need to create a Root structure. What if the key I need is 3 layers deep in the JSON structure?

Here is the extension that does the decoding for me:

extension Endpoint where Response: Swift.Decodable {
  convenience init(method: Method = .get,
                   path: Path,
                   codingKey: String? = nil,
                   parameters: Parameters? = nil) {
    self.init(method: method, path: path, parameters: parameters, codingKey: codingKey) {
      if let key = codingKey {
        guard let value = try decoder.decode([String:Response].self, from: $0)[key] else {
          throw RestClientError.valueNotFound(codingKey: key)
        }
        return value
      }

      return try decoder.decode(Response.self, from: $0)
    }
  }
}

The API is defined like this:

extension API {
  static func getMusic() -> Endpoint<[Applmusic]> {
    return Endpoint(method: .get,
                    path: "/api/music",
                    codingKey: "applmusic")
  }
}

回答1:


Updated: I made an extension of JSONDecoder out of this answer, you can check it here: https://github.com/aunnnn/NestedDecodable, it allows you to decode a nested model of any depth with a key path.

You can use it like this:

let post = try decoder.decode(Post.self, from: data, keyPath: "nested.post")

You can make a Decodable wrapper (e.g., ModelResponse here), and put all the logic to extract nested model with a key inside that:

struct DecodingHelper {

    /// Dynamic key
    private struct Key: CodingKey {
        let stringValue: String
        init?(stringValue: String) {
            self.stringValue = stringValue
            self.intValue = nil
        }

        let intValue: Int?
        init?(intValue: Int) {
            return nil
        }
    }

    /// Dummy model that handles model extracting logic from a key
    private struct ModelResponse<NestedModel: Decodable>: Decodable {
        let nested: NestedModel

        public init(from decoder: Decoder) throws {
            let key = Key(stringValue: decoder.userInfo[CodingUserInfoKey(rawValue: "my_model_key")!]! as! String)!
            let values = try decoder.container(keyedBy: Key.self)
            nested = try values.decode(NestedModel.self, forKey: key)
        }
    }

    static func decode<T: Decodable>(modelType: T.Type, fromKey key: String) throws -> T {
        // mock data, replace with network response
        let path = Bundle.main.path(forResource: "test", ofType: "json")!
        let data = try Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe)

        let decoder = JSONDecoder()

        // ***Pass in our key through `userInfo`
        decoder.userInfo[CodingUserInfoKey(rawValue: "my_model_key")!] = key
        let model = try decoder.decode(ModelResponse<T>.self, from: data).nested
        return model
    }
}

You can pass your desired key through userInfo of JSONDecoder ("my_model_key"). It is then converted to our dynamic Key inside ModelResponse to actually extract the model.

Then you can use it like this:

let appl = try DecodingHelper.decode(modelType: Applmusic.self, fromKey: "applmusic")
let amazon = try DecodingHelper.decode(modelType: Amazon.self, fromKey: "amazon")
let spotify = try DecodingHelper.decode(modelType: Spotify.self, fromKey: "spotify")
print(appl, amazon, spotify)

Full code: https://gist.github.com/aunnnn/2d6bb20b9dfab41189a2411247d04904


Bonus: Deeply nested key

After playing around more, I found you can easily decode a key of arbitrary depth with this modified ModelResponse:

private struct ModelResponse<NestedModel: Decodable>: Decodable {
    let nested: NestedModel

    public init(from decoder: Decoder) throws {
        // Split nested paths with '.'
        var keyPaths = (decoder.userInfo[CodingUserInfoKey(rawValue: "my_model_key")!]! as! String).split(separator: ".")

        // Get last key to extract in the end
        let lastKey = String(keyPaths.popLast()!)

        // Loop getting container until reach final one
        var targetContainer = try decoder.container(keyedBy: Key.self)
        for k in keyPaths {
            let key = Key(stringValue: String(k))!
            targetContainer = try targetContainer.nestedContainer(keyedBy: Key.self, forKey: key)
        }
        nested = try targetContainer.decode(NestedModel.self, forKey: Key(stringValue: lastKey)!)
    }

Then you can use it like this:

let deeplyNestedModel = try DecodingHelper.decode(modelType: Amazon.self, fromKey: "nest1.nest2.nest3")

From this json:

{
    "apple": { ... },
    "amazon": {
        "amzncode": "SPOT",
        "music_quality": "good",
        "stanley": "absent in apple"
    },
    "nest1": {
        "nest2": {
            "amzncode": "Nest works",
            "music_quality": "Great",
            "stanley": "Oh yes",

            "nest3": {
                "amzncode": "Nest works, again!!!",
                "music_quality": "Great",
                "stanley": "Oh yes"
            }
        }
    }
}

Full code: https://gist.github.com/aunnnn/9a6b4608ae49fe1594dbcabd9e607834




回答2:


You don't really need the nested struct Applmusic inside Response. This will do the job:

import Foundation

let json = """
{
    "applmusic":{
        "code":"AAPL",
        "quality":"good",
        "line":"She told me don't worry"
    },
    "I don't want this":"potatoe",
}
"""

public struct Applmusic: Codable {
    public let code: String
    public let quality: String
    public let line: String
}

public struct Response: Codable {
    public let applmusic: Applmusic
}

if let data = json.data(using: .utf8) {
    let value = try! JSONDecoder().decode(Response.self, from: data).applmusic
    print(value) // Applmusic(code: "AAPL", quality: "good", line: "She told me don\'t worry")
}

Edit: Addressing your latest comment

If the JSON response would change in a way that the applmusic tag is nested, you would only need to properly change your Response type. Example:

New JSON (note that applmusic is now nested in a new responseData tag):

{
    "responseData":{
        "applmusic":{
            "code":"AAPL",
            "quality":"good",
            "line":"She told me don't worry"
        },
        "I don't want this":"potatoe",
    }   
}

The only change needed would be in Response:

public struct Response: Decodable {

    public let applmusic: Applmusic

    enum CodingKeys: String, CodingKey {
        case responseData
    }

    enum ApplmusicKey: String, CodingKey {
        case applmusic
    }

    public init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)

        let applmusicKey = try values.nestedContainer(keyedBy: ApplmusicKey.self, forKey: .responseData)
        applmusic = try applmusicKey.decode(Applmusic.self, forKey: .applmusic)
    }
}

The previous changes wouldn't break up any existing code, we are only fine-tuning the private implementation of how the Response parses the JSON data to correctly fetch an Applmusic object. All calls such as JSONDecoder().decode(Response.self, from: data).applmusic would remain the same.

Tip

Finally, if you want to hide the Response wrapper logic altogether, you may have one public/exposed method which will do all the work; such as:

// (fine-tune this method to your needs)
func decodeAppleMusic(data: Data) throws -> Applmusic {
    return try JSONDecoder().decode(Response.self, from: data).applmusic
}

Hiding the fact that Response even exists (make it private/inaccessible), will allow you to have all the code through your app only have to call decodeAppleMusic(data:). For example:

if let data = json.data(using: .utf8) {
    let value = try! decodeAppleMusic(data: data)
    print(value) // Applmusic(code: "AAPL", quality: "good", line: "She told me don\'t worry")
}

Recommended read:

Encoding and Decoding Custom Types

https://developer.apple.com/documentation/foundation/archives_and_serialization/encoding_and_decoding_custom_types




回答3:


Interesting question. I know that it was 2 weeks ago but I was wondering how it can be solved using library KeyedCodable I created. Here is my proposition with generic:

struct Response<Type>: Codable, Keyedable where Type: Codable {

    var responseObject: Type!

    mutating func map(map: KeyMap) throws {
        try responseObject <-> map[map.userInfo.keyPath]
    }

    init(from decoder: Decoder) throws {
        try KeyedDecoder(with: decoder).decode(to: &self)
    }
}

helper extension:

private let infoKey = CodingUserInfoKey(rawValue: "keyPath")!
extension Dictionary where Key == CodingUserInfoKey, Value == Any {

   var keyPath: String {
        set { self[infoKey] = newValue }

        get {
            guard let key = self[infoKey] as? String else { return "" }
            return key
        }
    }

use:

let decoder = JSONDecoder()
decoder.userInfo.keyPath = "applmusic"
let response = try? decoder.decode(Response<Applmusic>.self, from: jsonData)

Please notice that keyPath may be nested more deeply I mean it may be eg. "responseData.services.applemusic".

In addition Response is a Codable so you can encode it without any additional work.



来源:https://stackoverflow.com/questions/50389383/swift-codable-extract-a-single-coding-key

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!