How to using ReactiveCocoa to transparently authenticate before making API calls?
Asked Answered
U

3

25

I am using ReactiveCocoa in an app which makes calls to remote web APIs. But before any thing can be retrieved from a given API host, the app must provide the user's credentials and retrieve an API token, which is then used to sign subsequent requests.

I want to abstract away this authentication process so that it happens automatically whenever I make an API call. Assume I have an API client class that contains the user's credentials.

// getThing returns RACSignal yielding the data returned by GET /thing.
// if the apiClient instance doesn't already have a token, it must
// retrieve one before calling GET /thing 
RAC(self.thing) = [apiClient getThing]; 

How can I use ReactiveCocoa to transparently cause the first (and only the first) request to an API to retrieve and, as a side effect, safely store an API token before any subsequent requests are made?

It is also a requirement that I can use combineLatest: (or similar) to kick off multiple simultaneous requests and that they will all implicitly wait for the token to be retrieved.

RAC(self.tupleOfThisAndThat) = [RACSignal combineLatest:@[ [apiClient getThis], [apiClient getThat]]];

Further, if the retrieve-token request is already in flight when an API call is made, that API call must wait until the retrieve-token request has completed.

My partial solution follows:

The basic pattern is going to be to use flattenMap: to map a signal which yields the token to a signal that, given the token, performs the desired request and yields the result of the API call.

Assuming some convenient extensions to NSURLRequest:

