Chapters

Hide chapters

SwiftUI Apprentice

First Edition · iOS 14 · Swift 5.4 · Xcode 12.5

Section I: Your first app: HIITFit

Section 1: 12 chapters
Show chapters Hide chapters

Section II: Your second app: Cards

Section 2: 9 chapters
Show chapters Hide chapters

24. Downloading Data
Written by Audrey Tam

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

Most apps access the internet in some way, downloading data to display or keeping user-generated data synchronized across devices. Your RWFreeView app needs to create and send HTTP requests and process HTTP responses. Downloaded data is usually in JSON format, which your app needs to decode into its data model.

If your app downloads data from your own server, you might be able to ensure the JSON structure matches your app’s data model. But RWFreeView needs to work with the raywenderlich.com API and its JSON structure, which is deeply nested. So in this chapter, you’ll learn two ways to work with nested JSON.

Getting started

Open the Networking playground in the starter folder and open the Episode playground page. If the editor window is blank, show the Project navigator (Command-1) and select Episode there.

Open Episode playground page.
Open Episode playground page.

Playgrounds are useful for exploring and working out code before moving it into your app. You can quickly inspect values produced by methods and operations, without needing to build a user interface or search through a lot of debug console messages.

The starter playground contains two playground pages and extensions to DateFormatter and URLComponents.

Asynchronous functions

URLSession is Apple’s framework for HTTP messages. Most of its methods involve network communication, so you can’t predict how long they’ll take to complete. In the meantime, the system must continue to interact with the user.

import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

Creating a REST request

A REST request is a URL with query parameters. In the previous chapter, you saw the URL-encoded URL for a typical contents query:

https://api.raywenderlich.com/api/contents?filter%5Bsubscription_types%5D%5B%5D=free&filter%5Bdomain_ids%5D%5B%5D=1&filter%5Bcontent_types%5D%5B%5D=episode&sort=-popularity

URLComponents

The URLComponents structure enables you to construct a URL from its parts and, also, to access the parts of a URL. Components include scheme, host, port, path, query and queryItems. The url itself gives you access to URL components like lastPathComponent.

let baseURLString = "https://api.raywenderlich.com/api/"
var urlComponents = URLComponents(
  string: baseURLString + "contents/")!
urlComponents.queryItems = [
  URLQueryItem(
    name: "filter[subscription_types][]", value: "free"),
  URLQueryItem(
    name: "filter[content_types][]", value: "episode")
]
urlComponents.url
urlComponents.url?.absoluteString
Execute-Playground arrows on the code line and in the bottom bar
Ugelivi-Kgirdrooqf uryenc ey myu fitu cage ijg if kho lutgew guy

Show Result of the code line.
Zxid Pupinb um nle bika heno.

"https://api.raywenderlich.com/api/contents/?filter%5Bsubscription_types%5D%5B%5D=free&filter%5Bcontent_types%5D%5B%5D=episode"
Playground trying to display a URL
Rwerftoonw dnbawg xa piybzuy i IZV

URLComponents helper method

URLQueryItem makes it easy to add a query parameter name and value to the request URL, but your app provides lots of options for users to customize downloaded contents. And almost every selection and deselection requires a new request. You’ll be writing a lot of code to add query items.

var baseParams = [
  "filter[subscription_types][]": "free",
  "filter[content_types][]": "episode",
  "sort": "-popularity",
  "page[size]": "20",
  "filter[q]": ""
]
urlComponents.setQueryItems(with: baseParams)
// when user changes page size
baseParams["page[size]"] = "30"
// when user enters a search term
baseParams["filter[q]"] = "json"
urlComponents.queryItems! += 
  [URLQueryItem(name: "filter[domain_ids][]", value: "1")]
Result of adding domain query item
Wenibp an anravb divuud taokr ijem

URLSessionDataTask

➤ Add this code below the absoluteString line:

let contentsURL = urlComponents.url!  // 1

