In this and the next episodes, we add the ability for the user to watch an episode. For that to work, the application needs to fetch the video for the episode from the mock API. Fetching a video is similar to fetching the list of episodes. The difference is that the user needs to be signed in to fetch a video because a video is a protected resource. The request to the /videos/:id endpoint needs to include an Authorization header. The value of the Authorization header is the access token the application receives after successfully signing in.

Defining the Video Model

Let's start by creating a model for a video. Add a Swift file to the Models group and name it Video.swift. Define a struct with name Video that conforms to the Decodable protocol. The Video struct defines three properties, duration of type Int, imageURL of type URL, and videoURL of type URL.

import Foundation

struct Video: Decodable {

    // MARK: - Properties

    let duration: Int

    let imageURL: URL
    let videoURL: URL

}

The next step is adding stub data for the /videos/:id endpoint. We use the stub data to update the implementation of the APIPreviewClient struct later in this episode. Add an empty file to the Preview Content > Stubs group, name it video.json, and populate it with stub data.

{
  "duration": 279,
  "videoURL": "https:\/\/player.vimeo.com\/external\/417515716.m3u8",
  "imageURL": "https:\/\/i.vimeocdn.com\/video\/957051089-482aeb3dc7eb7c852251297132be127accb7994fec9e95743f17e6136c1e539c-d_960x540"
}

Extending the APIService Protocol

To fetch the video for an episode, we extend the APIService protocol. Open APIService.swift and define a method with name video(id:accessToken:). The method accepts the identifier of the video as its first argument and an access token as its second argument. It returns a publisher with Output type Video and Failure type APIError.

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, accessToken: String) -> AnyPublisher<Video, APIError>

}

Updating the APIPreviewClient Struct

Updating the types that conform to the APIService protocol isn't difficult. You learned how to do that earlier in this series. Open APIPreviewClient.swift and add the video(id:accessToken:) method to conform the APIPreviewClient struct to the APIService protocol.

import Combine
import Foundation

struct APIPreviewClient: APIService {

    // MARK: - Methods

    func signIn(email: String, password: String) -> AnyPublisher<SignInResponse, APIError> {
        ...
    }

    func episodes() -> AnyPublisher<[Episode], APIError> {
    ...
    }

    func video(id: String, accessToken: String) -> AnyPublisher<Video, APIError> {
        
    }

}

Before we implement the video(id:accessToken:) method, we implement a helper method to avoid code duplication. We define a helper method that makes loading stub data trivial. At the bottom of APIPreviewClient.swift, define an extension for the APIPreviewClient struct and prefix the declaration with the fileprivate keyword.

import Combine
import Foundation

struct APIPreviewClient: APIService {
	...
}

fileprivate extension APIPreviewClient {

}

We define a method with name stubData(for:) that accepts a single argument of type String, the name of the file without the file extension, that contains the stub data.

fileprivate extension APIPreviewClient {

    func stubData(for resource: String) {
    	
    }

}

To make the helper method flexible, we take advantage of generics. The return type of the stubData(for:) method is T, a generic type. The implementation of the episodes() method shows that T needs to meet one requirement, it needs to conform to the Decodable protocol. We define this requirement in a pair of angle brackets following the method name. We covered that earlier in this series.

fileprivate extension APIPreviewClient {

    func stubData<T: Decodable>(for resource: String) -> T {
    	
    }

}

Move the guard statement of the episodes() method to the stubData(for:) method. We replace the episodes string literal with the value of the resource parameter and change the name of the episodes constant to stubData to make it more generic. Last but not least, we pass the generic type T as the first argument of the decode(_:from:) method. The studData(for:) method returns the value of stubData.

fileprivate extension APIPreviewClient {

    func stubData<T: Decodable>(for resource: String) -> T {
        guard
            let url = Bundle.main.url(forResource: resource, withExtension: "json"),
            let data = try? Data(contentsOf: url),
            let stubData = try? JSONDecoder().decode(T.self, from: data)
        else {
            fatalError("Unable to Load Stub Data")
        }

        return stubData
    }

}

This helper method shows how easy it is to use generics to avoid code duplication. The true power of generics becomes obvious at the call site. We can drastically simplify the implementation of the episodes() method by leveraging type inference and the stubData(for:) method. The compiler inspects the Output type of the publisher the episodes() method returns and infers the concrete type of T. The result is short and readable.

func episodes() -> AnyPublisher<[Episode], APIError> {
    Just(stubData(for: "episodes"))
        .setFailureType(to: APIError.self)
        .eraseToAnyPublisher()
}

The implementation of the video(id:accessToken:) method is identical with the exception of the argument we pass to the stubData(for:) method.

func video(id: String, accessToken: String) -> AnyPublisher<Video, APIError> {
    Just(stubData(for: "video"))
        .setFailureType(to: APIError.self)
        .eraseToAnyPublisher()
}

Because the implementations of the episodes() and video(id:accessToken:) methods are so similar, we could further optimize the implementation of the APIPreviewClient struct. We leave that for later. It is time to focus on the APIClient class.

Defining the API Endpoint

Before we update the APIClient class, we need to extend the APIEndpoint enum. Open APIEndpoint.swift and define a case with name video. Because the application fetches a video with a given identifier, we define an associated value with name id of type String. We also define an associated value for the access token, accessToken of type String. Remember that a video is a protected resource that requires the user to be signed in hence the access token.

import Foundation

enum APIEndpoint {

    // MARK: - Cases

