Chapters

Hide chapters

Real-World iOS by Tutorials

First Edition · iOS 15 · Swift 5.5 · Xcode 13

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

3. Data Layer - Networking
Written by Aaqib Hussain

In the previous chapter, you learned the app’s basic structure and identified the features you’ll implement. You also familiarized yourself with high cohesion, loosely coupled code and the JSON structure that the Petfinder API provides.

In this chapter, you’ll build on what you learned in the previous chapter. You’ll create a networking layer and implement it following the principles of high cohesion so that you can reuse it for future projects.

Along the way, you’ll learn what a data layer is and how to:

  • Implement the networking side of a data layer and use it to fetch data.

  • Write asynchronous code with the async/await API.

  • Display fetched data using SwiftUI.

  • Implement a token refresh mechanism.

Getting started

Open starter project and build and run. You’ll see:

Starter app with empty data.
Starter app with empty data.

By the end of this chapter, you’ll create a network layer and render Petfinder API data. Your goal is to reach here:

Final app with animal list.
Final app with animal list.

Before you jump into writing code, take a moment to learn about data layers and their responsibilities.

Demystifying the data layer

A data layer is responsible for interactions with the data sources. A data source can be any particular database, file or data set hosted physically or digitally somewhere. Two examples of data sources are Petfinder’s API and Core Data.

A data layer acts as a layer of abstraction between the consumer of the data and the data sources, so the consumer doesn’t have to worry about the complexities of manipulating the data.

With a data layer, it’s easier to change the data sources without affecting or breaking anything else. For example, say you previously used Core Data in the app and now want to replace it with Realm. You can do that without breaking a sweat!

The data layer also provides data to the domain layer for further processing.

In this chapter, you’ll implement the networking side of the data layer. You’ll implement the persistence part of the data layer in Chapter 4, “Defining the Data Layer - Databases”.

Now, it’s time to start setting up the network layer.

Creating the request

First, you need to set up the central part of the network layer: the request for the network call.

Navigate to Core/data/api and create a group called request. Then create a file and name it RequestProtocol.swift.

Add this code:

protocol RequestProtocol {
  // 1
  var path: String { get }

  // 2
  var headers: [String: String] { get }
  var params: [String: Any] { get }

  // 3
  var urlParams: [String: String?] { get }

  // 4
  var addAuthorizationToken: Bool { get }

  // 5
  var requestType: RequestType { get }
}

You’ll use this protocol as a template for all your requests. Here’s what each property does:

  1. This property is the endpoint usually attached at the end of the base url.
  2. These are the headers and params you want to send with the request. The content of params will act as the request’s body.
  3. You’ll use this dictionary to attach query params in the URL.
  4. This boolean represents if your request needs to add the authorization token.
  5. By adding this, you make all the requests specify their type using RequestType.

Adding request types

Inside the request group, create a file named RequestType.swift. Then add:

enum RequestType: String {
  case GET
  case POST
}

There are five REST API request types: POST, GET, PUT, PATCH and DELETE. You only need POST and GET for this app.

Giving a default implementation to RequestProtocol

You don’t need all the properties for every request. You’ll define a default implementation of the RequestProtocol to simplify things.

In RequestProtocol.swift, create the following extension:

extension RequestProtocol {
  // 1
  var host: String {
    APIConstants.host
  }
  // 2
  var addAuthorizationToken: Bool {
    true
  }
  // 3
  var params: [String: Any] {
    [:]
  }

  var urlParams: [String: String?] {
    [:]
  }

  var headers: [String: String] {
    [:]
  }
}

Here’s what’s going on:

  1. This is the app’s base URL. Since there is only one, there’s no need to add the protocol definitions.
  2. By default, every request has an authorization token.
  3. Some requests don’t require params, urlParams and headers, so they have a default value of an empty dictionary.

Still in the same extension, add this method:

