Front-End Web & Mobile

Simple serverless WebSocket real-time API with AWS AppSync (little or no GraphQL experience required)

AWS AppSync simplifies application development by letting applications securely access, manipulate, and receive data as well as real-time updates from multiple data sources, such as databases or APIs. Taking advantage of GraphQL subscriptions to perform real-time operations, AppSync can push data to clients that choose to listen to specific events from the backend. This means that you can easily and effortlessly make any supported data source in AWS AppSync real-time with connection management handled automatically between the clients and the API endpoint. A backend service can easily broadcast data to connected clients or clients can send data to other clients, depending on the use case. Real-time data, connections, scalability, fan-out and broadcasting are all handled by intelligent client libraries and AppSync, allowing you to focus on your application business use cases and requirements instead of dealing with the complex infrastructure to manage WebSockets connections at scale.

Sometimes applications just require a very simple WebSocket API where clients listen to a specific channel or topic. Generic JSON data with no specific shape or strongly typed requirements is pushed to clients listening to a given channel in a pure and simple publish-subscribe (pub/sub) pattern. While AppSync is a fully managed GraphQL API service, it is possible to implement a simple WebSocket API in the service with little or no GraphQL knowledge in minutes thanks to useful tools such as the AWS CDK and AWS Amplify libraries. These powerful tools can easily abstract and automatically generate all required GraphQL code both on the API backend and on the client side. The simple WebSocket API we discuss in this article is generic so it can be used in any scenario that requires data to be pushed to any number of WebSocket clients listening to a channel. As long as clients are subscribed and listening to the same channel, they’ll all receive the data. In the next sections we discuss how to implement both the API backend and a web client application in few minutes.

API backend

Usually, data in a GraphQL API is modeled based on a GraphQL schema written in SDL (schema definition language) where types, fields, and operations are defined. If you’re not familiar with SDL, the AWS CDK provides an option to easily generate the GraphQL schema programmatically leveraging a code-first approach to define our simple WebSocket API in AppSync. All we require is 58 lines of TypeScript code to configure and generate our simple WebSocket API with no need to provide a hard coded GraphQL schema:

import * as cdk from '@aws-cdk/core';
import { GraphqlApi, AuthorizationType, Directive, ObjectType, GraphqlType, ResolvableField, Field, MappingTemplate } from '@aws-cdk/aws-appsync';

export class CdkStack extends cdk.Stack {
  constructor(scope: cdk.Construct, id: string, props?: cdk.StackProps) {
    super(scope, id, props);

    const api = new GraphqlApi(this, 'Api', {
      name: 'WS-API',
      authorizationConfig: {
        defaultAuthorization: {
          authorizationType: AuthorizationType.API_KEY
        }
      }
    });

    const channel = new ObjectType('Channel', {
      definition: {
        name: GraphqlType.string({ isRequired: true }),
        data: GraphqlType.awsJson({ isRequired: true }),
      },
    });

    api.addType(channel);

    api.addQuery('getChannel', new Field({
      returnType: channel.attribute()
    }));

    api.addMutation('publish2channel', new ResolvableField({
      returnType: channel.attribute(),
      args: { name: GraphqlType.string({ isRequired: true }), data: GraphqlType.awsJson({ isRequired: true }) },
      dataSource: api.addNoneDataSource('pubsub'),
      requestMappingTemplate: MappingTemplate.fromString(`
        {
          "version": "2017-02-28",
          "payload": {
              "name": "$context.arguments.name",
              "data": $util.toJson($context.arguments.data)
          }
        }`
      ),
      responseMappingTemplate: MappingTemplate.fromString(`$util.toJson($context.result)`)
    }))

    api.addSubscription('subscribe2channel', new Field({
      returnType: channel.attribute(),
      args: { name: GraphqlType.string({ isRequired: true }) },
      directives: [Directive.subscribe('publish2channel')],
    }));

    new cdk.CfnOutput(this, 'graphqlUrl', { value: api.graphqlUrl })
    new cdk.CfnOutput(this, 'apiKey', { value: api.apiKey! })
    new cdk.CfnOutput(this, 'apiId', { value: api.apiId })
    new cdk.CfnOutput(this, 'region', { value: this.region })

  }
}

