apollo-feature-requests icon indicating copy to clipboard operation
apollo-feature-requests copied to clipboard

Document how to use client with AppSync

Open StefanSmith opened this issue 4 years ago • 13 comments

Recently, AWS AppSync published details of their websocket subscription workflow (https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html). It is now possible to implement a GraphQL client using only Apollo libraries, without the need for AppSync's own SDK. My team did this recently in order to side-step certain AppSync SDK bugs. I think it would be useful to others if this was available somewhere in Apollo's documentation. I provide it here for the Apollo team to disseminate if possible. There is no offline support in this example.

A number of customizations were required in order to make it work:

  • Set connection timeout (default 30 seconds) to 5 minutes since AppSync's keep alive messages are not so frequent
  • Override SubscriptionClient from subscriptions-transport-ws to use UUID operation IDs as this is recommended by AppSync
  • Override browser WebSocket class to compute URL on instantiation since SubscriptionClient has an invariant websocket URL but header query string parameter needs to stay in sync with JWT token
  • Also override browser WebSocket class to filter out messages with data.type === 'start_ack' since SubscriptionClient cannot handle this type of message sent by AppSync
  • Schedule async refresh of JWT token every time websocket is instantiated, in case the token has expired. Unfortunately, there is no way to await the refresh so instead we rely on SubscriptionClient to retry the websocket connection on authorization failure. Eventually, a connection attempt will be made with a valid JWT token.
  • Use custom SubscriptionClient middleware to modify operations to include serialized GraphQL query and variables in a data property and to add authorization information to extensions

Example usage

import {ApolloClient, InMemoryCache} from "@apollo/client";
import {createAppSyncHybridLink} from "./appSyncHybridLink";

export const createAppSyncApolloClient = async ({appSyncApiUrl, getJwtToken, cacheConfig, connectToDevTools}) =>
    new ApolloClient({
            link: await createAppSyncHybridLink({appSyncApiUrl, getJwtToken}),
            cache: new InMemoryCache(cacheConfig),
            connectToDevTools
        }
    );

// Note: getJwtToken can be asynchronous, for example with Amplify.js (https://docs.amplify.aws/lib/q/platform/js):
// const getJwtToken = async () => (await Auth.currentSession()).getIdToken().getJwtToken()
// appSyncHybridLink.js

import {ApolloLink} from "@apollo/client";
import {createAppSyncSubscriptionWebsocketLink} from "./appSyncSubscriptionWebSocketLink";
import {createAppSyncHttpLink} from "./appSyncHttpLink";
import {getMainDefinition} from "@apollo/client/utilities";

export const createAppSyncHybridLink = async ({appSyncApiUrl, getJwtToken}) => ApolloLink.split(
    isSubscriptionOperation,
    await createAppSyncSubscriptionWebsocketLink({appSyncApiUrl, getJwtToken}),
    createAppSyncHttpLink({appSyncApiUrl, getJwtToken})
);

const isSubscriptionOperation = ({query}) => {
    const {kind, operation} = getMainDefinition(query);
    return kind === 'OperationDefinition' && operation === 'subscription';
};
// appSyncHttpLink.js

import {setContext} from "@apollo/link-context";
import {ApolloLink, HttpLink} from "@apollo/client";

export const createAppSyncHttpLink = function ({appSyncApiUrl, getJwtToken}) {
    const authorizationHeaderLink = setContext(async (request, previousContext) => ({
        ...previousContext,
        headers: {
            ...previousContext.headers,
            Authorization: await getJwtToken()
        }
    }));

    return ApolloLink.concat(
        authorizationHeaderLink,
        new HttpLink({uri: appSyncApiUrl})
    );
};
// appSyncSubscriptionWebSocketLink.js

import {WebSocketLink} from "@apollo/link-ws";
import {UUIDOperationIdSubscriptionClient} from "./UUIDOperationIdSubscriptionClient";
import {createAppSyncAuthorizedWebSocket} from "./appSyncAuthorizedWebSocket";
import {cacheWithAsyncRefresh} from "./asyncUtils";
import {createAppSyncGraphQLOperationAdapter} from "./appSyncGraphQLOperationAdapter";