// 1
func createURLRequest(authToken: String) throws -> URLRequest {
  // 2
  var components = URLComponents()
  components.scheme = "https"
  components.host = host
  components.path = path
  // 3
  if !urlParams.isEmpty {
    components.queryItems = urlParams.map {
      URLQueryItem(name: $0, value: $1)
    }
  }

  guard let url = components.url
  else { throw NetworkError.invalidURL }

  // 4
  var urlRequest = URLRequest(url: url)
  urlRequest.httpMethod = requestType.rawValue
  // 5
  if !headers.isEmpty {
    urlRequest.allHTTPHeaderFields = headers
  }
  // 6
  if addAuthorizationToken {
    urlRequest.setValue(authToken,
      forHTTPHeaderField: "Authorization")
  }
  // 7
  urlRequest.setValue("application/json",
    forHTTPHeaderField: "Content-Type")
  // 8
  if !params.isEmpty {
    urlRequest.httpBody = try JSONSerialization.data(
      withJSONObject: params)
  }

  return urlRequest
}

In this method, you:

  1. Use RequestProtocol.createURLRequest(authToken:) to create the request with an authorization token which throws an error in case of failures like an invalid URL.
  2. You set up the base components of the URL by setting scheme, host and path.
  3. Then you add urlParams to url components if it’s not empty.
  4. Create an URLRequest using url.
  5. If you need to add any headers to the request, add them to the allHTTPHeaderFields.
  6. Add an authorization token to the request if addAuthorizationToken is true.
  7. The Petfinder API expects data to be of type JSON. So, set the request’s content type to application/json.
  8. Finally, you add non-empty parameters to the request as data in the httpBody. Since Petfinder API works with JSON, you serialize the params using NSJSONSerialization.data(withJSONObject:options:).

Note: When you conform to a protocol, you can also overwrite its default implementations if you need to do something other than the default behaviors.

You completed the request part. Before you create the network call, there’s a concept you need to learn: async/await.

async/await

Apple introduced async/await with iOS 15 in Swift, but with the release of Xcode 13.2, it’s also backward compatible starting with iOS 13. Swift now lets you write asynchronous code without using a completion handler. Say bye to completion handlers and Hello to async/await.

Writing asynchronous code can be cumbersome and a bit difficult to manage. It can also cause some unwanted errors. With async/await, you can write structured code and keep errors to a minimum.

Unlike a typical method, an async method suspends execution when waiting for a response. Other than waiting for a response, it works like a typical method. You can call methods with await when you want your method to suspend and wait for the response.

With async/await, you can achieve structured concurrency, which means you’re aware of the order of execution of your statements. Unlike the completion handler, every statement depends on the statement above it, making it a linear code execution.

So how does this work? How can you write an async method? Take a look at the following code syntax:

func name(parameters) async throws -> type {
  return type
}

Note: throws isn’t part of the method syntax. You use this keyword when you need to throw an error from the method. You’ll learn about it later in the chapter.

Following the above syntax, here’s an example of writing a method for performing a network request:

func perform(_ request: URLRequest) async throws -> Data {
  let (data, response) =
    try await URLSession.shared.data(for: request)
  return data
}

Here’s a code breakdown:

In the method signature, you indicate async. Inside, you call URLSession.data(for:delegate:) and use try await to tell the system this is an asynchronous operation, and it should suspend it. You use try because it can throw an error. Once the request finishes, it returns Data and URLResponse objects. It stores both in data and response correspondingly. Finally, it returns data.

In case of failure, perform(_:) throws an error.

Here you can see an example of how you’ll use the above method to get data from a request:

guard let url = URL(string: "<--some-url-->") else {
  return
}

// 1
let urlRequest = URLRequest(url: url)
// 2
Task {
  do {
    // 3
    let data = try await perform(urlRequest)
    // do operations on data
  } catch { // 4
      print(error.localizedDescription)
  }
}

Here you:

  1. Create the URLRequest with the URL.

  2. A Task creates an asynchronous environment for async methods to execute in. You use Task to provide an asynchronous container for perform(_:). This isn’t necessary inside an async method.

  3. Using urlRequest, you initialize the request. Every method marked with an async must use await while calling it. The method either returns the data or throws an error.

  4. If there’s an error, its description prints in the console.

That’s the advantage you get with async/await.

