At this point, you should have a good understanding of the networking layer we are building. Even though we have written quite a bit of code, the networking layer we built isn't complex. We simply combined a number of common patterns and techniques to create a solution that is easy to use and extend. Later in this series, I show you that it is also easy to test.

In this and the next episodes, I show you how to perform the basic CRUD operations most APIs support. CRUD stands for Create, Read, Update, and Delete. The resource we operate on is video progress. We won't cover the integration into the application. We only focus on the networking layer. You already know how to perform a basic GET request. In this and the next episodes, you learn how to perform POST, PUT, and DELETE requests.

Revisiting the Cocoacasts API

The video progress endpoints of the Cocoacasts API are protected just like the /videos/:id endpoint is. This makes sense because a video can only be watched if the user is signed in and only a user can make progress.

The Cocoacasts API combines the create and update operations into a single endpoint. This approach keeps the API simple and it drastically simplifies the implementation on the client. I explain how that works in a moment. Let's start with the read operation, the R in CRUD.

To fetch the progress for a video, the application sends a request to the /videos/:id/progress endpoint. The response includes the identifier of the video and the cursor. The cursor is the last known position in the video.

GET /api/v1/videos/:id/progress
{
  "videoID": "123",
  "cursor": 185
}

As the user watches a video, the application sends updates to the Cocoacasts API. When that happens is an implementation detail of the application. Some applications send updates at regular time intervals while others send updates when the user performs an action, such as pausing playback or closing the player.

The application should be as dumb as possible and that means it shouldn't need to know if the user already made progress for a given video. The application should be able to simply send the position of the video to the Cocoacasts API and not worry about creating or updating a resource on the backend. I mention this because a client typically sends a POST request to create a resource and a PUT request to update a resource. The Cocoacasts API combines these endpoints into a single endpoint. It is the Cocoacasts API that figures out if it needs to create or update the progress for a given video, not the client.

The path of the endpoint is identical to that of the previous endpoint. The difference between the endpoints is the HTTP method or verb, POST instead of GET. The response of a POST request is identical to that of a GET request, it includes the identifier of the video and the cursor. The body of the request is also different. It contains the cursor of the video.

POST /api/v1/videos/:id/progress
{
  "videoID": "123",
  "cursor": 185
}

To delete the progress for a given video, the client performs a DELETE request. You could say that it resets the user's progress for a given video. At some point, we might want to add a button to the Cocoacasts application to mark a video as unwatched. Tapping that button would delete the user's progress for the video. To make that work, the application would send a DELETE request to the Cocoacasts API. The response of a successful DELETE request has a status code of 200 and no body.

DELETE /api/v1/videos/:id/progress

Fetching the Progress for a Video

To fetch the progress for a video, we first need to define a model that encapsulates the progress for a video. Add a Swift file to the Networking > Models group and name it VideoProgressResponse.swift. Define a struct with name VideoProgressResponse that conforms to the Decodable protocol. The struct defines two properties, cursor of type Int and videoID of type String.

import Foundation

struct VideoProgressResponse: Decodable {

    // MARK: - Properties

    let cursor: Int
    let videoID: String

}

Extending the APIService Protocol

To add support for fetching the progress for a video, we need to extend the APIService protocol. Open APIService.swift and define a method to fetch the progress for a video. It doesn't matter how you name the methods of the CRUD operations, but it is important to be consistent. I usually keep the methods for fetching data as simple as possible. I name the method for fetching the progress for a video progressForVideo(id:). The method accepts the identifier of the video as an argument and returns a publisher. The publisher's Output type is VideoProgressResponse and its Failure type is APIError.

import Combine
import Foundation

protocol APIService {

    // MARK: - Properties

	...

    func progressForVideo(id: String) -> AnyPublisher<VideoProgressResponse, APIError>

}

Extending the APIPreviewClient Struct

You know what the next step is if you watched the previous episodes of this series. We need to extend the types that conform to the APIService protocol. Before we update the APIPreviewClient struct, we need to add stub data. We add an empty file to the Preview Content > Stubs group, name it video-progress.json, and populate it with stub data.

{
    "cursor": 124,
    "videoID": "123"
}

Open APIPreviewClient.swift and add the progressForVideo(id:) method to conform the APIPreviewClient struct to the APIService protocol. The implementation is identical to that of the episodes() and video(id:) methods. The only difference is the string we pass to the stubData(for:) method.

