Show HN: TypeScript DSL for expressive AWS SNS filters as type-safe code

4 months ago 17

SNS Filter  Static Badge

📨 Typescript DSL for expressive AWS SNS filter policies as type-safe code.

Github Codespaces


  • 📦 Type-Safe DSL: Define AWS SNS filter policies using a type-safe TypeScript DSL.
  • 🧩 Composable: Build complex filter policies using logical operators and nested conditions in TypeScript.
  • 🛠️ Use it Anywhere: Usable with the JavaScript AWS SDK and the AWS CDK.
  • 📚 Versioned Types: Allows you to define and version your types transiting through AWS SNS.

sns-filter is a Typescript Domain-Specific Language (DSL) making it easy to write AWS SNS filter policies in a type-safe way, whether you're creating policies using the AWS SDK or the AWS Cloud Development Kit (CDK).

ℹ️ Filter policies adhere to the Filter Policy Language and make it possible in AWS SNS to filter events based on message attributes or message payloads transiting through an AWS SNS topic, allowing users to control how events flow through the fan-out process.

Creating a conditional statement as a filter is simple and expressive using the when primitive. Here we are using the equals operator to check if the data.value attribute is equal to the string foo.

equals accepts both string and number values.

import { when } from 'sns-filter'; // Ensure the nested property data.value equals foo const filter = when('data.value').equals('foo'); // Get the value of the filter policy. console.log(filter.value());
See the result

Below is the resulting filter policy in JSON format, which is generated by the value() method.

{ "data": { "value": ["foo"] } }

The equalsIgnoreCase operator checks if a specific string attribute matches a given value, ignoring case sensitivity.

import { when } from 'sns-filter'; // Ensures `data.value` equals `FoO`, ignoring case. const filter = when('data.value').equalsIgnoreCase('FoO');
See the result

Below is the resulting filter policy in JSON format, which is generated by the value() method.

{ "data": { "value": [{"equals-ignore-case": "FoO"}] } }

The exists operator checks if a specific attribute, whatever its type, exists in the message. This is useful for filtering messages based on the presence of certain attributes.

import { when } from 'sns-filter'; // Ensure the nested property `data.value` exists. const filter = when('data.value').exists();
See the result

Below is the resulting filter policy in JSON format, which is generated by the value() method.

{ "data": { "value": [{"exists": true}] } }

The startsWith operator checks if a string attribute starts with a given string value. This is useful for filtering messages based on prefixes.

import { when } from 'sns-filter'; // Ensure the property `data.value` starts with `foo`. const filter = when('data.value').startsWith('foo');
See the result

Below is the resulting filter policy in JSON format, which is generated by the value() method.

{ "data": { "value": [{"prefix": "foo"}] } }

The endsWith operator checks if a string attribute ends with a given value. This is useful for filtering messages based on suffixes.

import { when } from 'sns-filter'; // Ensure the property `data.value` ends with `foo`. const filter = when('data.value').endsWith('foo');
See the result

Below is the resulting filter policy in JSON format, which is generated by the value() method.

{ "data": { "value": [{"suffix": "foo"}] } }

The lt, lte, gt, and gte operators are used to compare numeric attributes. They respectively check whether a numeric attribute is less than, less than or equal to, greater than, or greater than or equal to a specified value.

In AWS SNS, numeric attributes can refer to both integers and floating-point numbers with at most 5 digits of precision.

import { when } from 'sns-filter'; // Ensure the property `data.value` is less than `10`. const filter = when('data.value').lt(10); // Ensure the property `data.value` is less than or equal to `10`. const filter = when('data.value').lte(10); // Ensure the property `data.value` is greater than `10`. const filter = when('data.value').gt(10); // Ensure the property `data.value` is greater than or equal to `10`. const filter = when('data.value').gte(10);
See an example result

Below is the resulting filter policy in JSON format using the lt operator, which is generated by the value() method.

{ "data": { "value": [{"numeric": ["<", 10]}] } }

The between operator checks if a numeric attribute falls within a given range (inclusive). This is useful for filtering messages based on numeric ranges.