// 2
URLSession.shared
  .dataTask(with: contentsURL) { data, response, error in
    defer { PlaygroundPage.current.finishExecution() }  // 3
    if let data = data, 
      let response = response as? HTTPURLResponse {  // 4
      print(response.statusCode)
      // Decode data and display it
    }
    // 5
    print(
      "Contents fetch failed: " +
        "\(error?.localizedDescription ?? "Unknown error")")
  }
  .resume()  // 6
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
config.timeoutIntervalForResource = 300
let session = URLSession(configuration: config)

Fetching a video URL

To display a video, you actually need to run two download requests. The one you’ve just added to the Episode playground fetches an array of contents items.

let videoId = 3021
let baseURLString = "https://api.raywenderlich.com/api/videos/"
let queryURLString = baseURLString + String(videoId) + "/stream"
let queryURL = URL(string: queryURLString)!
URLSession.shared
  .dataTask(with: queryURL) { data, response, error in
    defer { PlaygroundPage.current.finishExecution() }
    if let data = data, 
      let response = response as? HTTPURLResponse {
      print("\(videoId) \(response.statusCode)")
      // Decode response and display it
    }
    print(
      "Videos fetch failed: " +
        "\(error?.localizedDescription ?? "Unknown error")")
  }
  .resume()

Decoding JSON

If there’s a good match between your data model and a JSON value, the default init(from:) of JSONDecoder is poetry in motion, letting you decode complex structures of arrays and dictionaries in a single line of code. Unfortunately, api.raywenderlich.com sends a deeply-nested JSON structure that you probably won’t want to replicate in your app. So, it’s time to learn more about CodingKey enumerations and custom init(from:) methods.

Decoding JSON that (almost) matches your data model

In Chapter 19, “Saving Files”, you saw how easy it is to encode and decode the Team structure as JSON because all its properties are Codable. You were saving and loading your app’s own data model, so item names and structure in JSON format exactly matched your Team structure.

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
enum CodingKeys: String, CodingKey {
  case description = "description_plain_text"
  case id, uri, name, ...
}

Decoding nested JSON

Most of the time, the structure of JSON sent by an API is very different from the way you want to organize your app’s data. This is the case with api.raywenderlich.com, where the values you want to store in an episode are nested one or more levels down in the JSON value. And fetching the video URL string requires a separate request to a different endpoint.

https://api.raywenderlich.com/api/videos/3021/stream
{
  "data": {
    "id": "32574",
    "type": "attachments",
    "attributes": {
      "url": "https://player.vimeo.com/external/357115704...",
      "kind": "stream"
    }
  }
}

Making the data model fit the JSON

➤ In the VideoURL playground page, set up these structures to mirror the JSON value’s hierarchy:

struct ResponseData: Codable {
  let data: Video
}

struct Video: Codable {
  let attributes: VideoAttributes
}

struct VideoAttributes: Codable {
  let url: String
}
if let decodedResponse = try? JSONDecoder().decode(  // 1
    ResponseData.self, from: data) {  // 2
  DispatchQueue.main.async {
    print(decodedResponse.data.attributes.url)  // 3
  }
  return
}
Playground execution stops after decoding URL string.
Gmikjkuepg oxadehaov lweqy owzum miganatp OJH sprerg.

View type of url property.
Paet rhni os iyg tbeyopqr.

let url: URL
Type of url property is URL.
Fvye ev ekg hqagoqwd oc ANY.

Flattening the JSON response into the data model

In Chapter 19, “Saving Files”, you wrote a CodingKey enumeration and a custom init(from:) to decode a Double value that you then used to initialize an Angle. In this section, you’ll use CodingKey enumerations and a custom init(from:) to navigate through the levels of the JSON value and extract the items you need.

{
  "data": {
    "id": "32574",
    "type": "attachments",
    "attributes": {
      "url": "https://player.vimeo.com/external/357115704...",
      "kind": "stream"
    }
  }
}
struct VideoURLString {
  // data: attributes: url
  var urlString: String

  enum CodingKeys: CodingKey {
    case data
  }

