The user needs to be signed in to watch a video so the next feature we implement is the ability for the user to sign in with their email and password. This episode illustrates how a proper foundation can save time and reduce complexity. The improvements we made in the previous episode simplify the changes we need to make in this and the next episodes.

Extending the API Service Protocol

Before we extend the APIService protocol, we need to define a type that encapsulates the response of a sign in request. Add a group with name Models to the Networking group. Add a Swift file with name SignInResponse.swift to the Models group. Define a struct, SignInResponse, that conforms to the Decodable protocol. The struct defines two properties, accessToken of type String and refreshToken of type String.

import Foundation

struct SignInResponse: Decodable {

    // MARK: - Properties

    let accessToken: String
    let refreshToken: String

}

With SignInResponse defined, we can extend the APIService protocol. Open APIService.swift and define a method, signIn(email:password:), that accepts an email and a password, and returns a publisher. The Output type of the publisher is SignInResponse. The Failure type of the publisher is APIError.

import Combine
import Foundation

protocol APIService {

    // MARK: - Properties

    func signIn(email: String, password: String) -> AnyPublisher<SignInResponse, APIError>
    
    func episodes() -> AnyPublisher<[Episode], APIError>

}

Open APIPreviewClient.swift and add the signIn(email:password:) method to conform APIPreviewClient to the APIService protocol. The implementation is similar to that of the episodes() method. We create an instance of the Just struct, passing in a SignInResponse object. Because the Failure type of the publisher is Never and the signIn(email:password:) method is expected to return a publisher with Failure type APIError, we apply the setFailureType operator to meet that requirement. We wrap the publisher with a type eraser by applying the eraseToAnyPublisher operator.

import Combine
import Foundation

struct APIPreviewClient: APIService {

    // MARK: - Methods

    func signIn(email: String, password: String) -> AnyPublisher<SignInResponse, APIError> {
        Just(
            SignInResponse(
                accessToken: "123",
                refreshToken: "456"
            )
        )
        .setFailureType(to: APIError.self)
        .eraseToAnyPublisher()
    }

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

}

Defining an API Endpoint

Open APIClient.swift and add the signIn(email:password:) method to conform APIClient to the APIService protocol. Before we can implement the signIn(email:password:) method, we need to extend the APIEndpoint enum.

import Combine
import Foundation

final class APIClient: APIService {

    // MARK: - Methods

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

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

	...

}

Open APIEndpoint.swift and define a case, auth, for the sign in request. The auth case defines two associated values of type String, email and password.

import Foundation

enum APIEndpoint {

    // MARK: - Cases

    case auth(email: String, password: String)
    case episodes

	...

}

The mock API uses basic authentication to authenticate the user. This means we need to add an Authorization header to the request that contains the user's credentials prefixed with the word Basic. The basic authentication specification doesn't define the HTTP method of the request, but it is safer to use POST for requests that involve authentication.

We need to update three computed properties, httpMethod, headers, and path. Add the auth case to the switch statement of the computed httpMethod property. As I mentioned, the HTTP method of the sign in request should be POST.

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

Updating the computed path property is easy. Add the auth case to the switch statement of the computed path property, returning auth.

private var path: String {
    switch self {
    case .auth:
        return "auth"
    case .episodes:
        return "episodes"
    }
}

Updating the computed headers property requires a bit more work. We define a variable, headers, of type Headers. The variable stores the headers we defined earlier.

private var headers: Headers {
    var headers: Headers = [
        "Content-Type": "application/json",
        "X-API-TOKEN": Environment.apiToken
    ]
    
    return headers
}

The basic authentication header should only be added when the user signs in. We use an if-case-let statement to access the associated values of the auth case. You can use a switch statement if you find the syntax of the if-case-let statement confusing.

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

    return headers
}

We create a string by concatenating the user's email and password, separating email and password with a colon. The resulting string needs to be base 64 encoded. To do that, we convert the string to a Data object using UTF-8 encoding. To create a base 64 encoded string from the Data object, we invoke the base64EncodedString() method on the Data object. It is safe to forced unwrap the result of the String to Data conversion since we use Unicode encoding. That conversion should never fail.

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

    return headers
}

We add the Authorization header to the dictionary of headers. Notice that the value of the header is the base 64 encoded string prefixed with the word Basic.

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

    return headers
}

These are the only changes we need to make to the APIEndpoint enum to add support for signing in. Revisit APIClient.swift. In the signIn(email:password:) method, we create an APIEndpoint object and pass it to the request(_:) method.

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

	...

}

Updating the Sign In View Model

Open SignInViewModel.swift. The changes we need to make are small since most of the plumbing is already in place. To enable the ability to sign in, we need to update the signIn() method and inject an API service into the sign in view model. Let's start with the latter.

Declare a private, constant property, apiService, of type APIService. The plan is to inject the API service into the view model during initialization. That allows us to declare the property privately.

private let apiService: APIService

To enable initializer injection, we need to update the initializer. The initializer accepts an API service as its first argument and a keychain service as its second argument. The view model holds on to both objects through its apiService and keychainService properties.

// MARK: - Initialization

init(apiService: APIService, keychainService: KeychainService) {
    self.apiService = apiService
    self.keychainService = keychainService
}

The next step is updating the signIn() method. The method returns early if the user isn't able to sign in, that is, if the email is invalid or the password is empty.