import { when } from 'sns-filter'; // Ensure the property `data.value` is between `10` and `20`. const filter = when('data.value').between(10, 20);
See the result

Below is the resulting filter policy in JSON format, which is generated by the value() method.

{ "data": { "value": [{"numeric": [">=", 10, "<=", 20]}] } }

The includes operator checks if an attribute—string or numeric—contains a given value. This allows to define simple OR conditions based on the value attribute of a single attribute.

import { when } from 'sns-filter'; // Ensure the property `data.value` includes `foo`. const filter = when('data.value').includes('foo', 'bar', 'baz'); // You can also use numeric types. const filter = when('data.value').includes(42, 10);

Note that mixing strings and numbers is not supported in the same includes condition.

See the result

Below is the resulting filter policy in JSON format using the includes operator with strings, which is generated by the value() method.

{ "data": { "value": ["foo", "bar", "baz"] } }

Below is the resulting filter policy in JSON format using the includes operator with numbers, which is generated by the value() method.

{ "data": { "value": [42, 10] } }

The matches operator checks if a string attribute matches a given pattern. Today, it only supports matching a string attribute against an IPv4 CIDR value.

If the passed value is not a valid IPv4 CIDR, an exception will be raised at runtime.

import { when, cidr4 } from 'sns-filter'; // The CIDR to match against. const cidr = cidr4('192.168.1.0/24'); // Ensure the property `data.value` matches the CIDR pattern. const filter = when('data.value').matches(cidr);
See the result

Below is the resulting filter policy in JSON format, which is generated by the value() method.

{ "data": { "value": [{"cidr": "192.168.1.0/24"}] } }

The power of this DSL lies in fluently composing multiple conditions using logical operators such as and, or, and not. This allows you to create complex filter policies that can handle more complex scenarios.

The not operator simply negates a condition, allowing to filter messages that do not match a specific attribute or value. It can be used with the above operators to create more complex filter policies.

Note that AWS SNS does not support negation on all conditional operators, please refer to the AWS SNS documentation for more details. If you attempt to use not with an unsupported operator, an exception will be raised at runtime.

import { when } from 'sns-filter'; // Ensure the property `data.value` does not equal `foo`. const filter = when('data.value') .not() .equals('foo'); // Ensure the property `data.value` does not exist. const filter = when('data.value') .not() .exists(); // Ensure the property `data.value` does not start with `foo`. const filter = when('data.value') .not() .startsWith('foo');
See the result

Below is the resulting filter policy in JSON format using the equals operator.

{ "data": { "value": [{"anything-but": "foo"}] } }

Below is the resulting filter policy in JSON format using the exists operator.

{ "data": { "value": [{"exists": false}] } }

Below is the resulting filter policy in JSON format using the startsWith operator.

{ "data": { "value": [{"anything-but": {"prefix": "foo"}}] } }

The and operator allows you to combine multiple conditions, ensuring that all specified conditions must be true for the filter to match. This is useful for creating filters that require multiple attributes or values to match a certain criteria.

import { when } from 'sns-filter'; // Ensure the properties `data.value` equals `foo` // AND `data.type` equals `bar`. const filter = when('data.value') .equals('foo') .and(when('data.type').equals('bar'));
See the result

Below is the resulting filter policy in JSON format, which is generated by the value() method.

{ "data": { "value": ["foo"], "type": ["bar"] } }

You can chain multiple and conditions together to add more conditions to the filter.

const filter = when('data.value') .equals('foo') .and(when('data.type').not().equals('bar')) .and(when('data.status').equals('active'));
See the result

Below is the resulting filter policy in JSON format, which is generated by the value() method.

{ "data": { "value": ["foo"], "type": [{"anything-but": "bar"}], "status": ["active"] } }

In AWS SNS, the and operator cannot be used on the same attribute multiple times. For example, you cannot have a conditional that checks if data.value is equal to foo and another conditional that checks whether data.value is greater than 10 in the same filter policy.

⚠️ The below example will raise an exception at runtime.

