Chapters

Hide chapters

SwiftUI Apprentice

Second Edition · iOS 16 · Swift 5.7 · Xcode 14.2

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. TheMet 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 TheMet needs to work with the metmuseum.org API and its JSON structure, so you’ll learn some techniques for working with JSON data names and structure that differ from your app’s data model names and structure.

Getting Started

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

Open DownloadingData playground.
Open DownloadingData playground.

The starter playground contains the Object and ObjectIDs structures from TheMet and, in its Sources folder, an extension to URLComponents.

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

URLSession

URLSession is Apple’s framework for HTTP messages. Apple’s documentation page includes this note:

Asynchronous Methods

Most URLSession 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. To make this possible, URLSession methods are asynchronous: They dispatch their work onto another queue and immediately return control to the main queue, so it can respond to user interface events. You’ll call a URLSession method from an asynchronous method, which suspends while the network task completes, then resumes execution to process the response from the server.

Creating a REST Request URL

A REST request URL often includes query parameters. Here’s one from the metmuseum.org’s API:

https://collectionapi.metmuseum.org/public/collection/v1/search?medium=Quilts|Silk|Bedcovers&q=quilt
https://collectionapi.metmuseum.org/public/collection/v1/search?medium=Quilts%7CSilk%7CBedcovers&q=quilt

URLComponents

URLComponents 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. URL itself gives you access to URL components like lastPathComponent.

let baseURLString = "https://collectionapi.metmuseum.org/public/collection/v1/"
var urlComponents = URLComponents(
  string: baseURLString + "search")!
urlComponents.queryItems = [
  URLQueryItem(name: "medium", value: "Quilts|Silk|Bedcovers"),
  URLQueryItem(name: "q", value: "quilt")
]
urlComponents.url
urlComponents.url?.absoluteString
Execute-Playground arrows on the code line and in the bottom bar
Iwehabo-Lwayzfeocs olsosl uy ybo riya yuji adf ic mno yefseh cof

Show Result of the code line.
Knex Lerufl it scu piqo vayo.

"https://collectionapi.metmuseum.org/public/collection/v1/search?medium=Quilts%7CSilk%7CBedcovers&q=quilt"
Playground trying to display a URL
Hbarwsaebt dnjegw ma pojzgid e ENW

URLComponents Helper Method

URLQueryItem makes it easy to add a query parameter name and value to the request URL. The name and value arguments of URLQueryItem look like dictionary key and value items, so it’s easy to create a dictionary of parameter names and values, then transform this dictionary into a queryItems array. It’s especially easy when Alfian Losari has already done it. :] It’s in Sources/URLComponentsExtension.swift in this playground.

var baseParams = [
  "hasImages": "true",
  "q": ""
]
urlComponents.setQueryItems(with: baseParams)
baseParams["q"] = "rhino"
urlComponents.setQueryItems(with: baseParams)
urlComponents.url?.absoluteString
hasImages first, q second
sajAwides pewlw, j benosg

q first, hasImages second
f cubxv, yuzOmekix roqubt

{"total":3,"objectIDs":[452648,241715,452174]}
{"total":103,"objectIDs":[551786,852562,472562,317877,544740,329077,437422,459027,459028,544320,200668,824771,438821,460281,310453,53660,452102,207157,237451,450605,549236,451725,436607,435997,436098,712539,192770,39901,435848,776714,439327,204587,197460,197461,448280,485416,383883,334030,811772,811771,687615,377933,436102,452032,437059,850659,430812,736196,626692,759529,822751,435702,435621,452740,40080,436658,488221,764636,436105,39742,437585,228995,437878,60470,452364,228990,200840,53238,838076,53162,436838,436803,452651,437868,453385,201718,437174,437508,435991,464118,451287,436884,436885,435864,437368,438779,73651,44759,436529,435844,437873,341703,437159,453895,437173,844492,39895,436099,733847,437936,450761,435678,437061]}
var baseParams = [
  "hasImages": "true"
]
let queryTerm = "rhino wolf"
urlComponents.queryItems! += [URLQueryItem(name: "q", value: queryTerm)]
urlComponents.queryItems
urlComponents.url?.absoluteString
URL to search for `rhino wolf`
ORK ke ziifrd pid `phida sigz`

Sending the Request With URLSession

You’ve got your URL. Now, you’ll create a URLRequest, send it in a URLSession, check the HTTPURLResponse and decode the JSON data.