    case auth(email: String, password: String)
    case episodes
    case video(id: String, accessToken: String)

	...

}

We need to update three computed properties, path, headers, and httpMethod. To update the computed path property, we use string interpolation and the value of the id associated value.

private var path: String {
    switch self {
    case .auth:
        return "auth"
    case .episodes:
        return "episodes"
    case let .video(id: id, accessToken: _):
        return "videos/\(id)"
    }
}

The computed httpMethod property is easy to update. Like the /episodes endpoint, the HTTP method of a request made to the /videos/:id endpoint is GET.

private var httpMethod: HTTPMethod {
    switch self {
    case .auth:
        return .post
    case .episodes,
         .video:
        return .get
    }
}

Updating the computed headers property isn't difficult either. We use an if-case-let statement to access the associated values of the video case. We are only interested in the accessToken associated value. We add the Authorization header to the dictionary of headers. Notice that the value of the header is the access token prefixed with the word Bearer.

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)"
    }

    if case let .video(id: _, accessToken: accessToken) = self {
        headers["Authorization"] = "Bearer \(accessToken)"
    }

    return headers
}

Updating the APIClient Class

Open APIClient.swift and add the video(id:accessToken:) method to conform the APIClient class to the APIService protocol. The implementation is pretty simple if you watched the previous episodes of this series. In the video(id:accessToken:) method, we create an APIEndpoint object and pass it to the request(_:) method. That's it.

import Combine
import Foundation

final class APIClient: APIService {

    // MARK: - Methods

    func signIn(email: String, password: String) -> AnyPublisher<SignInResponse, APIError> {
        request(.auth(email: email, password: password))
    }

    func episodes() -> AnyPublisher<[Episode], APIError> {
        request(.episodes)
    }

    func video(id: String, accessToken: String) -> AnyPublisher<Video, APIError> {
        request(.video(id: id, accessToken: accessToken))
    }

	...
	
}

Updating the View Model

Now that the APIClient class supports fetching the video for an episode, we can update the implementation of the VideoViewModel class. Open VideoViewModel.swift and declare a variable property, isFetching, of type Bool. Prefix the property declaration with the Published property wrapper and declare the setter privately. To display a message to the user if something goes wrong, we declare a variable property, errorMessage, of type String?. Prefix the property declaration with the Published property wrapper and declare the setter privately.

import Combine
import Foundation
import AVFoundation

final class VideoViewModel: ObservableObject {

    // MARK: - Properties

    @Published private(set) var player: AVPlayer?

    @Published private(set) var isFetching = false

    @Published private(set) var errorMessage: String?
    
    ...

}

To fetch the video, we need an object that conforms to the APIService protocol. Declare a private, constant property, apiService, of type APIService.

private let apiService: APIService

We use initializer injection to inject the API service into the view model. We covered that earlier in this series.

// MARK: - Initialization

init(videoID: String, apiService: APIService) {
    self.apiService = apiService

    fetchVideo(with: videoID)
}

In the fetchVideo(with:) method, we set the isFetching property to true to update the user interface. The view model invokes the video(id:accessToken:) method on the APIService object. We pass a dummy access token to the video(id:accessToken:) method for now.

// MARK: - Helper Methods

private func fetchVideo(with videoID: String) {
    isFetching = true

    apiService.video(id: videoID, accessToken: "abcdef")
}

The view model subscribes to the publisher returned by the video(id:accessToken:) method by invoking the sink(receiveCompletion:receiveValue:) method. In the completion handler, we set the isFetching property to false to update the user interface. The view model switches on the Completion object, printing a message in the finished and failure cases. We improve the implementation later.

private func fetchVideo(with videoID: String) {
    isFetching = true

    apiService.video(id: videoID, accessToken: "abcdef")
        .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
        	
        }).store(in: &subscriptions)
}

In the value handler, we use the video URL of the video to create an AVPlayer instance. We store a reference to the AVPlayer instance in the view model's player property.

private func fetchVideo(with videoID: String) {
    isFetching = true

    apiService.video(id: videoID, accessToken: "abcdef")
        .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)
}

Open EpisodeView.swift and update the initialization of the VideoViewModel instance by passing an APIClient instance to the initializer.

import SwiftUI

struct EpisodeView: View {

    ...

    // MARK: - View

    var body: some View {
        VStack {
            ...
            .sheet(isPresented: $showVideo) {
                VideoView(
                    viewModel: VideoViewModel(
                        videoID: viewModel.videoID,
                        apiService: APIClient()
                    )
                )
            }
            ...
        }
    }

}

Open VideoView.swift and update the computed previews property of the VideoView_Previews struct. To create the VideoViewModel instance, we pass an APIPreviewClient instance as the second argument of the initializer.

struct VideoView_Previews: PreviewProvider {
    static var previews: some View {
        VideoView(
            viewModel: VideoViewModel(
                videoID: Episode.episodes[0].videoID,
                apiService: APIPreviewClient()
            )
        )
    }
}

It's a Start

Even though we added the ability to fetch a video for an episode, there are a few issues we need to address. The most important issue relates to the access token. We pass the access token to the video(id:accessToken:) method. This works, but it isn't the most elegant solution. Passing an access token for every endpoint that requires authorization is tedious and verbose. It also exposes the access token to objects that shouldn't need to know about the access token.

What's Next?

In the next episode, we improve the current implementation. You learn about type erasure and how protocol-oriented programming is very effective in keeping implementation details hidden from other objects.