Graphql-flutter: [Auth] How to re-request an operation with new context?

Created on 16 Apr 2019  路  15Comments  路  Source: zino-app/graphql-flutter

I'm trying to write my own AuthLink to allow me to handle expired access_tokens and refresh them. In the ideal implementation, I would try the request again, like in Apollo.

In Apollo I'm able to use the onError link to just call forward(newOperation) after passing the updated headers. This will pass the operation along and request it again.

I tried implementing a similar approach in our flutter project, and I think I'm pretty close.

I correctly catch when the operation fails, but I'm not able to get the operation to fire again. My approach was to call await controller.addStream(forward(operation)); after calling operation.setContext to update the headers. This doesn't work, though. All future GQL requests will succeed, but the initial request that failed will not fire again unless I trigger the operation again.

It _might_ be because Request is already filled in on the operation but, I'm not sure.

class CustomAuthLink extends Link {
  CustomAuthLink()
      : super(
          request: (Operation operation, [NextLink forward]) {
            StreamController<FetchResult> controller;
            final storage = new FlutterSecureStorage();

            Future<void> onListen() async {
              try {
                String accessToken = await storage.read(key: 'access_token');

                operation.setContext(<String, Map<String, String>>{
                  'headers': {
                    'Authorization': 'Bearer $accessToken',
                  }
                });
              } catch (error) {
                controller.addError(error);
              }

              await controller.addStream(forward(operation));

              // We have a response from the API 
              int statusCode = operation.getContext()['response'].statusCode;
              if (statusCode < 200 || statusCode >= 400) {
                // If the request failed, try getting a new Access Token
                try {
                  final response = await Auth.refresh(); // hit the api and get a new pair of tokens

                  operation.setContext(<String, Map<String, String>>{
                    'headers': {
                      'Authorization': 'Bearer ${response.accessToken}',
                    }
                  });
                  await controller.addStream(forward(operation)); // <--- I thought, re-make the request
                } catch (e) {
                  // handle that we can't log them in
                }
              }
              await controller.close();
            }

            controller = StreamController<FetchResult>(onListen: onListen);
            return controller.stream;
          },
        );
}
docs & examples flutter investigate link question

Most helpful comment

Did anyone find a solution to the @Kyle-Mendes 's problem?

All 15 comments

Sorry to bump this with a comment, but I would love if any of the contributors / maintainers could provide some direction here. I'm new to dart, so I might be missing something in how the Streams work.

I would love to contribute to this project in terms of documentation or a PR if I'm able to get this to work.

We're wrapping up work on our app soon, and this is the last major thing we have left to figure out, so any direction would be greatly appreciated!

Have you tried stepping through the code using a debugger and see why the second request doesn't work. I will try and re-create your example when i get sometime tomorrow.

@mainawycliffe

I'll see if I can get that to work, but right now I'm struggling. I might just be misunderstanding the order that things get resolved, and how Streams work. I'll make sure to update here if I have a breakthrough!

@mainawycliffe

I think I'm getting to the bottom of this. I believe that the above code snippet actually works, and the operation is added back to the stream and requested again.

However, I think that when the new operation resolves successfully, it is not triggering an update in state any where, so the builder for gql isn't getting the new results, and therefore isn't re-rendering.

I'm not 100% sure, but that's my best working theory right now. Does this sound plausible?

If so, any ideas on what I might be able to do to get the builder to re-render with the new results?

@Kyle-Mendes Greetings, I got exactly the same issue. I currently can't understand why does the class which calls this API, doesn't wait for the listener to finish its work. So currently, after
await controller.addStream(forward(operation));
Data is being processed in my API class and it doesn't wait for the second call. @Kyle-Mendes how have you solved it?

@Kyle-Mendes Aha, I just reviewed your last comment. Seems like we are at the same point. @mainawycliffe I guess the problem here, is that
await controller.addStream(forward(operation));
Doesn't stop the stream from returning request's response. Correspondingly even if you will redo the request, Future won't be interested in the other result, cause probably you call it same as me:

response = await _client.query(QueryOptions(
        document: me,
        fetchPolicy: FetchPolicy.networkOnly,
        errorPolicy: ErrorPolicy.all,
      ));

@mainawycliffe Can I please get some help on that? What else can I do to help you investigate that?

@Nazacheres this slipped my mind, I have yet to implement similar functionality, so I don't have a working sample but I will investigate and report back over the weekend. If you can share a reproducible sample repo, it would really save some time.

@Kyle-Mendes did you ever find a solution, and if so can you please share.

I did not find a solution yet. I am still unable to get the widget to re-render when my added stream completes.

We did a little work-around for now, though. We added to the auth-link to check if the token was valid BEFORE setting the authentication header. It's not ideal, but it works for now.

@Kyle-Mendes So on each request, you check if the token is correct? Do you do it by decoding the token and calculating the time, or making an additional request?

@Nazacheres For our API, we get back an expires_at time stamp. So, we check to see if the token is expired before setting the header. If it is, we refresh the token, attach it, and then make the request.

It's not ideal, since tokens can expire for other reasons, so a true solution using the stream would definitely be preferable.

@Kyle-Mendes I am currently working on another workaround will let you know till the end of tomorrow. Thank you for your help.

@Kyle-Mendes I managed to fix it! My understanding of streams in dart probably far from perfect, however here is my solution based on how I imagine streams working. This is my whole custom link:

final Link retryLink = Link(request: (
      Operation operation, [
      NextLink forward,
    ]) {
      StreamController<FetchResult> controller;
      Future<void> onListen() async {
        await controller.addStream(refreshToken(tokenManager, tokenAPI, controller, forward, operation).asStream());
        await controller.close();
      }

      controller = StreamController<FetchResult>.broadcast(onListen: onListen);

      return controller.stream;
    });

And here is method refreshToken:

Future<FetchResult> refreshToken(ITokenManager tokenManager, TokenAPI tokenAPI,
      StreamController<FetchResult> controller, NextLink forward, Operation operation) async {
    try {
      var mainStream = forward(operation);
      var firstEvent = await whenFirst(mainStream);

        return firstEvent;
    } catch (e) {
      Logger.root.severe(e.toString());

      if (e is ClientException && e.message.contains("401") && (await tokenManager.hasTokens())) {
        Logger.root.info('User logged out. But token persents. Refreshing token');
        final Token token = await tokenAPI.refreshToken();
        if (token.isValid()) {
          await tokenManager.setAccessToken(token.accessToken);
          await tokenManager.setRefreshToken(token.refreshToken);

          return whenFirst(forward(operation));
        } else {
          await tokenManager.removeCredentials();
          return whenFirst(forward(operation));
        }
      } else {
        return Future.error(e);
      }
    }
  }

As you can see instead of forwarding request, I am forwarding custom future (which then is converted to stream).
In this method, I am forwarding the request to other links, but not giving it to the main stream. It kinda breaks the logic of streams, cause in whenFirst I do:

Future<T> whenFirst<T>(Stream<T> source) async {
  try {
    await for (T value in source) {
      if (value != null) {
        return value;
      }
    }
  } catch (e) {
    return Future.error(e);
  }
}

and it throws ClientException, not graphql error.

However this is the concept how I managed it to work for me, if you guys @Kyle-Mendes and @mainawycliffe see how this can be improved please let me know.

@Nazacheres can you create repository with working sample, if your code actually works?

Did anyone find a solution to the @Kyle-Mendes 's problem?

Was this page helpful?
0 / 5 - 0 ratings

Related issues

micimize picture micimize  路  16Comments

mainawycliffe picture mainawycliffe  路  16Comments

smkhalsa picture smkhalsa  路  15Comments

rajihawa picture rajihawa  路  19Comments

Zony-Zhao picture Zony-Zhao  路  32Comments