If you were to write something similar with a completion handler, you would get something like this:

func perform(_ request: URLRequest,
  completionHandler: @escaping (Result<Data, Error>) -> ()) {
  // 1
  URLSession.shared.dataTask(with: request) {
    data, response, error in
    // 2
    if let err = error {
      completionHandler(.failure(err))
      return
    }
    // 3
    guard let data = data,
          let response = response as? HTTPURLResponse,
          response.statusCode == 200 else {
            return
          }
    // 4
    completionHandler(.success(data))
  }.resume()
}

Here’s a code breakdown:

  1. You use the URLSession to fetch the data.
  2. If there’s an error, you call the failure completion handler.
  3. The guard statement checks if data is there and the status is 200 from the response.
  4. The completion handler takes this object and returns it to the caller.

Here’s a representation of how you’d call the request method if it were with a completion handler:

guard let url = URL(string: "<--some-url-->") else {
  return
}
let urlRequest = URLRequest(url: url)
perform(_ request: urlRequest) { response in
  switch response {
    case .success(let data):
      // do some operations on data
    case .failure(let error):
      print(error.localizedDescription)
  }
}

Observe the differences in these pieces of code. The async/await is much cleaner and easier to understand. It also requires the least amount of error handling.

That’s not the case with the completion handler. As you can see, you have to handle all errors manually in perform(_:) with the completion handler.

Several interdependent tasks using completion handlers will quickly turn your code into a pyramid of doom. In contrast, async/await keeps your code structured and makes it easier to read and understand.

Now that you understand how to use async/await, you’ll use it to create the networking.

Creating the networking brain

When it comes to networking, having a single point for making a network request or having a layer of abstraction can save you time and make your code easier to maintain. To make requests, you’ll use URLSession.

URLSession provides an API to download or upload data to the network on a defined endpoint. You can read more in its official documentation.

Instead of writing a method and using URLSession.shared to make network calls directly inside it, you’ll write a layer on top of URLSession to make network calls to achieve less coupling and high cohesion. It’ll also be easy to change it if such requirements arise.

Time to create your first layer! Under Core/data/api/network, create a file named APIManager.swift. Then, create this protocol:

protocol APIManagerProtocol {
  func perform(_ request: RequestProtocol, authToken: String) async throws -> Data
}

This protocol has one requirement, the implementation of perform(_:authToken:). This method expects an object that conforms to RequestProtocol, an authentication token and returns Data. If the request fails, it throws an error.

Except for the authentication request, all the network calls for Petfinder API require a token. So you ask for authToken, too.

Still in the same file, add the following class:

// 1
class APIManager: APIManagerProtocol {
  // 2
  private let urlSession: URLSession

  // 3
  init(urlSession: URLSession = URLSession.shared) {
    self.urlSession = urlSession
  }
}

This class:

  1. Indicates APIManager must conform to APIManagerProtocol.
  2. Creates a private variable to store the URLSession.
  3. Passes in the initializer the default shared URLSession. shared provides a singleton that returns a URLSession.

For most use cases, like this app, using URLSession.shared is enough. But keep in mind that you shouldn’t do things like customizing the cache, cookie storage or credential storage when using shared. For that, create a URLSessionConfiguration object.

Note: Read more about URLSessionConfiguration in the official documentation.

Still inside APIManager, add the method below:

func perform(_ request: RequestProtocol,
  authToken: String = "") async throws -> Data {
  // 1
  let (data, response) = try await urlSession.data(for: request.createURLRequest(authToken: authToken))
  // 2  
  guard let httpResponse = response as? HTTPURLResponse,
    httpResponse.statusCode == 200
  else {
    // 3
    throw NetworkError.invalidServerResponse
  }
  return data
}

Here’s a code breakdown:

  1. URLSession.data(for:) uses async/await to process a request and return data and an URLResponse. Here you used try because it can also throw an error.
  2. You check if the response code is 200. If this condition passes, data is returned.
  3. If their response isn’t successful, you return invalidServerResponse. NetworkError is a custom error enumeration and part of the starter project. A custom error enumeration makes it easier to customize the thrown error with meaningful messages.