const APPSYNC_MAX_CONNECTION_TIMEOUT_MILLISECONDS = 5 * 60 * 1000;

export const createAppSyncSubscriptionWebsocketLink = async ({appSyncApiUrl, getJwtToken}) => {
    const appSyncApiHost = new URL(appSyncApiUrl).host;
    const getAppSyncAuthorizationInfo = async () => ({host: appSyncApiHost, Authorization: await getJwtToken()});

    return new WebSocketLink(
        new UUIDOperationIdSubscriptionClient(
            `wss://${(appSyncApiHost.replace('appsync-api', 'appsync-realtime-api'))}/graphql`,
            {timeout: APPSYNC_MAX_CONNECTION_TIMEOUT_MILLISECONDS, reconnect: true, lazy: true},
            // We want to avoid expired authorization information being used but SubscriptionClient synchronously
            // instantiates websockets (on connection/reconnection) so the best we can do is schedule an async refresh
            // and suffer failed connection attempts until a fresh token has been retrieved
            createAppSyncAuthorizedWebSocket(await cacheWithAsyncRefresh(getAppSyncAuthorizationInfo))
        ).use([createAppSyncGraphQLOperationAdapter(getAppSyncAuthorizationInfo)])
    );
};
// UUIDOperationIdSubscriptionClient.js

// AppSync recommends using UUIDs for Subscription IDs but SubscriptionClient uses an incrementing number
import {SubscriptionClient} from "subscriptions-transport-ws";
import {v4 as uuid4} from "uuid";

export class UUIDOperationIdSubscriptionClient extends SubscriptionClient {
    generateOperationId() {
        return uuid4();
    }
}
// asyncUtils.js

export const cacheWithAsyncRefresh = async asyncSupplier => {
    let value;

    const asyncRefresh = async () => value = await asyncSupplier();

    // Warm cache
    await asyncRefresh();

    return () => {
        asyncRefresh().catch(console.error);
        return value;
    };
};
// appSyncGraphQLOperationAdapter.js

import * as graphqlPrinter from "graphql/language/printer";

export const createAppSyncGraphQLOperationAdapter = getAppSyncAuthorizationInfo => ({
    applyMiddleware: async (options, next) => {
        // AppSync expects GraphQL operation to be defined as a JSON-encoded object in a "data" property
        options.data = JSON.stringify({
            query: typeof options.query === 'string' ? options.query : graphqlPrinter.print(options.query),
            variables: options.variables
        });

        // AppSync only permits authorized operations
        options.extensions = {'authorization': await getAppSyncAuthorizationInfo()};

        // AppSync does not care about these properties
        delete options.operationName;
        delete options.variables;
        // Not deleting "query" property as SubscriptionClient validation requires it

        next();
    }
});
// appSyncAuthorizedWebSocket.js

import {asBase64EncodedJson} from "./encodingUtils";

export const createAppSyncAuthorizedWebSocket = (getAppSyncAuthorizationInfo) => {
    return class extends WebSocket {
        // SubscriptionClient takes a fixed websocket url so we append query string parameters every time the websocket
        // is created, in case the authorization information has changed.
        constructor(url, protocols = undefined) {
            super(
                `${url}?header=${asBase64EncodedJson(getAppSyncAuthorizationInfo())}&payload=${asBase64EncodedJson({})}`,
                protocols
            );
        }

        // AppSync acknowledges GraphQL subscriptions with "start_ack" messages but SubscriptionClient cannot handle them
        set onmessage(handler) {
            super.onmessage = event => {
                if (event.data) {
                    const data = this._tryParseJsonString(event.data);

                    if (data && data.type === 'start_ack') {
                        return;
                    }
                }

                return handler(event);
            };
        }

        _tryParseJsonString(jsonString) {
            try {
                return JSON.parse(jsonString);
            } catch (e) {
                return undefined;
            }
        }
    };
};
// encodingUtils.js

export const asBase64EncodedJson = value => btoa(JSON.stringify(value));

StefanSmith avatar Apr 28 '20 20:04 StefanSmith

I tried this and always getting this as first message from the server :

