Functionless is a TypeScript plugin that transforms TypeScript code into Service-to-Service (aka. "functionless") integrations, such as AWS AppSync Resolvers and Velocity Templates, or (coming soon) Amazon States Language for AWS Step Functions.
For example, the below function creates an Appsync Resolver Pipeline with two stages:
- Call the
myTable
DynamoDB Table - Call the
myFunction
Lambda Function
const getItem = new AppsyncFunction<(id: string) => Item | null>(
($context, id) => {
const item = myTable.get({
id: $util.toDynamoDB(id),
});
const score = myFunction(item);
return {
...item,
score,
};
}
);
Functionless parses the TypeScript code and converts it to Apache Velocity Templates and an AWS Appsync CloudFormation configuration, saving you from writing all of that boilerplate. Below are snippets of the Velocity Templates that this TypeScript code generates.
Get Item:
#set($id = $util.toDynamoDB(id))
{
"operation": "GetItem",
"key": {
"id": $util.toJson($id)
}
}
Invoke Function:
{
"operation": "Invoke",
"payload": {
"item": $util.toJson($context.stash.item)
}
}
Resolver Mapping Template:
#set(v1 = {})
#foreach($k in $context.stash.item.keySet())
$util.qr($v1.put($k, $context.stash.item[$k]))
#end
$util.qr($v1.put('score', $context.stash.score))
$util.toJson($v1)
Final JSON Output of the Resolver
{
"id": "user-id",
"score": 9001
}
Paul Swail has a piece on this topic which is worth reading: https://serverlessfirst.com/functionless-integration-trade-offs/.
In short: these integrations have many advantages over using AWS Lambda Functions, including:
- lower latency - there is no cold start, so a service-to-service integration will feel "snappy" when compared to a Lambda Function.
- lower cost - there's no intermediate Lambda Invocation when AppSync calls DynamoDB directly.
- higher scalability - the handlers are not subject to Lambda's concurrent invocation limits and are running on dedicated Amazon servers.
- no operational maintenance - such as upgrading dependencies, patching security vulnerabilities, etc. - theoretically, once the configuration is confirmed to be correct, it then becomes entirely AWS's responsibility to ensure the code is running optimally.
The downsides of these integrations are their dependence on Domain Specific Languages (DSL) such as Apache Velocity Templates or Amazon States Language JSON. These DSLs are difficult to work with since they lack the type-safety and expressiveness of TypeScript. Functionless aims to solve this problem by converting beautiful, type-safe TypeScript code directly into these configurations.
First, install the functionless
and ts-patch
NPM packages.
npm install --save-dev functionless ts-patch
Then, add ts-patch install -s
to your prepare
script (see ts-patch for mode details.)
{
"scripts": {
"prepare": "ts-patch install -s"
}
}
Make sure to run npm install
to bootstrap ts-patch
(via the prepare
script).
npm install
Finally, configure the functionless/lib/compile
TypeScript transformer plugin in your tsconfig.json
:
{
"compilerOptions": {
"plugins": [
{
"transform": "functionless/lib/compile"
}
]
}
}
functionless
makes configuring services like AWS Appsync as easy as writing TypeScript functions.
There are three aspects your need to learn before using Functionless in your CDK application:
- Appsync Integration interfaces for
Function
andTable
. AppsyncFunction
construct for defining Appsync Resolver with TypeScript syntax.- Add Resolvers to an
@aws-cdk/aws-appsync-alpha.GraphQLApi
.
You must wrap your CDK L2 Constructs in the corresponding wrapper class provided by functionless. Currently, Lambda Function
and DynamoDB Table
are supported.
Function
The Function
wrapper annotates an aws_lambda.Function
with a TypeScript function signature that controls how it can be called from within an AppsyncFunction
.
import { aws_lambda } from "aws-cdk-lib";
import { Function } from "functionless";
const myFunc = new Function<(name: string) => string>(
new aws_lambda.Function(this, "MyFunc", {
..
})
);
Within an AppsyncFunction, you can use the myFunc
reference like an ordinary Function:
new AppsyncFunction(() => {
return myFunc("my name");
});
The arguments are converted into keys on an object that is passed to the Lambda Function.
{
"name": "my name"
}
Table
The Table
wrapper annotates an aws_dynamodb.Table
with a type-safe interface that describes the Table's data.
See typesafe-dynamodb
for more information on how to model DynamoDB Tables with TypeScript.
In short: you first declare an interface
describing the data in your Table:
interface Item {
key: string;
data: number;
}
Then, wrap a aws_dynamodb.Table
CDK Construct with the functionless.Table
construct, specify the Item
type, Partition Key "id"
and (optionally) the Range Key.
import { aws_dynamodb } from "aws-cdk-lib";
import { Table } from "functionless";
// see https://github.com/sam-goodwin/typesafe-dynamodb for more information on type-safe DynamoDB Tables.
const myTable = new Table<Item, "key">(
new aws_dynamodb.Table(this, "MyTable", {
..
})
)
Finally, call getItem
, putItem
, etc. (see: #3) from within an AppsyncFunction:
new AppsyncFunction(() => {
return myTable.get({
key: $util.toDynamoDB("key"),
});
});
After wrapping your Functions/Tables, you can then instantiate an AppsyncFunction
and interact with them using standard TypeScript syntax.
const getItem = new AppsyncFunction<
// you must explicitly provide a type-signature for the function
(key: string) => Item | null
>(($context, key) => {
const item = myTable.get({
key: {
S: key,
},
});
const processedName = myFunc(item.key);
return {
...item,
processedName,
};
});
Calls to services such as Table or Function can only be performed at the top-level. See below for some examples of valid and invalid service calls
Valid:
// stash the result of the service call - the most common use-case
const item = myTable.get();
// calling the service but discarding the result is fine
myTable.get();
Invalid:
// you cannot in-line a call as the if condition, store it as a variable first
if (myTable.get()) {
}
if (condition) {
// it is not currently possible to conditionally call a service, but this will be supported at a later time
myTable.get();
}
for (const item in list) {
// resolvers cannot be contained within a loop
myTable.get();
}
When you create a new AppsyncFunction
, it does not immediately generate an Appsync Resolver. AppsyncFunction
is more like a template for creating resolvers and can be re-used across more than one API.
To add to an API, use the addResolver
utility on AppsyncFunction
.
const app = new App();
const stack = new Stack(app, "stack");
const schema = new appsync.Schema({
filePath: path.join(__dirname, "..", "schema.gql"),
});
const api = new appsync.GraphqlApi(stack, "Api", {
name: "demo",
schema,
authorizationConfig: {
defaultAuthorization: {
authorizationType: appsync.AuthorizationType.IAM,
},
},
xrayEnabled: true,
});
// create a template AppsyncFunction
const getPerson = new AppsyncFunction(..);
// use it add resolvers to a GraphqlApi.
getPerson.addResolver(api, {
typeName: "Query",
fieldName: "getPerson",
});
In order to write effective VTL templates, it helps to understand how TypeScript syntax maps to Velocity Template Statements.
An AppSync Request Mapping Template is synthesized by evaluating all Expressions to a series of #set
, $util.qr
, #foreach
and #if
statements. The end result is an object containing the returned result of the function which can then be converted to JSON with $util.toJson
.
The following section provides a reference guide on how each of the supported TypeScript syntax is mapped to VTL.
A reference to a top-level Function Parameter is mapped to a $context.arguments
-prefixed reference in VTL:
new AppsyncFunction((arg: string) => {
return arg;
});
#return($context.arguments.arg)
If in the top-level scope, all Variables are stored in $context.stash
.
new AppsyncFunction(() => {
const a = "value";
const b = a;
});
#set($context.stash.a = 'value')
#set($context.stash.b = $context.stash.a)
If in a nested scope, then the local variable name is used. These variables will not be available across Resolver Pipeline stages - but this should not be a problem as they are contained within a nested scope in TypeScript also.
new AppsyncFunction(() => {
if (condition) {
const a = "value";
const b = a;
}
for (const i in list) {
const a = "value";
const b = a;
}
});
#if($condition)
#set($a = 'value')
#set($b = $a)
#end
#foreach($i in $list)
#set($a = 'value')
#set($b = $a)
#end
Template expressions translate almost 1:1 with VTL:
const a = `hello ${name}`;
#set($context.stash.a = "hello ${name}")
a[0] = value;
a.prop = value;
a["prop"] = value;
a[prop] = value;
$util.qr($a[0] = $value)
$util.qr($a.prop = $value)
$util.qr($a['prop'] = $value)
$util.qr($a[$prop] = $value)
Array Literals can contain arbitrary expressions.
const a = [];
const b = ["hello", 1, util.toJson(a)];
#set($a = [])
#set($b = ['hello', 1, $util.toJson($a)])
There is a special case when you use a SpreadElementExpr
(e.g. [...list]
) because there is no way to achieve this behavior in VTL without first assigning a list and then using addAll
to copy the items in.
If you ever use SpreadElementExpr
, a temporary variable will be first initialized with an empty array ([]
):
const c = [...b];
#set($v1 = [])
$util.qr($c.addAll($b))
#set($c = $v1)
An ObjectLiteralExpr
is first stored as an empty map {}
in a temporary variable and subsequent statements are generated to add each of the elements in.
const a = {
key: "string",
};
#set($a = {})
$util.qr($a.put('key', 'string'))
If you spread an object into another, a java.util.Map.putAll
statement is generated to copy over each item in the source object into the destination object.
const a = {
...obj,
};
#set($a = {})
$util.qr($a.putAll($obj))
The $util.*
utility functions are translated verbatim into a VTL expression.
$util.error("error");
const a = $util.toJson(val);
$util.error('error')
#set($a = $util.toJson($val))
An if
statement translates to a series of #if
, #else
statements.
if (a === "hello") {
return a;
}
#if($a == 'hello')
#return($a)
#end
#elseif
is not used because evaluating the condition may translate to a series of #set
or $util.qr
statements. For this reason, all else if
clauses are translated to #else
with a nested #if
:
if (a === "hello") {
return a;
} else if (call() === "hello") {
return false;
}
#if($a == 'hello')
#return($a)
#else
#set($v1 = call())
#if($v1 === "hello")
#return($a)
#end
#end
A conditional expression, i.e. cond ? then : else
are translated into #if
and #else
statements that assign a shared variable with the result of their computation;
const a = condition ? "left" : "right;
#if($condition)
#set($result = 'left')
#else
#set($result = 'right')
#end
#set($a = $result)
A for-in
statement iterates over the keys in an object using java.util.Map.keySet()
.
for (const i in obj) {
const a = obj[i];
}
#foreach($i in $obj.keySet())
#set($a = $obj[$i])
#end
A for-of
statement iterates over the items in a java.util.List
.
for (const item in list) {
}
#foreach($item in $list)
#end
When you map over a list, a new list is created and then #foreach
is used to iterate over the source list, evaluate your function and add the result to the new list.
Warning: chains of map
, forEach
and reduce
results in redundant #foreach
loops, see functionless#2
const newList = list.map((i) => i + 1);
#set($newList = [])
#foreach($i in $list)
$util.qr($newList.add($i + 1))
#end
forEach
is similar to map
except it does not produce a value. The (below) example emulates map
with forEach
.
Warning: chains of map
, forEach
and reduce
results in redundant #foreach
loops, see functionless#2
const newList = [];
list.forEach((i) => newList.push(i + 1));
#set($newList = [])
#foreach($i in $list)
$util.qr($newList.add($i + 1))
#end
reduce
has two variants: 1) with an initialValue
and 2) without.
Warning: chains of map
, forEach
and reduce
results in redundant #foreach
loops, see functionless#2
If there is no initial value, then the list cannot be empty - if an empty list is encountered an error will be raised with $util.error
.
Within the loop, the first value will not be processed by your function, instead it becomes the first value $a
.
// without an initial value
const sum = list.reduce((a, b) => a + b);
#set(sum = [])
#if($list.isEmpty())
$util.error('Reduce of empty array with no initial value')
#end
#foreach($b in $list)
#if($foreach.index == 0)
#set($a = $b)
#else
#set($a = $a + $b)
#end
#end
If there is an initial value, then it is stored as a variable, referenced in the #foreach
loop and overwritten at the end of each loop.
// with an initial value
const obj = list.reduce((a: Record<string, boolean>, b: string) => {
...a,
[b]: true
}, {})
#set($a = {})
#foreach($b in $obj)
#set($v1 = {})
$util.qr($v1.putAll($a))
$util.qr($v1.put($b, true))
#set($a = $v1)
#end
When you compile your application with tsc
, the functionless/lib/compile
transformer will replace the function declaration, F
, in new AppsyncFunction(F)
with its corresponding Abstract Syntax Tree representation. This representation is then synthesized to Velocity Templates and AWS AppSync Resolver configurations, using the @aws-cdk/aws-appsync-alpha
CDK Construct Library.
For example, this function declaration:
new AppsyncFunction<(input: { name: string }) => Person>((_$context, input) => {
const person = this.personTable.putItem({
key: {
id: {
S: $util.autoId(),
},
},
attributeValues: {
name: {
S: input.name,
},
},
});
return person;
});
Is replaced with the following AST data structure:
new AppsyncFunction(
new FunctionDecl(
[new ParameterDecl("input")],
new BlockStmt([
new VariableStmt(
"person",
new CallExpr(
new PropAccessExpr(
new ReferenceExpr(() => this.personTable),
"putItem"
),
{
input: new ObjectLiteralExpr([
new PropAssignExpr(
"key",
new ObjectLiteralExpr([
new PropAssignExpr(
"id",
new ObjectLiteralExpr([
new PropAssignExpr(
"S",
new CallExpr(
new PropAccessExpr(new Identifier("$util"), "autoId"),
{}
)
),
])
),
])
),
new PropAssignExpr(
"attributeValues",
new ObjectLiteralExpr([
new PropAssignExpr(
"name",
new ObjectLiteralExpr([
new PropAssignExpr(
"S",
new PropAccessExpr(new Identifier("input"), "name")
),
])
),
])
),
]),
}
)
),
new ReturnStmt(new Identifier("person")),
])
)
);
Functionless converts TypeScript function syntax into a FunctionDecl
AST data object. This object contains a total representation of the syntax contained within the Function and can then be processed within your CDK application.
To get a FunctionDecl
for a function, use the functionless.reflect
utility:
import { reflect } from "functionless";
const functionDecl = reflect((arg: string) => {
return `${arg}_1`;
});
Then, write a recursive function to process the representation:
import { FunctionlessNode } from "functionless";
function processExpr(node: FunctionlessNode) {
// do work
if (node.kind === "FunctionDecl") {
// blah
}
}
See the following files to understand the structure of the Abstract Syntax Tree:
For an example of an evaluator, see vtl.ts.