Pipeline Resolvers Support
This RFC will document a process to transition the Amplify CLI to use AppSync pipeline resolvers. The driving use case for this feature is to allow users to compose their own logic with the logic that is generated by the GraphQL Transform. For example, a user might want to authorize a mutation that creates a message by first verifying that the user is enrolled in the message's chat room. Other examples include adding custom input validation or audit logging to @model mutations. This document is not necessarily final so please leave your comments so we can address any concerns.
Github Issues
Proposal 1: Use pipelines everywhere
Back in 2018, AppSync released a feature called pipeline resolvers. Pipeline resolvers allow you to serially execute multiple AppSync functions within the resolver for a single field (not to be confused with AWS Lambda functions). AppSync functions behave similarly to old style AppSync resolvers and contain a request mapping template, a response mapping template, and a data source. A function may be referenced by multiple AppSync resolvers allowing you to reuse the same function for multiple resolvers. The AppSync resolver context ($ctx in resolver templates) has also received a new stash
map that lives throughout the execution of a pipeline resolver. You may use the $ctx.stash
to store intermediate results and pass information between functions.
The first step towards supporting pipeline resolvers is to switch all existing generated resolvers to use pipeline resolvers. To help make the generated functions more reusable, each function defines a set of arguments that it expects to find in the stash. The arguments for a function are passed by setting a value in the $ctx.stash.args
under a key that matches the name of the function. Below you can read the full list of functions that will be generated by different directives.
Generated Functions
Function: CreateX
Generated by @model
and issues a DynamoDB PutItem operation with a condition expression to create records if they do not already exist.
Arguments
The CreateX function expects
{
"stash": {
"args": {
"CreateX": {
"input": {
"title": "some title",
},
"condition": {
"expression": "attribute_not_exists(#id)",
"expressionNames": {
"#id": "id"
},
"expressionValues": {}
}
}
}
}
}
Function: UpdateX
Generated by @model
and issues a DynamoDB UpdateItem operation with a condition expression to update if the item exists.
Arguments
The UpdateX function expects
{
"stash": {
"args": {
"UpdateX": {
"input": {
"title": "some other title",
},
"condition": {
"expression": "attribute_exists(#id)",
"expressionNames": {
"#id": "id"
},
"expressionValues": {}
}
}
}
}
}
Function: DeleteX
Generated by @model
and issues a DynamoDB DeleteItem operation with a condition expression to delete if the item exists.
Arguments
The UpdateX function expects
{
"stash": {
"args": {
"DeleteX": {
"input": {
"id": "123",
},
"condition": {
"expression": "attribute_exists(#id)",
"expressionNames": {
"#id": "id"
},
"expressionValues": {}
}
}
}
}
}
Function: GetX
Generated by @model
and issues a DynamoDB GetItem operation.
Arguments
The UpdateX function expects
{
"stash": {
"args": {
"GetX": {
"id": "123"
}
}
}
}
Function: ListX
Generated by @model
and issues a DynamoDB Scan operation.
Arguments
The ListX function expects
{
"stash": {
"args": {
"ListX": {
"filter": {
"expression": "",
"expressionNames": {},
"expressionValues": {}
},
"limit": 20,
"nextToken": "some-next-token"
}
}
}
}
Function: QueryX
Generated by @model and issues a DynamoDB Query operation.
Arguments
The QueryX function expects
{
"stash": {
"args": {
"QueryX": {
"query": {
"expression": "#hashKey = :hashKey",
"expressionNames": {
"#hashKey": "hashKeyAttribute",
"expressionValues": {
":hashKey": {
"S": "some-hash-key-value"
}
}
}
},
"scanIndexForward": true,
"filter": {
"expression": "",
"expressionNames": {},
"expressionValues": {}
},
"limit": 20,
"nextToken": "some-next-token",
"index": "some-index-name"
}
}
}
}
Function: AuthorizeCreateX
Generated by @auth
when used on an OBJECT.
Arguments
The AuthorizeCreateX function expects no additional arguments. The AuthorizeCreateX function will look at $ctx.stash.CreateX.input
and validate it against the $ctx.identity
. The function will manipulate $ctx.stash.CreateX.condition
such that the correct authorization conditions are added.
Function: AuthorizeUpdateX
Generated by @auth
when used on an OBJECT.
Arguments
The AuthorizeUpdateX function expects no additional arguments. The AuthorizeUpdateX function will look at $ctx.stash.UpdateX.input
and validate it against the $ctx.identity
. The function will manipulate $ctx.stash.UpdateX.condition
such that the correct authorization conditions are added.
Function: AuthorizeDeleteX
Generated by @auth
when used on an OBJECT.
Arguments
The AuthorizeDeleteX function expects no additional arguments. The AuthorizeDeleteX function will look at $ctx.stash.DeleteX.input
and validate it against the $ctx.identity
. The function will manipulate $ctx.stash.DeleteX.condition
such that the correct authorization conditions are added.
Function: AuthorizeGetX
Generated by @auth
when used on an OBJECT.
Arguments
The AuthorizeGetX function expects no additional arguments. The AuthorizeGetX function will look at $ctx.stash.GetX.result
and validate it against the $ctx.identity
. The function will return null and append an error if the user is unauthorized.
Function: AuthorizeXItems
Filters a list of items based on @auth
rules placed on the OBJECT. This function can be used by top level queries that return multiple values (list, query) as well as by @connection fields.
Arguments
The AuthorizeXItems function expects $ctx.prev.result
to contain a list of "items" that should be filtered. This function returns the filtered results.
Function: HandleVersionedCreate
Created by the @versioned directive and sets the initial value of an objects version to 1.
Arguments
The HandleVersionedCreate function augments the $ctx.stash.CreateX.input
such that it definitely contains an initial version.
Function: HandleVersionedUpdate
Created by the @versioned directive and updates the condition expression with version information.
Arguments
The HandleVersionedUpdate function uses the $ctx.stash.UpdateX.input
to append a conditional update expression to $ctx.stash.UpdateX.condition
such that the object is only updated if the versions match.
Function: HandleVersionedDelete
Created by the @versioned directive and updates the condition expression with version information.
Arguments
The HandleVersionedDelete function uses the $ctx.stash.DeleteX.input
to append a conditional update expression to $ctx.stash.DeleteX.condition
such that the object is only deleted if the versions match.
Function: SearchX
Created by the @searchable directive and issues an Elasticsearch query against your Elasticsearch domain.
Arguments
The SearchX function expects a single argument "params".
{
"stash": {
"args": {
"SearchX": {
"params": {
"body": {
"from": "",
"size": 10,
"sort": ["_doc"],
"query": {
"match_all": {}
}
}
}
}
}
}
}
Generated Resolvers
The @model, @connection, and @searchable directives all add resolvers to fields within your schema. The @versioned and @auth directives will only add functions to existing resolvers created by the other directives. This section will look at the resolvers generated by the @model, @connection, and @searchable directives.
type Post @model {
id: ID!
title: String
}
This schema will create the following resolvers:
Mutation.createPost
The Mutation.createPost resolver uses its own RequestMappingTemplate to setup the $ctx.stash
such that it's pipeline is parameterized to return the correct results.
Mutation.createPost.req.vtl
#set($ctx.stash.args = {})
#set($ctx.stash.args.CreatePost = {
"input": $ctx.args.input
})
Function 1: CreatePost
The function will insert the value provided via $ctx.stash.CreatePost.input
and return the results.
Mutation.createPost.res.vtl
Return the result of the last function in the pipeline.
Mutation.updatePost
The Mutation.updatePost resolver uses its own RequestMappingTemplate to setup the $ctx.stash
such that it's pipeline is parameterized to return the correct results.
Mutation.updatePost.req.vtl
#set($ctx.stash.args = {})
#set($ctx.stash.args.UpdatePost = {
"input": $ctx.args.input
})
Function 1: UpdatePost
The function will update the value provided via $ctx.stash.UpdatePost.input
and return the results.
Mutation.updatePost.res.vtl
Return the result of the last function in the pipeline.
Mutation.deletePost
The Mutation.deletePost resolver uses its own RequestMappingTemplate to setup the $ctx.stash
such that it's pipeline is parameterized to return the correct results.
Mutation.deletePost.req.vtl
#set($ctx.stash.args = {})
#set($ctx.stash.args.DeletePost = {
"input": $ctx.args.input
})
Function 1: DeletePost
The function will delete the value designated via $ctx.stash.DeletePost.input.id
and return the results.
Mutation.deletePost.res.vtl
Return the result of the last function in the pipeline.
Query.getPost
The Query.getPost resolver uses its own RequestMappingTemplate to setup the $ctx.stash
such that it's pipeline is parameterized to return the correct results.
Query.getPost.req.vtl
#set($ctx.stash.args = {})
#set($ctx.stash.args.GetPost = {
"id": $ctx.args.id
})
Function 1: GetPost
The function will get the value designated via $ctx.stash.GetPost.id
and return the results.
Query.getPost.res.vtl
Return the result of the last function in the pipeline.
Query.listPosts
The Query.listPosts resolver uses its own RequestMappingTemplate to setup the $ctx.stash
such that it's pipeline is parameterized to return the correct results.
Query.listPosts.req.vtl
#set($ctx.stash.args = {})
#set($ctx.stash.args.ListPosts = {
"filter": $util.transform.toDynamoDBFilterExpression($ctx.args.filter),
"limit": $ctx.args.limit,
"nextToken": $ctx.args.nextToken
})
Function 1: ListPosts
The function will get the value designated via $ctx.stash.ListPosts.id
and return the results.
Query.listPosts.res.vtl
Return the result of the last function in the pipeline.
type Post @model {
id: ID!
title: String
comments: [Comment] @connection(name: "PostComments")
}
type Comment @model {
id: ID!
content: String
post: Post @connection(name: "PostComments")
}
The example above would create the following resolvers
Post.comments
The Post.comments resolver uses its own RequestMappingTemplate to setup the $ctx.stash
such that it's pipeline is parameterized to return the correct results.
Post.comments.req.vtl
#set($ctx.stash.args = {})
#set($ctx.stash.args.QueryComments = {
"query": {
"expression": "#connectionAttribute = :connectionAttribute",
"expressionNames": {
"#connectionAttribute": "commentPostId"
},
"expressionValues": {
":connectionAttribute": {
"S": "$ctx.source.id"
}
}
},
"scanIndexForward": true,
"filter": $util.transform.toDynamoDBFilterExpression($ctx.args.filter),
"limit": $ctx.args.limit,
"nextToken": $ctx.args.nextToken,
"index": "gsi-PostComments"
})
Function 1: QueryPosts
The function will get the values designated via $ctx.stash.QueryPosts
and return the results.
Post.comments.res.vtl
Return the result of the last function in the pipeline.
Comment.post
The Comment.post resolver uses its own RequestMappingTemplate to setup the $ctx.stash
such that it's pipeline is parameterized to return the correct results.
Comment.post.req.vtl
#set($ctx.stash.args = {})
#set($ctx.stash.args.GetPost = {
"id": "$ctx.source.commentPostId"
})
Function 1: GetPost
The function will get the values designated via $ctx.stash.GetPost
and return the results.
Comment.post.res.vtl
Return the result of the last function in the pipeline.
type Post @model @searchable {
id: ID!
title: String
}
Query.searchPosts
The Query.searchPosts resolver uses its own RequestMappingTemplate to setup the $ctx.stash
such that it's pipeline is parameterized to return the correct results.
Query.searchPosts.req.vtl
#set($ctx.stash.args = {})
#set($ctx.stash.args.SearchPosts = {
"query": $util.transform.toElasticsearchQueryDSL($ctx.args.filter),
"sort": [],
"size": $context.args.limit,
"from": "$context.args.nextToken"
})
Function 1: SearchPosts
The function will get the values designated via $ctx.stash.GetPost
and return the results.
Comment.post.res.vtl
Return the result of the last function in the pipeline.
@auth resolvers
The @auth directive does not add its own resolvers but will augment the behavior of existing resolvers by manipulating values in the $ctx.stash
.
Mutation.createX
- @auth will add logic to the request mapping template of the resolver that injects a condition into $ctx.stash.CreateX.condition
Mutation.updateX
- @auth will add logic to the request mapping template of the resolver that injects a condition into $ctx.stash.UpdateX.condition
Mutation.deleteX
- @auth will add logic to the request mapping template of the resolver that injects a condition into $ctx.stash.DeleteX.condition
Query.getX
- @auth will add logic to the response mapping template of the resolver that will return the value if authorized.
Query.listX
- @auth will add logic to the response mapping template of the resolver that will filter $ctx.prev.result.items
based on the auth rules.
Query.searchX
- @auth will add logic to the response mapping template of the resolver that will filter $ctx.prev.result.items
based on the auth rules.
Query.queryX
- @auth will add logic to the response mapping template of the resolver that will filter $ctx.prev.result.items
based on the auth rules.
Model.connectionField
- @auth will add logic to the response mapping template of the resolver that will filter $ctx.prev.result.items
based on the auth rules.
The @versioned directive does not add its own resolver but will augment the behavior of existing resolvers by manipulating values in the $ctx.stash
.
Mutation.createX
- @versioned will add logic to the request mapping template of the resolver that injects a condition into $ctx.stash.CreateX.condition
Mutation.updateX
- @versioned will add logic to the request mapping template of the resolver that injects a condition into $ctx.stash.UpdateX.condition
Mutation.deleteX
- @versioned will add logic to the request mapping template of the resolver that injects a condition into $ctx.stash.DeleteX.condition
Proposal 2: The @before and @after directives
There are many possibilities for how to expose pipeline functions via the transform. Defining a function of your own requires a request mapping template, response mapping template, and a data source. Using a function requires that you place that function, in order, within a pipeline resolver. Any directive(s) introduced would need to be able to accomodate both of these requirements. Here are a few options for discussion.
Before & After directives for adding logic to auto-generated model mutations
The main use case for this approach is to add custom authorization/audit/etc logic to mutations that are generated by the Amplify CLI. For example, you might want to lookup that a user is a member of a chat room before they can create a message. Currently this design only supports mutations but if you have suggestions for how to generalize this for read operations, comment below.
directive @before(mutation: ModelMutation!, function: String!, datasource: String!) ON OBJECT
directive @after(mutation: ModelMutation!, function: String!, datasource: String!) ON OBJECT
enum ModelMutation {
create
update
delete
}
Which would be used like so:
# Messages are only readable via @connection fields.
# Message mutations are pre-checked by a custom function.
type Message
@model(queries: null)
@before(mutation: create, function: "AuthorizeUserIsChatMember", datasource: "ChatRoomTable")
{
id: ID!
content: String
room: Room @connection(name: "ChatMessages")
}
type ChatRoom @model @auth(rules: [{ allow: owner, ownerField: "members" }]) {
id: ID!
messages: [Message] @connection(name: "ChatMessages")
members: [String]
}
To implement your function logic, you would drop two files in resolvers/
called AuthorizeUserIsChatMember.req.vtl
& AuthorizeUserIsChatMember.res.vtl
:
## AuthorizeUserIsChatMember.req.vtl **
{
"operation": "GetItem",
"key": {
"id": "$ctx.args.input.messageRoomId"
}
}
## AuthorizeUserIsChatMember.res.vtl **
#if( ! $ctx.result.members.contains($ctx.identity.username) )
## If the user is not a member do not allow the CreatePost function to be called next. **
$util.unauthorized()
#else
## Do nothing and allow the CreatePost function to be called next. **
$ctx.result
#end
The @before directive specifies which data source should be called and the order of the functions could be determined by the order of the @before directives on the model. The @after directive would work similarly except the function would run after the generated mutation logic.
Audit mutations with a single AppSync function
type Message
@model(queries: null)
@after(mutation: create, function: "AuditMutation", datasource: "AuditTable")
{
id: ID!
content: String
}
# The Audit model is not exposed via the API but will create a table
# that can be used by your functions.
type Audit @model(queries: null, mutations: null, subscriptions: null) {
id: ID!
ctx: AWSJSON
}
You could then use function templates like this:
## AuditMutation.req.vtl **
## Log the entire resolver ctx to a DynamoDB table **
#set($auditRecord = {
"ctx": $ctx,
"timestamp": $util.time.nowISO8601()
})
{
"operation": "PutItem",
"key": {
"id": "$util.autoId()"
},
"attributeValues": $util.dynamodb.toMapValuesJson($auditRecord)
}
## AuditMutation.res.vtl **
## Return the same value as the previous function **
$util.toJson($ctx.prev.result)
Request for comments
The goal is to provide simple to use and effective abstractions. Please leave your comments with questions, concerns, and use cases that you would like to see covered.