payload: {errors: [{message: "json: cannot unmarshal object into Go value of type string", errorCode: 400}]} type: "connection_error".

any idea ?

Bariah96 avatar Apr 04 '21 19:04 Bariah96

@Bariah96 Are you using IAM authentication ? I remember having a similar issue, it was regarding the signing of the messages as far as I remember

philiiiiiipp avatar Apr 04 '21 22:04 philiiiiiipp

@StefanSmith super nice that you posted this, helped me along quite a bit :-) !

I noticed that you might be able to get rid of the cacheWithAsyncRefresh and the custom websocket if you change the UUIDOperationIdSubscriptionClient to something like:

import { SubscriptionClient } from 'subscriptions-transport-ws';
import { v4 as uuid4 } from 'uuid';

const asBase64EncodedJson = (data: $Object): string =>
  btoa(JSON.stringify(data));

// @ts-ignore
export default class UUIDOperationIdSubscriptionClient extends SubscriptionClient {
  authFunction;
  originalUrl;

  constructor(url, args, authFunction) {
    super(url, args);
    this.authFunction = authFunction;
    this.originalUrl = url;
  }

  connect = async () => {
    const authInfo = await this.authFunction();

    /** @see https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html#iam */
    // @ts-ignore
    this.url = `${this.originalUrl}?header=${asBase64EncodedJson(
      authInfo,
    )}&payload=${asBase64EncodedJson({})}`;

    // @ts-ignore
    super.connect();
  };

  generateOperationId() {
    return uuid4();
  }

  processReceivedData(receivedData) {
    try {
      const parsedMessage = JSON.parse(receivedData);
      if (parsedMessage?.type === 'start_ack') return;
    } catch (e) {
      throw new Error('Message must be JSON-parsable. Got: ' + receivedData);
    }

    // @ts-ignore
    super.processReceivedData(receivedData);
  }
}

You just have to adjust it to generate the correct auth string.

philiiiiiipp avatar Apr 04 '21 22:04 philiiiiiipp

@Bariah96 Are you using IAM authentication ? I remember having a similar issue, it was regarding the signing of the messages as far as I remember

@philiiiiiipp I'm using Cognito user pools (jwt) for authentication. Anyway, i figured out what was happening, my connection url contained a fulfilled promise object since the function getting the authentication details is async and wasn't waiting on it, while it should be a string. Thanks for replying :)

Bariah96 avatar Apr 05 '21 23:04 Bariah96

Minimalist solution for API_KEY auth

Derived from the code above but for the simple, default case of API_KEY authentication, which is fixed, and without the split link to support mutations and queries over http; in production code you would copy those from the original solution above.

const { ApolloClient, InMemoryCache, gql } = require("@apollo/client");
const { WebSocketLink } = require('@apollo/client/link/ws');
const WebSocket = require('ws');

const API_URL = "https://<secret>.appsync-api.eu-west-1.amazonaws.com/graphql"
const API_KEY = "da2-<secret>"
const WSS_URL = API_URL.replace('https','wss').replace('appsync-api','appsync-realtime-api')
const HOST = API_URL.replace('https://','').replace('/graphql','')
const api_header = {
    'host': HOST,
    'x-api-key': API_KEY
}
const header_encode = obj => btoa(JSON.stringify(obj));
const connection_url = WSS_URL + '?header=' + header_encode(api_header) + '&payload=' +  header_encode({})

//------------------------------------------------------------------------------------------------
const {SubscriptionClient} = require("subscriptions-transport-ws");
const uuid4 = require("uuid").v4;

class UUIDOperationIdSubscriptionClient extends SubscriptionClient {
    generateOperationId() {
      // AppSync recommends using UUIDs for Subscription IDs but SubscriptionClient uses an incrementing number
      return uuid4();
    }
    processReceivedData(receivedData) {
      try {
        const parsedMessage = JSON.parse(receivedData);
        if (parsedMessage?.type === 'start_ack') return; // sent by AppSync but meaningless to us
      } catch (e) {
        throw new Error('Message must be JSON-parsable. Got: ' + receivedData);
      }
      super.processReceivedData(receivedData);
    }
}

