Skip to content
This repository has been archived by the owner on Apr 14, 2023. It is now read-only.

onSubscribe and use middleware to modify runtime SubscriptionOptions #78

Merged
merged 14 commits into from
May 16, 2017
Merged

onSubscribe and use middleware to modify runtime SubscriptionOptions #78

merged 14 commits into from
May 16, 2017

Conversation

srtucker22
Copy link
Contributor

@srtucker22 srtucker22 commented Feb 20, 2017

TODO:

  • Make sure all of the significant new logic is covered by tests
  • Rebase your changes on master so that they can be merged easily
  • Make sure all tests and linter rules pass
  • Update CHANGELOG.md with your change

@srtucker22
Copy link
Contributor Author

This is related to #75 -- while it doesn't create a default context, it instead allows you to override the SubscriptionOptions passed into subscribe much like applyMiddleware overrides the request in networkInterface.

It's a very basic solution to the broad challenge I'm facing -- having some macro controls over subscription creation with apollo-client. A typical example of this is setting authorization context for new subscriptions after a connection is made. We want to make a connection right away without worrying about authorization, and then run all queries through the same logic to establish auth context when subscribing to events.

@NeoPhi
Copy link
Contributor

NeoPhi commented Mar 3, 2017

I feel this would be better handled in the glue layer between the networkInterface and the wsClient. An alternative implementation of addGraphQLSubscriptions with hooks would allow you to modify the options being passed to subscribe.

@srtucker22
Copy link
Contributor Author

so something like?

import { SubscriptionClient } from './client';
import { print } from 'graphql-tag/printer';

// Quick way to add the subscribe and unsubscribe functions to the network interface
export function addGraphQLSubscriptions(networkInterface: any, wsClient: SubscriptionClient): any {
  return Object.assign(networkInterface, {
    subscribe(request: any, handler: any): number {
      if(networkInterface.onSubscribe) {
        request = networkInterface.onSubscribe(request);
      }
      
      return wsClient.subscribe({
        query: print(request.query),
        variables: request.variables,
        context: request.context,
      }, handler);
    },
    unsubscribe(id: number): void {
      wsClient.unsubscribe(id);
    },
  });
}

At the very least I think the subscribe function should be able to pass context.

Just to be clear about the use case I'm running into, which I don't think is that uncommon:
I want to be able to login/logout and have that auth context passed to my subscriptions.
If using apollo-client or react-apollo, there is no way of affecting context.
you can pass context when first connecting to socket, but it would be inefficient to open/close connection every login/logout.

Even if we exposed context in subscribe, you wouldn't want to have to explicitly pass context in every subscribe or subscribeToMore call if context is something high level like a JWT token. I also imagine there are other things someone might need to do before every subscribe call just like it's import to have middleware before a gql request sent with networkInterface.

@Urigo
Copy link
Contributor

Urigo commented Mar 8, 2017

@srtucker22 looks really good and I agree that this is a common use case that would be beneficial to support.

If we choose to support it then it will be easier then @NeoPhi solution overriding the subscribe method.

Maybe a better way of supporting it would be to add another client life-cycle hook called onSubscribe and this transport will call this hook right after this and extend the options with the return value of the hook.

That means that the hooks can return only the part of the options that it want to override or extend and not making it necessary to build the whole options object again from scratch.

In the client there is already lifecycle hook event emitter we can use for that, here is an example - https://github.com/apollographql/subscriptions-transport-ws/blob/master/src/client.ts#L147-L157

Would love to hear both of your thoughts..

@Urigo
Copy link
Contributor

Urigo commented Mar 8, 2017

also, maybe the best thing will be to make it as similar as possible to that existing API - http://dev.apollodata.com/core/network.html#networkInterfaceMiddleware

@srtucker22
Copy link
Contributor Author

Hey @Urigo, good to hear from you!

I'm all for networkInterfaceMiddleware style middleware. Are you thinking it would look something like this?

SubscriptionClient.use([{
  applyMiddleware(options, next) {
    // modify options for SubscriptionClient.subscribe here
    next();
  }
}]);

My only concern with adding middlware this way (as it's written in apollo-client) is it introduces promises and async. I like how in the current setup, subscribe is a synchronous function that returns the subscription ID or throws. I think it would be best to still return an ID synchronously, but I wouldn't want to use promises in the background, especially when things throw errors. We could restrict middleware funcs to sync only, but that kind of defeats the purpose of using next(). Maybe can be overcome with iterators and generators?

@srtucker22
Copy link
Contributor Author

ok so this latest push will use either client.onSubscribe or client.use, but both are syncronous and so very basic.

i can forsee use cases where someone would like to run an async function to modify options before subscribing, and this doesn't address that. but anything with async middleware before subscribe will essentially demand breaking changes whereas these changes don't.

happy to modify more if we want to go down the breaking changes path :)

