Martin Bean
Martin Bean

Reputation: 39389

How to decode JSON object with any number of key–value pairs in Swift?

I’m using Swift to try and decode JSON:API-formatted JSON results. The JSON I’m trying to parse has a shape like this:

{
    "data": {
        "type": "video",
        "id": "1",
        "attributes": {
            "name": "Test Video",
            "duration": 1234
        }
    }
}

I’m trying to create a Swift struct that will encode these JSON objects, but I’m having issues with the attributes key as it could contain any number of attributes.

The Swift structs I’m trying to encode the above into look like this:

struct JSONAPIMultipleResourceResponse: Decodable {
    var data: [JSONAPIResource]
}
struct JSONAPIResource: Decodable {
    var type: String
    var id: String
    var attributes: [String, String]?
}

The type and id attributes should be present in every JSON:API result. The attributes key should be a list of any number of key–value pairs; both the key and value should be strings.

I’m then trying to decode a JSON response from an API like this:

let response = try! JSONDecoder().decode(JSONAPIMultipleResourceResponse.self, from: data!)

The above works if I leave the type and id properties in my JSONAPIResource Swift struct, but as soon as I try and do anything with attributes I get the following error:

Fatal error: 'try!' expression unexpectedly raised an error: Swift.DecodingError.valueNotFound(Swift.String, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "data", intValue: nil), _JSONKey(stringValue: "Index 0", intValue: 0), CodingKeys(stringValue: "attributes", intValue: nil), _JSONKey(stringValue: "poster_path", intValue: nil)], debugDescription: "Expected String but found null value instead.", underlyingError: nil)): file /Users/[User]/Developer/[App]/LatestVideosQuery.swift, line 35  
2020-07-14 16:13:08.083721+0100 [App][57157:6135473] Fatal error: 'try!' expression unexpectedly raised an error: Swift.DecodingError.valueNotFound(Swift.String, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "data", intValue: nil), _JSONKey(stringValue: "Index 0", intValue: 0), CodingKeys(stringValue: "attributes", intValue: nil), _JSONKey(stringValue: "poster_path", intValue: nil)], debugDescription: "Expected String but found null value instead.", underlyingError: nil)): file /Users/[User]/Developer/[App]/LatestVideosQuery.swift, line 35

I get Swift is very strongly typed, but I’m unsure on how to encode this unstructured data in Swift. My plan is to have generic JSONAPIResource representations of resources coming back from my API, then I can then map into model structs in my Swift application, i.e. convert the above JSON:API resources into a Video struct.

Also, I’m trying to naïvely convert values in the attributes object to strings but as you may see, duration is an integer value. If there’s a way to have attributes in my JSONAPIResource struct retain primitive values such as integers and booleans and not just strings, I’d be keen to read how!

Upvotes: 0

Views: 1871

Answers (2)

vadian
vadian

Reputation: 285082

If there is a consistent relationship between the type value and the key-value pairs in attributes I recommend to declare attributes as enum with associated types and decode the type depending on the type value.

For example

let jsonString = """
{
    "data": {
        "type": "video",
        "id": "1",
        "attributes": {
            "name": "Test Video",
            "duration": 1234
        }
    }
}
"""

enum ResourceType : String, Decodable {
   case video
}

enum AttributeType {
    case video(Video)
}

struct Video : Decodable {
    let name : String
    let duration : Int
}

struct Root : Decodable {
    let data : Resource
}

struct Resource : Decodable {
    let type : ResourceType
    let id : String
    let attributes : AttributeType
    
    private enum CodingKeys : String, CodingKey { case type, id, attributes }
    
    init(from decoder : Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.type = try container.decode(ResourceType.self, forKey: .type)
        self.id = try container.decode(String.self, forKey: .id)
        switch self.type {
            case .video:
                let videoAttributes = try container.decode(Video.self, forKey: .attributes)
                attributes = .video(videoAttributes)
        }
    }
}

let data = Data(jsonString.utf8)

do {
    let result = try JSONDecoder().decode(Root.self, from: data)
    print(result)
} catch {
    print(error)
}

Upvotes: 0

New Dev
New Dev

Reputation: 49590

If it's a completely generic bag of key/values (which might indicate a need for a possible design change), you can create an enum to hold the different (primitive) values that JSON can hold:

enum JSONValue: Decodable {
   case number(Double)
   case integer(Int)
   case string(String)
   case bool(Bool)
   case null

   init(from decoder: Decoder) throws {
      let container = try decoder.singleValueContainer()

      if let int = try? container.decode(Int.self) {
         self = .integer(int)
      } else if let double = try? container.decode(Double.self) {
         self = .number(double)
      } else if let string = try? container.decode(String.self) {
         self = .string(string)
      } else if let bool = try? container.decode(Bool.self) {
         self = .bool(bool)
      } else if container.decodeNil() {
         self = .null
      } else {
        // throw some DecodingError
      }
   }
}

and then you could set attributes to:

var attributes: [String: JSONValue]

Upvotes: 2

Related Questions