In the previous episodes, we added support for fetching, creating, and updating video progress. In this episode, you learn how to delete the progress for a video, the D in CRUD. Deleting the progress for a video is a bit special because the body of the response is empty. Let me show you what changes we need to make to add support for deleting the progress for a video.

Deleting the Progress for a Video

To delete the progress for a video, we send a DELETE request to the Cocoacasts API. The path of the endpoint is identical to the path of the endpoints we used to fetch, create, and update video progress. There are three differences, though. The HTTP method is DELETE instead of GET, the body of the response is empty, and the status code of the response is 204.

You probably know the steps we need to take to add support for deleting the progress for a video. We first extend the APIService protocol. Open APIService.swift and define a method with name deleteProgressForVideo(id:). The method defines one parameter, id of type String, the identifier of the video for which to delete the progress.

Because the body of the response is empty, the Output type of the publisher is Void. The Failure type is identical to that of the other video progress methods, APIError. The publisher the deleteProgressForVideo(id:) method returns emits Void on success and an APIError object on failure.

import Combine
import Foundation

protocol APIService {

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

}

Extending the APIPreviewClient Struct

Open APIPreviewClient.swift and add the deleteProgressForVideo(id:) method to conform the APIPreviewClient struct to the APIService protocol. The implementation is a bit different because the Output type of the publisher the method returns is Void. We create a publisher using the Just struct, passing an empty tuple to the initializer. That is the element the publisher emits if the request is successful. We apply the setFailureType operator to set the publisher's Failure type to APIError and wrap the publisher with a type eraser.

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

    func deleteProgressForVideo(id: String) -> AnyPublisher<Void, APIError> {
        Just(())
            .setFailureType(to: APIError.self)
            .eraseToAnyPublisher()
    }

}

Extending the APIClient Class

The next stop is the APIEndpoint enum. We define a case with name deleteVideoProgress. The case defines an associated value of type String for the identifier of the video.

import Foundation

enum APIEndpoint {

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

	...

}

As I mentioned earlier, the path is identical to that of the videoProgress and updateVideoProgress cases.

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

What is special about this endpoint is that the HTTP method is DELETE.

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

Because the body of the request is empty, we return nil from the computed httpBody property.

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,
         .deleteVideoProgress:
        return nil
    }
}

Video progress is a protected resource so we return true from the computed requiresAuthorization property.

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

With the APIEndpoint enum updated, we can update the APIClient class. The implementation of the deleteProgressForVideo(id:) method is similar to that of the progressForVideo(id:) and updateProgressForVideo(id:cursor:) methods. We create an APIEndpoint object, passing the identifier of the video as the associated value. We invoke the request(_:) method, passing in the APIEndpoint object.

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

    func deleteProgressForVideo(id: String) -> AnyPublisher<Void, APIError> {
        request(.deleteVideoProgress(id: id))
    }

	...

}

We seem to have a problem, though. The compiler throws an error because Void cannot conform to the Decodable protocol. Void is a type alias for an empty tuple and tuples cannot conform to protocols. This is a problem because the Output type of the publisher the deleteProgressForVideo(id:) method returns needs to conform to the Decodable protocol.

Tuple Cannot Conform to Protocols

To resolve this issue, we need to define a type that conforms to the Decodable protocol. This requires a few changes, but it isn't too difficult.

Add a Swift file to the Networking > Models group and name it NoContent.swift. Define a struct with name NoContent that conforms to the Decodable protocol. As the name suggests, the NoContent struct doesn't define any properties.

import Foundation

struct NoContent: Decodable {

}

Revisit the APIService protocol in APIService.swift and change the Output type of the publisher the deleteProgressForVideo(id:) method returns to NoContent.

import Combine
import Foundation

protocol APIService {

	...

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

}

We apply this change to the APIClient class. Notice that the compiler no longer throws an error because NoContent conforms to the Decodable protocol.

func deleteProgressForVideo(id: String) -> AnyPublisher<NoContent, APIError> {
    request(.deleteVideoProgress(id: id))
}

We also need to update the method signature and implementation of the deleteProgressForVideo(id:) method in APIPreviewClient.swift. The Output type of the publisher the deleteProgressForVideo(id:) method returns is NoContent. We no longer pass an empty tuple to the initializer of the Just struct. We pass it a NoContent object instead.

func deleteProgressForVideo(id: String) -> AnyPublisher<NoContent, APIError> {
    Just(NoContent())
        .setFailureType(to: APIError.self)
        .eraseToAnyPublisher()
}

We still need to make one change to make this solution work. Even though the NoContent struct conforms to the Decodable protocol, an empty body isn't valid JSON. That means an empty body cannot be decoded to a NoContent object. The NoContent struct only fulfills the requirement that the Output type of the publisher the deleteProgressForVideo(id:) method returns conforms to the Decodable protocol. That silenced the error we encountered earlier.

Open APIClient.swift and navigate to the request(_:) method. Before evaluating the do-catch statement, the API client inspects the status code of the response. A response with no content has a 204 status code. If the body of the response is empty, there is no need to decode the response in the do-catch statement. We use an if statement to inspect the status code.

We create a NoContent object and cast it to T, the Output type of the publisher the request(_:) method returns. If the cast to T succeeds, the API client returns the NoContent object from the closure of the tryMap operator. Take a moment to understand this change because it is a key ingredient of the solution we are implementing.

private func request<T: Decodable>(_ endpoint: APIEndpoint) -> AnyPublisher<T, APIError> {
    do {
        let accessToken = accessTokenProvider.accessToken
        let request = try endpoint.request(accessToken: accessToken)

        return URLSession.shared.dataTaskPublisher(for: request)
            .tryMap { data, response -> T in
                guard let statusCode = (response as? HTTPURLResponse)?.statusCode else {
                    throw APIError.failedRequest
                }

                guard (200..<300).contains(statusCode) else {
                    if statusCode == 401 {
                        throw APIError.unauthorized
                    } else {
                        throw APIError.failedRequest
                    }
                }

                if statusCode == 204, let noContent = NoContent() as? T {
                    return noContent
                }

                do {
                    return try JSONDecoder().decode(T.self, from: data)
                } catch {
                    print("Unable to Decode Response \(error)")
                    throw APIError.invalidResponse
                }
            }

            ...
}

We test the implementation like we did in the previous episodes. Open VideoViewModel.swift and update the fetchVideo(with:) method. After we invoke the video(id:) method on the API service, we invoke the deleteProgressForVideo(id:) method. As before, we keep the implementation simple because we only want to make sure the solution we implemented works.

private func fetchVideo(with videoID: String) {
    ...
    
    apiService.deleteProgressForVideo(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 NoContent object and the Completion object should be equal to finished.

NoContent()
finished

What's Next?

CRUD operations are very common, especially if the API you are working with is RESTful. You will come across variations of what we covered in the past few episodes, but the key ideas should be similar.