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/awaitAPI. -
Display fetched data using SwiftUI.
-
Implement a token refresh mechanism.
Getting started
Open starter project and build and run. You’ll see:
By the end of this chapter, you’ll create a network layer and render Petfinder API data. Your goal is to reach here:
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:
- This property is the endpoint usually attached at the end of the base url.
- These are the
headersandparamsyou want to send with the request. The content ofparamswill act as the request’s body. - You’ll use this dictionary to attach query params in the URL.
- This boolean represents if your request needs to add the authorization token.
- 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:
- This is the app’s base URL. Since there is only one, there’s no need to add the protocol definitions.
- By default, every request has an authorization token.
- Some requests don’t require
params,urlParamsandheaders, 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:
- Use
RequestProtocol.createURLRequest(authToken:)to create the request with an authorization token which throws an error in case of failures like an invalid URL. - You set up the base components of the URL by setting
scheme,hostandpath. - Then you add
urlParamsto url components if it’s not empty. - Create an
URLRequestusingurl. - If you need to add any headers to the request, add them to the
allHTTPHeaderFields. - Add an authorization token to the request if
addAuthorizationTokenistrue. - The Petfinder API expects data to be of type JSON. So, set the request’s content type to
application/json. - Finally, you add non-empty parameters to the request as data in the
httpBody. Since Petfinder API works with JSON, you serialize theparamsusingNSJSONSerialization.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:
throwsisn’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:
-
Create the
URLRequestwith the URL. -
A
Taskcreates an asynchronous environment forasyncmethods to execute in. You useTaskto provide an asynchronous container forperform(_:). This isn’t necessary inside anasyncmethod. -
Using
urlRequest, you initialize the request. Every method marked with anasyncmust useawaitwhile calling it. The method either returns the data or throws an error. -
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:
- You use the
URLSessionto fetch the data. - If there’s an error, you call the failure completion handler.
- The
guardstatement checks if data is there and the status is 200 from the response. - 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:
- Indicates
APIManagermust conform toAPIManagerProtocol. - Creates a private variable to store the
URLSession. - Passes in the initializer the default shared
URLSession.sharedprovides a singleton that returns aURLSession.
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
URLSessionConfigurationin 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:
-
URLSession.data(for:)uses async/await to process a request and return data and anURLResponse. Here you usedtrybecause it can also throw an error. - You check if the response code is
200. If this condition passes,datais returned. - If their response isn’t successful, you return
invalidServerResponse.NetworkErroris 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:
- You set up the initializer and set
apiManagerwith a default value. - Navigate to Core/data/api/parser, and you’ll see
DataParser. It conforms to a protocolDataParserProtocolthat implements a method that takes inDataand returns a genericDecodable. Then it usesJSONDecoderto decodeDatainto aDecodable. Here you simply give the object a default value. - Implement
perform(_:). Inside it, you callperformfromAPIManagerto 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,classorenumto create a request. For this app, you’ll useenums.
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:
- Declare an enum called
AuthTokenRequestthat conforms toRequestProtocoland has one caseauth. - Add
path, which returns the endpoint to fetch the token. - Implement
paramsand assign a key-value with the credentials to make the request. Make sure to updateclientIdandclientSecretin APIConstants.swift with your keys. - Since it’s the authentication token fetch request itself,
addAuthorizationTokenisfalse. - For this request,
requestTypeneeds to bePOST.
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:
- Fetch the token based on the
AuthTokenRequest. It returns either aDataobject or throws anError. - Parse the token and map it to
APIToken. - 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:
- You get the authentication token and store it in
authToken. - Then, you pass the authentication token to the
perform(_:)of theAPIManagerobject to add it to theURLRequest. - You decode and return the result of parsing
datainto the specificTtype.
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:
- Creates an enum called
AnimalsRequestthat conforms toRequestProtocol. This enum has two cases:getAnimalsWith(page:latitude:longitude:)andgetAnimalsBy(name:age:type:). - Makes
pathreturn the endpoint to fetch animals from the Petfinder API. -
urlParamscreates the query parameters depending on the current case. For the first case, it addspageto the query parameters and the latitude and longitude if it exists. For the latter case, it addsnamealong withageandtypeif it’s notnil. You also passrandomto thesortparam so that you can get random results with that location. -
requestTypeisGETsince 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:
- Calls
perform(_:)and stores the result inanimalsContainer. Since this method uses generics, you need to indicate the type, in this case,AnimalsContainer. You pass1to thepageas an argument andniltolatitudeandlongitudebecause you won’t work with location or pagination in this chapter. - Stores the list of animals returned by the request in
animals. - 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:
- Sets up a
Listwith aForEachthat creates anAnimalRowfor each animal. - Uses
task(priority:_:)to callfetchAnimals(). Since this is an asynchronous method, you need to useawaitso the system can handle it properly. - Adds an
overlay(alignment:content:)that will show aProgressViewwhenisLoadingis 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
AsyncImagevisit Apple’s official documentation.
Finally, build and run. You’ll see the list of animals, although your list might have different animal names:
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:
- If the saved token is valid,
isTokenValidreturnstrue. - If there is no saved token, it fetches the token again.
- 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.
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:
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:
- Import PetSave using
@testableattribute.@testablecompiles the module with testing enabled. In this module, thepublicclass or struct and its members now behave like they areopenand the ones marked with aninternalbehave like they arepublic. - Create
APIManagerMockand make it conform toAPIManagerProtocol. - Since
requestis of typeRequestProtocol, it has a propertypath.pathwill contain the location of the mock file. This function uses this information to get the file and convert its content toData. - 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:
- Returns a random string of length eight.
- Returns a random
APITokenusingrandomString. - 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:
- Sets the
requestType. For this case, it could be anything since this is a mock. - Reads the path for
AnimalsMock.jsoninBundle.mainif 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:
- Gets a reference to a
UserDefaultsinstance and removes all its content. It returns early in case of any errors. - Initializes
requestManagerwith 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:
- Fetches animals from the local JSON.
guardchecks that some data is returned and fails the test if any errors occur. - To keep it simple, tests the first and last animal object.
- Tests if objects are the same as you expected.
Build and run the test. You’ll see the test pass.
Challenge
It’s time you take control and create tests yourself.
Create tests for AccessTokenManager. Cover the following scenarios:
- Test that when you call
AccessTokenManager.fetchToken()it returns a non-empty value. Call this testtestRequestToken(). - Using a previously stored token, check that the code returns the same token if you call
AccessTokenManager.fetchToken()again. Call this testtestCachedToken(). - Test if you can refresh the token and get a new one using
AccessTokenManager.refreshWith(apiToken:). Call this testtestRefreshToken()
So, how do you do that? Here are some hints that might help.
- Create a file called AccessTokenManagerTests.swift. Initialize the object with
UserDefaultslike you did inRequestManagerTests. In the test class’s setup method, persist a dummy token and an expiry date to thatUserDefaultsobject. - For writing
testRefreshToken(), you need to use a combination of methods that includes bothfetchToken()andrefreshWith(apiToken:)from theAccessTokenManagerobject. - You’ll need help from
AccessTokenTestHelperwhen 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.
-
AsyncImageis 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.