How to use AppSync enhanced subscription filtering

Subscription filters give finer control over what messages clients get

Author's image
Tamás Sallai
5 mins

Arguments-based subscription filtering

Originally, AppSync only supported arguments-based filtering for subscriptions. There, the arguments the clients send for the subscription are matched to the event objects and if all match only then AppSync pushed the notification to the client.

For example, this schema defines a subscription that returns a TodoEvent:

type TodoEvent {
	userId: ID!
	groupId: ID!
	todo: Todo!
}

type Mutation {
	notifyTodo(id: ID!): TodoEvent!
}

type Subscription {
	todo(userId: ID, groupId: ID): TodoEvent
	@aws_subscribe(mutations: ["notifyTodo"])
}

The userId and the groupId matches top-level fields in the TodoEvent type, so clients can fine-tune what events they are interested in.

For example, to get only events related to a single user, provide the userId:

subscription MySubscription {
  todo(userId: "user1") {
    todo {
      checked
      created
      id
      name
    }
  }
}

And for a group, use the groupId:

subscription MySubscription {
  todo(groupId: "group1") {
    todo {
      checked
      created
      id
      name
    }
  }
}

Clients can also combine the two. In the above example, it does not make much sense, but all supplied arguments must match for the event to be sent to the client.

While this is a very simple mechanism, it is surprisingly powerful. Combined with some custom resolver logic that validates the arguments for access control, it is usually enough.

Enhanced subscription filtering

AppSync recently added support for a different filtering mechanism, called enhanced subscription filtering. This gives more programmatic control over what events are sent to the clients by allowing 3 things:

First, the resolver can define the fields for the filtering, bringing the logic to the backend instead of the clients.

Second, it allows more operators than just strict equality. Now it's possible to do allowlisting with the in, denylisting with notIn, numerical comparisions with ge, gt, between, le, lt, and basic string operations such as contains, notContains, and beginsWith.

And third, it allows defining and and or logic between fields, which allows rather complex operator trees.

The documentation provides a few examples about what is possible.

Configuration

Enhanced filtering works by calling the $extensions.setSubscriptionFilter() in the response mapping template of the subscription resolver. This function gets the filter configuration.

The configuration object is defined in the documentation. It's useful to become familiar with the structure to see what is possible.

Note that calling $extensions.setSubscriptionFilter() switches the subscription to enhanced filtering and that disables argument-based filters. Since filtering is also a security feature, as that is the mechanism that defines what data the clients can access, it is important to set conditions for equality cases too.

Implementation

Let's implement an example!

Here, we have Todo items with user, group, and severity:

enum Severity {
	LOW
	MEDIUM
	HIGH
}

type Todo {
	id: ID!
	name: String!
	checked: Boolean!
	created: AWSDateTime!
	user: User!
	severity: Severity!
}

type TodoEvent {
	userId: ID!
	groupId: ID!
	todoId: ID!
	severity: Severity!
	todo: Todo
}

type Mutation {
	notifyTodo(userId: ID!, groupId: ID!, severity: Severity!, id: ID!): TodoEvent!
}

Then a subscription with 3 optional arguments:

type Subscription {
	todo(userId: ID, groupId: ID, minSeverity: Severity): TodoEvent
	@aws_subscribe(mutations: ["notifyTodo"])
}

The userId and the groupId should work like arguments-based filters, so when they are defined, they need equality. Then the minSeverity should define a level where less important Todo items are filtered out.

The implementation relies on the $util.transform.toSubscriptionFilter utility function (docs). This is because it is very hard to assemble the filter JSON without it.

We'll use the version with 2 arguments:

  • the first is a map that contains the fields, the operators, and the values
  • the second is a list that defines what fields to remove from the first map

It seems strange to do it this way, but there is good reason why it works like this. In a VTL function call you can't use conditionals, which makes it very hard to include fields only if they are defined. Rather, add everything to the first argument and pass a list what to remove.

So first, implement the map:

#set($severityLevels = [])
#if($ctx.args.minSeverity == "HIGH")
	$util.qr($severityLevels.add("HIGH"))
#end
#if($ctx.args.minSeverity == "MEDIUM")
	$util.qr($severityLevels.add("MEDIUM"))
	$util.qr($severityLevels.add("HIGH"))
#end
#if($ctx.args.minSeverity == "LOW")
	$util.qr($severityLevels.add("LOW"))
	$util.qr($severityLevels.add("MEDIUM"))
	$util.qr($severityLevels.add("HIGH"))
#end

$extensions.setSubscriptionFilter($util.transform.toSubscriptionFilter({
	"userId": {"eq": $ctx.args.userId},
	"groupId": {"eq": $ctx.args.groupId},
	"severity": {"in": $severityLevels}
}))

The first part constructs the severity levels the filter includes. Then it assembles the values for filtering. Note that it includes all 3 fields even if they are optional.

This is where the second argument comes into play:

#set($nonDefinedFilters = [])
#if($util.isNull($ctx.args.userId))
	$util.qr($nonDefinedFilters.add("userId"))
#end
#if($util.isNull($ctx.args.groupId))
	$util.qr($nonDefinedFilters.add("groupId"))
#end
#if($util.isNull($ctx.args.minSeverity))
	$util.qr($nonDefinedFilters.add("severity"))
#end

$extensions.setSubscriptionFilter($util.transform.toSubscriptionFilter({
	"userId": {"eq": $ctx.args.userId},
	"groupId": {"eq": $ctx.args.groupId},
	"severity": {"in": $severityLevels}
},
$nonDefinedFilters
))

This builds the $nonDefinedFilters list that includes what is missing. Then it is passed as the second argument, so the result won't contain them.

Conclusion

Enhanced subscription filtering adds both a lot of control over subscription events and a lot of extra complexity. With its ability to define not only strict equality but also other operators as well as a decision tree for the filters, it could cover use-cases simple arguments-based filters can't cover.

But it also brings complexity to an area that is extremely sensitive. Safeguarding data is a hard task by itself, and the programming model of enhanced filtering does not help reduce mental complexity here. So, my recommendation is that if arguments-based filtering is enough in your case, stick with that.

November 22, 2022
In this article