// appSyncGraphQLOperationAdapter.js
const graphqlPrinter = require("graphql/language/printer");
const createAppSyncGraphQLOperationAdapter = () => ({
    applyMiddleware: async (options, next) => {
        // AppSync expects GraphQL operation to be defined as a JSON-encoded object in a "data" property
        options.data = JSON.stringify({
            query: typeof options.query === 'string' ? options.query : graphqlPrinter.print(options.query),
            variables: options.variables
        });

        // AppSync only permits authorized operations
        options.extensions = {'authorization': api_header};

        // AppSync does not care about these properties
        delete options.operationName;
        delete options.variables;
        // Not deleting "query" property as SubscriptionClient validation requires it

        next();
    }
});

// WebSocketLink
const wsLink = new WebSocketLink(
      new UUIDOperationIdSubscriptionClient(
        connection_url,
        {timeout: 5 * 60 * 1000, reconnect: true, lazy: true, connectionCallback: (err) => console.log("connectionCallback", err ? "ERR" : "OK", err || "")},
        WebSocket
      ).use([createAppSyncGraphQLOperationAdapter()])
  );

const client = new ApolloClient({
    cache: new InMemoryCache(),
    link: wsLink,
});

holyjak avatar Jun 10 '21 11:06 holyjak

Hey guys,

thanks for sharing this with the community, it's really helpful and there's really a lot of struggle with these configs.

So I was trying to use this in my project and after setting it all up I'm getting this error: image

Also, this is how I'm using it: image

Can anybody help me with this error ?! I'm really confused with all this setup and still not working.

Thanks in advance.

lyvyu avatar Aug 05 '21 20:08 lyvyu

Not sure if this will address your issue, but I can cross-post my current solution from https://github.com/awslabs/aws-mobile-appsync-sdk-js/issues/448#issuecomment-886408564 here: https://gist.github.com/razor-x/e19d7d776cdf58d04af1e223b0757064

razor-x avatar Aug 05 '21 21:08 razor-x

@razor-x thanks man for the reply, appreciate it, but I ended up using AppSyncClient instead and got everything working. I just spend a tremendous amount of time on this and thought it's enough.

Once again, thanks for the reply and help.

lyvyu avatar Aug 08 '21 21:08 lyvyu