import Combine
import Foundation

struct APIPreviewClient: APIService {

    // MARK: - Methods
	
	...
	
    func progressForVideo(id: String) -> AnyPublisher<VideoProgressResponse, APIError> {
        Just(stubData(for: "video-progress"))
            .setFailureType(to: APIError.self)
            .eraseToAnyPublisher()
    }

}

Earlier in this series, I mentioned that we can optimize the implementation of the APIPreviewClient struct since we repeat ourselves in the episodes(), video(id:), and progressForVideo(id:) methods. In the fileprivate extension for the APIPreviewClient struct, we define a method with name publisher(for:). The method accepts the name of the resource as an argument and returns an object of type AnyPublisher. The Output type of the publisher is T, a generic type. The Failure type of the publisher is APIError. Because the Output type needs to be Decodable, we define the requirement in a pair of angle brackets following the method name.

fileprivate extension APIPreviewClient {

    func publisher<T: Decodable>(for resource: String) -> AnyPublisher<T, APIError> {

    }

    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
    }

}

We move the implementation of the progressForVideo(id:) method to the publisher(for:) method and replace the string literal with the value stored in the resource parameter.

fileprivate extension APIPreviewClient {

    func publisher<T: Decodable>(for resource: String) -> AnyPublisher<T, APIError> {
        Just(stubData(for: resource))
            .setFailureType(to: APIError.self)
            .eraseToAnyPublisher()
    }

    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
    }

}

The implementations of the episodes(), video(id:), and progressForVideo(id:) methods is reduced to a single line. The publisher(for:) method accepts the file name of the stub data without the extension.

import Combine
import Foundation

struct APIPreviewClient: APIService {

    // MARK: - Methods

	...

    func episodes() -> AnyPublisher<[Episode], APIError> {
        publisher(for: "episodes")
    }

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

    func progressForVideo(id: String) -> AnyPublisher<VideoProgressResponse, APIError> {
        publisher(for: "video-progress")
    }

}

Extending the APIClient Class

Before we update the APIClient class, we need to extend the APIEndpoint enum. Define a case, videoProgress, for the /videos/:id/progress endpoint. The case defines an associated value with name id of type String, the identifier of the video for which to fetch the progress.

import Foundation

enum APIEndpoint {

    // MARK: - Cases

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

	...

}

We need to update the computed path, httpMethod, and requiresAuthorization properties. We use string interpolation to define the path for the videoProgress case. The implementation is similar to that of the video case.

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

Updating the computed httpMethod property is simple. The HTTP method for the /videos/:id/progress endpoint is GET.

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

Like the /videos/:id endpoint, the /videos/:id/progress endpoint requires that the user is signed in. This means the computed requiresAuthorization property returns true for the videoProgress case.

private var requiresAuthorization: Bool {
    switch self {
    case .auth,
         .episodes:
        return false
    case .video,
         .videoProgress:
        return true
    }
}

Open APIClient.swift and add the progressForVideo(id:) method. The implementation is very similar to that of the video(id:) method. The only difference is the APIEndpoint object we pass to the request(_:) method.

import Combine
import Foundation

final class APIClient: APIService {

    ...
    
    // MARK: - API Service

    ...
    
    func progressForVideo(id: String) -> AnyPublisher<VideoProgressResponse, APIError> {
        request(.videoProgress(id: id))
    }

    ...

}

Even though we won't cover the integration into the application, we can test the implementation. Open VideoViewModel.swift and update the fetchVideo(with:) method. After invoking the video(id:) method on the API service, we invoke the progressForVideo(id:) method. We keep the implementation simple because we only want to verify that everything works as expected.

private func fetchVideo(with videoID: String) {
    ...
    
    apiService.progressForVideo(id: videoID)
        .sink(receiveCompletion: { completion in
            print(completion)
        }, receiveValue: { response in
            print(response)
        }).store(in: &subscriptions)
}

Build and run the application to test the implementation. Make sure you are signed in. Tap an episode from the list of episodes and tap the Play Episode button to play the video. The output in the console should display the progress for the video and the Completion object should be equal to finished.

VideoProgressResponse(cursor: 185, videoID: "632759499")
finished

What's Next?

In the next episode, we add support for creating and updating the progress for a video. Adding support for this endpoint requires a few changes. To create or update the progress for a video, we need to set the body of the request. We cover the required changes in the next episode.