const filter = when('data.value') .equals('foo') .and(when('data.value').gt(10));

The or operator allows you to define a filter policy to express an OR relationship between multiple attributes in the policy.

import { when } from 'sns-filter'; // Ensure the property `data.value` equals `foo` // OR `data.type` equals `bar`. const filter = when('data.value') .equals('foo') .or(when('data.type').equals('bar'));
See the result

Below is the resulting filter policy in JSON format, which is generated by the value() method.

{ "data": { "$or": [{ "value": ["foo"], }, { "type": ["bar"] }] } }

You can chain multiple or conditions together to create more complex filters. Note that will create a multi-operand OR condition, meaning that any of the conditions can match for the filter to be considered valid.

// More complex filter with multiple conditions. const filter = when('data.value') .equals('foo') .or(when('data.type').not().equals('bar')) .or(when('data.status').equals('active'));
See the result

Below is the resulting filter policy in JSON format, which is generated by the value() method.

{ "data": { "$or": [{ "value": ["foo"], }, { "type": [{"anything-but": "bar"}] }, { "status": ["active"] }] } }

The order and hierarchy in which you are using the or operator does matter and impacts the precedence of conditional statements. For example, the following filter will be evaluated as (data.type == 'foo') OR ((data.type != 'bar') OR (data.status == 'active')).

const filter = when('data.type') .equals('foo') .or( when('data.type') .not() .equals('bar') .or(when('data.status').equals('active')) );
See the result

Below is the resulting filter policy in JSON format, which is generated by the value() method.

{ "data": { "$or": [{ "type": ["foo"], }, { "$or": [{ "type": [{"anything-but": "bar"}] }, { "status": ["active"] }] }] } }

You can combine and and or operators to create even more complex filter policies.

import { when } from 'sns-filter'; // Ensure the property `data.value` equals `foo` // AND (data.type equals `bar` OR data.status equals `active`). const filter = when('data.value') .equals('foo') .and( when('data.type') .equals('bar') .or( when('data.status').equals('active') ) );
See the result

Below is the resulting filter policy in JSON format, which is generated by the value() method.

{ "data": { "value": ["foo"], "$or": [{ "type": ["bar"], }, { "status": ["active"] }] } }

While the when primitive allows for some type-safety, ensuring you are defining the correct types for your operands, it does not check whether the rule you are defining is valid for the type of the data you are using.

This where the TypedCondition primitive comes in which takes in the concrete type of the event you are creating a filter for.

import { TypedCondition } from 'sns-filter'; // Define the type of the event you are filtering. type MyEvent = { data: { value: string; type: string; status: string; }; }; // Create a typed condition for the event. const when = TypedCondition<MyEvent>(); // Define your conditional filter as usual. const filter = when('data.value').equals('foo');

The difference is that the TypedCondition primitive will ensure that the path you are using to define your filter is valid for the, and that the operands you are using actually match the type of the event you are filtering.

For example, if you pass in a path that does not exist in the type, a compile-time error will be raised.

// 👇 This will cause a compile-time error. const filter = when('data.nonExistentProperty'); ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

This also allows the typed interface to provide you with a nice auto-completion experience in your IDE, making it easier to define your filter policies.


Typed Condition Example


The TypedCondition primitive also ensures that the operands you are using match the type of the event you are filtering. For example, if you try to use a numeric operand such as lt on a string attribute, a compile-time error will be raised.

type MyEvent = { data: { value: string; type: string; status: string; }; }; // 👇 This will cause a compile-time error const filter = when('data.value').lt(42); ^^^^^^

Often the events sent over an AWS SNS topic can be of different types, and you may want to filter based on the possible types of events. You can use union types with the TypedCondition primitive to define a filter that can handle multiple event types.

type MyEventA = { data: { str: string; }; }; type MyEventB = { data: { number: number; }; }; // Create a typed condition for the union of the two event types. const when = TypedCondition<MyEventA | MyEventB>(); // Define your conditional filter spanning both event types. const filter = when('data.str') .equals('foo') .or(when('data.number').gt(10));
Read Entire Article