@srtucker22 srtucker22 changed the title beforeSubscribe to ClientOptions to modify runtime SubscriptionOptions onSubscribe and use middleware to modify runtime SubscriptionOptions Mar 14, 2017
@Urigo
Copy link
Contributor

Urigo commented Mar 17, 2017

Thank you for your recent changes.
I think that adding support for async is important, and I think it might be simpler.
Can you please explain why the changes will break the API?

Thanks @srtucker22

@srtucker22
Copy link
Contributor Author

srtucker22 commented Mar 21, 2017

so i guess if you do it this way (latest commit) the changes aren't breaking per se -- before this change you already had to catch errors (1) when subscribing and (2) in the handler, but now some of those errors (like having a query) will also be caught in the handler where they weren't before, like if your middleware accidentally deleted the query.

however, this code has an issue --> if you call client.unsubscribe before client.subscribe finishes (as was written in a few tests), you will not unsubscribe because unsub will run first in the queue / run before the graphqlSubId is created and is necessary to call unsubscribe on the server. not sure how you want to tackle this

apologies for being touch and go. i've been away in Bali these past few weeks and will be traveling for another couple months or so. just want to give you a heads up i might not respond for a few days in between coms

@srtucker22
Copy link
Contributor Author

ok, this version tracks unsubs and waits to sendMessage for unsubscribe if it comes before sendMessage for init for a given sub. doesn't feel elegant, but gets the job done

@srtucker22
Copy link
Contributor Author

a little cleaner now. lemme know what you think

in tokyo now so better internet sitch

src/client.ts Outdated
const { query, variables, operationName, context } = options;
public subscribe(opts: SubscriptionOptions, handler: (error: Error[], result?: any) => void) {
// this.eventEmitter.emit('subscribe', options);
this.checkSubscriptionParams(opts, handler);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would just rely on the this. applyMiddlewares handling instead of checking it twice. This also avoids the case that sometimes the handler is called with the error and not.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so if we change this, it will break the following tests as they currently stand:

  1. should throw an exception when query is not provided
  2. should throw an exception when query is not valid

because those errors currently throw outside the handler

i'm all for deferring all error handling to the handler (aside from throwing if there isn't a handler), but was trying to make this feature without making breaking changes. but i agree it's cleaner -- so if you confirm this is the route to go, i'm happy to make the change

