Search code examples
swiftfirebase-authenticationsiesta-swift

Swift Siesta - How to include asynchronous code into a request chain?


I try to use Siesta decorators to enable a flow where my authToken gets refreshed automatically when a logged in user gets a 401. For authentication I use Firebase.

In the Siesta documentation there is a straight forward example on how to chain Siesta requests, but I couldn't find a way how to get the asynchronous Firebase getIDTokenForcingRefresh:completion: working here. The problem is that Siesta always expects a Request or a RequestChainAction to be returned, which is not possible with the Firebase auth token refresh api.

I understand that the request chaining is primarily done for Siesta-only use cases. But is there a way to use asynchronous third party APIs like FirebaseAuth which don't perfectly fit in the picture?

Here is the code:

init() {
    configure("**") {
        $0.headers["jwt"] = self.authToken
        
        $0.decorateRequests {
          self.refreshTokenOnAuthFailure(request: $1)
     }  
  }

func refreshTokenOnAuthFailure(request: Request) -> Request {
  return request.chained {
    guard case .failure(let error) = $0.response,  // Did request fail…
      error.httpStatusCode == 401 else {           // …because of expired token?
        return .useThisResponse                    // If not, use the response we got.
    }

    return .passTo(
      self.createAuthToken().chained {             // If so, first request a new token, then:
        if case .failure = $0.response {           // If token request failed…
          return .useThisResponse                  // …report that error.
        } else {
          return .passTo(request.repeated())       // We have a new token! Repeat the original request.
        }
      }
    )
  }
}

//What to do here? This should actually return a Siesta request
func createAuthToken() -> Void {
  let currentUser = Auth.auth().currentUser
  currentUser?.getIDTokenForcingRefresh(true) { idToken, error in
    if let error = error {
      // Error
      return;
    }
    self.authToken = idToken
    self.invalidateConfiguration()
  }
}

Edit:

Based on the suggested answer of Adrian I've tried the solution below. It still does not work as expected:

  • I use request() .post to send a request
  • With the solution I get a failure "Request Cancelled" in the callback
  • After the callback of createUser was called, the original request is sent with the updated jwt token
  • This new request with the correct jwt token is lost as the callback of createUser is not called for the response -> So onSuccess is never reached in that case.

How do I make sure that the callback of createUser is only called after the original request was sent with the updated jwt token? Here is my not working solution - happy for any suggestions:

 // This ends up with a requestError "Request Cancelled" before the original request is triggered a second time with the refreshed jwt token.
    func createUser(user: UserModel, completion: @escaping CompletionHandler) {
    do {
        let userAsDict = try user.asDictionary()
        Api.sharedInstance.users.request(.post, json: userAsDict)
            .onSuccess {
                data in
                if let user: UserModel = data.content as? UserModel {
                    completion(user, nil)
                } else {
                    completion(nil, "Deserialization Error")
                }
        }.onFailure {
            requestError in
            completion(nil, requestError)
        }
    } catch let error {
        completion(nil, nil, "Serialization Error")
    }
}

The Api class:

    class Api: Service {
    
    static let sharedInstance = Api()
    var jsonDecoder = JSONDecoder()
    var authToken: String? {
        didSet {
            // Rerun existing configuration closure using new value
            invalidateConfiguration()
            // Wipe any cached state if auth token changes
            wipeResources()
        }
    }
    
    init() {
        configureJSONDecoder(decoder: jsonDecoder)
        super.init(baseURL: Urls.baseUrl.rawValue, standardTransformers:[.text, .image])
        SiestaLog.Category.enabled = SiestaLog.Category.all
        
        configure("**") {
            $0.expirationTime = 1
            $0.headers["bearer-token"] = self.authToken
            $0.decorateRequests {
                self.refreshTokenOnAuthFailure(request: $1)
            }
        }
        
        self.configureTransformer("/users") {
            try self.jsonDecoder.decode(UserModel.self, from: $0.content)
        }
        
    }
    
    var users: Resource { return resource("/users") }
    
    func refreshTokenOnAuthFailure(request: Request) -> Request {
        return request.chained {
            guard case .failure(let error) = $0.response,  // Did request fail…
                error.httpStatusCode == 401 else {           // …because of expired token?
                    return .useThisResponse                    // If not, use the response we got.
            }
            return .passTo(
                self.refreshAuthToken(request: request).chained {          // If so, first request a new token, then:
                    if case .failure = $0.response {
                        return .useThisResponse                  // …report that error.
                    } else {
                        return .passTo(request.repeated())       // We have a new token! Repeat the original request.
                    }
                }
            )
        }
    }
    
    func refreshAuthToken(request: Request) -> Request {
        return Resource.prepareRequest(using: RefreshJwtRequest())
            .onSuccess {
                self.authToken = $0.text                  // …make future requests use it
        }
    }
}

The RequestDelegate:

    class RefreshJwtRequest: RequestDelegate {

    func startUnderlyingOperation(passingResponseTo completionHandler: RequestCompletionHandler) {
        if let currentUser = Auth.auth().currentUser {
            currentUser.getIDTokenForcingRefresh(true) { idToken, error in
                if let error = error {
                    let reqError = RequestError(response: nil, content: nil, cause: error, userMessage: nil)
                    completionHandler.broadcastResponse(ResponseInfo(response: .failure(reqError)))
                    return;
                }
                let entity = Entity<Any>(content: idToken ?? "no token", contentType: "text/plain")
                completionHandler.broadcastResponse(ResponseInfo(response: .success(entity)))            }
        } else {
            let authError = RequestError(response: nil, content: nil, cause: AuthError.NOT_LOGGED_IN_ERROR, userMessage: "You are not logged in. Please login and try again.".localized())
            completionHandler.broadcastResponse(ResponseInfo(response: .failure(authError)))
        }
    }
    
    func cancelUnderlyingOperation() {}

    func repeated() -> RequestDelegate { RefreshJwtRequest() }

    private(set) var requestDescription: String = "CustomSiestaRequest"
}

Solution

  • First off, you should rephrase the main thrust of your question so it's not Firebase-specific, along the lines of "How do I do request chaining with some arbitrary asynchronous code instead of a request?". It will be much more useful to the community that way. Then you can mention that Firebase auth is your specific use case. I'm going to answer your question accordingly.

    (Edit: Having answered this question, I now see that Paul had already answered it here: How to decorate Siesta request with an asynchronous task)

    Siesta's RequestDelegate does what you're looking for. To quote the docs: "This is useful for taking things that are not standard network requests, and wrapping them so they look to Siesta as if they are. To create a custom request, pass your delegate to Resource.prepareRequest(using:)."

    You might use something like this as a rough starting point - it runs a closure (the auth call in your case) that either succeeds with no output or returns an error. Depending on use, you might adapt it to populate the entity with actual content.

    // todo better name
    class SiestaPseudoRequest: RequestDelegate {
        private let op: (@escaping (Error?) -> Void) -> Void
    
        init(op: @escaping (@escaping (Error?) -> Void) -> Void) {
            self.op = op
        }
    
        func startUnderlyingOperation(passingResponseTo completionHandler: RequestCompletionHandler) {
            op {
                if let error = $0 {
                    // todo better
                    let reqError = RequestError(response: nil, content: nil, cause: error, userMessage: nil)
                    completionHandler.broadcastResponse(ResponseInfo(response: .failure(reqError)))
                }
                else {
                    // todo you might well produce output at this point
                    let ent = Entity<Any>(content: "", contentType: "text/plain")
                    completionHandler.broadcastResponse(ResponseInfo(response: .success(ent)))
                }
            }
        }
    
        func cancelUnderlyingOperation() {}
    
        func repeated() -> RequestDelegate { SiestaPseudoRequest(op: op) }
    
        // todo better
        private(set) var requestDescription: String = "SiestaPseudoRequest"
    }
    

    One catch I found with this is that response transformers aren't run for such "requests" - the transformer pipeline is specific to Siesta's NetworkRequest. (This took me by surprise and I'm not sure that I like it, but Siesta seems to be generally full of good decisions, so I'm mostly taking it on faith that there's a good reason for it.)

    It might be worth watching out for other non request-like behaviour.