5

I'm playing around with the new Codable protocol in Swift 4. I'm pulling JSON data from a web API via URLSession. Here's some sample data:

{
  "image_id": 1,
  "resolutions": ["1920x1200", "1920x1080"]
}

I'd like to decode this into structs like this:

struct Resolution: Codable {
  let x: Int
  let y: Int
}

struct Image: Codable {
  let image_id: Int
  let resolutions: Array<Resolution>
}

But I'm not sure how to convert the resolution strings in the raw data into separate Int properties in the Resolution struct. I've read the official documentation and one or two good tutorials, but these focus on cases where the data can be decoded directly, without any intermediate processing (whereas I need to split the string at the x, convert the results to Ints and assign them to Resolution.x and .y). This question also seems relevant, but the asker wanted to avoid manual decoding, whereas I'm open to that strategy (although I'm not sure how to go about it myself).

My decoding step would look like this:

let image = try JSONDecoder().decode(Image.self, from data)

Where data is supplied by URLSession.shared.dataTask(with: URL, completionHandler: Data?, URLResponse?, Error?) -> Void)

Hamish
  • 78,605
  • 19
  • 187
  • 280
ACB
  • 73
  • 1
  • 5

1 Answers1

9

For each Resolution, you want to decode a single string, and then parse that into two Int components. To decode a single value, you want to get a singleValueContainer() from the decoder in your implementation of init(from:), and then call .decode(String.self) on it.

You can then use components(separatedBy:) in order to get the components, and then Int's string initialiser to convert those to integers, throwing a DecodingError.dataCorruptedError if you run into an incorrectly formatted string.

Encoding is simpler, as you can just use string interpolation in order to encode a string into a single value container.

For example:

import Foundation

struct Resolution {
  let width: Int
  let height: Int
}

extension Resolution : Codable {
  init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()

    let resolutionString = try container.decode(String.self)
    let resolutionComponents = resolutionString.components(separatedBy: "x")

    guard resolutionComponents.count == 2,
      let width = Int(resolutionComponents[0]),
      let height = Int(resolutionComponents[1])
      else {
        throw DecodingError.dataCorruptedError(in: container, debugDescription:
          """
          Incorrectly formatted resolution string "\(resolutionString)". \
          It must be in the form <width>x<height>, where width and height are \
          representable as Ints
          """
        )
      }

    self.width = width
    self.height = height
  }

  func encode(to encoder: Encoder) throws {
    var container = encoder.singleValueContainer()
    try container.encode("\(width)x\(height)")
  }
}

You can then use it like so:

struct Image : Codable {

    let imageID: Int
    let resolutions: [Resolution]

    private enum CodingKeys : String, CodingKey {
        case imageID = "image_id", resolutions
    }
}

let jsonData = """
{
  "image_id": 1,
  "resolutions": ["1920x1200", "1920x1080"]
}
""".data(using: .utf8)!

do {
    let image = try JSONDecoder().decode(Image.self, from: jsonData)
    print(image)
} catch {
    print(error)
}

// Image(imageID: 1, resolutions: [
//                                  Resolution(width: 1920, height: 1200),
//                                  Resolution(width: 1920, height: 1080)
//                                ]
// )

Note we've defined a custom nested CodingKeys type in Image so we can have a camelCase property name for imageID, but specify that the JSON object key is image_id.

Hamish
  • 78,605
  • 19
  • 187
  • 280
  • Quick follow-up: When encoding like so `JSONEncoder().encode(resolution)` I get an error: `Top-level Resolution encoded as string JSON fragment`. Presumably because the custom encoder doesn't format the output as a JSON object (with `{` and such). I like the current behaviour of the custom encoder, so is there an alternative to `JSONEncoder` which will play nicely with it? – ACB Jul 13 '17 at 23:34
  • @Subject22 Exactly, it encodes `Resolution` as a string. You could remove the `encode(to:)` implementation and rely on the auto-generated implementation that will encode to a keyed container (JSON object); but then your `Codable` conformance won't be round-trippable (decoding will expect a string). Another possibility would be to introduce another type (such as in the Q&A you linked to) in order to deal with encoding and decoding a resolution to and from a string, and just have `Resolution` itself use the default `Codable` implementation... – Hamish Jul 14 '17 at 11:37
  • e.g https://gist.github.com/hamishknight/a3d8e3579900e468491fe4ca6c1661a7 – Hamish Jul 14 '17 at 11:37
  • Or you could do the reverse, and have a separate struct that deals with encoding/decoding a resolution with a keyed container, e.g https://gist.github.com/hamishknight/3750bcdd188cfef00d46f30c97b9f4d1 – Hamish Jul 14 '17 at 11:47
  • I think I get it Thanks! What I really needed was a way to send a resolution back to the server, but in the form of an HTTP GET, rather than as JSON. I figured I'd just encode my `Resolution` struct and concatenate it into the URL. The issue is that `JSONEncoder` wants to make JSON data, whereas I'm really looking for a string representation of a resolution (which just happens to look like the JSON encoded version of a `Resolution` struct). So I made `Resolution` conform to `CustomStringConvertible` and used `String()` instead of `JSONEncoder.encode()`. – ACB Jul 14 '17 at 11:56
  • @Subject22 Ah okay, yes you right that you shouldn't be using `JSONEncoder` for that. Happy to be of help :) – Hamish Jul 14 '17 at 12:00