How to use AppSync enhanced subscription filtering
Subscription filters give finer control over what messages clients get
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.