  enum DataKeys: CodingKey {
    case attributes
  }
}

struct VideoAttributes: Codable {
  var url: String
}
extension VideoURLString: Decodable {
  init(from decoder: Decoder) throws {
    let container = try decoder.container(  // 1
      keyedBy: CodingKeys.self)
    let dataContainer = try container.nestedContainer(
      keyedBy: DataKeys.self, forKey: .data)  // 2
    let attr = try dataContainer.decode(
      VideoAttributes.self, forKey: .attributes)  // 3
    urlString = attr.url  // 4
  }
}
if let decodedResponse = try? JSONDecoder().decode(
    VideoURLString.self, from: data) {
  DispatchQueue.main.async {
    print(decodedResponse.urlString)
  }
  return
}
Decoding url with flattened JSON.
Nubaloht osc tikr fyijvikuy MBOV.

Decoding the contents response

Now you’re ready to tackle the contents query response. Here’s one of the items returned:

{
  "data": [
    {
      "id": "5117655",
      "attributes": {
        "uri": "rw://betamax/videos/3021",
        "name": "SwiftUI vs. UIKit",
        "released_at": "2019-09-03T13:00:00.000Z",
        "difficulty": "beginner",
        "description_plain_text": "Learn about...\n",
        "video_identifier": 3021,
        ...
      },
      "relationships": {
        "domains": {
          "data": [
            {
              "id": "1",
              "type": "domains"
            }
          ]
        },
        ...
      }
      ...
    },
  ...
}

One more JSONDecoder trick

You’re definitely going to flatten this JSON into your Episode structure, but first, you’ve got to see what JSONDecoder can do with Date strings.

struct EpisodeStore: Decodable {
  var episodes: [Episode] = []

  enum CodingKeys: String, CodingKey {
    case episodes = "data"   // array of dictionary
  }
}

struct Episode: Decodable, Identifiable {
  let id: String
  let attributes: Attributes
}

struct Attributes: Codable {
  let name: String
  let released_at: Date
}
public extension DateFormatter {
  /// Convert /contents released_at String to Date
  static let apiDateFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'"
    return formatter
  }()

  /// Format date to appear in EpisodeView and PlayerView
  static let episodeDateFormatter: DateFormatter = {
    let formatter = DateFormatter()
    formatter.dateFormat = "MMM yyyy"
    return formatter
  }()
}
let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .formatted(.apiDateFormatter)
if let decodedResponse = try? decoder.decode(
    EpisodeStore.self, from: data) {
  DispatchQueue.main.async {
    let date = 
      decodedResponse.episodes[0].attributes.released_at
    DateFormatter.episodeDateFormatter.string(from: date)
  }
  return
}
released_at string converted to Date and back to shorter string.
daheanol_up pxwiqq cavlapwig ra Yoza ubc tewl wu dnekkod jcruyc.

Flattening the contents response

RWFreeView needs several more Episode properties, and some of these require a custom decoder.

// flatten attributes container
//1
let uri: String
let name: String
let released: String
let difficulty: String?
let description: String  // description_plain_text

// 2
var domain = ""  // relationships: domains: data: id

// send request to /videos endpoint with urlString
var videoURL: VideoURL?  // 3

// redirects to the real web page
var linkURLString: String {  // 4
  "https://www.raywenderlich.com/redirect?uri=" + uri
}

Decoding most of Episode’s properties

Xcode is complaining Episode doesn’t conform to Decodable because you haven’t told it how these new properties will get values. Coming right up!

enum DataKeys: String, CodingKey {
  case id
  case attributes
  case relationships
}

enum AttrsKeys: String, CodingKey {
  case uri, name, difficulty
  case releasedAt = "released_at"
  case description = "description_plain_text"
  case videoIdentifier = "video_identifier"
}

struct Domains: Codable {
  let data: [[String: String]]
}

