Even though there is nothing inherently wrong with a view model performing network requests, it isn't an approach I recommend. Moving the networking logic out of the view model has a number of benefits. It reduces code duplication, facilitates unit testing, and improves the maintainability of the project to name a few.

Defining a Protocol

To build a testable networking layer, we embrace protocol-oriented programming or POP. How that works and why it improves the testability of the networking layer becomes clear later. Create a group with name Protocols in the Networking group and add a Swift file with name APIService.swift. Define a protocol with name APIService.

The APIService protocol defines an interface objects can use to communicate with the mock API. Because we use a reactive approach, add an import statement for the Combine framework at the top. We define a method to fetch the list of episodes from the mock API. The method, episodes(), returns a publisher. The Output type of the publisher is [Episode] and the Failure type is APIError.

import Combine
import Foundation

protocol APIService {

    // MARK: - Properties

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

}

You may be wondering why we declare episodes() as an instance method instead of a computed property. That is a valid point. There are a few reasons for declaring episodes() as an instance method. First, the APIService protocol should have a consistent interface. Later in this series, we add support for endpoints that accept a parameters. We can't use a computed property if we want to pass those parameters to the API service. Using a mix of instance methods and computed properties is confusing and results in an inconsistent interface. Second, a computed property implies that the value that is returned is computed or derived. That is what a computed property returns, but that is not what happens. To fetch the list of episodes, the API client creates and sends a request to the mock API. Even though this is and should be an implementation detail private to the API client, I feel it is more appropriate to define episodes() as an instance method than a computed property. This choice will make more sense as we add support for more endpoints.

Conforming to the Protocol

With the APIService protocol in place, we can create a type that conforms to the protocol. Add a Swift file to the Networking group and name it APIClient.swift. Add an import statement for the Combine framework at the top and define a final class with name APIClient that conforms to the APIService protocol. To conform to APIService, we need to implement the episodes() method.

import Combine
import Foundation

final class APIClient: APIService {

    // MARK: - Methods

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

}

Open EpisodesViewModel.swift in the assistant editor on the right. We move the implementation of the fetchEpisodes() method of EpisodesViewModel to the episodes() method of APIClient with the exception of the sink(receiveCompletion:receiveValue:) method. We fix the implementation of fetchEpisodes() later in this episode.

func episodes() -> AnyPublisher<[Episode], APIError> {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")

    isFetching = true

    URLSession.shared.dataTaskPublisher(for: request)
        .tryMap { data, response -> [Episode] in
            guard
                let response = response as? HTTPURLResponse,
                (200..<300).contains(response.statusCode)
            else {
                throw APIError.failedRequest
            }

            do {
                return try JSONDecoder().decode([Episode].self, from: data)
            } catch {
                print("Unable to Decode Response \(error)")
                throw APIError.invalidResponse
            }
        }
        .mapError { error -> APIError in
            switch error {
            case let apiError as APIError:
                return apiError
            case URLError.notConnectedToInternet:
                return APIError.unreachable
            default:
                return APIError.failedRequest
            }
        }
        .receive(on: DispatchQueue.main)
}

We need to make three changes to satisfy the compiler. First, remove the reference to the isFetching property of EpisodesViewModel. Second, wrap the publisher the receive operator returns in a type eraser by applying the eraseToAnyPublisher operator. We discuss type erasure and the eraseToAnyPublisher operator in detail in Building Reactive Applications With Combine. Third, return the publisher the eraseToAnyPublisher operator returns from the episodes() method.

