Search code examples
swiftcodabledecodable

Swift Decodable: Inject value in nested generic property


I have this API response structure (from Strapi v4):

{
    "data": [
        {
            "id": 1,
            "attributes": {
                "description": "test",
            }
        }
    ]    
}

I have this generic code to handle API responses and to inject the ID to my child object:

struct StrapiArrayResponse<Content: StrapiDataObjectContent>: Codable {
    var data: [StrapiDataObject<Content>]
}

struct StrapiDataObject<Content: StrapiDataObjectContent>: Codable {
    let id: Int
    var attributes: Content
    
    init(from decoder: Decoder) throws {
        let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.attributes = try container.decode(Content.self, forKey: .attributes)
        self.attributes.id = id
    }
}

protocol StrapiDataObjectContent: Codable {
    var id: Int! { get set } // I don't want this to be an Optional
}

I want my id to be a let instead of an optional var.

Is there a better way to inject the ID to my child objects (StrapiDataObjectContent)?


Solution

  • Here is a solution for the problem but it isn't so straightforward and requires some work.

    Since you want id to be a constant we need a way to initialise Content with it so one way then is to add an init to the protocol.

    protocol StrapiDataObjectContent: Codable {
        var id: Int { get } //also removed 'set'
        init(id: Int, copy: Self)
    }
    

    As you see this init takes an already existing object as parameter so this is kind of a copy method

    So an implementation (based on the json in the question) could then be

    init(id: Int, copy: Test) {
        self.id = id
        self.description = copy.description
    } 
    

    We then need to change init(from:) in StrapiDataObject to

     init(from decoder: Decoder) throws {
        let container: KeyedDecodingContainer<CodingKeys> = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        let attributes = try container.decode(Content.self, forKey: .attributes)
        self.attributes = Content(id: id, copy: attributes)
    }
    

    Now this compiles but we will get a runtime error since id is expected by the decoder for Content but doesn't exists in the json.

    So this leads to the major drawback of this solution, every type conforming to StrapiDataObjectContent needs to implement a custom init(from:) just to avoid decoding the id property

    To demonstrate here is a full example (based on the json in the question)

    struct Test: StrapiDataObjectContent {
        let id: Int
        let description: String
    
        init(from decoder: Decoder) throws {
            id = 0
            let container = try decoder.container(keyedBy: CodingKeys.self)
            description = try container.decode(String.self, forKey: .description)
        }
    
        init(id: Int, copy: Test) {
            self.id = id
            self.description = copy.description
        }
    }