Skip to content

Instantly share code, notes, and snippets.

@michaelcbrook
Created December 14, 2021 17:31
Show Gist options
  • Save michaelcbrook/ae3a0b6c9aed7536460f188a2ff86cc1 to your computer and use it in GitHub Desktop.
Save michaelcbrook/ae3a0b6c9aed7536460f188a2ff86cc1 to your computer and use it in GitHub Desktop.
Apollo Client Example

Apollo Client Example

This example shows how to configure Apollo Client for a Meteor app, using the Pitchly GraphQL API.

This example supports:

  1. Websocket subscriptions
  2. Automatic token refresh and retry when access token is invalid or expires
  3. Automatic retry when there is a brief network interruption
  4. Error normalization
  5. Automatic cache clear and refetch on app logout
  6. Automatic query teardown when Blaze templates are destroyed (using Blaze Apollo)

Prerequisites

You must import the accounts-pitchly package into your app to allow users to log into your app using Pitchly and make authenticated requests. This package makes the user's access token available automatically via:

Meteor.user()?.services?.pitchly?.accessToken

This package also provides the Pitchly.refreshAccessToken Meteor method to tell the server to refresh the current user's access token. This is automatically called whenever a request returns 'UNAUTHENTICATED' before retrying the request automatically using the new access token.

Exports

This example exports two variables, apolloClient and normalizeGraphQLErrors, so that they can be used in other places throughout your app.

import { ApolloClient, InMemoryCache, split, HttpLink } from '@apollo/client/core';
import { getMainDefinition } from '@apollo/client/utilities';
import { WebSocketLink } from '@apollo/client/link/ws';
import { setContext } from '@apollo/client/link/context';
import { onError } from '@apollo/client/link/error';
import { RetryLink } from '@apollo/client/link/retry';
import { fromPromise } from '@apollo/client/link/utils/fromPromise';
import { setup } from 'meteor/swydo:blaze-apollo';
// use the origin defined by platformOrigin, or fall back to this default if undefined
const platformOrigin = Meteor?.settings?.public?.platformOrigin || "https://platform.pitchly.com";
const httpURI = platformOrigin + "/graphql";
const wsURI = platformOrigin.replace(/^http/i, "ws") + "/subscriptions";
const httpLink = new HttpLink({
uri: httpURI
});
// attach authorization header to each HTTP request
const authLink = setContext((_, { headers }) => {
const token = Meteor.user()?.services?.pitchly?.accessToken;
return {
headers: {
...headers,
authorization: token ? `Bearer ${token}` : "",
}
}
});
const wsLink = new WebSocketLink({
uri: wsURI,
options: {
reconnect: true
}
});
// attach authorization header to each websocket subscription (not just on initial connection)
wsLink.subscriptionClient.use([{
applyMiddleware: (options, next) => {
const token = Meteor.user()?.services?.pitchly?.accessToken;
options.authorization = token ? `Bearer ${token}` : "";
next();
}
}]);
// If an HTTP request returns an "UNAUTHENTICATED" error, this will automatically
// refresh the access token and retry the original request with the new access
// token without any interruption.
// Despite the accounts-pitchly package automatically refreshing access tokens
// on page load and on a regular interval, this is still important because:
//
// 1) Requests may be made prior to the token being totally refreshed. This will
// cause the request to "wait" until a valid access token is acquired.
//
// 2) Access tokens may be invalidated at any time by Pitchly, even prior to the
// accessTokenExpiresAt date. This ensures that we get a new access token as
// soon as we know it's invalid and we don't have to wait until the interval
// in accounts-pitchly runs again (only runs every 6 minutes).
// Inspired from: https://stackoverflow.com/a/62872754/2658450
const errorLink = onError(({ graphQLErrors, networkError, operation, forward }) => {
if (graphQLErrors) {
// we need to convert a Meteor.call to a function returning a promise
const callWithPromise = (method, params) => {
return new Promise((resolve, reject) => {
Meteor.call(method, params, (err, res) => {
if (err) {
reject(err);
return;
}
resolve(res);
});
});
}
for (let err of graphQLErrors) {
switch (err.extensions.code) {
// this should match whatever error code Pitchly sends back when the
// access token is invalid or expired
case 'UNAUTHENTICATED':
return fromPromise(
callWithPromise("Pitchly.refreshAccessToken", { force: true }).catch((error) => {
// Handle token refresh errors e.g clear stored tokens, redirect to login, ...
return;
})
).filter((value) => Boolean(value))
.flatMap(({ refreshed, accessToken }) => {
const oldHeaders = operation.getContext().headers;
// modify the operation context with a new token
operation.setContext({
headers: {
...oldHeaders,
authorization: `Bearer ${accessToken}`,
},
});
// retry the request, returning the new observable
if (Meteor.isDevelopment) {
console.log("Retrying GraphQL request because server returned 'UNAUTHENTICATED'...");
}
return forward(operation);
});
}
}
}
});
// Will automatically retry requests that fail due to network errors (e.g. the user
// loses internet connectivity). By default, the request will be retried in 300ms
// with exponential backoff up to 5 times, which means the request will only throw
// back an error if it repeatedly fails for at least 4800ms (almost 5 seconds).
const retryLink = new RetryLink();
const splitLink = split(
({ query }) => {
const definition = getMainDefinition(query);
return (
definition.kind === 'OperationDefinition' &&
definition.operation === 'subscription'
);
},
wsLink,
// retryLink executes first, httpLink last
retryLink.concat(errorLink.concat(authLink.concat(httpLink)))
);
const client = new ApolloClient({
link: splitLink,
cache: new InMemoryCache()
});
// Convert an array of errors from Apollo into a single Meteor error object.
// This expects an array like the one received from result.getErrors() using
// blaze-apollo and returns a Meteor.Error object if an error is present. If
// there are no errors, will return undefined.
const normalizeGraphQLErrors = function(errors) {
if (errors && errors.length > 0) {
const error = errors[0];
if (error.networkError) {
// network error, e.g. failed to connect to server because of internet connectivity issues...
return new Meteor.Error("NETWORK_ERROR", "Couldn't connect to Pitchly. Please try again.", error.networkError);
} else {
if (error.graphQLErrors && error.graphQLErrors.length > 0) {
// application error...
const gqlError = error.graphQLErrors[0];
return new Meteor.Error(gqlError.extensions.code, gqlError.message, gqlError);
} else {
// probably won't happen, but just in case...
return new Meteor.Error("INTERNAL_SERVER_ERROR", "There was an internal error. Please try again.", error);
}
}
}
};
// clear Apollo Client cache on Meteor logout
Accounts.onLogout(() => {
client.resetStore();
});
// make Apollo Client work with Blaze
setup({ client });
// export these variables so they can be imported and used elsewhere
export {
client as apolloClient,
normalizeGraphQLErrors
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment