Front-End Web & Mobile

AWS AppSync WebSockets-based subscriptions for real-time updates now support nested filtering

AWS AppSync is a fully managed service that enables developers to build digital experiences based on real-time data. With AppSync, you can configure data sources to push and publish real-time data updates to subscribed clients. AppSync handles connection management, scalability, fan-out and broadcasting, allowing you to focus on your application business needs instead of managing complex infrastructure. With AppSync, developers can easily specify filtering rules for their subscriptions to target specific connected clients based on the published data.

Today, AppSync introduces support for filtering on nested schema fields in enhanced subscription filters. With this change, you can now target specific sub-items within the data published in the selection set of you mutations. This gives you additional control over how data should be broadcasted via subscriptions. You can create types in your schema composed of complex fields and create subscriptions that have up to five levels of nesting. You do not have to make changes to you nested schema types, and you can start using this functionality of AppSync’s pub/sub engine right away to implement real-time experiences.

Getting started

To show how you can start using this feature in your own application, we will use a simple schema inspired by the Real-Time Live Sports Updates API. Head to the AppSync console, and create a new AppSync GraphQL API with this schema:

type Game {
    id: ID!
}

type GameEvent {
    id: ID!
    game: Game
    player: Player
    createdAt: AWSDateTime!
}

type Player {
    id: ID!
    stats: Stats
}

type Stats {
    points: Int
    assists: Int
}

type Query {
    getGameEvent(id: ID!): GameEvent
}

type Mutation {
    createGameEvent(input: CreateGameEventInput!): GameEvent
}

type Subscription {
    onCreateGameEvent(gameId: ID!, playerId: ID, points: Int, assists: Int): GameEvent
        @aws_subscribe(mutations: ["createGameEvent"])
}

input CreateGameEventInput {
    gameId: ID!
    playerId: ID!
    points: Int
    assists: Int
}

The schema allows you to create game events with the createGameEvent mutation, and to subscribe to these events with the onCreateGameEvent subscription. Note that GameEvent has a player field that is a complex nested type. The API uses a NONE resolver that publishes data without saving it to a data source. The createGameEvent resolver is attached to the NONE resolver and simply forwards the payload locally.

import { util } from '@aws-appsync/utils';

export function request(ctx) {
    const { gameId, playerId: id, assists, points } = ctx.args.input;
    const payload = {
        id: util.autoId(),
        createdAt: util.time.nowISO8601(),
        game: { id: gameId },
        player: { id, stats: { assists, points } },
    };
    return { payload };
}

export const response = (ctx) => ctx.result;

The subscription onCreateGameEvent resolver is associated with a NONE resolver as well.

import { util, extensions } from '@aws-appsync/utils';

export const request = (ctx) => ({ payload: null });

export function response(ctx) {
    const { playerId, assists, points } = ctx.args;
    const filter = { 'game.id': { eq: ctx.args.gameId } };
    const playerFilter = [];
    if (playerId) {
        playerFilter.push({
            'player.id': { eq: playerId },
            or: [
                { ...(points ? { 'player.stats.points': { ge: points } } : null) },
                { ...(assists ? { 'player.stats.assists': { ge: assists } } : null) },
            ],
        });
    }
    filter.or = playerFilter;
    console.log('filter:', filter);
    extensions.setSubscriptionFilter(util.transform.toSubscriptionFilter(filter));
    return null;
}

The resolver response handler sets up a subscription that notifies connected clients when a game event for a specific game (gameId) has happened. Optionally, if the playerId is provided, the resolver further filters for events that involve the specified player and conditionally filters on assists and points. Note the use of extensions.setSubscriptionFilter to set up the filter, and util.transform.toSubscriptionFilter to transform the filter to the appropriate format.

You can test your subscription from the AppSync console Query editor. First, establish the subscription. In the example below, you subscribe to an hypothetical NBA game, listening for game events between Dallas and Milwaukee, that involves Luka Doncic where they have at least 30 points or 10 assists.

subscription OnGameEvent {
  onCreateGameEvent(gameId: "DALvsMIL", playerId: "LUKADONCIC77", points: 30, assists: 10) {
    game {
      id
    }
    createdAt
    id
    player {
      id
      stats {
        assists
        points
      }
    }
  }
}

In your Amazon CloudWatch Logs, you can see the log line with the configured filter that includes the nested rules (logged from the subscription resolver code):

{
    "game.id": { "eq": "DALvsMIL" },
    "or": [
        {
            "player.id": { "eq": "LUKADONCIC77" },
            "or": [
                { "player.stats.points": { "ge": 30 } },
                { "player.stats.assists": { "ge": 10 } }
            ]
        }
    ]
}

To test the subscription, open another instance of the Query Editor in a new tab, and send a mutation:

mutation NewGameEvent {
  createGameEvent(input: {gameId: "DALvsMIL", playerId: "LUKADONCIC77", assists: 11, points: 33}) {
    createdAt
    game {
      id
    }
    id
    player {
      id
      stats {
        assists
        points
      }
    }
  }
}

You can make changes to the game ID and the involved player to see how and when the subscription is triggered.

Invalidating subscriptions

You can invalidate live subscriptions using nested filters. For example, let’s say you want to close all active subscriptions when a game ends. First, update your onCreateGameEvent subscription response handler and add the following lines before the return statement:

const invalidation = { 'game.id': { eq: ctx.args.gameId } };
extensions.setSubscriptionInvalidationFilter(util.transform.toSubscriptionFilter(invalidation));

This creates an invalidation filter in the subscription based on the nested field game.id. Next, add the endGame mutation to your schema:

type Mutation {
    createGameEvent(input: CreateGameEventInput!): GameEvent
    endGame(gameId: ID!): Game
}

Create a resolver for endGame with the following code:

import { extensions } from '@aws-appsync/utils';
export function request(ctx) {
    return { payload: { id: ctx.args.gameId } };
}

export function response(ctx) {
    extensions.invalidateSubscriptions({
        subscriptionField: 'onCreateGameEvent',
        payload: { 'game.id': ctx.args.gameId },
    });
    return ctx.result;
}

When the endGame mutation is called with a game ID, the resolver will send a subscription invalidation request with that specific ID. Stop and restart your subscription. Then, to invalidate a subscription, use the endGame mutation:

mutation EndGame {
  endGame(gameId: "DALvsMIL") {
    id
  }
}

Any subscription that uses an invalidation filter that matches the value is ended and the following message is shown:

{
  "message": "Subscription complete."
}

Conclusion

In this post, we reviewed the new nested object filtering functionality for AppSync’s enhanced filters. We also showed how you can use nested object filtering to invalidate subscriptions. To learn more about using enhanced filtering in your own subscriptions, see the documentation and the tutorials. You can also find easy-to-use samples and guides in the samples repository.