let queryURL = urlComponents.url  // 1
let request = URLRequest(url: queryURL!)
let session = URLSession.shared  // 2
let decoder = JSONDecoder()  // 3

Task {  // 4
  let (data, response) = try await session.data(for: request)
  guard 
    let response = response as? HTTPURLResponse,
    (200..<300).contains(response.statusCode)
  else {
    print(">>> response outside bounds")
    return
  }
  let objectIDs = try decoder.decode(ObjectIDs.self, from: data)  // 5
  objectIDs.objectIDs
}
let config = URLSessionConfiguration.default
config.waitsForConnectivity = true
config.timeoutIntervalForResource = 300
let session = URLSession(configuration: config)
IDs of matching objects
UTd uc dahmloqq exyuxbh

Decoding JSON

If there’s a good match between your data model and a JSON value, the default init() of JSONDecoder is poetry in motion, letting you decode complex structures of arrays and dictionaries in a single line of code. However, some web APIs send deeply-nested JSON structures that you probably won’t want to replicate in your app. Then, you’ll need to use CodingKey enumerations and custom init(from:) methods.

Decoding What You Need

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.

Response body at codebeautify.org/jsonviewer
Tolfiyke muxj oc buzezaaasunx.ekf/rropgeekoh

struct Object: Codable, Hashable {
  let objectID: Int
  let title: String
  let creditLine: String
  let objectURL: String
  let isPublicDomain: Bool
  let primaryImageSmall: String
}
Complete Object structure app.quicktype.io
Camyvumo Eqxozg qjginqula ibd.poejdfxza.uo

Decoding When JSON Name != Property Name

JSON values sent by real-world APIs might not match the way you want to name or structure your app’s data.

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
enum CodingKeys: String, CodingKey {
  case imageURL = "primaryImageSmall"
  case objectID, title, creditLine, objectURL, isPublicDomain
}

Decoding Nested JSON

Many APIs send JSON data whose structure is very different from the way you want to organize your app’s data. A nested array or dictionary might contain a single value that you want to use in your app.

"tags": [
    {
        "term": "Animals",
        "AAT_URL": "http://vocab.getty.edu/page/aat/300249525",
        "Wikidata_URL": "https://www.wikidata.org/wiki/Q729"
    },
    {
        "term": "Poetry",
        "AAT_URL": "http://vocab.getty.edu/page/aat/300055931",
        "Wikidata_URL": "https://www.wikidata.org/wiki/Q482"
    },
    {
        "term": "Men",
        "AAT_URL": "http://vocab.getty.edu/page/aat/300025928",
        "Wikidata_URL": "https://www.wikidata.org/wiki/Q8441"
    },
    {
        "term": "Horses",
        "AAT_URL": "http://vocab.getty.edu/page/aat/300250148",
        "Wikidata_URL": "https://www.wikidata.org/wiki/Q726"
    }
]

Downloading Objects

Now, back to your playground code, where you sent a query request then decoded the response data into an ObjectIDs instance. You now have an array of objectID values, and you need to send a request for each object. You’ll store the downloaded objects in an array.

var objects: [Object] = []
for objectID in objectIDs.objectIDs {
  let objectURLString = baseURLString + "objects/\(objectID)"  // 1
  let objectURL = URL(string: objectURLString)
  let objectRequest = URLRequest(url: objectURL!)
  let (data, response) = try await session.data(for: objectRequest)  // 2
  guard 
    let response = response as? HTTPURLResponse,
    (200..<300).contains(response.statusCode)
  else {
    print(">>> response outside bounds")
    return
  }
  let object = try decoder.decode(Object.self, from: data)  // 3
  objects.append(object)
}
objects
Array of matching objects
Isrot oy mewjnehr idvexsr

Downloading Data in Your App

Your playground code is working fine, sending requests for objects that match a query term and decoding the JSON responses. Now, you’ll copy this code into your app, with a few modifications to safely unwrap optional values, catch and print errors and clarify error messages.

func fetchObjects(for queryTerm: String) async throws {
}

TheMetService

It’s good practice to keep your networking code separate from your data model, in a separate file. And, it’s common to use either “networking” or “service” in the filename.

struct TheMetService {
  let baseURLString = "https://collectionapi.metmuseum.org/public/collection/v1/"
  let session = URLSession.shared
  let decoder = JSONDecoder()

  func getObjectIDs(from queryTerm: String) async throws -> ObjectIDs? {
    // insert code here
    return nil
  }

