1

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()
}
jnpdx
  • 45,847
  • 6
  • 64
  • 94
Björn
  • 338
  • 1
  • 13

1 Answers1

3

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()
Phil Dukhov
  • 67,741
  • 15
  • 184
  • 220
  • Thanks :) I will be having a look at it tomorrow and will share my findings. Thanks again for the answer! – Björn Sep 05 '21 at 19:49
  • I seem to get an error and I do not really understand it... Could you have a look? https://imgur.com/a/DEAtUwJ – Björn Sep 06 '21 at 11:40
  • @Björn Looks like I've forget `.eraseToAnyPublisher()` after `.flatMap`, added it to my answer – Phil Dukhov Sep 06 '21 at 11:45
  • https://imgur.com/a/m6Og1QZ Is it not required to add AnyPublisher in this line: `.flatMap { loginResult -> AnyPublisher in` ? If I only add the .eraseToAnyPublisher() it gives me: 'Type of expression is ambiguous without more context' – Björn Sep 06 '21 at 11:50
  • @Björn with `eraseToAnyPublisher` it should get it from the return type of the function, but if that does not help - yes, adding an explicit type would be necessary – Phil Dukhov Sep 06 '21 at 11:53
  • @Björn that means you have to add an explicit type=) Sometimes it is hard to tell when the compiler can inherit a type and when it cannot. – Phil Dukhov Sep 06 '21 at 11:59
  • The party does not end here hahaha: https://imgur.com/a/FTH2ngO – Björn Sep 06 '21 at 12:02
  • @Björn see [this answer](https://stackoverflow.com/a/57597385/3585796) – Phil Dukhov Sep 06 '21 at 12:08
  • The code is working now, but it did not solve the original problem of not being able to return any values inside the .sink of the called login() function... I need to redo the function or return `return Fail(error: Error.errorCode(statusCode: response.statusCode, errorMessage: "Oops")).eraseToAnyPublisher()` just like before – Björn Sep 06 '21 at 14:32
  • @Björn what value do you expect to be returned? I'm launching the same request which is failed with 403 with this line `request(ofType: type, from: endpoint, body: body)`, it should return something – Phil Dukhov Sep 06 '21 at 14:34
  • Im sorry Philip, im just starting to learn Swift, thanks for your patience: https://imgur.com/a/0PqP1XI – Björn Sep 06 '21 at 14:40
  • 1
    @Björn sure no problem. It's conflicting with a local variable, add `self.` before `request`, and also you need return before `signinModel.login()` – Phil Dukhov Sep 06 '21 at 15:00
  • You really are amazing haha. It finally works. You have no idea how happy you made me today :) – Björn Sep 06 '21 at 15:12
  • @Björn You're welcome =) Good luck on the path – Phil Dukhov Sep 06 '21 at 15:15