func signIn() {
    guard canSignIn else {
        return
    }
}

We set isSigningIn to true before sending the sign in request to the mock API. This informs the user that the sign in request is in flight.

func signIn() {
    guard canSignIn else {
        return
    }

    isSigningIn = true
}

The view model uses the API service to send the sign in request to the mock API, passing in the user's email and password. The view model subscribes to the publisher the signIn(email:password:) method returns by invoking the sink(receiveCompletion:receiveValue:) method. In the completion handler, we set password to an empty string and isSigningIn to false. We use a switch statement to switch on the Completion object. If the request failed, we need to show an error message to the user.

func signIn() {
    guard canSignIn else {
        return
    }

    isSigningIn = true
 
    apiService.signIn(email: email, password: password)
        .sink(receiveCompletion: { [weak self] completion in
            self?.password = ""
            self?.isSigningIn = false

            switch completion {
            case .finished:
                ()
            case .failure:
            
            }
        }, receiveValue: { [weak self] response in
        	
        }).store(in: &subscriptions)
}

Declare a variable property, errorMessage, of type String? and prefix the property declaration with the Published property wrapper. We declare the setter of the property privately.

@Published private(set) var errorMessage: String?

Let's continue with the implementation of the signIn() method. Before the API service sends the request to the mock API, the view model sets errorMessage to nil to update the user interface.

func signIn() {
    guard canSignIn else {
        return
    }

    isSigningIn = true
    errorMessage = nil

    apiService.signIn(email: email, password: password)
        .sink(receiveCompletion: { [weak self] completion in
            self?.password = ""
            self?.isSigningIn = false

            switch completion {
            case .finished:
                ()
            case .failure:
            	
            }
        }, receiveValue: { [weak self] response in
        	
        }).store(in: &subscriptions)
}

In the failure case of the switch statement, we populate the errorMessage property with an error message.

func signIn() {
    guard canSignIn else {
        return
    }

    isSigningIn = true
    errorMessage = nil

    apiService.signIn(email: email, password: password)
        .sink(receiveCompletion: { [weak self] completion in
            self?.password = ""
            self?.isSigningIn = false

            switch completion {
            case .finished:
                ()
            case .failure:
                self?.errorMessage = "The email/password combination is invalid."
            }
        }, receiveValue: { [weak self] response in
			
        }).store(in: &subscriptions)
}

In the value handler, we pass the access token and the refresh token to the keychain service by invoking the setAccessToken(_:) and setRefreshToken(_:) methods.

func signIn() {
    guard canSignIn else {
        return
    }

    isSigningIn = true
    errorMessage = nil

    apiService.signIn(email: email, password: password)
        .sink(receiveCompletion: { [weak self] completion in
            self?.password = ""
            self?.isSigningIn = false

            switch completion {
            case .finished:
                ()
            case .failure:
                self?.errorMessage = "The email/password combination is invalid."
            }
        }, receiveValue: { [weak self] response in
            self?.keychainService.setAccessToken(response.accessToken)
            self?.keychainService.setRefreshToken(response.refreshToken)
        }).store(in: &subscriptions)
}

The last step is updating the user interface. Open SignInView.swift. We use optional binding to safely unwrap the value of the view model's errorMessage property. The sign in view displays the error message above the email text field. We set the font weight of the Text object to light and the foreground color to the application's accent color.

var body: some View {
    NavigationView {
        ZStack {
            VStack(spacing: 20.0) {
                VStack(alignment: .leading, spacing: 20.0) {
                    if let errorMessage = viewModel.errorMessage {
                        Text(errorMessage)
                            .fontWeight(.light)
                            .foregroundColor(.accentColor)
                    }

                    ...
                }
                CapsuleButton(title: "Sign In") {
                    viewModel.signIn()
                }
                .disabled(!viewModel.canSignIn)
                Spacer()
            }
            .padding()
            .textFieldStyle(.roundedBorder)

            if viewModel.isSigningIn {
                ZStack {
                    Color(white: 1.0)
                        .opacity(0.75)
                    ProgressView()
                        .progressViewStyle(.circular)
                }
            }
        }
        .navigationTitle("Sign In")
    }
}

You could also display an alert to the user. Choose the option that best fits your application.

Before we can give the implementation a try, we need to update the initialization of the sign in view model. Update the computed body property of the profile view by passing an APIClient instance to the initializer of the SignInViewModel class.

var body: some View {
    VStack {
        if viewModel.isSignedIn {
            ...
        } else {
            SignInView(
                viewModel: SignInViewModel(
                    apiService: APIClient(),
                    keychainService: viewModel.keychainService
                )
            )
        }
    }
}

Open SignInView.swift and update the computed previews property of the SignInView_Previews struct by passing an APIPreviewClient instance to the initializer of the SignInViewModel class.

struct SignInView_Previews: PreviewProvider {
    static var previews: some View {
        SignInView(
            viewModel: SignInViewModel(
                apiService: APIPreviewClient(),
                keychainService: KeychainService()
            )
        )
    }
}

Build and run the application to give the implementation a try.

Build and Run the Application

What's Next?

I hope you agree that adding the ability for the user to sign in was straightforward to implement thanks to the improvements we made in the previous episode. There is one problem, though. We need to improve error handling. The application shows the same error message regardless of the error. That is confusing and frustrating to the user. We fix that in the next episode.