func episodes() -> AnyPublisher<[Episode], APIError> {
    var request = URLRequest(url: URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1/episodes")!)

    request.addValue("1772bb7bc78941e2b51c9c67d17ee76e", forHTTPHeaderField: "X-API-TOKEN")

    return URLSession.shared.dataTaskPublisher(for: request)
        .tryMap { data, response -> [Episode] in
            guard
                let response = response as? HTTPURLResponse,
                (200..<300).contains(response.statusCode)
            else {
                throw APIError.failedRequest
            }

            do {
                return try JSONDecoder().decode([Episode].self, from: data)
            } catch {
                print("Unable to Decode Response \(error)")
                throw APIError.invalidResponse
            }
        }
        .mapError { error -> APIError in
            switch error {
            case let apiError as APIError:
                return apiError
            case URLError.notConnectedToInternet:
                return APIError.unreachable
            default:
                return APIError.failedRequest
            }
        }
        .receive(on: DispatchQueue.main)
        .eraseToAnyPublisher()
}

The APIClient class needs to be flexible and hard-coding the API token and base URL isn't a good idea. Open Environment.swift and define a static computed property, apiBaseURL, of type URL. The static computed apiBaseURL property returns the API's base URL. Notice that we forced unwrap the result of the initialization because that should never fail.

static var apiBaseURL: URL {
    URL(string: "https://cocoacasts-mock-api.herokuapp.com/api/v1")!
}

We do the same for the API token. We define a static computed property, apiToken, of type String that returns the API token.

static var apiToken: String {
    "1772bb7bc78941e2b51c9c67d17ee76e"
}

Revisit APIClient.swift. We access the API's base URL through the Environment enum and append the path of the episodes endpoint. We do the same for the API token.

func episodes() -> AnyPublisher<[Episode], APIError> {
    var request = URLRequest(url: Environment.apiBaseURL.appendingPathComponent("episodes"))

    request.addValue(Environment.apiToken, forHTTPHeaderField: "X-API-TOKEN")

    ...
}

Integrating the API Client

The view model uses an object conforming to the APIService protocol to fetch the list of episodes. Open EpisodesViewModel.swift and declare a private, constant property, apiService, of type APIService. Notice that the view model is unaware of the existence of the APIClient class. This is protocol-oriented programming in action. The view model is compatible with any type that conforms to the APIService protocol. This comes in useful later.

final class EpisodesViewModel: ObservableObject {

    // MARK: - Properties

    ...
    
    private let apiService: APIService

	...

}

The API service is injected into the view model during initialization. This simply means that the initializer accepts an argument of type APIService. In the initializer, the value of the apiService parameter is stored in the apiService property.

final class EpisodesViewModel: ObservableObject {

    // MARK: - Properties

    ...
    
    private let apiService: APIService

    // MARK: -

    private var subscriptions: Set<AnyCancellable> = []

    // MARK: - Initialization

    init(apiService: APIService) {
        self.apiService = apiService
        
        fetchEpisodes()
    }

    // MARK: - Helper Methods

    private func fetchEpisodes() {
    	...
    }

}

Now that the view model has access to an object conforming to the APIService protocol, we can fix the fetchEpisodes() method. This is simpler than you might think because the API service does the heavy lifting, not the view model. In fetchEpisodes(), we set isFetching to true to communicate to the user the application is fetching the list of episodes. We then ask the API service to fetch the list of episodes by invoking the episodes() method. The rest of the implementation of fetchEpisodes() remains unchanged.

private func fetchEpisodes() {
    isFetching = true

    apiService.episodes()
        .sink(receiveCompletion: { [weak self] completion in
            self?.isFetching = false

            switch completion {
            case .finished:
                print("Successfully Fetched Episodes")
            case .failure(let error):
                print("Unable to Fetch Episodes \(error)")
                self?.errorMessage = error.message
            }
        }, receiveValue: { [weak self] episodes in
            self?.episodes = episodes
        }).store(in: &subscriptions)
}

Before we can build the application, we need to update the initialization of the EpisodesViewModel in RootView.swift. We create an instance of the APIClient class and pass it to the initializer of EpisodesViewModel.

var body: some View {
    TabView {
        EpisodesView(viewModel: EpisodesViewModel(apiService: APIClient()))
            .tabItem {
                Label("What's New", systemImage: "film")
            }
        ProfileView(viewModel: ProfileViewModel(keychainService: keychainService))
            .tabItem {
                Label("Profile", systemImage: "person")
            }
    }
}

We also need to update the initialization of the EpisodesViewModel class in the static previews property in EpisodesView.swift. This is a bit of a problem because we don't want to fetch the list of episode from the mock API every time the preview is updated. The good news is that we laid the foundation to make this problem straightforward to solve.

Add a Swift file to the Networking group and name it APIPreviewClient.swift. Define a struct, APIPreviewClient, that conforms to the APIService protocol. Implementing the episodes() method isn't difficult. Earlier in this series, the view model loaded the list of episodes from a JSON file included in the application bundle. We can reuse that implementation. We throw a fatal error in the else clause of the guard statement.

import Combine
import Foundation

struct APIPreviewClient: APIService {

    // MARK: - Methods

    func episodes() -> AnyPublisher<[Episode], APIError> {
        guard
            let url = Bundle.main.url(forResource: "episodes", withExtension: "json"),
            let data = try? Data(contentsOf: url),
            let episodes = try? JSONDecoder().decode([Episode].self, from: data)
        else {
            fatalError("Unable to Load Episodes")
        }
    }

}

We create an instance of the Just struct, passing in the array of Episode objects. A Just instance is a publisher that emits a single element. We wrap the publisher in a type eraser by applying the eraseToAnyPublisher operator.

There is one problem, though. The Failure type of the publisher is Never and it doesn't match the Failure type of the publisher the episodes() method returns. By applying the setFailureType operator, we can change the Failure type of the upstream publisher. The setFailureType operator accepts the Error type of the downstream publisher as an argument.

import Combine
import Foundation

struct APIPreviewClient: APIService {

    // MARK: - Methods

    func episodes() -> AnyPublisher<[Episode], APIError> {
        guard
            let url = Bundle.main.url(forResource: "episodes", withExtension: "json"),
            let data = try? Data(contentsOf: url),
            let episodes = try? JSONDecoder().decode([Episode].self, from: data)
        else {
            fatalError("Unable to Load Episodes")
        }

        return Just(episodes)
            .setFailureType(to: APIError.self)
            .eraseToAnyPublisher()
    }

}

In the static previews property of the EpisodesView_Previews struct, we create an instance of the APIPreviewClient and pass it to the initializer of the EpisodesViewModel class. Resume the preview to make sure we didn't break anything.

struct EpisodesView_Previews: PreviewProvider {
    static var previews: some View {
        EpisodesView(viewModel: EpisodesViewModel(apiService: APIPreviewClient()))
    }
}

Build and run the application to see the result. Nothing changed from the perspective of the user, but we laid the foundation of the networking layer.

Cleaning Up the Project

The JSON file of episodes shouldn't be included in the application's bundle because it is only used by the preview. Let's move the Stubs group in the Resources group to the Preview Content group.

What's Next?

The networking layer is taking shape, but there is plenty of room for improvement. In the next episode, we make a number of changes that pave the way for an extensible, flexible networking layer.