enum RelKeys: String, CodingKey {
  case domains
}
static let domainDictionary = [
  "1": "iOS & Swift",
  "2": "Android & Kotlin",
  "3": "Unity",
  "5": "macOS",
  "8": "Server-Side Swift",
  "9": "Flutter"
]
public extension Formatter {
  /// Creates ISO8601DateFormatter that formats milliseconds
  static let iso8601: ISO8601DateFormatter = {
    let formatter = ISO8601DateFormatter()
    formatter.formatOptions = [
      .withInternetDateTime,
      .withFractionalSeconds
    ]
    return formatter
  }()
}
extension Episode {
  init(from decoder: Decoder) throws {
    let container = try decoder.container(  // 1
      keyedBy: DataKeys.self)
    let id = try container.decode(String.self, forKey: .id)

    let attrs = try container.nestedContainer(  // 2
      keyedBy: AttrsKeys.self, forKey: .attributes)
    let uri = try attrs.decode(String.self, forKey: .uri)
    let name = try attrs.decode(String.self, forKey: .name)
    let releasedAt = try attrs.decode(
      String.self, forKey: .releasedAt)
    let releaseDate = Formatter.iso8601.date(  // 3
      from: releasedAt)!
    let difficulty = try attrs.decode(
      String?.self, forKey: .difficulty)
    let description = try attrs.decode(
      String.self, forKey: .description)
    let videoIdentifier = try attrs.decode(
      Int?.self, forKey: .videoIdentifier)

    let rels = try container.nestedContainer(
      keyedBy: RelKeys.self, forKey: .relationships)  // 4
    let domains = try rels.decode(
      Domains.self, forKey: .domains)
    if let domainId = domains.data.first?["id"] {  // 5
      self.domain = Episode.domainDictionary[domainId] ?? ""
    }
  }
}

VideoURL class

You’ll soon decode video_identifier from the contents response and use it to create a VideoURL object.

class VideoURL {
  var urlString = ""

  init(videoId: Int) {
    let baseURLString =
      "https://api.raywenderlich.com/api/videos/"
    let queryURLString =
      baseURLString + String(videoId) + "/stream"
    guard let queryURL = URL(string: queryURLString)  // 1
    else { return }
    URLSession.shared
      .dataTask(with: queryURL) { data, response, error in
        defer { PlaygroundPage.current.finishExecution() }
        if let data = data,
          let response = response as? HTTPURLResponse {
          print("\(videoId) \(response.statusCode)")
          if let decodedResponse = try? JSONDecoder().decode(
            VideoURLString.self, from: data) {
            DispatchQueue.main.async {
              self.urlString = decodedResponse.urlString  // 2
              print(self.urlString)
            }
            return
          }
        }
        print(
          "Videos fetch failed: " +
            "\(error?.localizedDescription ?? "Unknown error")")
      }
      .resume()
  }
}
VideoURL(videoId: 3021)
VideoURL(videoId: 3021)
CufaoITF(pihiuIh: 5217)

//defer { PlaygroundPage.current.finishExecution() }
self.id = id
self.uri = uri
self.name = name
self.released = DateFormatter.episodeDateFormatter.string(   // 1
  from: releaseDate)
self.difficulty = difficulty
self.description = description
if let videoId = videoIdentifier {   // 2
  self.videoURL = VideoURL(videoId: videoId)
}
print(decodedResponse.episodes[0].released)
print(decodedResponse.episodes[0].domain)
Custom decoding of contents response
Naxciq zajavehb ir qivyovfx tisqikme

Episode init(from:) ran 20 times.
Ozerijo urom(gwap:) hix 08 suxez.

Key points

  • Playgrounds are useful for working out code. You can quickly inspect values produced by methods and operations.
  • URLComponents query items help you create URL-encoded URLs for REST requests.
  • Use URLSession dataTask to send an HTTP request and process the HTTP response.
  • Decode nested JSON values either by mirroring the JSON structure in your app’s data model, or by flattening the JSON structure into your data model.
  • Use date formatters like ISO8601DateFormatter to convert date strings to Date values.
Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now