Layering on top of the brain

While most people love spaghetti, they usually don’t want their code to look like it. To keep classes and network layers loosely coupled, you’ll implement another class on top of APIManager to implement data parsing and token handling.

Under network, create a new file named RequestManager.swift and add:

protocol RequestManagerProtocol {
  func perform<T: Decodable>(_ request: RequestProtocol) async throws -> T
}

This method is similar to what you wrote in APIManagerProtocol, but with a slight difference. This method uses a generic of a type conforming to Decodable. It also takes in a request and returns either an error or an object of type Decodable.

perform(_:) expects to return the value in a type of object conforming to Decodable, and that type should be explicitly mentioned next to the variable declaration.

Confused? Well, don’t worry. Everything will start to make sense soon enough.

Now, create a class named RequestManager. Conform to RequestManagerProtocol and implement the method perform like this:

class RequestManager: RequestManagerProtocol {  
  let apiManager: APIManagerProtocol
  let parser: DataParserProtocol
  // 1  
  init(
  apiManager: APIManagerProtocol = APIManager(),
  parser: DataParserProtocol = DataParser() // 2
  ) {
    self.apiManager = apiManager
    self.parser = parser
  }

  func perform<T: Decodable>(
    _ request: RequestProtocol) async throws -> T {
      // 3
      let data = try await apiManager.perform(request, authToken: "")
  }
}

You probably see an error here: Missing return in instance method expected to return 'T'. Don’t worry about that. You’ll fix it later on.

For now, focus on the code breakdown:

  1. You set up the initializer and set apiManager with a default value.
  2. Navigate to Core/data/api/parser, and you’ll see DataParser. It conforms to a protocol DataParserProtocol that implements a method that takes in Data and returns a generic Decodable. Then it uses JSONDecoder to decode Data into a Decodable. Here you simply give the object a default value.
  3. Implement perform(_:). Inside it, you call perform from APIManager to make a network call.

Fetching the authentication token

Now that you’ve completed the fetching and parsing parts, you can use it to fetch the authentication token to work with the APIs. The Petfinder API provides an endpoint /v2/oauth2/token for fetching the authentication token. You also need to send credentials along with it as a POST request:

{
  "grant_type": "client_credentials",
  "client_id": "CLIENT-ID",
  "client_secret": "CLIENT-SECRET"
}

You can read more about it at petfinder’s documentation.

Authentication token request

To fetch a token, first, you’ll create a request.

Under Core/data/api/request, create a group called auth. Inside the new group, create a file named AuthTokenRequest.swift.

Note: You can use a struct, class or enum to create a request. For this app, you’ll use enums.

When creating requests, you’ll conform to RequestProtocol. In AuthTokenRequest.swift, add:

// 1
enum AuthTokenRequest: RequestProtocol {
  case auth
  // 2
  var path: String {
    "/v2/oauth2/token"
  }
  // 3
  var params: [String: Any] {
    [
      "grant_type": APIConstants.grantType,
      "client_id": APIConstants.clientId,
      "client_secret": APIConstants.clientSecret
    ]
  }
  // 4
  var addAuthorizationToken: Bool {
    false
  }
  // 5
  var requestType: RequestType {
    .POST
  }
}

Here, you:

  1. Declare an enum called AuthTokenRequest that conforms to RequestProtocol and has one case auth.
  2. Add path, which returns the endpoint to fetch the token.
  3. Implement params and assign a key-value with the credentials to make the request. Make sure to update clientId and clientSecret in APIConstants.swift with your keys.
  4. Since it’s the authentication token fetch request itself, addAuthorizationToken is false.
  5. For this request, requestType needs to be POST.

The Petfinder API returns authentication token’s JSON that looks like this:

{
  "token_type": "Bearer",
  "expires_in": 3600,
  "access_token": "..."
}

Navigate to api/model and open APIToken.swift. The APIToken struct maps to the JSON above.

Now that you’ve written the request for an authentication token, you can write the method for fetching.

Open APIManager.swift and update the protocol with this code:

