Open StefanSmith opened 4 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 ?
@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
@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 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 :)
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,
});
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:
Also, this is how I'm using it:
Can anybody help me with this error ?! I'm really confused with all this setup and still not working.
Thanks in advance.
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 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.
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!
With the latest upgrade tried setting up and running appsync subscriptions with apollo client - https://github.com/kodehash/appsync-nodejs-apollo-client/tree/master
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
fromsubscriptions-transport-ws
to use UUID operation IDs as this is recommended by AppSync- Override browser
WebSocket
class to compute URL on instantiation sinceSubscriptionClient
has an invariant websocket URL butheader
query string parameter needs to stay in sync with JWT token- Also override browser
WebSocket
class to filter out messages withdata.type === 'start_ack'
sinceSubscriptionClient
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 adata
property and to add authorization information toextensions
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 👍
@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)
Thanks everyone for all the help here. That really helped.
After some more research, I found this project. That did the trick for me
Is anyone has made a npm package out of it? This would be really awesome :pray:
@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 !
It would help you https://gist.github.com/wellitongervickas/087fb0d0550c429aae4500e4e4e9f624
library is not implement the payload data properly
@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?
@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 '';
}
},
};
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:
SubscriptionClient
fromsubscriptions-transport-ws
to use UUID operation IDs as this is recommended by AppSyncWebSocket
class to compute URL on instantiation sinceSubscriptionClient
has an invariant websocket URL butheader
query string parameter needs to stay in sync with JWT tokenWebSocket
class to filter out messages withdata.type === 'start_ack'
sinceSubscriptionClient
cannot handle this type of message sent by AppSyncSubscriptionClient
to retry the websocket connection on authorization failure. Eventually, a connection attempt will be made with a valid JWT token.SubscriptionClient
middleware to modify operations to include serialized GraphQL query and variables in adata
property and to add authorization information toextensions
Example usage