How to make Swift Codable types more versatile

。_饼干妹妹 提交于 2020-07-19 04:28:08

问题


I currently am working with an API that deals with bus predictions. There is an interesting quirk with the JSON that is returned for the predictions of a certain stop. When there are multiple predictions for a stop, the JSON looks something like this:

...
"direction": {
            "prediction": [
                {
                    "affectedByLayover": "true",
                    "block": "241",
                    "dirTag": "loop",
                    "epochTime": "1571785998536",
                    "isDeparture": "false",
                    "minutes": "20",
                    "seconds": "1208",
                    "tripTag": "121",
                    "vehicle": "1698"
                },
                {
                    "affectedByLayover": "true",
                    "block": "241",
                    "dirTag": "loop",
                    "epochTime": "1571787798536",
                    "isDeparture": "false",
                    "minutes": "50",
                    "seconds": "3008",
                    "tripTag": "122",
                    "vehicle": "1698"
                },
                {
                    "affectedByLayover": "true",
                    "block": "241",
                    "dirTag": "loop",
                    "epochTime": "1571789598536",
                    "isDeparture": "false",
                    "minutes": "80",
                    "seconds": "4808",
                    "tripTag": "123",
                    "vehicle": "1698"
                }
            ],
            "title": "Loop"
        }
...

However, when there is only one prediction for a stop, the JSON looks like this instead:

...
"direction": {
            "prediction": 
                {
                    "affectedByLayover": "true",
                    "block": "241",
                    "dirTag": "loop",
                    "epochTime": "1571785998536",
                    "isDeparture": "false",
                    "minutes": "20",
                    "seconds": "1208",
                    "tripTag": "121",
                    "vehicle": "1698"
                }
            "title": "Loop"
        }
...

Notice that the "prediction" is no longer inside an array -- this is where I believe things are getting complicated when using a Swift Codable type to decode the JSON. My model looks like this for the "direction" and "prediction"

struct BTDirection: Codable {
    let title: String!
    let stopTitle: String!
    let prediction: [BTPrediction]!
}

struct BTPrediction: Codable {
    let minutes: String!
    let vehicle: String!
}

Basically what is happening is prediction in BTDirection is looking for an Array of BTPrediction, however in the 2nd case above, this wouldn't be an Array so the decoding fails. How can I make my models more flexible to accommodate both an array or a single object? Ideally, in the 2nd case prediction would still be an array of a single BTDirection. Any help on this would be much appreciated.


回答1:


You can try

struct BTDirection:Codable {

    let title,stopTitle: String
    let prediction: [BTPrediction]

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        title = try container.decode(String.self, forKey: .title)
        stopTitle = try container.decode(String.self, forKey: .stopTitle)
        do {
            let res = try container.decode([BTPrediction].self, forKey: .prediction)
            prediction = res
        }
        catch { 
              let res = try container.decode(BTPrediction.self, forKey: .prediction)
              prediction = [res] 
        }  
    }
}



回答2:


To add on to Sh_Khan's answer, if you have multiple places in your API responses where this sort of thing happens, you can extract this custom decoding and encoding out to a custom wrapper type so you don't have to repeat it everywhere, like:

/// Wrapper type that can be encoded/decoded to/from either
/// an array of `Element`s or a single `Element`.
struct ArrayOrSingleItem<Element> {
    private var elements: [Element]
}

extension ArrayOrSingleItem: Decodable where Element: Decodable {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()

        do {
            // First try decoding the single value as an array of `Element`s.
            elements = try container.decode([Element].self)
        } catch {
            // If decoding as an array of `Element`s didn't work, try decoding
            // the single value as a single `Element`, and store it in an array.
            elements = try [container.decode(Element.self)]
        }
    }
}

extension ArrayOrSingleItem: Encodable where Element: Encodable {
    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()

