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:
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
headers
andparams
you want to send with the request. The content ofparams
will 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
,urlParams
andheaders
, 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
,host
andpath
. - Then you add
urlParams
to url components if it’s not empty. - Create an
URLRequest
usingurl
. - If you need to add any headers to the request, add them to the
allHTTPHeaderFields
. - Add an authorization token to the request if
addAuthorizationToken
istrue
. - 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 theparams
usingNSJSONSerialization.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:
-
Create the
URLRequest
with the URL. -
A
Task
creates an asynchronous environment forasync
methods to execute in. You useTask
to provide an asynchronous container forperform(_:)
. This isn’t necessary inside anasync
method. -
Using
urlRequest
, you initialize the request. Every method marked with anasync
must useawait
while 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
URLSession
to fetch the data. - If there’s an error, you call the failure completion handler.
- The
guard
statement 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
APIManager
must conform toAPIManagerProtocol
. - Creates a private variable to store the
URLSession
. - Passes in the initializer the default shared
URLSession
.shared
provides 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
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:
-
URLSession.data(for:)
uses async/await to process a request and return data and anURLResponse
. Here you usedtry
because it can also throw an error. - You check if the response code is
200
. If this condition passes,data
is returned. - 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:
- You set up the initializer and set
apiManager
with a default value. - Navigate to Core/data/api/parser, and you’ll see
DataParser
. It conforms to a protocolDataParserProtocol
that implements a method that takes inData
and returns a genericDecodable
. Then it usesJSONDecoder
to decodeData
into aDecodable
. Here you simply give the object a default value. - Implement
perform(_:)
. Inside it, you callperform
fromAPIManager
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
orenum
to create a request. For this app, you’ll useenum
s.
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
AuthTokenRequest
that conforms toRequestProtocol
and has one caseauth
. - Add
path
, which returns the endpoint to fetch the token. - Implement
params
and assign a key-value with the credentials to make the request. Make sure to updateclientId
andclientSecret
in APIConstants.swift with your keys. - Since it’s the authentication token fetch request itself,
addAuthorizationToken
isfalse
. - For this request,
requestType
needs 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 aData
object 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 theAPIManager
object to add it to theURLRequest
. - You decode and return the result of parsing
data
into the specificT
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:
- Creates an enum called
AnimalsRequest
that conforms toRequestProtocol
. This enum has two cases:getAnimalsWith(page:latitude:longitude:)
andgetAnimalsBy(name:age:type:)
. - Makes
path
return the endpoint to fetch animals from the Petfinder API. -
urlParams
creates the query parameters depending on the current case. For the first case, it addspage
to the query parameters and the latitude and longitude if it exists. For the latter case, it addsname
along withage
andtype
if it’s notnil
. You also passrandom
to thesort
param so that you can get random results with that location. -
requestType
isGET
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:
- Calls
perform(_:)
and stores the result inanimalsContainer
. Since this method uses generics, you need to indicate the type, in this case,AnimalsContainer
. You pass1
to thepage
as an argument andnil
tolatitude
andlongitude
because 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
List
with aForEach
that creates anAnimalRow
for each animal. - Uses
task(priority:_:)
to callfetchAnimals()
. Since this is an asynchronous method, you need to useawait
so the system can handle it properly. - Adds an
overlay(alignment:content:)
that will show aProgressView
whenisLoading
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:
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,
isTokenValid
returnstrue
. - 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
@testable
attribute.@testable
compiles the module with testing enabled. In this module, thepublic
class or struct and its members now behave like they areopen
and the ones marked with aninternal
behave like they arepublic
. - Create
APIManagerMock
and make it conform toAPIManagerProtocol
. - Since
request
is of typeRequestProtocol
, it has a propertypath
.path
will 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
APIToken
usingrandomString
. - 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.json
inBundle.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:
- Gets a reference to a
UserDefaults
instance and removes all its content. It returns early in case of any errors. - 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:
- Fetches animals from the local JSON.
guard
checks 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
UserDefaults
like you did inRequestManagerTests
. In the test class’s setup method, persist a dummy token and an expiry date to thatUserDefaults
object. - For writing
testRefreshToken()
, you need to use a combination of methods that includes bothfetchToken()
andrefreshWith(apiToken:)
from theAccessTokenManager
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.