- (RACSignal *)requestSignalWithURLRequest:(NSURLRequest *)urlRequest {
    if ([urlRequest isSignedWithAToken])
        return [self performURLRequest:urlRequest];

    return [[self getToken] flattenMap:^ RACSignal * (id token) {
        NSURLRequest *signedRequest = [urlRequest signedRequestWithToken:token];
        assert([urlRequest isSignedWithAToken]);
        return [self requestSignalWithURLRequest:signedRequest];
    }
}

Now consider the subscription implementation of -getToken.

  • In the trivial case, when the token has already been retrieved, the subscription yields the token immediately.
  • If the token has not been retrieved, the subscription defers to an authentication API call which returns the token.
  • If the authentication API call is in flight, it should be safe to add another observer without causing the authentication API call to be repeated over the wire.

However I'm not sure how to do this. Also, how and where to safely store the token? Some kind of persistent/repeatable signal?

Ulyanovsk answered 28/12, 2012 at 8:20 Comment(0)
M
45

So, there are two major things going on here:

  1. You want to share some side effects (in this case, fetching a token) without re-triggering them every time there's a new subscriber.
  2. You want anyone subscribing to -getToken to get the same values no matter what.

In order to share side effects (#1 above), we'll use RACMulticastConnection. Like the documentation says:

A multicast connection encapsulates the idea of sharing one subscription to a signal to many subscribers. This is most often needed if the subscription to the underlying signal involves side-effects or shouldn't be called more than once.

Let's add one of those as a private property on the API client class:

@interface APIClient ()
@property (nonatomic, strong, readonly) RACMulticastConnection *tokenConnection;
@end

Now, this will solve the case of N current subscribers that all need the same future result (API calls waiting on the request token being in-flight), but we still need something else to ensure that future subscribers get the same result (the already-fetched token), no matter when they subscribe.

This is what RACReplaySubject is for:

A replay subject saves the values it is sent (up to its defined capacity) and resends those to new subscribers. It will also replay an error or completion.

To tie these two concepts together, we can use RACSignal's -multicast: method, which turns a normal signal into a connection by using a specific kind of subject.

We can hook up most of the behaviors at initialization time:

- (id)init {
    self = [super init];
    if (self == nil) return nil;

    // Defer the invocation of -reallyGetToken until it's actually needed.
    // The -defer: is only necessary if -reallyGetToken might kick off
    // a request immediately.
    RACSignal *deferredToken = [RACSignal defer:^{
        return [self reallyGetToken];
    }];

    // Create a connection which only kicks off -reallyGetToken when
    // -connect is invoked, shares the result with all subscribers, and
    // pushes all results to a replay subject (so new subscribers get the
    // retrieved value too).
    _tokenConnection = [deferredToken multicast:[RACReplaySubject subject]];

    return self;
}

Then, we implement -getToken to trigger the fetch lazily:

- (RACSignal *)getToken {
    // Performs the actual fetch if it hasn't started yet.
    [self.tokenConnection connect];

    return self.tokenConnection.signal;
}

Afterwards, anything that subscribes to the result of -getToken (like -requestSignalWithURLRequest:) will get the token if it hasn't been fetched yet, start fetching it if necessary, or wait for an in-flight request if there is one.

Mascia answered 28/12, 2012 at 16:18 Comment(7)
How would you handle log out? Or multiple accounts?Limen
@ColinBarrett Each of those would require a detailed answer in their own right – this was just the simplest solution to the problem laid out above. Supporting logout might involve putting tokenSignal into a RACReplaySubject of capacity 1, so that you can push a new signal onto it at will. Multiple accounts would be a much bigger change, because assumably the request API would need to be updated too. I'd be happy to answer either in more detail in a new question on SO or an issue on GitHub.Mascia
Throw out the APIClient instance to log out and create a new one with different credentials to log back in. Use multiple APIClient instances to support multiple accounts.Ulyanovsk
@JustinSpahr-Summers I was more just curious, don't actually have a need myself.Limen
@JustinSpahr-Summers I think using ReactiveCocoa to handle network requests is such a common usecase that there might exist an opportunity for some kind of generic API client class that (mostly) transparently handles things like login, logout, network availability observation, retry on error, etc. Thoughts?Harty
@Harty It's hard to know what could actually be abstracted out of that, since RAC already has primitives that will do the heavy lifting for you. If you have ideas, feel free to file an issue on the RAC repo and we can talk about it in more detail: github.com/ReactiveCocoa/ReactiveCocoa/issuesMascia
this is a great example of how a task that would normally be fairly complex using "standard" cocoa can become WAY simpler by using RAC. awesome!Frasquito
H
3

How about

...

@property (nonatomic, strong) RACSignal *getToken;

...

- (id)init {
    self = [super init];
    if (self == nil) return nil;

    self.getToken = [[RACSignal defer:^{
        return [self reallyGetToken];
    }] replayLazily];
    return self;
}

To be sure, this solution is functional identical to Justin's answer above. Basically we take advantage of the fact that convenience method already exists in RACSignal's public API :)

Harty answered 20/10, 2013 at 7:11 Comment(0)
M
0

Thinking about token will expire later and we have to refresh it.

I store token in a MutableProperty, and used a lock to prevent multiple expired request to refresh the token, once the token is gained or refreshed, just request again with new token.

For the first few requests, since there's no token, request signal will flatMap to error, and thus trigger refreshAT, meanwhile we do not have refreshToken, thus trigger refreshRT, and set both at and rt in the final step.

here's full code

static var headers = MutableProperty(["TICKET":""])
static let atLock = NSLock()
static let manager = Manager(
    configuration: NSURLSessionConfiguration.defaultSessionConfiguration()
)

internal static func GET(path:String!, params:[String: String]) -> SignalProducer<[String: AnyObject], NSError> {
    let reqSignal = SignalProducer<[String: AnyObject], NSError> {
        sink, dispose in
        manager.request(Router.GET(path: path, params: params))
        .validate()
        .responseJSON({ (response) -> Void in
            if let error = response.result.error {
                sink.sendFailed(error)
            } else {
                sink.sendNext(response.result.value!)
                sink.sendCompleted()
            }
        })
    }

    return reqSignal.flatMapError { (error) -> SignalProducer<[String: AnyObject], NSError> in
            return HHHttp.refreshAT()
        }.flatMapError({ (error) -> SignalProducer<[String : AnyObject], NSError> in
            return HHHttp.refreshRT()
        }).then(reqSignal)
}

private static func refreshAT() -> SignalProducer<[String: AnyObject], NSError> {
    return SignalProducer<[String: AnyObject], NSError> {
        sink, dispose in
        if atLock.tryLock() {
            Alamofire.Manager.sharedInstance.request(.POST, "http://example.com/auth/refresh")
                .validate()
                .responseJSON({ (response) -> Void in
                    if let error = response.result.error {
                        sink.sendFailed(error)
                    } else {
                        let v = response.result.value!["data"]
                        headers.value.updateValue(v!["at"] as! String, forKey: "TICKET")
                        sink.sendCompleted()
                    }
                    atLock.unlock()
                })
        } else {
            headers.signal.observe(Observer(next: { value in
                print("get headers from local: \(value)")
                sink.sendCompleted()
            }))
        }
    }
}

private static func refreshRT() -> SignalProducer<[String: AnyObject], NSError> {
    return SignalProducer<[String: AnyObject], NSError> {
        sink, dispose in
        Alamofire.Manager.sharedInstance.request(.POST, "http://example.com/auth/refresh")
        .responseJSON({ (response) -> Void in
            let v = response.result.value!["data"]                
            headers.value.updateValue(v!["at"] as! String, forKey: "TICKET")                
            sink.sendCompleted()
        })
    }
}
Moultrie answered 19/10, 2016 at 3:34 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.