In the previous episode, you learned about CRUD operations and we applied this to video progress. We added the ability to fetch the progress for a video, the R in CRUD. In this episode, we cover creating and updating video progress, the C and U in CRUD.

Creating and Updating the Progress for a Video

Remember from the previous episode that the Cocoacasts API exposes a single endpoint for creating and updating the progress for a video. This drastically simplifies the implementation on the client. To create a resource, you typically send a POST request to the API. To update a resource, you typically send a PUT request to the API. To create or update the progress for a video, the Cocoacasts API expects a POST request.

Extending the APIService Protocol

We start by extending the APIService protocol. Open APIService.swift and define a method with name updateProgressForVideo(id:cursor:). The method defines two parameters. The first parameter, id of type String, defines the identifier of the video for which to update the progress. The second parameter, cursor of type Int, defines the cursor or the last known position in the video. The return type is identical to that of the progressForVideo(id:) method, a publisher with Output type VideoProgressResponse and Failure type APIError.

import Combine
import Foundation

protocol APIService {

	...
	
    func progressForVideo(id: String) -> AnyPublisher<VideoProgressResponse, APIError>
    func updateProgressForVideo(id: String, cursor: Int) -> AnyPublisher<VideoProgressResponse, APIError>

}

Extending the APIPreviewClient Struct

Open APIPreviewClient.swift and add the updateProgressForVideo(id:cursor:) method to conform the APIPreviewClient struct to the APIService protocol. The implementation of the updateProgressForVideo(id:cursor:) method is identical to that of the progressForVideo(id:) method. The implementation is short and simple thanks to the helper method we implemented in the previous episode.

import Combine
import Foundation

struct APIPreviewClient: APIService {
	
	...

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

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

}

Extending the APIClient Class

Before we update the APIClient class, we need to extend the APIEndpoint enum. Open APIEndpoint.swift and define a case with name updateVideoProgress. The case defines two associated values. The first associated value, id of type String, defines the identifier of the video for which to update the progress. The second associated value, cursor of type Int, defines the cursor or the last known position in the video.

import Foundation

enum APIEndpoint {

	...
	
    case videoProgress(id: String)
    case updateVideoProgress(id: String, cursor: Int)

	...

}

The next step is updating the computed path, httpMethod, and requiresAuthorization properties. The path is identical to that of the videoProgress case. That is easy.

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),
         let .updateVideoProgress(id: id, cursor: _):
        return "videos/\(id)/progress"
    }
}

I mentioned earlier that the HTTP method of a request to create or update the progress for a video is POST.

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

The computed requiresAuthorization property returns true for the updateVideoProgress case because video progress is a protected resource.

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

We need to set the body of the request to pass the cursor to the Cocoacasts API. Let me show you how that works.

Revisit the request(accessToken:) method. To set the body of the request, we assign a Data object to the httpBody property of the URLRequest object. We assign the value of the computed httpBody property to the httpBody property of the URLRequest object. The computed httpBody property doesn't exist yet. Let's implement that now.

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

    request.httpBody = httpBody

    return request
}

Define a private, variable property, httpBody, of type Data?. We use a switch statement to switch on the APIEndpoint object. Every case returns nil with the exception of the updateVideoProgress case.

private var httpBody: Data? {
    switch self {
    case let .updateVideoProgress(id: _, cursor: cursor):
        // Return HTTP Body
    case .auth,
         .episodes,
         .video,
         .videoProgress:
        return nil
    }
}

We use the Encodable protocol to create the HTTP body of the request to create or update the progress for a video. Add a Swift file to the Networking > Models group and name it UpdateVideoProgressBody.swift. Define a struct with name UpdateVideoProgressBody that conforms to the Encodable protocol. The struct defines a property, cursor, of type Int.

import Foundation

struct UpdateVideoProgressBody: Encodable {

    // MARK: - Properties

    let cursor: Int

}

Revisit APIEndpoint.swift and navigate to the computed httpBody property. We create an UpdateVideoProgressBody object using the value of the cursor associated value. We create a JSONEncoder instance and pass the UpdateVideoProgressBody object to its encode(_:) method using the try? keyword.

private var httpBody: Data? {
    switch self {
    case let .updateVideoProgress(id: _, cursor: cursor):
        let body = UpdateVideoProgressBody(cursor: cursor)
        return try? JSONEncoder().encode(body)
    case .auth,
         .episodes,
         .video,
         .videoProgress:
        return nil
    }
}

We can now update the APIClient class. The implementation of the updateProgressForVideo(id:cursor:) method is similar to that of the progressForVideo(id:) method. We create an APIEndpoint object, passing the identifier of the video and the cursor as associated values. We invoke the request(_:) method, passing in the APIEndpoint object. That's it.

import Combine
import Foundation

final class APIClient: APIService {

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

    func updateProgressForVideo(id: String, cursor: Int) -> AnyPublisher<VideoProgressResponse, APIError> {
        request(.updateVideoProgress(id: id, cursor: cursor))
    }

	...

}

We test the implementation like we did in the previous episode. Open VideoViewModel.swift and update the fetchVideo(with:) method. After we invoke the video(id:) method on the API service, we invoke the updateProgressForVideo(id:cursor:) method. We keep the implementation simple because we only want to know that everything works as expected.

private func fetchVideo(with videoID: String) {
    ...
    
    apiService.updateProgressForVideo(id: videoID, cursor: 120)
        .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: 120, videoID: "632759499")
finished

What's Next?

In the next episode, we add support for deleting the progress for a video. Adding support for that endpoint requires a few changes. Deleting the progress for a video is a bit special because the body of the response is empty. We cover the required changes in the next episode.