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":"[email protected]"
}
}
}
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?
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+.
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 }
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)
}
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
}