src/client.ts Outdated
this.applyMiddlewares(opts).then(options => {
try {
this.checkSubscriptionParams(options, handler);
} catch (e) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Feels like this should just be a catch on the promise to catch any issues.

src/client.ts Outdated
this.checkSubscriptionParams(options, handler);
} catch (e) {
handler([e]);
this.unsubscribe(subId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should unsubscribe before calling the handler.

src/client.ts Outdated
setTimeout( () => {
if (this.waitingSubscriptions[subId]) {
handler([new Error('Subscription timed out - no response from server')]);
this.unsubscribe(subId);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should unsubscribe before calling the handler.

src/client.ts Outdated
@@ -165,9 +172,14 @@ export class SubscriptionClient {
}

public unsubscribe(id: number) {
if (!this.subscriptions[id] && !this.waitingSubscriptions[id]) {
this.waitingUnsubscribes[id] = true;
Copy link
Contributor

@NeoPhi NeoPhi Apr 3, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what waitingUnsubscribes is giving us. Each subscribe call is going to generate a new id and I don't see this being used elsewhere to set a timeout to ensure that it was cleaned up.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the issue waitingUnsubscribes tries to resolve is if you unsubscribe before the middleware promise resolves. you're supposed to be able to call client.unsubscribe synchronously (as it stands currently -- and i'd like to keep it this way), but if we remove waitingUnsubscribes, the following would fail:

  1. should trigger onUnsubscribe when client unsubscribes

SUBSCRIPTION_END gets sent before SUBSCRIPTION_START and so we don't actually properly unsubscribe. we could check for an unsubscribe call before we send SUBSCRIPTION_START which would prevent a needless START immediately followed by END, but I didn't want to break the existing tests (again was trying to keep this a non-breaking feature addition). let me know if this makes sense and which direction you'd like me to go forward with

@helfer
Copy link
Contributor

helfer commented Apr 18, 2017

@Urigo could you take another look at this? Thanks! 🙂

@Urigo
Copy link
Contributor

Urigo commented Apr 18, 2017

hi, I'm sorry for the delay but we have a huge important change here that should land soon and after that I will address the rest of the PRs.
@srtucker22 also be aware of that change because probably after there will be a need for rebase and changes

@srtucker22
Copy link
Contributor Author

thanks for the heads up -- looks cool. also looks like i'm gonna have to rewrite my subscription tutorial :)

src/client.ts Outdated
public applyMiddlewares(options: SubscriptionOptions): Promise<SubscriptionOptions> {
return new Promise((resolve, reject) => {
const queue = (funcs: MiddlewareInterface[], scope: any) => {
const next = () => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be reject with next(error)?

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following lines should be change to:

      const queue = (funcs: MiddlewareInterface[], scope: any) => {
        const next = (error) => {
          if (error) {
            reject(error);
          } else {
            if (funcs.length > 0) {
              const f = funcs.shift();
              if (f) {
                f.applyMiddleware.apply(scope, [options, next]);
              }
            } else {
              resolve(options);
            }
          }
        };
        next();
      };

And this line update to:

}).catch(e => handler([e]));

src/client.ts Outdated
public applyMiddlewares(options: SubscriptionOptions): Promise<SubscriptionOptions> {
return new Promise((resolve, reject) => {
const queue = (funcs: MiddlewareInterface[], scope: any) => {
const next = () => {
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following lines should be change to:

      const queue = (funcs: MiddlewareInterface[], scope: any) => {
        const next = (error) => {
          if (error) {
            reject(error);
          } else {
            if (funcs.length > 0) {
              const f = funcs.shift();
              if (f) {
                f.applyMiddleware.apply(scope, [options, next]);
              }
            } else {
              resolve(options);
            }
          }
        };
        next();
      };

And this line update to:

}).catch(e => handler([e]));

@giautm
Copy link

giautm commented May 5, 2017

Is this ready for merge, wait too long. :(

@srtucker22
Copy link
Contributor Author

SO, here is my proposal with the latest:

Everything works, but I've changed up a few tests since we're in the spirit of major code overhaul:

  1. All query, variables, and operationName related errors are thrown in the handler. Since we're giving users the option to use asynchronous middleware to change all of these options, they should really only get checked once they have been updated. Plus, I think it's much more intuitive that all errors get handled by the handler! (changed tests for throwing exceptions to queries to get handled by handler)

  2. Because client.subscribe returns the opId synchronously, but sends the first message asynchronously (after middleware), there is a period in between where the user might try and unsubscribe before the first message is sent. I think the most reasonable thing to do if someone tries to unsubscribe before they've ever actually sent a subscription message to the server is to just cancel the subscription message altogether. In this scenario, the GQL_START message won't make it to the queue (1st changed test), and on the server, onUnsubscribe won't get triggered (2nd changed test).

Let me know what's good!

@Urigo
Copy link
Contributor

Urigo commented May 16, 2017

@srtucker22 thank you so much for an amazing work and your patience keeping this PR updated.

@Urigo Urigo merged commit 9889859 into apollographql:master May 16, 2017
@Urigo Urigo mentioned this pull request May 16, 2017
5 tasks
@srtucker22
Copy link
Contributor Author

no worries!

@mistic mistic mentioned this pull request May 19, 2017
@@ -423,7 +424,7 @@ export class SubscriptionServer {
query: parsedMessage.payload.query,
variables: parsedMessage.payload.variables,
operationName: parsedMessage.payload.operationName,
context: Object.assign({}, isObject(initResult) ? initResult : {}),
context: Object.assign({}, isObject(initResult) ? initResult : {}, parsedMessage.payload.context),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is context sent over the network?
@Urigo @helfer @Stubilo - Thats a serious security issue, you shall not release the package with that

@quadsurf
Copy link

quadsurf commented Aug 26, 2017

This is still not working for me... I'm still getting "Insufficient Permissions" when I have permissions enabled... however, it works fine when permissions are disabled. I have even hard coded in the server auth token, and it still doesn't work.

There seems to be some property naming inconsistency in this library:

  • some use:
    connectionParams: {
    Authorization: getToken()
    }
  • some use:
    connectionParams: {
    authToken: getToken()
    }

So is the correct property name "Authorization" or "authToken"? Either way, I've tested both, and both fail.

HERE ARE MY VERSIONS
react-native@latest
subscriptions-transport-ws@0.8.2
react-apollo@1.4.8

HERE IS MY ERROR
Unhandled GraphQL subscription error Array [
Object {
"code": 3008,
"locations": Array [
Object {
"column": 7,
"line": 4,
},
],
"message": "Insufficient Permissions",
"path": Array [
"User",
"node",
"id",
],
"requestId": "subscription:cj6...:1",
},
Object {
"code": 3008,
"locations": Array [
Object {
"column": 7,
"line": 5,
},
],
"message": "Insufficient Permissions",
"path": Array [
"User",
"node",
"type",
],
"requestId": "subscription:cj6...:1",
},
]

ISSUES I'VE REVIEWED
#171 (comment) (the one recommended by @lewisblackwood)
https://github.com/graphcool/feature-requests/issues/317
#102

baconz pushed a commit to PhiloInc/subscriptions-transport-ws that referenced this pull request Apr 24, 2018
* log server events to logFunction

* update changelog
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

None yet

7 participants