  func getObject(from objectID: Int) async throws -> Object? {
    return nil
  }
}

getObjectIDs(from:)

➤ In getObjectIDs(from:), insert this code above return nil:

let objectIDs: ObjectIDs?  // 1

guard 
  var urlComponents = URLComponents(string: baseURLString + "search")
else {  // 2
  return nil
}
let baseParams = ["hasImages": "true"]
urlComponents.setQueryItems(with: baseParams)
urlComponents.queryItems! += [URLQueryItem(name: "q", value: queryTerm)]
guard let queryURL = urlComponents.url else { return nil }
let request = URLRequest(url: queryURL)
let (data, response) = try await session.data(for: request)  // 1
guard
  let response = response as? HTTPURLResponse,
  (200..<300).contains(response.statusCode)
else {
  print(">>> getObjectIDs response outside bounds")
  return nil
}

do {  // 2
  objectIDs = try decoder.decode(ObjectIDs.self, from: data)
} catch {
  print(error)
  return nil
}
return objectIDs  // 3

getObject(from:)

fetchObjects(queryTerm:) in TheMetStore will loop over the objectIDs array, calling getObject(from:) for each objectID.

let object: Object?  // 1

let objectURLString = baseURLString + "objects/\(objectID)"  // 2
guard let objectURL = URL(string: objectURLString) else { return nil }
let objectRequest = URLRequest(url: objectURL)

let (data, response) = try await session.data(for: objectRequest)  // 3
if let response = response as? HTTPURLResponse {
  let statusCode = response.statusCode
  if !(200..<300).contains(statusCode) {
    print(">>> getObject response \(statusCode) outside bounds")
    print(">>> \(objectURLString)")
    return nil
  }
}

do {  // 4
  object = try decoder.decode(Object.self, from: data)
} catch {
  print(error)
  return nil
}
return object  // 5

Fetching Objects in ContentView

TheMetStore is closely connected to your SwiftUI views — its main responsibility is to publish values for the views to present to users. It uses TheMetService to do this.

fetchObjects(for:)

➤ In TheMetStore.swift, add these properties to TheMetStore:

let service = TheMetService()
let maxIndex: Int
init(_ maxIndex: Int = 30) {
  self.maxIndex = maxIndex
}
if let objectIDs = try await service.getObjectIDs(from: queryTerm) {  // 1
  for (index, objectID) in objectIDs.objectIDs.enumerated()  // 2
  where index < maxIndex {
    if let object = try await service.getObject(from: objectID) {
      objects.append(object)
    }
  }
}

Sending the First Request

➤ In the body of ContentView, fold the VStack so you can see the closing brace of NavigationStack, then add this modifier to NavigationStack:

.task {
  do {
    try await store.fetchObjects(for: query)
  } catch {}
}
Search for rhino when the app starts.
Ciodmk dam qqopu zpiz xbe iwx lboycf.

Sending a New Request

Tapping the Search the Met button shows an alert where you can enter a new query term. Tapping Search or the return key should call fetchObjects(for:).

Task {
  do {
    store.objects = []
    try await store.fetchObjects(for: query)
  } catch {}
}
Search for persimmon.
Peingr wan yojpulyun.

Search for cat then search for rhino.
Neijjj kuc hos cges jaiclg wit vqoqe.

Canceling the Running Task

You need to cancel the running task before starting the next task. To cancel a task, you must give it a name.

@State private var fetchObjectsTask: Task<Void, Error>?
fetchObjectsTask?.cancel()
fetchObjectsTask = Task {
Cancel search for cat before searching for rhino.
Zumjax xaobkv suj liz joqose quengwigk xig dfera.

Publishing changes from background threads is not allowed...
Kipdexzucz djifqis ztiy geswjguocz srseizz ex quj umjagak...

Publishing Changes on the Main Thread

“Publishing changes …”: Whenever you call fetchObjects(for:) to run a new search, store publishes updates to objects, which updates the list in ContentView.

Purple published property
Duzspi wodxaqriy fqinuxmn

await MainActor.run {
  objects.append(object)
}
Purple problem solved.
Pubbho pvisven qokzof.

Showing a Progress View

After entering a new query term, there’s a brief moment when the list is blank. Users expect to see some indication that your app is working — a progress view or spinner.

.overlay {
  if store.objects.isEmpty { ProgressView() }
}
Progress view while objects is empty
Bkehpumx qaaf xxuli aspidsg an uhtgc

Key Points

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