Sebastian Limbach
Sebastian Limbach

Reputation: 85

Swift Codable: Decode different array of items with same root objects

I'm currently trying to decode JSON which looks like this:

{
  "result": {
    "success": true,
    "items": [
      {
        "timeEntryID": "1",
        "start": "1519558200",
        "end": "1519563600",
        "customerName": "Test-Customer",
        "projectName": "Test-Project",
        "description": "Entry 1",
      },
      {
        "timeEntryID": "2",
        "start": "1519558200",
        "end": "1519563600",
        "customerName": "Test-Customer",
        "projectName": "Test-Project",
        "description": "Entry 2",
      }
    ],
    "total": "2"
  },
  "id": "1"
}

The decoding process for this specific type of JSON is pretty simple. I just need something like this:

struct ResponseKeys: Decodable {
    let result: ResultKeys

    struct ResultKeys: Decodable {
        let success: Bool
        let items: [Item]
    }
}

Now the problem I'm facing is that every response of the server has the same structure as the above JSON but with different item types. So sometimes it is let items: [Item] but it could also be let items: [User] if I make a call to the User endpoint.

Because it would be an unnecessary duplication of code if I would write the above swift code for every endpoint with just the modification of the items array, I created a custom decoder:

enum KimaiAPIResponseKeys: String, CodingKey {
    case result

    enum KimaiResultKeys: String, CodingKey {
        case success
        case items
    }
}

struct Activity: Codable {
    let id: Int
    let description: String?
    let customerName: String
    let projectName: String
    let startDateTime: Date
    let endDateTime: Date

    enum CodingKeys: String, CodingKey {
        case id = "timeEntryID"
        case description
        case customerName
        case projectName
        case startDateTime = "start"
        case endDateTime = "end"
    }
}

extension Activity {

    init(from decoder: Decoder) throws {
        let resultContainer = try decoder.container(keyedBy: KimaiAPIResponseKeys.self)
        let itemsContainer = try resultContainer.nestedContainer(keyedBy: KimaiAPIResponseKeys.KimaiResultKeys.self, forKey: .result)
        let activityContainer = try itemsContainer.nestedContainer(keyedBy: Activity.CodingKeys.self, forKey: .items)

        id = Int(try activityContainer.decode(String.self, forKey: .id))!
        description = try activityContainer.decodeIfPresent(String.self, forKey: .description)
        customerName = try activityContainer.decode(String.self, forKey: .customerName)
        projectName = try activityContainer.decode(String.self, forKey: .projectName)
        startDateTime = Date(timeIntervalSince1970: Double(try activityContainer.decode(String.self, forKey: .startDateTime))!)
        endDateTime = Date(timeIntervalSince1970: Double(try activityContainer.decode(String.self, forKey: .endDateTime))!)
    }

}

The decoder works perfectly if "items" does only contain a single object and not an array:

{
  "result": {
    "success": true,
    "items":
      {
        "timeEntryID": "2",
        "start": "1519558200",
        "end": "1519563600",
        "customerName": "Test-Customer",
        "projectName": "Test-Project",
        "description": "Entry 2",
      },
    "total": "2"
  },
  "id": "1"
}

If items is an array I get the following error:

typeMismatch(Swift.Dictionary, Swift.DecodingError.Context(codingPath: [__lldb_expr_151.KimaiAPIResponseKeys.result], debugDescription: "Expected to decode Dictionary but found an array instead.", underlyingError: nil))

I just cannot figure out how to modify my decoder to work with an array of items. I created a Playground file with the working and not working version of the JSON. Please take a look and try it out: Decodable.playground

Thank you for your help!

Upvotes: 3

Views: 2739

Answers (2)

Atabany
Atabany

Reputation: 21

You Can Use Generics, It's a neat way to deal with this situation.

  struct MainClass<T: Codable>: Codable  {
     let result: Result<T>
     let id: String
  }

  struct Result <T: Codable>: Codable {
     let success: Bool
     let items: [T]
     let total: String
  }

and here you will get the items

   let data = Data()
   let decoder = JSONDecoder()
   let modelObjet = try! decoder.decode(MainClass<User>.self, from: data)
   let users = modelObjet.result.items

In my opinion, Generics is the best way to handle the duplication of code like this situations.

Upvotes: 2

vadian
vadian

Reputation: 285260

My suggestion is to decode the dictionary/dictionaries for items separately

struct Item : Decodable {

    enum CodingKeys: String, CodingKey {
        case id = "timeEntryID"
        case description, customerName, projectName
        case startDateTime = "start"
        case endDateTime = "end"
    }

    let id: Int
    let startDateTime: Date
    let endDateTime: Date
    let customerName: String
    let projectName: String
    let description: String?

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = Int(try container.decode(String.self, forKey: .id))!
        description = try container.decodeIfPresent(String.self, forKey: .description)
        customerName = try container.decode(String.self, forKey: .customerName)
        projectName = try container.decode(String.self, forKey: .projectName)
        startDateTime = Date(timeIntervalSince1970: Double(try container.decode(String.self, forKey: .startDateTime))!)
        endDateTime = Date(timeIntervalSince1970: Double(try container.decode(String.self, forKey: .endDateTime))!)
    }
}

And in Activity use a conditional initializer, it provides it's own do catch block. First it tries to decode a single item and assigns the single item as array to the property. If it fails it decodes an array.

enum KimaiAPIResponseKeys: String, CodingKey {
    case result, id

    enum KimaiResultKeys: String, CodingKey {
        case success
        case items
    }
}

struct Activity: Decodable {
    let id: String
    let items: [Item]
}

extension Activity {

    init(from decoder: Decoder) throws {
        let rootContainer = try decoder.container(keyedBy: KimaiAPIResponseKeys.self)
        id = try rootContainer.decode(String.self, forKey: .id)
        let resultContainer = try rootContainer.nestedContainer(keyedBy: KimaiAPIResponseKeys.KimaiResultKeys.self, forKey: .result)
        do {
            let item = try resultContainer.decode(Item.self, forKey: .items)
            items = [item]
        } catch {
            items = try resultContainer.decode([Item].self, forKey: .items)
        }
    }
} 

Upvotes: 3

Related Questions