protocol APIManagerProtocol {
  func perform(_ request: RequestProtocol, authToken: String) async throws -> Data
  func requestToken() async throws -> Data
}

This newly introduced method returns you the authentication token data. Now implement it in APIManager class like this:

func requestToken() async throws -> Data {
  try await perform(AuthTokenRequest.auth)
}

Here, you use the same perform method you declared above and the AuthTokenRequest enum request you just created.

Creating the authentication token call

Now that you’ve set up everything to fetch a token, you’ll create the authentication token call. Open RequestManager.swift and add this method to RequestManager:

func requestAccessToken() async throws -> String {
  // 1
  let data = try await apiManager.requestToken()
  // 2
  let token: APIToken = try parser.parse(data: data)
  // 3
  return token.bearerAccessToken
}

Here, you:

  1. Fetch the token based on the AuthTokenRequest. It returns either a Data object or throws an Error.
  2. Parse the token and map it to APIToken.
  3. Return the authentication token.

Now update your perform(_:) implementation in RequestManager with:

func perform<T: Decodable>(_ request: RequestProtocol)
  async throws -> T {
    // 1
    let authToken = try await requestAccessToken()
    // 2
    let data = try await apiManager.perform(request,
      authToken: authToken)

    // 3
    let decoded: T = try parser.parse(data: data)
    return decoded
}

Once you update this, the error in this method disappears. Now take a look at the code:

  1. You get the authentication token and store it in authToken.
  2. Then, you pass the authentication token to the perform(_:) of the APIManager object to add it to the URLRequest.
  3. You decode and return the result of parsing data into the specific T type.

For now, it’s OK to fetch the token each time. Later, you’ll use the existing AccessTokenManager under api/token to persist the token locally.

Animals fetching request

The Petfinder API lets you fetch animals depending on your location. You can also query by name, age or type of the animal. Check out the entire list of fields that you can query at Petfinder’s official documentation.

Now you’ll write the request to fetch the animals from the Petfinder API. First, create an animals group under Core/data/api/request.

Inside animals, create a file called AnimalsRequest.swift and add:

// 1
enum AnimalsRequest: RequestProtocol {
  case getAnimalsWith(
    page: Int, latitude: Double?, longitude: Double?)
  case getAnimalsBy(name: String, age: String?, type: String?)
  // 2
  var path: String {
    "/v2/animals"
  }
  // 3
  var urlParams: [String: String?] {
    switch self {
    case let .getAnimalsWith(page, latitude, longitude):
      var params = ["page": String(page)]
      if let latitude = latitude {
        params["latitude"] = String(latitude)
      }

      if let longitude = longitude {
        params["longitude"] = String(longitude)
      }
      params["sort"] = "random"
      return params

    case let .getAnimalsBy(name, age, type):
      var params: [String: String] = [:]
      if !name.isEmpty {
        params["name"] = name
      }

      if let age = age {
        params["age"] = age
      }

      if let type = type {
        params["type"] = type
      }
      return params
    }
  }
  // 4
  var requestType: RequestType {
    .GET
  }
}

This code:

  1. Creates an enum called AnimalsRequest that conforms to RequestProtocol. This enum has two cases: getAnimalsWith(page:latitude:longitude:) and getAnimalsBy(name:age:type:).
  2. Makes path return the endpoint to fetch animals from the Petfinder API.
  3. urlParams creates the query parameters depending on the current case. For the first case, it adds page to the query parameters and the latitude and longitude if it exists. For the latter case, it adds name along with age and type if it’s not nil. You also pass random to the sort param so that you can get random results with that location.
  4. requestType is GET since this is a request to get data from the API.

Adjusting domain models

Before you test it, there’s one thing more you need to do. The current domain models can’t work as they are right now. The Petfinder API returns data like this:

{
   "animals": [
      // animals
   ],
   "pagination": {
      // pagination data
   }
}

The list of animals isn’t directly part of the body; it’s inside a key called animals. You need to create another model to work with the Petfinder API JSON.

Navigate to Core/domain/model/animal and create a file named AnimalsContainer.swift. Inside it, add

