apollographql / apollo-feature-requests

🧑‍🚀 Apollo Client Feature Requests | (no 🐛 please).
Other
130 stars 7 forks source link

Document how to use client with AppSync #224

Open StefanSmith opened 4 years ago

StefanSmith commented 4 years ago

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:

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 commented 3 years ago

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 commented 3 years ago

@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 commented 3 years ago

@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 commented 3 years ago

@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 commented 3 years ago

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 commented 3 years ago

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 commented 3 years ago

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

lyvyu commented 3 years ago

@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 commented 3 years ago

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!

ko-deloitte commented 3 years ago

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 commented 2 years ago

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 commented 2 years ago

@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 commented 2 years ago

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 commented 2 years ago

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

TruongNV-deha commented 1 year ago

@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 commented 1 year ago

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

library is not implement the payload data properly

royroev commented 11 months ago

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

kendevops commented 2 months ago

@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 if you still need help with using cognito authorization then this would help https://docs.amplify.aws/gen1/javascript/build-a-backend/graphqlapi/upgrade-guide/. use the

import { fetchAuthSession } from 'aws-amplify/auth'

and instead of apiKey, change to jwtToken and you can do something like this

  const auth = {
  type: process.env.NEXT_PUBLIC_APPSYNCTYPE! as "AMAZON_COGNITO_USER_POOLS",
  jwtToken: async () => {
    try {
      const authSession = await fetchAuthSession();
      return authSession.tokens?.accessToken?.toString() || '';
    } catch (error) {
      console.error('Error fetching JWT token', error);
      return '';
    }
  },
};