Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Document how to use client with AppSync #224

Open
StefanSmith opened this issue Apr 28, 2020 · 17 comments
Open

Document how to use client with AppSync #224

StefanSmith opened this issue Apr 28, 2020 · 17 comments
Labels
help-wanted Pull requests encouraged!

Comments

@StefanSmith
Copy link

StefanSmith commented Apr 28, 2020

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));
@Bariah96
Copy link

Bariah96 commented Apr 4, 2021

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 ?

@philiiiiiipp
Copy link

@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
Copy link

@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.

@Bariah96
Copy link

Bariah96 commented Apr 5, 2021

@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 :)

@holyjak
Copy link

holyjak commented Jun 10, 2021

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,
});

@lyvyu
Copy link

lyvyu commented Aug 5, 2021

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.

@razor-x
Copy link

razor-x commented Aug 5, 2021

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

@lyvyu
Copy link

lyvyu commented Aug 8, 2021

@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.

@hwillson
Copy link
Member

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 hwillson added the help-wanted Pull requests encouraged! label Sep 28, 2021
@ko-deloitte
Copy link

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

@chamithrepo
Copy link

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 👍

@neha-2022
Copy link

@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)

@bboure
Copy link

bboure commented Sep 23, 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

@Hideman85
Copy link

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

@TruongNV-deha
Copy link

TruongNV-deha commented Jul 11, 2023

@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 !

@wellitongervickas
Copy link

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

library is not implement the payload data properly

@royroev
Copy link

royroev commented Dec 13, 2023

@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?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
help-wanted Pull requests encouraged!
Projects
None yet
Development

No branches or pull requests