iosjsonswiftdecodingdecoder

Customise JSON decoder for nested object with Swift for iOS


Using Swift for iOS 13+, I need to decode a JSON response. It contains a nested object which needs a value (type) of the parent object for it decoding.

The JSON structure:

{
    "name":"My email",
    "type":"Email",
    "content":{
        "issued":"2023-08-25T12:58:39Z",
        "attributes":{
            "email":"mail@mail.com"
        }
    }
}

OR

{
    "name":"My telephone",
    "type":"Telephone",
    "content":{
        "issued":"2023-08-25T12:58:39Z",
        "attributes":{
            "telephone":"+33123456789"
        }
    }
}

attributes content depends of type. So content nested object needs to know type to be able to decode attributes.

The structures:

struct Response: Decodable {
    let name: String
    let type: String
    let content: ContentResponse?

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        type = try container.decode(String.self, forKey: .type)
        // !!! Here I need to pass the "type" value to content decoding
        content = try container.decodeIfPresent(ContentResponse.self, forKey: .content)
    }
}

struct ContentResponse: Decodable {
    let issued: Date
    let attributes: AttributesResponse?

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        issued = try container.decode(Date.self, forKey: .issued)
        if container.contains(.attributes) {
            // !!! Here I can't access the "type" value from parent object
            switch Type.fromString(type: type) {
            case .telephone:
                attributes = try container.decode(AttributesTelephoneResponse.self, forKey: .attributes)
            case .email:
                attributes = try container.decode(AttributesEmailResponse.self, forKey: .attributes)
            default:
                // Unsupported type 
                throw DecodingError.dataCorruptedError(forKey: .attributes, in: contentContainer, debugDescription: "Type \"\(type)\" not supported for attributes")
            }
        } else {
            attributes = nil
        }
    }
}

class DocumentResponse: Decodable {}

class AttributesEmailResponse: DocumentResponse {
    let email: String
}

class AttributesTelephoneResponse: DocumentResponse {
    let telephone: String
}

As you can see, init(from decoder: Decoder) of ContentResponse needs to know the type to be able to know which class to use for attributes decoding.

How can I pass the type decoded on Response to the nested object ContentResponse for it decoding?

NOT WORKING #1

I found maybe a solution here https://www.andyibanez.com/posts/the-mysterious-codablewithconfiguration-protocol/ using CodableWithConfiguration with decodeIfPresent(_:forKey:configuration:), but it targets iOS 15+ while I target iOS 13+.

NOT WORKING #2

I could have used userInfo of decoder in init(from decoder: Decoder) to pass type, but it's read-only:

var userInfo: [CodingUserInfoKey : Any] { get }

SOLUTION

Thanks to @Joakim Danielson for the answer.

The solution is to do the whole decoding in Response:

struct Response: Decodable {
    let name: String
    let type: String
    let content: ContentResponse?

    enum CodingKeys: String, CodingKey {
        case name
        case type
        case content
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        type = try container.decode(String.self, forKey: .type)
        // Content
        if container.contains(.content) {
            let issued = try contentContainer.decode(Date.self, forKey: .issued)
            let attributes: DocumentResponse?
            switch DocumentType.fromString(type: type) {
            case .telephone:
                let value = try contentContainer.decode(DocumentTelephoneResponse.self, forKey: .attributes)
                attributes = .telephone(value)
            case .email:
                let value = try contentContainer.decode(DocumentEmailResponse.self, forKey: .attributes)
                attributes = .email(value)
            default:
                throw DecodingError.dataCorruptedError(forKey: .attributes, in: contentContainer, debugDescription: "Type \"\(type)\" not supported for attributes")
            }
            content = ContentResponse(issued: issued, expires: expires, attributes: attributes, controls: controls)
        } else {
            content = nil
        }
    }
}

with:

public enum DocumentType: String, Codable {
    case telephone = "Telephone"
    case email = "Email"

    static public func fromString(type: String) -> Type? {
        Type(rawValue: type) ?? nil
    }
}

struct ContentResponse: Decodable {
    let issued: Date
    let attributes: DocumentResponse?

    enum CodingKeys: String, CodingKey {
        case issued
        case attributes
    }
}

enum DocumentResponse: Decodable {
    case email(DocumentEmailResponse)
    case telephone(DocumentTelephoneResponse)
}


Solution

  • I would use enums for this, one for the type of content and one to hold the decoded content.

    First the enum for the type

    enum ContentType: String, Codable {
        case email = "Email"
        case phone = "PhoneNumber"
        case none
    }
    

    And then the one for content

    enum Content: Codable {
        case email(DocumentEmailResponse)
        case phone(DocumentPhoneNumberResponse)
        case none
    }
    

    I changed the types used in Content to be structures

    struct DocumentEmailResponse: Codable {
        let email: String
    }
    
    struct DocumentPhoneNumberResponse: Codable {
        let phoneNumber: String
    }
    

    Then all custom decoding takes part in Response where the Content values is decoded using a nested container.

    struct Response: Codable {
        let name: String
        let type: ContentType
        let content: Content
    
        enum ContentCodingKeys: String, CodingKey {
            case attributes
        }
    
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self)
            name = try container.decode(String.self, forKey: .name)
            type = try container.decode(ContentType.self, forKey: .type)
            let contentContainer = try container.nestedContainer(keyedBy: ContentCodingKeys.self, forKey: .content)
            switch type {
            case .email:
                let value = try contentContainer.decode(DocumentEmailResponse.self, forKey: .attributes)
                content = .email(value)
            case .phone:
                let value = try contentContainer.decode(DocumentPhoneNumberResponse.self, forKey: .attributes)
                content = .phone(value)
            default:
                content = .none
            }
        }
    }
    

    Both the enumerations contains a none case but depending on what might be optional or not and personal preferences you might want to remove them if possible, I wasn't completely sure which and when values can be nil.


    As noted by @Rob in the comments it might be good to handle unknown types without failing the decoding process. Below is an alternative way to do this and also see the link in the comments for another alternative.

    The new custom init for decoding is

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        let contentValue = try container.decode(String.self, forKey: .type)
    
        self.type = ContentType(rawValue: contentValue) ?? .none
        let contentContainer = try container.nestedContainer(keyedBy: ContentCodingKeys.self, forKey: .content)
        switch type {
        case .email:
            let value = try contentContainer.decode(DocumentEmailResponse.self, forKey: .attributes)
            content = .email(value)
        case .phone:
            let value = try contentContainer.decode(DocumentPhoneNumberResponse.self, forKey: .attributes)
            content = .phone(value)
        case .none:
            content = .unknownType(contentValue)
        }
    }
    

    This requires a change to the Content enum

    enum Content: Codable {
        case email(DocumentEmailResponse)
        case phone(DocumentPhoneNumberResponse)
        case unknownType(String)
        //case none <- this might still be relevant if content is optional
    }