After deploying the CDK stack with cdk deploy, the command will output the GraphQL API endpoint, API ID, and API key. Copy all these output details as they are required to setup the client in the next section.

The CDK code above creates an AppSync API in your AWS account with the following generated GraphQL schema. The schema is short and simple. It defines a single type Channel that only has a name and data fields associated with it, making it generic enough so it can be used for almost any simple pub/sub use case that requires connectivity via WebSockets. Publishers just need to choose a channel name and send any JSON blob to the API. Clients subscribed to the specified channel in a secure WebSocket connection (wss://) automatically receive the data payload. As the publisher defines channel names when sending requests to the API, channels themselves are completely ephemeral.

type Channel {
    name: String!
    data: AWSJSON!
}

type Mutation {
    publish2channel(name: String!, data: AWSJSON!): Channel
}

type Query {
    getChannel: Channel
}

type Subscription {
    subscribe2channel(name: String!): Channel
        @aws_subscribe(mutations: ["publish2channel"])
}

schema {
    query: Query
    mutation: Mutation
    subscription: Subscription
}

In AppSync, data defined and modeled in the GraphQL schema is linked to data sources where data is stored. AppSync can retrieve data from multiple data sources simultaneously, automatically constructing a JSON response payload based on the data requested by the client defined in a GraphQL operation. For our simple WebSocket API there’s no need to persist data anywhere, which means the data source is simply AppSync itself. We use AppSync’s built-in local resolvers to configure the API to manage multiple ephemeral pub/sub channels and WebSocket connections, automatically delivering and filtering data to subscribed clients based only on the channel name. For the sake of simplicity, API calls are authorized with API keys.

API clients

Now that our simple WebSocket API backend is deployed, we’re ready to configure our WebSocket clients to receive data. We leverage the Amplify libraries to connect clients to the backend API. While in this article we use React as our client of choice to create a simple web app, Amplify libraries also support iOS, Android, and Flutter clients, providing the same capabilities in these different runtimes. The supported Amplify clients provide simple abstractions to interact with AppSync GraphQL API backends with few lines of code, including built-in WebSocket capabilities fully compatible with the AppSync WebSocket real-time protocol out of the box.

We start by installing a couple of tools and dependencies using NPM to create a boilerplate React app:

$ npx create-react-app simplews-app 
$ cd simplews-app
$ npm install --save aws-amplify
$ curl -sL https://aws-amplify.github.io/amplify-cli/install | bash && $SHELL

Notice we install the Amplify CLI. However, we don’t need an Amplify CLI project as our backend was already deployed with the AWS CDK. We only require the Amplify CLI to automatically generate GraphQL client code and nothing else.

Next go to the AWS AppSync Console, select the API deployed earlier and, in the Schema section, click Export schema. Download and copy the schema file to the root of the React project’s /simplews-app folder, where you need to execute the following command using the API ID returned by the CDK stack and accepting all defaults:

$ amplify add codegen --apiId xxxxxxxxxxxxxxxxxxxxxx

A src/graphql folder is automatically generated in the project with all required GraphQL code needed to interact with our simple API. Replace the content of the existing boilerplate src/App.js file with the following 78 lines of code, updating the AppSync endpoint settings provided as outputs of the cdk deploy command executed previously:

import logo from "./logo.svg";
import "./App.css";
import React, { useEffect,useState } from "react";
import Amplify, { API, graphqlOperation } from "aws-amplify";
import * as subscriptions from "./graphql/subscriptions"; //codegen generated code
import * as mutations from "./graphql/mutations"; //codegen generated code

//AppSync endpoint settings
const myAppConfig = {
  aws_appsync_graphqlEndpoint:
    "https://xxxxxxxxxxxxxxxxxx.appsync-api.us-west-2.amazonaws.com/graphql",
  aws_appsync_region: "us-west-2",
  aws_appsync_authenticationType: "API_KEY",
  aws_appsync_apiKey: "da2-xxxxxxxxxxxxxxxxxx",
};

Amplify.configure(myAppConfig);

function App() {
  const [send, setSend] = useState("");
  const [received, setReceived] = useState("");

  //Define the channel name here
  let channel = "robots";
  let data = "";

  //Publish data to subscribed clients
  async function handleSubmit(evt) {
    evt.preventDefault();
    evt.stopPropagation();
    const publish = await API.graphql(
      graphqlOperation(mutations.publish2channel, { name: channel, data: send })
    );
    setSend("Enter valid JSON here... (use quotes for keys and values)");
  }

  useEffect(() => {
    //Subscribe via WebSockets
    const subscription = API.graphql(
      graphqlOperation(subscriptions.subscribe2channel, { name: channel })
    ).subscribe({
      next: ({ provider, value }) => {
        setReceived(value.data.subscribe2channel.data);
      },
      error: (error) => console.warn(error),
    });
    return () => subscription.unsubscribe();
  }, [channel]);

  if (received) {
    data = JSON.parse(received);
  }

  //Display pushed data on browser
  return (
    <div className="App">
      <header className="App-header">
        <p>Send/Push JSON to channel "{channel}"...</p>
        <form onSubmit={handleSubmit}>
          <textarea
            rows="5"
            cols="60"
            name="description"
            onChange={(e) => setSend(e.target.value)}
          >
            Enter valid JSON here... (use quotes for keys and values)
          </textarea>
          <br />
          <input type="submit" value="Submit" />
        </form>
        <p>Subscribed/Listening to channel "{channel}"...</p>
        <pre>{JSON.stringify(data, null, 2)}</pre>
      </header>
    </div>
  );
}

export default App;

Less than 150 lines of code between backend API and frontend client code, we’re all set and ready to test the end-to-end simple WebSocket API solution. Start the React app locally by executing the command:

$ npm start

Open http://localhost:3000 in multiple browsers or multiple browser windows to test, confirming JSON data is successfully broadcasted and pushed to multiple clients subscribed to the specified channel. Connection management, fan-out and broadcasting are all sorted between the Amplify libraries and AppSync:

Clients only receive data published to the specific channel they’re subscribed to, making it seamless to effectively filter the data different clients receive. While data shared in the robots channel could be specific to robotic devices, maybe a cakes channel is where clients should be subscribed to receive the latest cake recipes. Any client with a valid API key can publish any JSON blob to a given channel. For instance, it’s possible to use curl to publish data to our React clients subscribed via WebSockets:

curl 'https://xxxxxxxxxxxxxxxxxxxx.appsync-api.us-west-2.amazonaws.com/graphql' \
  -H 'content-type: application/json' \
  -H 'x-api-key: da2-xxxxxxxxxxxxxxxxxxxx' \
  --data-raw $'{"query":"mutation Publish2channel($data: AWSJSON\u0021, $name: String\u0021) {\\n  publish2channel(data: $data, name: $name) {\\n    data\\n    name\\n  }\\n}\\n","variables":{"name":"robots","data":"{\\"source\\":\\"curl\\"}"}}' \

If you prefer to visualize both backend (CDK/TypeScript) and frontend (Amplify/React) code in a repository, it’s all available on GitHub.

What more can we do?

This solution allows clients to subscribe to a single specific channel defined in the client code and receive any unstructured JSON payload sent to the channel by a publisher. What if there’s a requirement for a single client to be subscribed to multiple channels? In order to support this use case, there’s no need to modify anything in the API backend. As channels are ephemeral and just defined by their name, the client just needs to declare and initiate multiple subscriptions for different channels. Different subscriptions all share the same secure WebSocket connection between a given client and the AppSync API real-time endpoint. The Amplify clients support up to 100 subscriptions per client in a single WebSocket connection, which means we can have clients subscribed to 100 different channels if needed:

  const subscription1 = API.graphql(
    graphqlOperation(subscriptions.subscribe2channel, {name: "my1stChannel"})
    ).subscribe({
      next: ({ provider, value }) => {
        setReceivedfromChannel1(value.data.subscribe2channel.data);
      },
      error: error => console.warn(error)
    });
  const subscription2 = API.graphql(
    graphqlOperation(subscriptions.subscribe2channel, { name: "my2ndChannel" })
  ).subscribe({
    next: ({ provider, value }) => {
      setReceivedfromChannel2(value.data.subscribe2channel.data);
    },
    error: (error) => console.warn(error),
  });
  const subscription3 = API.graphql(
    graphqlOperation(subscriptions.subscribe2channel, { name: "my3rdChannel" })
  ).subscribe({
    next: ({ provider, value }) => {
      setReceivedfromChannel3(value.data.subscribe2channel.data);
    },
    error: (error) => console.warn(error),
  });

Another area of potential improvement is authorization. Currently, any client with a valid API key can access the API, which means it’s not possible to differentiate users by identity. The simple WebSocket API can be easily enhanced to take advantage of any supported AppSync authorization modes to comply with any security requirement, leveraging IAM, OpenID Connect, Cognito User Pools, or any custom authorization logic with AWS Lambda. It’s just a matter of changing the API definition in the CDK TypeScript stack to support one or even multiple authorization modes at the same time:

const api = new GraphqlApi(this, 'Api', {
    name: 'WS-API',
    authorizationConfig: {
        defaultAuthorization: {
            authorizationType: AuthorizationType.LAMBDA,
            lambdaAuthorizerConfig: {
            handler: myAuthFunction
            }
        },
        additionalAuthorizationModes: [
            {
            authorizationType: AuthorizationType.IAM
            }
        ]
    }
});

GraphQL provides a strongly typed system out of the box. However we’re not taking advantage of its built-in type checking capabilities in the simple WebSocket API we deployed. We can easily enhance our API definition in CDK to mix and match typed and generic JSON data by adding fields to identify publisher (user) IDs, e-mails, likes, or adding a date when data was sent to the channel. We can still send generic JSON in the data field as before:

const channel = new ObjectType('Channel', {
    definition: {
        name: GraphqlType.string({ isRequired: true }),
        date: GraphqlType.awsDate({ isRequired: false }),
        likes: GraphqlType.int({ isRequired: false }),
        publisherId: GraphqlType.id({ isRequired: true }),
        publisherEmail: GraphqlType.awsEmail({ isRequired: false }),
        data: GraphqlType.awsJson({ isRequired: true }),
    },
});

Adding fields to the channel type in the simple API would allow to further filter the data clients receive in the WebSocket channels since new fields can be used as arguments to the subscribed clients in addition to the channel name. Other than arguments defined on the client side, enhanced server-side filters for real-time data are currently under consideration. If you’d like to learn more as well as provide feedback, please refer to this RFC on GitHub.

Finally, in the current solution data is not persisted anywhere. The GraphQL API receives a mutation to publish a message to a channel and automatically sends it to all connected subscribed clients. What if there’s a requirement to persist the data so previously published messages can be accessed at any time? In AppSync, data defined and modeled in the GraphQL schema is linked to data sources where data is stored. AppSync can retrieve data from multiple data sources simultaneously, automatically constructing a JSON response payload based on the data requested by the client defined in a GraphQL operation. You can easily replace the existing local/none data source with a scalable serverless database such as Amazon DynamoDB while keeping all the existing built-in real-time functionality of the simple WebSocket API. Adapt a CDK pattern from Serverless Land or use a handy CDK example to extend your WebSocket API to persist data in DynamoDB.

Conclusion

In this article, we learned how to easily deploy a simple pub/sub WebSocket API in AppSync then how we can enhance the solution with few tweaks. The generated API is generic enough to be used for any use case where JSON data needs to be pushed to connected WebSocket clients in real-time. Little or no GraphQL knowledge is required to implement the solution as all GraphQL code and constructs are abstracted and autogenerated by the AWS CDK for the API backend and by AWS Amplify for the client code. Once the simple WebSocket API is in place we saw how it can be easily adapted to address future growth requirements such as support for multiple channels, additional authorization modes, built-in strongly typed checking, and persisting data in a cloud data source. Best of all, you don’t need to worry about managing WebSockets infrastructure as AppSync is fully serverless and automatically scales according to any demand. Connections, fan-out, and broadcasting are all handled by intelligent Amplify client libraries based on JavaScript, iOS, Android, or Flutter, and AppSync providing a full end-to-end solution to transmit real-time data to your applications and helping to seamlessly implement rich experiences for your end users with few lines of code.