cojoj
cojoj

Reputation: 6475

Ignoring not supported Decodables

I've been using Codables in my current project with a great pleasure - everything is fine, most of the stuff I get out of the box and it's built in - perfect! Though, recently I've stumbled on a first real issue, which can't be solved automatically they way I want it.


Problem description
I have a JSON coming from the backend which is a nested thing. It looks like this

{
    "id": "fef08c8d-0b16-11e8-9e00-069b808d0ecc",
    "title": "Challenge_Chapter",
    "topics": [
        {
            "id": "5145ea2c-0b17-11e8-9e00-069b808d0ecc",
            "title": "Automation_Topic",
            "elements": [
                {
                    "id": "518dfb8c-0b18-11e8-9e00-069b808d0ecc",
                    "title": "Automated Line examle",
                    "type": "text_image",
                    "video": null,
                    "challenge": null,
                    "text_image": {
                        "background_url": "",
                        "element_render": ""
                    }
                },
                {
                    "id": "002a1776-0b18-11e8-9e00-069b808d0ecc",
                    "title": "Industry 3.0 vs. 4.0: A vision of the new manufacturing world",
                    "type": "video",
                    "video": {
                        "url": "https://www.youtube.com/watch?v=xxx",
                        "provider": "youtube"
                    },
                    "challenge": null,
                    "text_image": null
                },
                {
                    "id": "272fc2b4-0b18-11e8-9e00-069b808d0ecc",
                    "title": "Classmarker_element",
                    "type": "challenge",
                    "video": null,
                    "challenge": {
                        "url": "https://www.classmarker.com/online-test/start/",
                        "description": null,
                        "provider": "class_marker"
                    },
                    "text_image": null
                }
            ]
        }
    ]
}

Chapter is the root object and it contains a list of Topics and each topic contains a list of Elements. Pretty straightforward, but I get stuck with the lowest level, Elements. Each Element has an enum coming from the backend, which looks like this: [ video, challenge, text_image ], but iOS app doesn't support challenges, so my ElementType enum in Swift looks like:

public enum ElementType: String, Codable {
    case textImage = "text_image"
    case video = "video"
}

Of, course, it throws, because the first thing which happens is it tries to decode challenge value for this enum and it's not there, so my whole decoding fails.

What I want
I simply want decoding process to ignore Elements which can't be decoded. I don't need any Optionals. I just want them not to be present in Topic's array of Elements.

My reasoning and it's drawbacks
Of course, I've made a couple of attempts to solve this problem. First one, and the simples one is just to marks ElementType as Optional, but with this approach later on I'll have to unwrap everything and handle this - which is rather a tedious task. My second thought was to have something like .unsupported case in my enum, but again, later I want to use this to generate cells and I'll have to throw or return Optional - basically, same issues as previous idea. My last idea, but I haven't tested it yet, is to write a custom init() for decodable and somehow deal with it there, but I'm not sure whether it's Element or Topic which should be responsible for this? If I write it in Element, I can't return nothing, I'll have to throw, but if I put it in Topic I'll have to append successfully decoded elements to array. The thing is what will happen if at some point I will be fetching Elements directly - again I won't be able to do it without throwing.

TL;DR
I want init(from decoder: Decoder) throws not to throw, but to return Optional.

Upvotes: 1

Views: 280

Answers (2)

vadian
vadian

Reputation: 285092

I recommend to create an umbrella protocol for all three types

protocol TypeItem {}

Edit: To conform to the requirement that only two types can be considered you have to use classes to get reference semantics

Then create classes TextImage and Video and a Dummy class adopting the protocol. All instances of the Dummy class will be removed after the decoding process.

class TextImage : TypeItem, Decodable {
    let backgroundURL : String
    let elementRender : String

    private enum CodingKeys : String, CodingKey {
        case backgroundURL = "background_url"
        case elementRender = "element_render"
    }
}

class Video : TypeItem, Decodable {
    let url : URL
    let provider : String
}

class Dummy : TypeItem {}

Use the enum to decode type properly

enum Type : String, Decodable {
    case text_image, video, challenge
}

In the struct Element you have to implement a custom initializer which decodes the JSON to the structs depending on the type. The unwanted challange type is decoded into a Dummy instance. Due to the umbrella protocol you need only one property.

class Element : Decodable {
    let type : Type
    let id : String
    let title : String
    let item : TypeItem

    private enum CodingKeys : String, CodingKey {
        case id, title, type, video, text_image
    }

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        id = try container.decode(String.self, forKey: .id)
        title = try container.decode(String.self, forKey: .title)
        type = try container.decode(Type.self, forKey: .type)
        switch type {
        case .text_image: item = try container.decode(TextImage.self, forKey: .text_image)
        case .video: item = try container.decode(Video.self, forKey: .video)
        default: item = Dummy()
        }
    }
}

Finally create a Root struct for the root element and Topic for the topics array. In Topic add a method to filter the Dummy instances.

class Root : Decodable {
    let id : String
    let title : String
    var topics : [Topic]
}

class Topic : Decodable {
    let id : String
    let title : String
    var elements : [Element]

    func filterDummy() {
        elements = elements.filter{!($0.item is Dummy)}
    }
}

After the decoding call filterDummy() in each Topic to remove the dead items. Another downside is that you have to cast item to the static type for example

let result = try decoder.decode(Root.self, from: data)
result.topics.forEach({$0.filterDummy()})
if let videoElement = result.topics[0].elements.first(where: {$0.type == .video}) {
    let video = videoElement.item as! Video
    print(video.url)
}

Upvotes: 1

cojoj
cojoj

Reputation: 6475

I finally found something about this in SR-5953, but I think this is a hacky one.

Anyway, for the curious ones to allow this lossy decoding you need to manually decode everything. You can write it in you init(from decoder: Decoder), but a better approach would be to write a new helper struct called FailableCodableArray. Implementation would look like:

struct FailableCodableArray<Element: Decodable>: Decodable {
    // https://github.com/phynet/Lossy-array-decode-swift4
    private struct DummyCodable: Codable {}

    private struct FailableDecodable<Base: Decodable>: Decodable {
        let base: Base?

        init(from decoder: Decoder) throws {
            let container = try decoder.singleValueContainer()
            self.base = try? container.decode(Base.self)
        }
    }

    private(set) var elements: [Element]

    init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        var elements = [Element]()

        if let count = container.count {
            elements.reserveCapacity(count)
        }

        while !container.isAtEnd {
            guard let element = try container.decode(FailableDecodable<Element>.self).base else {
                _ = try? container.decode(DummyCodable.self)
                continue
            }

            elements.append(element)
        }

        self.elements = elements
    }
}

And than for the actual decoding of those failable elements you jusy have to write a simple init(from decoder: Decoder) implementation like:

init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    elements = try container.decode(FailableCodableArray<Element>.self, forKey: .elements).elements
}

As I've said, this solution works fine, but it feels a little hacky. It's an open bug, so you can vote it and let the Swift team see, that something like this built in would be a nice addition!

Upvotes: 3

Related Questions