Björn
Björn

Reputation: 370

Swift Combine Completion Handler with return of values

I have an API Service handler implemented which uses an authentication token in the header of the request. This token is fetched when the user logs in at the launch of the application. After 30 minutes, the token is expired. Thus, when a request is made after this timespan, the API returns an 403 statuscode. The API should then login again and restart the current API request.

The problem I am encountering is that the login function to fetch a new token, makes use of a completion handler to let the calling code know if the asynchronous login procedure has been successful or not. When the API gets a 403 statuscode, it calls the login procedure and and when that is complete, it should make the current request again. But this repeated API request should return some value again. However, returning a value is not possible in a completion block. Does anyone know a solution for the problem as a whole?

The login function is as follows:

func login (completion: @escaping (Bool) -> Void) {
    
    self.loginState = .loading
    
    let preparedBody = APIPrepper.prepBody(parametersDict: ["username": self.credentials.username, "password": self.credentials.password])

    let cancellable = service.request(ofType: UserLogin.self, from: .login, body: preparedBody).sink { res in
        switch res {
        case .finished:
            if self.loginResult.token != nil {
                self.loginState = .success
                self.token.token = self.loginResult.token!

                _ = KeychainStorage.saveCredentials(self.credentials)
                _ = KeychainStorage.saveAPIToken(self.token)

                completion(true)
            }
            else {
                (self.banner.message, self.banner.stateIdentifier, self.banner.type, self.banner.show) = ("ERROR", "TOKEN", "error", true)
                self.loginState = .failed(stateIdentifier: "TOKEN", errorMessage: "ERROR")
                completion(false)
            }
        case .failure(let error):
            (self.banner.message, self.banner.stateIdentifier, self.banner.type, self.banner.show) = (error.errorMessage, error.statusCode, "error", true)
            self.loginState = .failed(stateIdentifier: error.statusCode, errorMessage: error.errorMessage)
            completion(false)
        }
    } receiveValue: { response in
        self.loginResult = response
    }
    
    self.cancellables.insert(cancellable)
}

The API service is as follows:

func request<T: Decodable>(ofType type: T.Type, from endpoint: APIRequest, body: String) -> AnyPublisher<T, Error> {
    
    var request = endpoint.urlRequest
    request.httpMethod = endpoint.method
    
    if endpoint.authenticated == true {
        request.setValue(KeychainStorage.getAPIToken()?.token, forHTTPHeaderField: "token")
    }
    
    if !body.isEmpty {
        let finalBody = body.data(using: .utf8)
        request.httpBody = finalBody
    }
    
    return URLSession
        .shared
        .dataTaskPublisher(for: request)
        .receive(on: DispatchQueue.main)
        .mapError { _ in Error.unknown}
        .flatMap { data, response -> AnyPublisher<T, Error> in
            
            guard let response = response as? HTTPURLResponse else {
                return Fail(error: Error.unknown).eraseToAnyPublisher()
            }
            
            let jsonDecoder = JSONDecoder()
            
            if response.statusCode == 200 {
                return Just(data)
                    .decode(type: T.self, decoder: jsonDecoder)
                    .mapError { _ in Error.decodingError }
                    .eraseToAnyPublisher()
            }
            else if response.statusCode == 403 {
                
                let credentials = KeychainStorage.getCredentials()
                let signinModel: SigninViewModel = SigninViewModel()
                signinModel.credentials = credentials!
        
                signinModel.login() { success in
                    
                    if success == true {
------------------->    // MAKE THE API CALL AGAIN AND THUS RETURN SOME VALUE
                    }
                    else {
------------------->    // RETURN AN ERROR
                    }
        
                }
    
            }
            else if response.statusCode == 429 {
                return Fail(error: Error.errorCode(statusCode: response.statusCode, errorMessage: "Oeps! Je hebt teveel verzoeken gedaan, wacht een minuutje")).eraseToAnyPublisher()
            }
            else {
                do {
                    let errorMessage = try jsonDecoder.decode(APIErrorMessage.self, from: data)
                    return Fail(error: Error.errorCode(statusCode: response.statusCode, errorMessage: errorMessage.error ?? "Er is iets foutgegaan")).eraseToAnyPublisher()
                }
                catch {
                    return Fail(error: Error.decodingError).eraseToAnyPublisher()
                }
            }
        }
        .eraseToAnyPublisher()
}

Upvotes: 1

Views: 1909

Answers (1)

Phil Dukhov
Phil Dukhov

Reputation: 87605

You're trying to combine Combine with old asynchronous code. You can do it with Future, check out more about it in this apple article:

Future { promise in
    signinModel.login { success in

        if success == true {
            promise(Result.success(()))
        }
        else {
            promise(Result.failure(Error.unknown))
        }

    }
}
    .flatMap { _ in
        // repeat request if login succeed
        request(ofType: type, from: endpoint, body: body)
    }.eraseToAnyPublisher()

But this should be done when you cannot modify the asynchronous method or most of your codebase uses it.

In your case it looks like you can rewrite login to Combine. I can't build your code, so there might be errors in mine too, but you should get the idea:

func login() -> AnyPublisher<Void, Error> {

    self.loginState = .loading

    let preparedBody = APIPrepper.prepBody(parametersDict: ["username": self.credentials.username, "password": self.credentials.password])

    return service.request(ofType: UserLogin.self, from: .login, body: preparedBody)
        .handleEvents(receiveCompletion: { res in
            if case let .failure(error) = res {
                (self.banner.message,
                    self.banner.stateIdentifier,
                    self.banner.type,
                    self.banner.show) = (error.errorMessage, error.statusCode, "error", true)
                self.loginState = .failed(stateIdentifier: error.statusCode, errorMessage: error.errorMessage)
            }
        })
        .flatMap { loginResult in
            if loginResult.token != nil {
                self.loginState = .success
                self.token.token = loginResult.token!

                _ = KeychainStorage.saveCredentials(self.credentials)
                _ = KeychainStorage.saveAPIToken(self.token)

                return Just(Void()).eraseToAnyPublisher()
            } else {
                (self.banner.message, self.banner.stateIdentifier, self.banner.type, self.banner.show) = ("ERROR",
                    "TOKEN",
                    "error",
                    true)
                self.loginState = .failed(stateIdentifier: "TOKEN", errorMessage: "ERROR")
                return Fail(error: Error.unknown).eraseToAnyPublisher()
            }
        }
        .eraseToAnyPublisher()
}

And then call it like this:

signinModel.login()
    .flatMap { _ in
        request(ofType: type, from: endpoint, body: body)
    }.eraseToAnyPublisher()

Upvotes: 3

Related Questions