Swift Decodable - How to decode nested JSON that has been base64 encoded

1.4k views Asked by At

I am attempting to decode a JSON response from a third-party API which contains nested/child JSON that has been base64 encoded.

Contrived Example JSON

{
   "id": 1234,
   "attributes": "eyAibmFtZSI6ICJzb21lLXZhbHVlIiB9",  
}

PS "eyAibmFtZSI6ICJzb21lLXZhbHVlIiB9" is { 'name': 'some-value' } base64 encoded.

I have some code that is able to decode this at present but unfortunately I have to reinstanciate an additional JSONDecoder() inside of the init in order to do so, and this is not cool...

Contrived Example Code


struct Attributes: Decodable {
    let name: String
}

struct Model: Decodable {

    let id: Int64
    let attributes: Attributes

    private enum CodingKeys: String, CodingKey {
        case id
        case attributes
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        self.id = try container.decode(Int64.self, forKey: .id)

        let encodedAttributesString = try container.decode(String.self, forKey: .attributes)

        guard let attributesData = Data(base64Encoded: encodedAttributesString) else {
            fatalError()
        }

        // HERE IS WHERE I NEED HELP
        self.attributes = try JSONDecoder().decode(Attributes.self, from: attributesData)
    }
}

Is there anyway to achieve the decoding without instanciating the additional JSONDecoder?

PS: I have no control over the response format and it cannot be changed.

4

There are 4 answers

1
Larme On BEST ANSWER

I find the question interesting, so here is a possible solution which would be to give the main decoder an additional one in its userInfo:

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

var decoder = JSONDecoder()
let additionalDecoder = JSONDecoder() //here you can put the same one, you can add different options, same ones, etc.
decoder.userInfo = [CodingUserInfoKey.additionalDecoder: additionalDecoder]

Because the main method we use from JSONDecoder() is func decode<T>(_ type: T.Type, from data: Data) throws -> T where T : Decodable and I wanted to keep it as such, I created a protocol:

protocol BasicDecoder {
    func decode<T>(_ type: T.Type, from data: Data) throws -> T where T : Decodable
}

extension JSONDecoder: BasicDecoder {}

And I made JSONDecoder respects it (and since it already does...)

Now, to play a little and check what could be done, I created a custom one, in the idea of having like you said a XML Decoder, it's basic, and it's just for the fun (ie: do no replicate this at home ^^):

struct CustomWithJSONSerialization: BasicDecoder {
    func decode<T>(_ type: T.Type, from data: Data) throws -> T where T : Decodable {
        guard let dict = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { fatalError() }
        return Attributes(name: dict["name"] as! String) as! T
    }
}

So, init(from:):

guard let attributesData = Data(base64Encoded: encodedAttributesString) else { fatalError() }
guard let additionalDecoder = decoder.userInfo[.additionalDecoder] as? BasicDecoder else { fatalError() }
self.attributes = try additionalDecoder.decode(Attributes.self, from: attributesData)

Let's try it now!

var decoder = JSONDecoder()
let additionalDecoder = JSONDecoder()
decoder.userInfo = [CodingUserInfoKey.additionalDecoder: additionalDecoder]


var decoder2 = JSONDecoder()
let additionalDecoder2 = CustomWithJSONSerialization()
decoder2.userInfo = [CodingUserInfoKey.additionalDecoder: additionalDecoder]


let jsonStr = """
{
"id": 1234,
"attributes": "eyAibmFtZSI6ICJzb21lLXZhbHVlIiB9",
}
"""

let jsonData = jsonStr.data(using: .utf8)!

do {
    let value = try decoder.decode(Model.self, from: jsonData)
    print("1: \(value)")
    let value2 = try decoder2.decode(Model.self, from: jsonData)
    print("2: \(value2)")
}
catch {
    print("Error: \(error)")
}

Output:

$> 1: Model(id: 1234, attributes: Quick.Attributes(name: "some-value"))
$> 2: Model(id: 1234, attributes: Quick.Attributes(name: "some-value"))
2
gcharita On

After reading this interesting post, I came up with a reusable solution.

You can create a new NestedJSONDecodable protocol which gets also the JSONDecoder in it's initializer:

protocol NestedJSONDecodable: Decodable {
    init(from decoder: Decoder, using nestedDecoder: JSONDecoder) throws
}

Implement the decoder extraction technique (from the aforementioned post) together with a new decode(_:from:) function for decoding NestedJSONDecodable types:

protocol DecoderExtractable {
    func decoder(for data: Data) throws -> Decoder
}

extension JSONDecoder: DecoderExtractable {
    struct DecoderExtractor: Decodable {
        let decoder: Decoder
        
        init(from decoder: Decoder) throws {
            self.decoder = decoder
        }
    }
    
    func decoder(for data: Data) throws -> Decoder {
        return try decode(DecoderExtractor.self, from: data).decoder
    }
    
    func decode<T: NestedJSONDecodable>(_ type: T.Type, from data: Data) throws -> T {
        return try T(from: try decoder(for: data), using: self)
    }
}

And change your Model struct to conform to NestedJSONDecodable protocol instead of Decodable:

struct Model: NestedJSONDecodable {

    let id: Int64
    let attributes: Attributes

    private enum CodingKeys: String, CodingKey {
        case id
        case attributes
    }

    init(from decoder: Decoder, using nestedDecoder: JSONDecoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)

        self.id = try container.decode(Int64.self, forKey: .id)
        let attributesData = try container.decode(Data.self, forKey: .attributes)
        
        self.attributes = try nestedDecoder.decode(Attributes.self, from: attributesData)
    }
}

The rest of your code will remain the same.

3
vadian On

If attributes contains only one key value pair this is the simple solution.

It decodes the base64 encoded string directly as Data – this is possible with the .base64 data decoding strategy – and deserializes it with traditional JSONSerialization. The value is assigned to a member name in the Model struct.

If the base64 encoded string cannot be decoded a DecodingError will be thrown

let jsonString = """
{
   "id": 1234,
   "attributes": "eyAibmFtZSI6ICJzb21lLXZhbHVlIiB9",
}
"""

struct Model: Decodable {
    
    let id: Int64
    let name: String
    
    private enum CodingKeys: String, CodingKey {
        case id, attributes
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int64.self, forKey: .id)
        let attributeData = try container.decode(Data.self, forKey: .attributes)
        guard let attributes = try JSONSerialization.jsonObject(with: attributeData) as? [String:String],
            let attributeName = attributes["name"] else { throw DecodingError.dataCorruptedError(forKey: .attributes, in: container, debugDescription: "Attributes isn't eiter a dicionary or has no key name") }
        self.name = attributeName
    }
}

let data = Data(jsonString.utf8)

do {
    let decoder = JSONDecoder()
    decoder.dataDecodingStrategy = .base64
    let result = try decoder.decode(Model.self, from: data)
    print(result)
} catch {
    print(error)
}
1
MikeyWard On

You could create a single decoder as a static property of Model, configure it once, and use it for all your Model decoding needs, both externally and internally.

Unsolicited thought: Honestly, I would only recommend doing that if you're seeing a measurable loss of CPU time or crazy heap growth from the allocation of additional JSONDecoders… they're not heavyweight objects, less than 128 bytes unless there's some trickery I don't understand (which is pretty common though tbh):

let decoder = JSONDecoder()
malloc_size(Unmanaged.passRetained(decoder).toOpaque()) // 128