In the previous episode, we extended the API client with the ability to fetch the video for an episode. Because videos are protected resources, the request includes an Authorization header with an access token as its value. The solution we implemented works, but it is tedious to pass the access token to the API client and the object invoking the video(id:accessToken:) method shouldn't need to deal with access tokens. That is a responsibility of the API client.
Taking a Step Back
The current implementation is a bit naive. The implementation I have in mind is a bit more advanced, but it isn't that difficult if we break it down bit by bit.
We start by updating the APIService protocol. Open APIService.swift. The video(id:accessToken:) method no longer accepts an access token because the caller of the video(id:accessToken:) method shouldn't need to deal with access tokens or even know about them.
import Combine
import Foundation
protocol APIService {
// MARK: - Properties
func signIn(email: String, password: String) -> AnyPublisher<SignInResponse, APIError>
func episodes() -> AnyPublisher<[Episode], APIError>
func video(id: String) -> AnyPublisher<Video, APIError>
}
This means we also need to update the types conforming to the APIService protocol. Open APIPreviewClient.swift and update the method signature of the video(id:accessToken:) method by removing the accessToken parameter. The implementation remains unchanged.
func video(id: String) -> AnyPublisher<Video, APIError> {
Just(stubData(for: "video"))
.setFailureType(to: APIError.self)
.eraseToAnyPublisher()
}
Open APIClient.swift and remove the accessToken parameter of the video(id:accessToken:) method. This change implies that we also need to remove the accessToken associated value of the video case of APIEndpoint.
func video(id: String) -> AnyPublisher<Video, APIError> {
request(.video(id: id))
}
Let's update the implementation of VideoViewModel before we update the implementation of APIEndpoint. Open VideoViewModel.swift and remove the accessToken parameter from the video(id:accessToken:) method in fetchVideo(with:).
private func fetchVideo(with videoID: String) {
isFetching = true
apiService.video(id: videoID)
.sink(receiveCompletion: { [weak self] completion in
self?.isFetching = false
switch completion {
case .finished:
print("Successfully Fetched Video")
case .failure(let error):
print("Unable to Fetch Video \(error)")
}
}, receiveValue: { [weak self] video in
self?.player = AVPlayer(url: video.videoURL)
}).store(in: &subscriptions)
}
Updating the API Endpoint
Open APIEndpoint.swift and remove the accessToken associated value of the video case.
import Foundation
enum APIEndpoint {
// MARK: - Cases
case auth(email: String, password: String)
case episodes
case video(id: String)
...
}
We also need to update the computed path and headers properties.
private var path: String {
switch self {
case .auth:
return "auth"
case .episodes:
return "episodes"
case let .video(id: id):
return "videos/\(id)"
}
}
private var headers: Headers {
var headers: Headers = [
"Content-Type": "application/json",
"X-API-TOKEN": Environment.apiToken
]
if case let .auth(email: email, password: password) = self {
let authData = (email + ":" + password).data(using: .utf8)!
let encodedAuthData = authData.base64EncodedString()
headers["Authorization"] = "Basic \(encodedAuthData)"
}
return headers
}
You may be wondering how we plan to add the Authorization header to the request. We have two options. The first option is putting the API client in charge of this. This would work, but it would mean that the configuration of the request is no longer encapsulated by the APIEndpoint enum. That isn't a major issue, but I would like to avoid that if possible. The second option is keeping the configuration encapsulated in the APIEndpoint enum. That is the option I prefer, but how do we do that?
We convert the computed request property to a method. The method accepts a single argument, accessToken, of type String?. The API client passes the access token to the request(accessToken:) method if the user is signed in.
func request(accessToken: String?) -> URLRequest {
var request = URLRequest(url: url)
request.addHeaders(headers)
request.httpMethod = httpMethod.rawValue
return request
}
We use optional binding to safely access the access token and add the Authorization header to the request as we did in the computed headers property. Remember that the value of the Authorization header is the access token prefixed with the word Bearer.
func request(accessToken: String?) -> URLRequest {
var request = URLRequest(url: url)
request.addHeaders(headers)
request.httpMethod = httpMethod.rawValue
if let accessToken = accessToken {
request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
}
return request
}
There is one more improvement I would like to make. We should only add the Authorization header if the API client accesses a protected resource. We define a private, computed property, requiresAuthorization, of type Bool. We use a switch statement to return false for the auth and episodes cases and true for the video case.
private var requiresAuthorization: Bool {
switch self {
case .auth,
.episodes:
return false
case .video:
return true
}
}
Revisit the request(accessToken:) method. We only add the Authorization header if the accessToken parameter has a value and requiresAuthorization is equal to true.
func request(accessToken: String?) -> URLRequest {
var request = URLRequest(url: url)
request.addHeaders(headers)
request.httpMethod = httpMethod.rawValue
if requiresAuthorization, let accessToken = accessToken {
request.addValue("Authorization", forHTTPHeaderField: "Bearer \(accessToken)")
}
return request
}
With the APIEndpoint enum updated, it is time to update the APIClient class. We need to make a few changes to the request(_:) method. We create an instance of the KeychainService class and request the access token stored in the keychain. We create a URL request by invoking the request(accessToken:) method on the APIEndpoint object, passing in the access token. That's it.
private func request<T: Decodable>(_ endpoint: APIEndpoint) -> AnyPublisher<T, APIError> {
let keychainService = KeychainService()
let accessToken = keychainService.accessToken
let request = endpoint.request(accessToken: accessToken)
return URLSession.shared.dataTaskPublisher(for: request)
...
}
Extending APIError
Because we are dealing with a protected resource, we need to revisit how we handle errors. If the user attempts to access a protected resource without being signed in, the mock API returns a 401 or unauthorized response. Open APIError.swift and add a case with name unauthorized.
enum APIError: Error {
// MARK: - Cases
case unknown
case unreachable
case unauthorized
case failedRequest
case invalidResponse
}
Open APIClient.swift. We need to update the closure we pass to the tryMap operator. We use a guard statement to safely access the status code of the response. This means we first need to cast the URLResponse object to HTTPURLResponse. Even though the cast should never fail, we throw a failedRequest error if it does.
guard let statusCode = (response as? HTTPURLResponse)?.statusCode else {
throw APIError.failedRequest
}
We use another guard statement to make sure the status code falls within the success range. In the else clause, we use an if statement to check if the status code of the response is equal to 401. We throw an unauthorized error if it is.
guard (200..<300).contains(statusCode) else {
if statusCode == 401 {
throw APIError.unauthorized
} else {
throw APIError.failedRequest
}
}
With these changes in place, we can update the implementation of the APIErrorMapper struct. We start by adding a case with name video to the Context enum.
enum Context {
case signIn
case episodes
case video
}
Because we extended the APIError enum with the unauthorized case, we also need to update the body of the computed message property. We use a switch statement to switch on the Context object. If the value of context is equal to video we inform the user that they need to be signed in to watch the video of the episode. We use a default case to return a more generic message. We also return a custom error message for the unknown, failedRequest, and invalidResponse cases if the value of context is equal to video.
var message: String {
switch error {
case .unreachable:
return "You need to have a network connection."
case .unauthorized:
switch context {
case .video:
return "You need to be signed in to watch this episode."
default:
return "You need to be signed in."
}
case .unknown,
.failedRequest,
.invalidResponse:
switch context {
case .signIn:
return "The email/password combination is invalid."
case .episodes:
return "The list of episodes could not be fetched."
case .video:
return "The video could not be fetched."
}
}
}
Displaying the Error Message
Open VideoViewModel.swift and navigate to the fetchVideo(with:) method. We create an APIErrorMapper object in the failure case, passing in the APIError object and video as the context. We assign the value of the computed message property to the view model's errorMessage property.
private func fetchVideo(with videoID: String) {
isFetching = true
apiService.video(id: videoID)
.sink(receiveCompletion: { [weak self] completion in
self?.isFetching = false
switch completion {
case .finished:
print("Successfully Fetched Video")
case .failure(let error):
print("Unable to Fetch Video \(error)")
self?.errorMessage = APIErrorMapper(
error: error,
context: .video
).message
}
}, receiveValue: { [weak self] video in
self?.player = AVPlayer(url: video.videoURL)
}).store(in: &subscriptions)
}
To display the message, we need to update the VideoView struct. We keep it simple. In the else clause of the ZStack, we use optional binding to safely unwrap the value of the view model's errorMessage property. We use the error message to create a Text object, setting the foreground color to white. In the else clause, we display the circular progress view.
var body: some View {
ZStack {
Color.black
if let player = viewModel.player {
VideoPlayer(player: player)
} else {
if let errorMessage = viewModel.errorMessage {
Text(errorMessage)
.foregroundColor(.white)
} else {
ProgressView()
.tint(.white)
.progressViewStyle(.circular)
}
}
}
.ignoresSafeArea()
.statusBar(hidden: true)
}
Exit Early
The application requests the video for an episode even if the user isn't signed in. This is a bit wasteful because we know the request is bound to fail. We can improve the implementation by throwing an error as early as the API client knows the request is bound to fail. Open APIEndpoint.swift and mark the request(accessToken:) method as throwing. We throw an error if the endpoint requires authorization and access token is equal to nil.
func request(accessToken: String?) throws -> URLRequest {
var request = URLRequest(url: url)
request.addHeaders(headers)
request.httpMethod = httpMethod.rawValue
if requiresAuthorization {
if let accessToken = accessToken {
request.addValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
} else {
throw APIError.unauthorized
}
}
return request
}
Head back to APIClient.swift. We use a do-catch statement to create the URL request, prefixing the request(accessToken:) method call with the try keyword. In the catch clause, we cast the error to APIError. That case should always succeed, but we play it safe and handle the case in which the cast fails by falling back to an unknown error.
The catch clause returns a publisher that immediately terminates with an error. We use the Fail struct for this purpose. We need to wrap the publisher with a type eraser to keep the compiler happy.
private func request<T: Decodable>(_ endpoint: APIEndpoint) -> AnyPublisher<T, APIError> {
do {
let keychainService = KeychainService()
let accessToken = keychainService.accessToken
let request = try endpoint.request(accessToken: accessToken)
return URLSession.shared.dataTaskPublisher(for: request)
...
} catch {
if let apiError = error as? APIError {
return Fail(error: apiError)
.eraseToAnyPublisher()
} else {
return Fail(error: APIError.unknown)
.eraseToAnyPublisher()
}
}
}
Even though the implementation of the request(_:) method is a bit more complex, the application no longer accesses a protected resource if the user isn't signed in. We could do the same if the application doesn't have a network connection. In that scenario, it doesn't make sense for the API client to send a request as we know it is bound to fail. I leave that as an exercise for you.
What's Next?
The changes we made improved the implementation. The video view model no longer passes an access token to the video(id:) method of the API client. That is an improvement. We also introduced a problem, though. The implementation of the APIClient class is tightly coupled to that of the KeychainService class. That is something we resolve in the next episode.