struct AnimalsContainer: Decodable {
  let animals: [Animal]
  let pagination: Pagination
}

You created a structure that works with the corresponding returned data. Now the animal list and the pagination data can be mapped appropriately.

Fetching and presenting animals

Time for you to use the newly created request to display the animals list. Open AnimalsNearYouView.swift and add this line at the top, inside AnimalsNearYouView:

private let requestManager = RequestManager()  

This code creates an instance of RequestManager.

Then, at the bottom add this:

func fetchAnimals() async {
  do {
    // 1
    let animalsContainer: AnimalsContainer =
      try await requestManager.perform(AnimalsRequest.getAnimalsWith(
        page: 1,
        latitude: nil,
        longitude: nil))
    // 2
    self.animals = animalsContainer.animals
    // 3
    await stopLoading()
  } catch {}
}

This code:

  1. Calls perform(_:) and stores the result in animalsContainer. Since this method uses generics, you need to indicate the type, in this case, AnimalsContainer. You pass 1 to the page as an argument and nil to latitude and longitude because you won’t work with location or pagination in this chapter.
  2. Stores the list of animals returned by the request in animals.
  3. Calls stopLoading().

Then, in the body of AnimalsNearYouView, replace the current NavigationView implementation with:

NavigationView {
  // 1
  List {
    ForEach(animals) { animal in
      AnimalRow(animal: animal)
    }
  }
  // 2
  .task {
    await fetchAnimals()
  }
  .listStyle(.plain)
  .navigationTitle("Animals near you")
  // 3
  .overlay {
    if isLoading {
      ProgressView("Finding Animals near you...")
    }
  }
}.navigationViewStyle(StackNavigationViewStyle())

Here’s what this code does:

  1. Sets up a List with a ForEach that creates an AnimalRow for each animal.
  2. Uses task(priority:_:) to call fetchAnimals(). Since this is an asynchronous method, you need to use await so the system can handle it properly.
  3. Adds an overlay(alignment:content:) that will show a ProgressView when isLoading is true.

There’s another View that can be helpful when handling external requests: AsyncImage. Open AnimalRow.swift. You’ll see the following code inside:

AsyncImage(
  url: animal.picture,
  content: { image in image
    .resizable()
  }, placeholder: {
    Image("rw-logo")
      .resizable()
      .overlay {
        if animal.picture != nil {
          ProgressView()
            .frame(maxWidth: .infinity,
              maxHeight: .infinity)
            .background(.gray.opacity(0.4))
        }
      }
})
.aspectRatio(contentMode: .fit)
.frame(width: 112, height: 112)
.cornerRadius(8)

What is AsyncImage? AsyncImage is a view that loads and displays images asynchronously. It makes it easier to download images without blocking the UI. In this case, it displays an image using animal.picture, which contains the URL for the Pet’s photo.

Note: To read more about AsyncImage visit Apple’s official documentation.

Finally, build and run. You’ll see the list of animals, although your list might have different animal names:

List of animals from network.
List of animals from network.

Wooohoooo!!! Your app is already using the networking layer.

Using AccessTokenManager to persist token

To avoid re-fetching the token before it expires, you need to tweak your RequestManager a bit. Open RequestManager.swift and update like this:

class RequestManager: RequestManagerProtocol {
  let apiManager: APIManagerProtocol
  let parser: DataParserProtocol
  let accessTokenManager: AccessTokenManagerProtocol

  init(
    apiManager: APIManagerProtocol = APIManager(),
    parser: DataParserProtocol = DataParser(),
    accessTokenManager: AccessTokenManagerProtocol = AccessTokenManager()
  ) {
    self.apiManager = apiManager
    self.parser = parser
    self.accessTokenManager = accessTokenManager
  }

  // ...  

Here, you add accessTokenManager and update the initializer.

Now, update requestAccessToken with:

func requestAccessToken() async throws -> String {
  // 1
  if accessTokenManager.isTokenValid() {
    return accessTokenManager.fetchToken()
  }
  // 2
  let data = try await apiManager.requestToken()
  let token: APIToken = try parser.parse(data: data)
  // 3
  try accessTokenManager.refreshWith(apiToken: token)
  return token.bearerAccessToken
}

Here’s what you did:

