Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Swift: Codable - extract a single coding key

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")
  }
}
like image 869
Richard Topchii Avatar asked May 17 '18 10:05

Richard Topchii


People also ask

What does Codable do in Swift?

Codable allows you to insert an additional clarifying stage into the process of decoding data into a Swift object. This stage is the “parsed object,” whose properties and keys match up directly to the data, but whose types have been decoded into Swift objects.

What types are Codable Swift?

There are many types in Swift that are codable out of the box: Int , String , Date , Array and many other types from the Standard Library and the Foundation framework. If you want your type to be codable, the simplest way to do it is by conforming to Codable and making sure all its stored properties are also codable.

How do I decode an object in Swift?

The three-step process to decode JSON data in SwiftPerform a network request to fetch the data. Feed the data you receive to a JSONDecoder instance. Map the JSON data to your model types by making them conform to the Decodable protocol.


2 Answers

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

like image 144
aunnnn Avatar answered Oct 02 '22 02:10

aunnnn


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

like image 28
R.B. Avatar answered Oct 02 '22 03:10

R.B.