Thanks for the suggestion! If anyone is interested in working on a docs PR for this (in https://github.com/apollographql/apollo-client), that would be awesome!

hwillson avatar Sep 28 '21 15:09 hwillson

With the latest upgrade tried setting up and running appsync subscriptions with apollo client - https://github.com/kodehash/appsync-nodejs-apollo-client/tree/master

ko-deloitte avatar Nov 25 '21 06:11 ko-deloitte

Recently, AWS AppSync published details of their websocket subscription workflow (https://docs.aws.amazon.com/appsync/latest/devguide/real-time-websocket-client.html). It is now possible to implement a GraphQL client using only Apollo libraries, without the need for AppSync's own SDK. My team did this recently in order to side-step certain AppSync SDK bugs. I think it would be useful to others if this was available somewhere in Apollo's documentation. I provide it here for the Apollo team to disseminate if possible. There is no offline support in this example.

A number of customizations were required in order to make it work:

  • Set connection timeout (default 30 seconds) to 5 minutes since AppSync's keep alive messages are not so frequent
  • Override SubscriptionClient from subscriptions-transport-ws to use UUID operation IDs as this is recommended by AppSync
  • Override browser WebSocket class to compute URL on instantiation since SubscriptionClient has an invariant websocket URL but header query string parameter needs to stay in sync with JWT token
  • Also override browser WebSocket class to filter out messages with data.type === 'start_ack' since SubscriptionClient cannot handle this type of message sent by AppSync
  • Schedule async refresh of JWT token every time websocket is instantiated, in case the token has expired. Unfortunately, there is no way to await the refresh so instead we rely on SubscriptionClient to retry the websocket connection on authorization failure. Eventually, a connection attempt will be made with a valid JWT token.
  • Use custom SubscriptionClient middleware to modify operations to include serialized GraphQL query and variables in a data property and to add authorization information to extensions

Example usage

import {ApolloClient, InMemoryCache} from "@apollo/client";
import {createAppSyncHybridLink} from "./appSyncHybridLink";

export const createAppSyncApolloClient = async ({appSyncApiUrl, getJwtToken, cacheConfig, connectToDevTools}) =>
    new ApolloClient({
            link: await createAppSyncHybridLink({appSyncApiUrl, getJwtToken}),
            cache: new InMemoryCache(cacheConfig),
            connectToDevTools
        }
    );

// Note: getJwtToken can be asynchronous, for example with Amplify.js (https://docs.amplify.aws/lib/q/platform/js):
// const getJwtToken = async () => (await Auth.currentSession()).getIdToken().getJwtToken()
// appSyncHybridLink.js

import {ApolloLink} from "@apollo/client";
import {createAppSyncSubscriptionWebsocketLink} from "./appSyncSubscriptionWebSocketLink";
import {createAppSyncHttpLink} from "./appSyncHttpLink";
import {getMainDefinition} from "@apollo/client/utilities";

export const createAppSyncHybridLink = async ({appSyncApiUrl, getJwtToken}) => ApolloLink.split(
    isSubscriptionOperation,
    await createAppSyncSubscriptionWebsocketLink({appSyncApiUrl, getJwtToken}),
    createAppSyncHttpLink({appSyncApiUrl, getJwtToken})
);

const isSubscriptionOperation = ({query}) => {
    const {kind, operation} = getMainDefinition(query);
    return kind === 'OperationDefinition' && operation === 'subscription';
};
// appSyncHttpLink.js

import {setContext} from "@apollo/link-context";
import {ApolloLink, HttpLink} from "@apollo/client";

export const createAppSyncHttpLink = function ({appSyncApiUrl, getJwtToken}) {
    const authorizationHeaderLink = setContext(async (request, previousContext) => ({
        ...previousContext,
        headers: {
            ...previousContext.headers,
            Authorization: await getJwtToken()
        }
    }));

    return ApolloLink.concat(
        authorizationHeaderLink,
        new HttpLink({uri: appSyncApiUrl})
    );
};
// appSyncSubscriptionWebSocketLink.js

import {WebSocketLink} from "@apollo/link-ws";
import {UUIDOperationIdSubscriptionClient} from "./UUIDOperationIdSubscriptionClient";
import {createAppSyncAuthorizedWebSocket} from "./appSyncAuthorizedWebSocket";
import {cacheWithAsyncRefresh} from "./asyncUtils";
import {createAppSyncGraphQLOperationAdapter} from "./appSyncGraphQLOperationAdapter";

const APPSYNC_MAX_CONNECTION_TIMEOUT_MILLISECONDS = 5 * 60 * 1000;

export const createAppSyncSubscriptionWebsocketLink = async ({appSyncApiUrl, getJwtToken}) => {
    const appSyncApiHost = new URL(appSyncApiUrl).host;
    const getAppSyncAuthorizationInfo = async () => ({host: appSyncApiHost, Authorization: await getJwtToken()});

    return new WebSocketLink(
        new UUIDOperationIdSubscriptionClient(
            `wss://${(appSyncApiHost.replace('appsync-api', 'appsync-realtime-api'))}/graphql`,
            {timeout: APPSYNC_MAX_CONNECTION_TIMEOUT_MILLISECONDS, reconnect: true, lazy: true},
            // We want to avoid expired authorization information being used but SubscriptionClient synchronously
            // instantiates websockets (on connection/reconnection) so the best we can do is schedule an async refresh
            // and suffer failed connection attempts until a fresh token has been retrieved
            createAppSyncAuthorizedWebSocket(await cacheWithAsyncRefresh(getAppSyncAuthorizationInfo))
        ).use([createAppSyncGraphQLOperationAdapter(getAppSyncAuthorizationInfo)])
    );
};
// UUIDOperationIdSubscriptionClient.js

// AppSync recommends using UUIDs for Subscription IDs but SubscriptionClient uses an incrementing number
import {SubscriptionClient} from "subscriptions-transport-ws";
import {v4 as uuid4} from "uuid";

export class UUIDOperationIdSubscriptionClient extends SubscriptionClient {
    generateOperationId() {
        return uuid4();
    }
}
// asyncUtils.js

export const cacheWithAsyncRefresh = async asyncSupplier => {
    let value;

    const asyncRefresh = async () => value = await asyncSupplier();

    // Warm cache
    await asyncRefresh();

    return () => {
        asyncRefresh().catch(console.error);
        return value;
    };
};
// appSyncGraphQLOperationAdapter.js

import * as graphqlPrinter from "graphql/language/printer";

export const createAppSyncGraphQLOperationAdapter = getAppSyncAuthorizationInfo => ({
    applyMiddleware: async (options, next) => {
        // AppSync expects GraphQL operation to be defined as a JSON-encoded object in a "data" property
        options.data = JSON.stringify({
            query: typeof options.query === 'string' ? options.query : graphqlPrinter.print(options.query),
            variables: options.variables
        });

        // AppSync only permits authorized operations
        options.extensions = {'authorization': await getAppSyncAuthorizationInfo()};

        // AppSync does not care about these properties
        delete options.operationName;
        delete options.variables;
        // Not deleting "query" property as SubscriptionClient validation requires it

        next();
    }
});
// appSyncAuthorizedWebSocket.js

import {asBase64EncodedJson} from "./encodingUtils";

export const createAppSyncAuthorizedWebSocket = (getAppSyncAuthorizationInfo) => {
    return class extends WebSocket {
        // SubscriptionClient takes a fixed websocket url so we append query string parameters every time the websocket
        // is created, in case the authorization information has changed.
        constructor(url, protocols = undefined) {
            super(
                `${url}?header=${asBase64EncodedJson(getAppSyncAuthorizationInfo())}&payload=${asBase64EncodedJson({})}`,
                protocols
            );
        }

        // AppSync acknowledges GraphQL subscriptions with "start_ack" messages but SubscriptionClient cannot handle them
        set onmessage(handler) {
            super.onmessage = event => {
                if (event.data) {
                    const data = this._tryParseJsonString(event.data);

                    if (data && data.type === 'start_ack') {
                        return;
                    }
                }

                return handler(event);
            };
        }

        _tryParseJsonString(jsonString) {
            try {
                return JSON.parse(jsonString);
            } catch (e) {
                return undefined;
            }
        }
    };
};
// encodingUtils.js

export const asBase64EncodedJson = value => btoa(JSON.stringify(value));

Thanks for this. Saved my day 👍

chamithrepo avatar Jan 13 '22 16:01 chamithrepo

@holyjak - Were you able to execute subscription using client.subscribe() after using above solution?

I saw your were seeing issues earlier (https://community.apollographql.com/t/solved-using-client-subscribe-does-not-work-to-appsync-from-node/381/4)

neha-2022 avatar Aug 24 '22 07:08 neha-2022

Thanks everyone for all the help here. That really helped.

After some more research, I found this project. That did the trick for me

bboure avatar Sep 23 '22 11:09 bboure

Is anyone has made a npm package out of it? This would be really awesome :pray:

Hideman85 avatar Oct 31 '22 09:10 Hideman85

@razor-x thanks man for the reply, appreciate it, but I ended up using AppSyncClient instead and got everything working. I just spend a tremendous amount of time on this and thought it's enough.

Once again, thanks for the reply and help.

hi, i have the same problems

https://github.com/kodehash/appsync-nodejs-apollo-client/tree/master

Hi bro, I have the same problem as you, can you tell me how to fix it, because I don't know how to use AppSyncClient, I hope you answer me, that will save me. Thank u very much !

TruongNV-deha avatar Jul 11 '23 10:07 TruongNV-deha

It would help you https://gist.github.com/wellitongervickas/087fb0d0550c429aae4500e4e4e9f624

library is not implement the payload data properly

wellitongervickas avatar Nov 25 '23 19:11 wellitongervickas

@wellitongervickas

It would help you https://gist.github.com/wellitongervickas/087fb0d0550c429aae4500e4e4e9f624

library is not implement the payload data properly

This is a great solution if you make requests using Api Key. But what if we need to use Cognito authorization to establish a connection ? Any ideas?

royroev avatar Dec 13 '23 15:12 royroev