Below there are three functions. The first one is the function that I need to refactor. Basically what I'm hoping for is something similar what can be achieved using Promise Kit but in this case using Swifts combine framework.
The second function loginWithFacebook() returns a AuthCredential.
This AuthCredential needs to be passed on to the last functions which returns a type Future<UserProfileCompact, Error> which is a similar return type to the main function (1st function).
My question is is there a way to achieve this in a Swifty way, similar to Promise Kit doing this operation: return loginWithFacebook().then {loginWithFirebase(:_)}
// Call site is a View Model
// Main Function that needs to be refactored
func loginwithFacebook() -> Future<UserProfileCompact, Error> {
//This returs a Future Firebase Credential
loginWithFacebook()
//The above credential needs to be passed to this method and this returns a type Future<UserProfileCompact, Error>
loginWithFirebase(<#T##credentials: AuthCredential##AuthCredential#>)
}
private func loginWithFacebook() -> Future<AuthCredential,Error> {
return Future { [weak self] promise in
self?.loginManager.logIn(permissions: ["public_profile","email"], from: UIViewController()) { (loginResult, error) in
if let error = error {
promise(.failure(error))
} else if loginResult?.isCancelled ?? false {
//fatalError()
}
else if let authToken = loginResult?.token?.tokenString {
let credentials = FacebookAuthProvider.credential(withAccessToken: authToken)
promise(.success(credentials))
}
else{
fatalError()
}
}
}
}
private func loginWithFirebase(_ credentials: AuthCredential) -> Future<UserProfileCompact, Error> {
return Future { promise in
Auth.auth().signIn(with: credentials) { (result, error) in
if let error = error {
//Crashlytics.crashlytics().record(error: error)
promise(.failure(error))
}
else if let user = result?.user {
//Crashlytics.crashlytics().setUserID(user.uid)
let profile = UserProfileCompactMapper.map(firebaseUser: user)
promise(.success(profile))
}
else {
fatalError()
}
}
}
}
You can use a .flatMap operator, which takes a value from upstream and produces a publisher. This would look something like below.
Note, that it's also better to return a type-erased AnyPublisher at the function boundary, instead of the specific publisher used inside the function
func loginwithFacebook() -> AnyPublisher<UserProfileCompact, Error> {
loginWithFacebook().flatMap { authCredential in
loginWithFirebase(authCredential)
}
.eraseToAnyPublisher()
}
Related
I am trying to build RxSwift Auth token refresh service using following tutorial: https://www.donnywals.com/building-a-concurrency-proof-token-refresh-flow-in-combine/. However, I faced with issue, when user don't have an auth token and first refresh failed, but second refresh succeed, additional request is send, and after this (3-rd request) is completed, only then called main endpoint
So, what I see in network inspector:
request to refresh token (failed)
request to refresh token (succeed)
request to refresh token (succeed)
request to main endpoint (succeed)
But it should be:
request to refresh token (failed)
request to refresh token (succeed)
request to main endpoint (succeed)
I have following code for Authenticator
protocol AuthenticatorType {
func authenticate() -> Observable<Void>
func checkForValidAuthTokenOrRefresh(forceRefresh: Bool) -> Observable<Void>
}
extension AuthenticatorType {
func checkForValidAuthTokenOrRefresh(forceRefresh: Bool = false) -> Observable<Void> {
return checkForValidAuthTokenOrRefresh(forceRefresh: forceRefresh)
}
}
final class Authenticator<Provider: RxMoyaProviderType> where Provider.Target == AuthAPI {
private let provider: Provider
private let cookiesStorageProvider: CookiesStorageProviderType
private let queue = DispatchQueue(label: "Autenticator.\(UUID().uuidString)")
private var refreshInProgressObservable: Observable<Void>?
init(
provider: Provider,
cookiesStorageProvider: CookiesStorageProviderType
) {
self.provider = provider
self.cookiesStorageProvider = cookiesStorageProvider
}
func checkForValidAuthTokenOrRefresh(forceRefresh: Bool = false) -> Observable<Void> {
return queue.sync { [weak self] in
self?.getCurrentTokenOrRefreshIfNeeded(forceRefresh: forceRefresh) ?? .just(())
}
}
func authenticate() -> Observable<Void> {
provider.request(.authenticate(credentials: .defaultDebugAccount))
.map(LoginResponse.self)
.map { loginResponse in
guard loginResponse.login else {
throw AuthenticationError.loginRequired
}
}
.asObservable()
}
}
// MARK: - Helper methods
private extension Authenticator {
func getCurrentTokenOrRefreshIfNeeded(forceRefresh: Bool = false) -> Observable<Void> {
if let refreshInProgress = refreshInProgressObservable {
return refreshInProgress
}
if cookiesStorageProvider.isHaveValidAuthToken && !forceRefresh {
return .just(())
}
guard cookiesStorageProvider.isHaveValidRefreshToken else {
return .error(AuthenticationError.loginRequired)
}
let refreshInProgress = provider.request(.refreshToken)
.share()
.map { response in
guard response.statusCode != 401 else {
throw AuthenticationError.loginRequired
}
return response
}
.map(RefreshReponse.self)
.map { refreshResponse in
guard refreshResponse.refresh else {
throw AuthenticationError.loginRequired
}
}
.asObservable()
.do(
onNext: { [weak self] _ in self?.resetProgress() },
onError: { [weak self] _ in self?.resetProgress() }
)
refreshInProgressObservable = refreshInProgress
return refreshInProgress
}
func resetProgress() {
queue.sync { [weak self] in
self?.refreshInProgressObservable = nil
}
}
}
And thats how I refresh doing request (with logics to refresh token)
func request(_ token: Target, callbackQueue: DispatchQueue?) -> Observable<Response> {
authenticator.checkForValidAuthTokenOrRefresh()
.flatMapLatest { [weak self] res -> Observable<Response> in
self?.provider.request(token).asObservable() ?? .empty()
}
.map { response in
guard response.statusCode != 401 else {
throw AuthenticationError.loginRequired
}
return response
}
.retry { [weak self] error in
error.flatMap { error -> Observable<Void> in
guard let authError = error as? AuthenticationError, authError == .loginRequired else {
return .error(error)
}
return self?.authenticator.checkForValidAuthTokenOrRefresh(forceRefresh: true) ?? .never()
}
}
}
At first, I thought it was concurrency problem, I changed queue to NSLock, but it all was the same. Also I tried to use subscribe(on:) and observe(on:), thats also don't give any effect.
Maybe issue with do block, where I set refreshInProgressObservable to nil, because when I change onError, to afterError, I don't see third request to refresh token, but I also don't see any request to main endpoint.
I even tried to remove share(), but as you guess it don't help either.
Ah, and also I remember that 3-rd request fires instantly after second is completed, even if I add sleep in beginning of getCurrentTokenOrRefreshIfNeeded method. So that kinda strange
Edit
I tried another way to refresh token, using deferred block in Observable (inspired by Daniel tutorial).
Here is my code
final class NewProvider {
let authProvider: MoyaProvider<AuthAPI>
let apiProvider: MoyaProvider<AppAPI>
let refreshToken: Observable<Void>
init(authProvider: MoyaProvider<AuthAPI>, apiProvider: MoyaProvider<AppAPI>) {
self.authProvider = authProvider
self.apiProvider = apiProvider
refreshToken = authProvider.rx.request(.refreshToken)
.asObservable()
.share()
.map { _ in }
.catchAndReturn(())
}
func request(_ token: AppAPI) -> Observable<Response> {
Observable<Void>
.deferred {
if CookiesStorageProvider.isHaveValidAuthToken {
return .just(())
} else {
throw AuthenticationError.loginRequired
}
}
.flatMapLatest { [weak self] _ in
self?.apiProvider.rx.request(token).asObservable() ?? .never()
}
.retry { [weak self] error in
return error.flatMapLatest { [weak self] _ in
self?.refreshToken ?? .never()
}
}
}
}
It works perfectly for one request (like, "it sends request to refresh token only when auth token is missing and try to refresh token again if token refresh failed")
However, there is problem with multiple requests. If there is no auth token and multiple request are fired, it works well, requests are waiting for token to refresh. BUT, if token refresh failed, there is no attempt to try refresh token again. I don't know what can lead to this behaviour.
EDIT 2
I found out that if I place
.observe(on: SerialDispatchQueueScheduler(queue: queue, internalSerialQueueName: "test1"))
after
.share()
refreshToken = authProvider.rx.request(.refreshToken)
.asObservable()
.share()
.observe(on: SerialDispatchQueueScheduler(queue: queue, internalSerialQueueName: "test1"))
.map { _ in }
.catchAndReturn(())
All will be work as expected, but now I can't understand why its working this way
Okay, I pulled down your code and spent a good chunk of the day looking it over. A couple of review points:
This is way more complex than it needs to be for what it's doing.
Any time you have a var Observable, you are doing something wrong. Observables and Subjects should always be let.
There is no reason or need to use a DispatchQueue the way you did for Observables. This code doesn't need one at all, but even if it did, you should be passing in a Scheduler instead of using queues directly.
I could see no way for your code to actually use the new token in the retry once it has been received. Even if these tests did pass, the code still wouldn't work.
As far as this specific question is concerned. The fundamental problem is that you are calling getCurrentTokenOrRefreshIfNeeded(forceRefresh:) four times in the offending test and creating three refreshInProgress Observables. You are making three of them, because the second one has emitted a result and been disposed before the last call to the function is made. Each one emits a value so you end up with three next events in authAPIProviderMock.recordedEvents.
What is the fix? I could not find a fix without making major changes to the basic structure/architecture of the code. All I can do at this point is suggest that you check out my article on this subject RxSwift and Handling Invalid Tokens which contains working code for this use case and includes unit tests. Or revisit Donny's article which I presume works, but since there are no unit tests for his code, I can't be sure.
Edit
In answer to your question in the comments, here is how you would solve the problem using my service class:
First create a tokenAcquisitionService object. Since you don't actually need to pass a token value around, just use Void for the token type.
let service = TokenAcquisitionService(initialToken: (), getToken: { _ in URLSession.shared.rx.response(request: refreshTokenRequest) }, extractToken: { _ in })
(Use whatever you want in place of URLSession.shared.rx.response(request: refreshTokenRequest). The only requirement is that it returns an Observable<(response: HTTPURLResponse, data: Data)> and in this case the data can simply be Data() or anything else, since it is ignored. It can even present a view controller that asks the user to login.)
Now at the end of every request, include the following.
.do(onNext: { response in
guard response.response.statusCode != 401 else { throw TokenAcquisitionError.unauthorized }
})
.retry(when: { $0.renewToken(with: tokenAcquisitionService) })
Wrap the above however you want so you don't have to copy pasted it onto every request.
QED
I want to refactor some API calls to use Swift 5.5's new async/await in my SwiftUI project. However, it's unclear to me how to replace or accomodate the completions.
Here's an example function which I want to refactor:
static func getBooks(completion: #escaping ([Book]?) -> Void) {
let request = getRequest(suffix: "books")
URLSession.shared.dataTask(with: request) { data, response, error in
if let error = error {
fatalError("Error: \(error)")
}
if let data = data {
if let books = try? JSONDecoder().decode([Book].self, from: data) {
DispatchQueue.main.async {
print("books.count: \(books.count)")
completion(books)
}
return
} else {
fatalError("Unable to decode JSON")
}
} else {
fatalError("Data is nil")
}
}.resume()
}
I beleve the new function signature would look something like this:
static func getBooks() async throws -> ([Book]?) {
// ...
}
However, I have no idea what to do with the URLSession.shared.dataTask, DispatchQueue.main.async and completion, etc.
Anyone know what the new function body should look like?
Thanks
func getBooks() async throws -> [Book] {
let (data, _) = try await URLSession.shared.data(for: request)
return try JSONDecoder().decode([Book].self, from: data)
}
This will throw if the request fails, and if the response cannot be decoded. Since the function is marked as throwing, then the calling function has to handle the raised errors.
You don't need to declare the returned [Book] to be optional, because it will either return an honest array, or throw an error.
In your additional code, you had to call your completion handler on the main queue, because you were calling it from within the completion block of the request. You don't need to do that here.
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"
}
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.
I want to add a value to Firestore. When finished I want to return the added value. The value does get added to Firestore successfully. However, the value does not go through sink.
This is the function that does not work:
func createPremium(user id: String, isPremium: Bool) -> AnyPublisher<Bool,Never> {
let dic = ["premium":isPremium]
return Future<Bool,Never> { promise in
self.db.collection(self.dbName).document(id).setData(dic, merge: true) { error in
if let error = error {
print(error.localizedDescription)
} else {
/// does get called
promise(.success(isPremium))
}
}
}.eraseToAnyPublisher()
}
I made a test function that works:
func test() -> AnyPublisher<Bool,Never> {
return Future<Bool,Never> { promise in
promise(.success(true))
}.eraseToAnyPublisher()
}
premiumRepository.createPremium(user: userID ?? "1234", isPremium: true)
.sink { receivedValue in
/// does not get called
print(receivedValue)
}.cancel()
test()
.sink { recievedValue in
/// does get called
print("Test", recievedValue)
}.cancel()
Also I have a similar code snippet that works:
func loadExercises(category: Category) -> AnyPublisher<[Exercise], Error> {
let document = store.collection(category.rawValue)
return Future<[Exercise], Error> { promise in
document.getDocuments { documents, error in
if let error = error {
promise(.failure(error))
} else if let documents = documents {
var exercises = [Exercise]()
for document in documents.documents {
do {
let decoded = try FirestoreDecoder().decode(Exercise.self, from: document.data())
exercises.append(decoded)
} catch let error {
promise(.failure(error))
}
}
promise(.success(exercises))
}
}
}.eraseToAnyPublisher()
}
I tried to add a buffer but it did not lead to success.
Try to change/remove .cancel() method on your subscriptions. Seems you subscribe to the publisher, and then immediately cancel the subscription. The better option is to retain and store all your subscriptions in the cancellable set.
Im new to RXSwift and I've begun investigating how I can perform Promise like function chaining.
I think I'm on the right track by using flatmap but my implementation is very difficult to read so I suspect theres a better way to accomplish it.
What I have here seems to work but I'm getting a headache thinking about what It might looks like if I added another 3 or functions to the chain.
Here Is where I declare my 'promise chain'(hard to read)
LOGIN().flatMap{ (stuff) -> Observable<Int> in
return API(webSiteData: stuff).flatMap
{ (username) -> Observable<ProfileResult> in
return accessProfile(userDisplayName: username) }
}.subscribe(onNext: { event in
print("The Chain Completed")
print(event)
}, onError:{ error in
print("An error in the chain occurred")
})
These are the 3 sample functions I'm chaining
struct apicreds
{
let websocket:String
let token:String
}
typealias APIResult = String
typealias ProfileResult = Int
// FUNCTION 1
func LOGIN() -> Observable<apicreds> {
return Observable.create { observer in
print("IN LOGIn")
observer.onNext(apicreds(websocket: "the web socket", token: "the token"))
observer.on(.completed)
return Disposables.create()
}
}
// FUNCTION 2
func API(webSiteData: apicreds) -> Observable<APIResult> {
return Observable.create { observer in
print("IN API")
print (webSiteData)
// observer.onError(myerror.anError)
observer.on(.next("This is the user name")) // assiging "1" just as an example, you may ignore
observer.on(.completed)
return Disposables.create()
}
}
//FUNCTION 3
func accessProfile(userDisplayName:String) -> Observable<ProfileResult>
{
return Observable.create { observer in
// Place your second server access code
print("IN Profile")
print (userDisplayName)
observer.on(.next(200)) // 200 response from profile call
observer.on(.completed)
return Disposables.create()
}
}
This is a very common problem we run into while chaining operations. As a beginner I had written similar code using RxSwift in my projects as well. And there are two areas of improvement -
1. Refactor the code to remove nested flatMaps
2. Format it differently to make the sequence easier to follow
LOGIN()
.flatMap{ (stuff) -> Observable<APIResult> in
return API(webSiteData: stuff)
}.flatMap{ (username) -> Observable<ProfileResult> in
return accessProfile(userDisplayName: username)
}.subscribe(onNext: { event in
print("The Chain Completed")
print(event)
}, onError:{ error in
print("An error in the chain occurred")
})
In addition to nested flatMap and code formatting, you could omit return and explicit return types:
LOGIN()
.flatMap { webSiteData in API(webSiteData: webSiteData) }
parameter names
LOGIN()
.flatMap { API(webSiteData: $0) }
or even remove parameters at all where appropriate:
LOGIN()
.flatMap(API)
.flatMap(accessProfile)
.subscribe(
onNext: { event in
print(event)
}, onError:{ error in
print(error)
}
)
FYI there is Observable.just method which would be convenient here:
struct ApiCredentials {
let websocket: String
let token: String
}
func observeCredentials() -> Observable<ApiCredentials> {
let credentials = ApiCredentials(websocket: "the web socket", token: "the token")
return Observable.just(credentials)
}
Try to follow official Swift API Guidelines to make your code more readable.
You can also use the point-free style and just pass function references to flatMap:
LOGIN()
.flatMap(API)
.flatMap(accessProfile)
.subscribe(onNext: { event in
print("The Chain Completed")
print(event)
}, onError:{ error in
print("An error in the chain occurred")
})