0

I'm trying to decode "dependent" JSON API responses in Swift. Let's imagine a fictional API with two endpoints:

  • /players, returns an array of objects with following attributes:
    • id, an integer representing the player ID
    • name, a string representing the player name
  • /games, returns an array of objects with following attributes:
    • name, a string representing the name of the game
    • playerId1, an integer representing the ID of the first player
    • playerId2, an integer representing the ID of the second player

I model each type with a Swift struct:

struct Player: Decodable {
    var id: Int
    var name: String?
}

struct Game: Decodable {
    var name: String
    var player1: Player
    var player2: Player
    
    enum CodingKeys: String, CodingKey {
        case name
        case player1 = "playerId1"
        case player2 = "playerId2"
    }
}

I want to decode the response from /games into an array of Game objects, with correct Players attributes, so I extended Game with a custom initializer but I don't know how to retrieve all player attributes:

extension Game {
    
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        
        name = try values.decode(String.self, forKey: .name)
        
        //                                             HOW SHOULD I RETRIEVE THE PLAYER'S NAME GIVEN THEIR ID HERE?
        //                                                                         |
        //                                                                         |
        //                                                                         V
        player1 = Player(id: try values.decode(Int.self, forKey: .player1), name: nil)
        player2 = Player(id: try values.decode(Int.self, forKey: .player2), name: nil)
    }
}

To summarize, the API response from /games does not contain all the information I need for full initialization, so how should I proceed:

  • can/should I make two API calls, one to /games and another one to players and somehow merge them before decoding?
  • should I initialize my Players only partly (leaving unknown stuff to nil) and fill the details later? (That sound dangerous and cumbersome.)
  • anything else?

If you want to experiment with it, you can find a full example here

filaton
  • 2,257
  • 17
  • 27
  • Graphql might help here https://graphql.org – Cristik Nov 01 '20 at 19:32
  • @Cristik Thanks for the suggestion, but I would rather avoid GraphQL as I don't have control over the API and would like to avoid running my own server mimicking the original API in GraphQL :) – filaton Nov 02 '20 at 06:31

1 Answers1

2

My suggestion is to add two lazy instantiated properties to get the Player instances from the array.

The benefit of a lazy property over a computed property is that the value is calculated once and not until it's being accessed the first time. And a custom init(from:) method is not needed.

struct Game: Decodable {
    let name: String
    let playerId1: Int
    let playerId2: Int

    enum CodingKeys: String, CodingKey { case name, playerId1, playerId2 }

    lazy var player1 : Player? = players.first{ $0.id == playerId1 }
    lazy var player2 : Player? = players.first{ $0.id == playerId2 }
   
}

Alternatively create a CodingUserInfoKey

extension CodingUserInfoKey {
    static let players = CodingUserInfoKey(rawValue: "players")!
}

and an extension of JSONDecoder

extension JSONDecoder {
    convenience init(players: [Player]) {
        self.init()
        self.userInfo[.players] = players
    }
}

and pass the players array in the userInfo object of the JSON decoder

let decoder = JSONDecoder(players: players)
let games = try! decoder.decode([Game].self, from: Data(gamesResponse.utf8))
dump(games[0].player1)

Now you can get the actual players in the init(from: method.

struct Game: Decodable {
    let name: String
    let player1: Player
    let player2: Player
    
    enum CodingKeys: String, CodingKey {
        case name, playerId1, playerId2
    }
}

extension Game {
    
    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        guard let players = decoder.userInfo[.players] as? [Player] else { fatalError("No players array available") }
        name = try values.decode(String.self, forKey: .name)
        let playerId1 = try values.decode(Int.self, forKey: .playerId1)
        let playerId2 = try values.decode(Int.self, forKey: .playerId2)
        player1 = players.first{ $0.id == playerId1 }!
        player2 = players.first{ $0.id == playerId2 }!
    }
}

The code assumes that the players array contains all Player instances corresponding to the playerId values. If not then you have to declare player1 and player2 as optional and remove the exclamation marks.

vadian
  • 274,689
  • 30
  • 353
  • 361
  • `players` being some shared mutable global variable? – Cristik Nov 01 '20 at 20:15
  • @vadian Thanks for the nice answer. How could I make that work without the global variable, _i.e._ `players` and `games` would be instantiated by my ViewModel, so how do I define the models without the global variable? (Sorry that this was not captured by my example) – filaton Nov 02 '20 at 06:23
  • @vadian Thanks a lot for the update, that's exactly what I wanted and I would never have come up with such a construct myself! One final question: why did you decide to do an extension of `JSONDecoder` rather than a sub-class? Is it not considered "bad practice" to add very specific `init` to a generic class like `JSONDecoder`? – filaton Nov 02 '20 at 19:20
  • 1
    No, it's not bad practice. I'm *extending* the functionality of `JSONDecoder` by adding a convenience initializer. – vadian Nov 02 '20 at 19:26