        if elements.count == 1, let element = elements.first {
            // If the wrapped array of `Element`s has exactly one `Element` 
            // in it, encode this as just that one `Element`.
            try container.encode(element)
        } else {
            // Otherwise, encode the wrapped array just as it is - an array
            // of `Element`s.
            try container.encode(elements)
        }
    }
}

// This lets you treat an `ArrayOrSingleItem` like a collection of elements.
// If you need the elements as type `Array<Element>`, just instantiate a new
// `Array` from your `ArrayOrSingleItem` like:
//     let directions: ArrayOrSingleItem<BTDirection> = ...
//     let array: [BTDirection] = Array(directions)
extension ArrayOrSingleItem: MutableCollection {
    subscript(position: Int) -> Element {
        get { elements[position] }
        set { elements[position] = newValue }
    }

    var startIndex: Int { elements.startIndex }
    var endIndex: Int { elements.endIndex }

    func index(after i: Int) -> Int {
        elements.index(after: i)
    }
}

// This lets you instantiate an `ArrayOrSingleItem` from an `Array` literal.
extension ArrayOrSingleItem: ExpressibleByArrayLiteral {
    init(arrayLiteral elements: Element...) {
        self.elements = elements
    }
}

Then you can just declare your prediction (and any other property that has the potential to be either an array or a single item in your API response) like this:

struct BTDirection: Codable {
    let title: String?
    let stopTitle: String?
    let prediction: ArrayOrSingleItem<BTPrediction>?
}



回答3:


Both @TylerTheCompiler & @Sh_Khan provide very good technical input into solution that provide the mechanics of a solution, but the provided code will hit some implementation issues with the given json data:

  1. there are errors in the JSON posted that will stop codable working with it - I suspect these are just copy & paste errors, but if not you will have issues moving forwards.
  2. Because of the initial direction key the JSON effectively has 3 (or at least 2.5!) layers of nesting. This will either need to be flattened in init(from:) or, as in the below, need a temporary struct for ease of mapping. Flattening in the initialiser would be more elegant, a temporary struct is far quicker :-)
  3. CodingKeys, while obvious, isn't defined in the previous answers, so will cause errors compiling the init(from:)
  4. there's no stopTitle field in the JSON, so that will error on decoding unless it is treated as an optional. Here I've treated it as a concrete String and handled it in the decoding; you could just make it a String? and then the decoder would cope with it being absent.

Using "corrected" JSON (added opening braces, missing commas, etc) the following code will import both scenarios. I've not implemented the arrayOrSingleItem as all credit for that belongs with @TylerTheCompiler, but you could easily drop it in.

struct Direction: Decodable {
   let direction: BTDirection
}

struct BTDirection: Decodable {
   enum CodingKeys: String, CodingKey {
      case title
      case stopTitle
      case prediction
   }
   let prediction: [BTPrediction]
   let title: String
   let stopTitle: String

   init(from decoder: Decoder) throws {
      let container = try decoder.container(keyedBy: CodingKeys.self)
      do {
         prediction = try container.decode([BTPrediction].self, forKey: .prediction)
      } catch {
         let singlePrediction = try container.decode(BTPrediction.self, forKey: .prediction)
         prediction = [singlePrediction]
      }
      title = try container.decode(String.self, forKey: .title)
      stopTitle = try container.decodeIfPresent(String.self, forKey: .stopTitle) ?? "unnamed stop"
   }
}

struct BTPrediction: Decodable {
   let minutes: String
   let vehicle: String
}

and then to actually decode the JSON decode the top-level Direction type

let data = json.data(using: .utf8)
if let data = data {
   do {
      let bus = try decoder.decode(Direction.self, from: data)
      // extract the BTDirection type from the temporary Direction type
      // and do something with the decoded data
   }catch {
      //handle error
   }
}

In case you're not aware, JSON Validator is very useful for validating/correcting json.



来源:https://stackoverflow.com/questions/58513344/how-to-make-swift-codable-types-more-versatile

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!