  1. If the saved token is valid, isTokenValid returns true.
  2. If there is no saved token, it fetches the token again.
  3. Then, it refreshes the authentication token stored in UserDefaults.

To test this, place a breakpoint at return accessTokenManager.fetchToken. Keep a close eye on Xcode’s variables view.

Finally, build and run. The app needs to save the token once to return it on the next run. Then build and run again. The app will return the already saved token.

Breakpoint at accessTokenManager.fetchToken
Breakpoint at accessTokenManager.fetchToken

That’s awesome, right? You’ve finally done it, so it’s time to move on to the next stage.

Writing unit tests

Tests are an essential part of an app. When writing unit tests that involve network calls, you use mock data rather than making an actual network call. That’s going to be the case here. For writing tests, you’ll use the existing mock data you have in Preview Content/AnimalsMock.json.

Before you write any tests, you need to do some initial setup, some mocking, to be precise.

To keep the tests’ files organized in a structure, start by creating a few groups. First create this group structure: PetSaveTests/Tests/Core/data/api.

Then create two more groups under api: mock and helper.

Your groups will look like this:

Group structure
Group structure

Now, create APIManagerMock.swift under mock. Add this code:

// 1
@testable import PetSave

// 2
struct APIManagerMock: APIManagerProtocol {
  // 3
  func perform(_ request: RequestProtocol, authToken: String) async throws -> Data {
    return try Data(contentsOf: URL(fileURLWithPath: request.path), options: .mappedIfSafe)
  }
   // 4
  func requestToken() async throws -> Data {

  }
}

Here’s what you did:

  1. Import PetSave using @testable attribute. @testable compiles the module with testing enabled. In this module, the public class or struct and its members now behave like they are open and the ones marked with an internal behave like they are public.
  2. Create APIManagerMock and make it conform to APIManagerProtocol.
  3. Since request is of type RequestProtocol, it has a property path. path will contain the location of the mock file. This function uses this information to get the file and convert its content to Data.
  4. You return a dummy token from here. Leave it empty for now.

Then, under helper, create a file named AccessTokenTestHelper.swift. Add this code:

@testable import PetSave

enum AccessTokenTestHelper {
  // 1
  static func randomString() -> String {
    let letters = "abcdefghijklmnopqrstuvwxyz"
    return String(letters.shuffled().prefix(8))
  }
  // 2
  static func randomAPIToken() -> APIToken {
    return APIToken(tokenType: "Bearer", expiresIn: 10,
      accessToken: AccessTokenTestHelper.randomString())
  }
  // 3
  static func generateValidToken() -> String {
    """
    {
      "token_type": "Bearer",
      "expires_in": 10,
      "access_token": \"\(randomString())\"
    }
    """
  }
}

Here’s a code breakdown:

  1. Returns a random string of length eight.
  2. Returns a random APIToken using randomString.
  3. Generates random token data similar to the one the apps received from the Petfinder API.

Now, go back to APIManagerMock.swift and update the requestToken() method like this:

  func requestToken() async throws -> Data {
    Data(AccessTokenTestHelper.generateValidToken().utf8)
  }

Here you return the dummy token you created in the form of string json as Data.

Create a file named AnimalsRequestMock.swift under mock. Add:

@testable import PetSave

enum AnimalsRequestMock: RequestProtocol {
  case getAnimals
  // 1
  var requestType: RequestType {
    return .GET
  }
  // 2
  var path: String {
    guard let path = Bundle.main.path(
      forResource: "AnimalsMock", ofType: "json")
    else { return "" }
    return path
  }
}

This code:

  1. Sets the requestType. For this case, it could be anything since this is a mock.
  2. Reads the path for AnimalsMock.json in Bundle.main if available. If not, it sets it to an empty string.

You’ve completed the setup. Now, you can start writing tests.

First, you’ll write tests for RequestManager to check if the data is parsing correctly. To do this, navigate to api and create RequestManagerTests.swift. Then, add:

import XCTest
@testable import PetSave

class RequestManagerTests: XCTestCase {
  private var requestManager: RequestManagerProtocol?

  override func setUp() {
    super.setUp()
    // 1
    guard let userDefaults = UserDefaults(suiteName: #file) else {
      return
    }

    userDefaults.removePersistentDomain(forName: #file)

    // 2
    requestManager = RequestManager(
      apiManager: APIManagerMock(),
      accessTokenManager: AccessTokenManager(userDefaults: userDefaults)
    )
}

This code:

  1. Gets a reference to a UserDefaults instance and removes all its content. It returns early in case of any errors.
  2. Initializes requestManager with mock objects.

This code will execute before each test, so you’ll get a fresh instance of RequestManager every time.

Finally, add the following test:

func testRequestAnimals() async throws {
  // 1
  guard let container: AnimalsContainer =
    try await requestManager?.perform(
      AnimalsRequestMock.getAnimals) else {
      XCTFail("Didn't get data from the request manager")
      return
    }

  let animals = container.animals

  // 2
  let first = animals.first
  let last = animals.last

  // 3
  XCTAssertEqual(first?.name, "Kiki")
  XCTAssertEqual(first?.age.rawValue, "Adult")
  XCTAssertEqual(first?.gender.rawValue, "Female")
  XCTAssertEqual(first?.size.rawValue, "Medium")
  XCTAssertEqual(first?.coat?.rawValue, "Short")

  XCTAssertEqual(last?.name, "Midnight")
  XCTAssertEqual(last?.age.rawValue, "Adult")
  XCTAssertEqual(last?.gender.rawValue, "Female")
  XCTAssertEqual(last?.size.rawValue, "Large")
  XCTAssertEqual(last?.coat, nil)
}

This test:

  1. Fetches animals from the local JSON. guard checks that some data is returned and fails the test if any errors occur.
  2. To keep it simple, tests the first and last animal object.
  3. Tests if objects are the same as you expected.

Build and run the test. You’ll see the test pass.

Test Results - testRequestAnimals.
Test Results - testRequestAnimals.

Challenge

It’s time you take control and create tests yourself.

Create tests for AccessTokenManager. Cover the following scenarios:

  1. Test that when you call AccessTokenManager.fetchToken() it returns a non-empty value. Call this test testRequestToken().
  2. Using a previously stored token, check that the code returns the same token if you call AccessTokenManager.fetchToken() again. Call this test testCachedToken().
  3. Test if you can refresh the token and get a new one using AccessTokenManager.refreshWith(apiToken:). Call this test testRefreshToken()

So, how do you do that? Here are some hints that might help.

  • Create a file called AccessTokenManagerTests.swift. Initialize the object with UserDefaults like you did in RequestManagerTests. In the test class’s setup method, persist a dummy token and an expiry date to that UserDefaults object.
  • For writing testRefreshToken(), you need to use a combination of methods that includes both fetchToken() and refreshWith(apiToken:) from the AccessTokenManager object.
  • You’ll need help from AccessTokenTestHelper when writing these tests.

Give it a try! Check the solution in the challenge folder if you get stuck.

Key points

  • A data layer provides a layer of abstraction. It may consist of data from different providers.
  • Data layers process data and then hand it to the domain layer.
  • async/await helps you write asynchronous code without using completion handlers. Async/await makes your code readable by using structured concurrency.
  • AsyncImage is a view that lets you render images from the network using a URL without blocking the main thread.
  • Protocols help with testing and make mocking of classes and structs easier. They also help with abstraction.
  • Use mocks to make your tests fast and reliable.

Where to go from here?

Good job! You implemented your very first Network layer. With this, you reach the end of this chapter. This chapter was loaded with new concepts. You worked on the data layer and networking. You learned about the importance of high cohesion and how it leads you to write testable code.

If you want to learn more about concurrency, including async/await and Task, check out our book Modern Concurrency in Swift.

You can also check out the official documentation on Concurrency and Task if you need more details.

In the next chapter, you’ll learn about the other part of the data layer, databases. You’ll persist the data you get from the remote server using Core Data.

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.