AppSyncRouter
AppSyncRouter routes AWS AppSync resolver invocations to handlers, one GraphQL field at a time.
AppSync calls your function to resolve a single field, and the router picks a route from the type and field names on the event. Your handler is given that field's arguments along with whoever the caller was authorised as, and what it returns becomes the value of the field.
Install
npm install @lambda-event-router/base @lambda-event-router/appsync@lambda-event-router/base is a peer dependency, so install it yourself. It exports LambdaRouter, which every router plugs into.
Create the router
import { createAppSyncRouter } from '@lambda-event-router/appsync'
import { withResolverContext } from './middleware/withResolverContext'
const appSyncRouter = createAppSyncRouter({
middleware: [withResolverContext], // Optional
})createAppSyncRouter() on its own gives you a router with nothing attached to it.
Options
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
middleware | AppSyncResolverMiddleware[] | No | [] | Runs for every field this router resolves, before any route middleware. See Middleware |
batchItemFailures | boolean | No | false | Reports a failing entry of a batch on its own instead of failing the whole batch. See Batched resolvers |
Register routes
appSyncRouter.route({
filters: {
parentTypeName: 'Mutation',
fieldName: 'createOrder',
},
argumentsSchema: CreateOrderSchema, // Optional
middleware: [withOrderContext], // Optional
handler: createOrder,
})filters and handler are the only required keys.
route() returns the router, so you can chain registrations.
appSyncRouter.route(getOrderRoute).route(createOrderRoute)Routes are ranked by how specific they are. Where two overlap, the one matching only a subset of the other is tried first, whatever order you registered them in. Registration order decides the rest, so give each route filters no other route can match. See match order for how ranking works and what it cannot settle.
A field that matches no route throws No route matched for Mutation.createOrder. AppSync turns a failed invocation into an error on the field, so the client gets null for it and an entry in errors. See nothing matched for what the other routers do instead.
Convenience methods
query(), mutation() and subscription() fill in the parentTypeName filter and take the field name at the top level rather than inside filters.
// Both of these register the same route
appSyncRouter.query({
fieldName: 'getOrder',
handler: getOrder,
})
appSyncRouter.route({
filters: { parentTypeName: 'Query', fieldName: 'getOrder' },
handler: getOrder,
})| Method | Sets |
|---|---|
query() | parentTypeName: 'Query' |
mutation() | parentTypeName: 'Mutation' |
subscription() | parentTypeName: 'Subscription' |
All three take argumentsSchema, middleware and handler exactly as route() does. What they drop is the rest of the filters: custom is the only key left, and fieldName moves out to become a plain string rather than a FilterStringMatcher. A route covering several fields, a whole type or a nested field wants route(). See convenience methods for how the other routers use them.
AppSync runs a Subscription field's resolver when a client subscribes rather than for each message published afterwards, so a subscription() route decides who may connect.
Filters
Every filter key on one route, showing each form a value can take. All of them are optional, so set the ones that pick out the fields you want and leave the rest off.
appSyncRouter.route({
filters: {
parentTypeName: 'Mutation',
fieldName: ['createOrder', 'cancelOrder'], // Or a pattern: /Order$/
custom: ({ event }) => {
// Only a custom reaches the caller or the raw event
const { identity } = event
if (!identity || !('groups' in identity)) return false
return identity.groups?.includes('staff') ?? false
},
},
handler: writeOrder,
})| Filter | Type | Description |
|---|---|---|
parentTypeName | FilterStringMatcher | Matches the GraphQL type the field sits on, so Query, Mutation, Subscription or the parent type of a nested field resolver |
fieldName | FilterStringMatcher | Matches the field being resolved |
custom | (input: AppSyncResolverFilterInput) => boolean | Promise<boolean> | Anything the other filters cannot express. Given parentTypeName, fieldName and the raw event. Can be async |
FilterStringMatcher is string | RegExp | Array<string | RegExp>. See filters for how each form matches, including the * wildcard.
custom sees the arguments before any schema has run, so event.arguments there is whatever the client sent. See custom for where it sits in the filter order.
Handler
Handlers take one argument and return the value of the field.
import type { AppSyncResolverRequest } from '@lambda-event-router/appsync'
import { logger } from '@lambda-event-router/base'
export async function getOrder(request: AppSyncResolverRequest<GetOrderArgs>): Promise<Order> {
const { id } = request.arguments
logger.info(`Resolving order ${id}`)
return orders.byId(id)
}Request object
| Field | Type | Description |
|---|---|---|
arguments | TArgs | The field's arguments, validated when the route has an argumentsSchema |
identity | AppSyncIdentity | Who the caller is. The shape follows the API's authorisation mode, and it is undefined under an API key |
source | Record<string, unknown> | null | The object the parent resolver returned, and null on a top level field |
info | { selectionSetList, selectionSetGraphQL, parentTypeName, fieldName, variables } | Which field is being resolved and which parts of it the client asked for |
headers | Record<string, string | undefined> | The headers the client sent to AppSync |
domainName | string | null | The custom domain the request arrived on, and null on the API's default one |
prev | { result: Record<string, unknown> } | null | What the function before yours returned in a pipeline resolver |
stash | Record<string, unknown> | State shared across the functions of a pipeline resolver |
event | AppSyncResolverEvent<TArgs> | The untouched event from AWS |
context | Context | The Lambda context |
AppSyncResolverEvent, AppSyncIdentity and Context all come from aws-lambda rather than from this package.
identity is a union of four shapes, one per authorisation mode. Narrow it before reading a claim off it: 'groups' in identity picks out the Cognito user pool shape, and 'sub' in identity covers OpenID Connect as well.
arguments has to be renamed when you destructure it. It is a reserved word in strict mode, so const { arguments: args } = request compiles and const { arguments } = request does not.
Response type
Whatever you return becomes the value of the field. A route asks only for a Promise<unknown>, so the return type is yours to name and there is no AppSyncResolverResponse to import. Responses covers what AppSync does with it.
Inferred handlers
Nothing to look up and nothing to keep in sync. defineRoute reads the schema and hands your handler fully typed arguments, defaults and coercion included, so quantity below is a number without you declaring that anywhere.
import { defineRoute } from '@lambda-event-router/appsync'
import { logger } from '@lambda-event-router/base'
import { z } from 'zod'
const CreateOrderSchema = z.object({
input: z.object({ sku: z.string(), quantity: z.coerce.number().default(1) }),
})
export const createOrderRoute = defineRoute({
filters: { parentTypeName: 'Mutation', fieldName: 'createOrder' },
argumentsSchema: CreateOrderSchema,
}).handle(async ({ arguments: args }) => {
const { sku, quantity } = args.input
logger.info(`Creating ${quantity} of ${sku}`)
return orders.create({ sku, quantity })
})
appSyncRouter.route(createOrderRoute)The builder is defineRoute here and named after its router on the other two. The package holds three routers, so AppSyncAuthorizerRouter has defineAuthorizerRoute and AppSyncEventsRouter has defineEventsRoute.
Inference pays off most in a Lambda taking several event sources, since you never have to know any of their request shapes. See inferred handlers, where the same queue is written both ways to compare.
Annotated handlers
Annotating the request yourself splits route setup from business logic, using AppSyncResolverRequest and your own types.
// handlers/orders.ts
import type { AppSyncResolverRequest } from '@lambda-event-router/appsync'
import { logger } from '@lambda-event-router/base'
import { z } from 'zod'
export const CreateOrderSchema = z.object({
input: z.object({ sku: z.string(), quantity: z.coerce.number().default(1) }),
})
type CreateOrderArgs = z.infer<typeof CreateOrderSchema>
export async function createOrder(request: AppSyncResolverRequest<CreateOrderArgs>): Promise<Order> {
const { sku, quantity } = request.arguments.input
logger.info(`Creating ${quantity} of ${sku}`)
return orders.create({ sku, quantity })
}// appsync.ts
import { createAppSyncRouter } from '@lambda-event-router/appsync'
import { createOrder, CreateOrderSchema } from './handlers/orders.js'
const appSyncRouter = createAppSyncRouter()
appSyncRouter.mutation({
fieldName: 'createOrder',
argumentsSchema: CreateOrderSchema,
handler: createOrder,
})Derive the type from the schema with z.infer rather than hand-writing an interface that mirrors it.
All three registration forms take the type off the handler you pass them, so the same annotated function goes through route(), one of the convenience methods and defineRoute().handle() equally. Annotated handlers has the worked version.
Schema validation
One key takes a schema and it is optional.
const CreateOrderSchema = z.object({
input: z.object({
sku: z.string(),
quantity: z.coerce.number().int().positive().default(1),
}),
})
appSyncRouter.route({
filters: { parentTypeName: 'Mutation', fieldName: 'createOrder' },
argumentsSchema: CreateOrderSchema,
handler: createOrder,
})| Key | Validates |
|---|---|
argumentsSchema | The field's arguments |
Any Standard Schema library works. Validation runs after a route has matched, so arguments failing their schema throw rather than falling through to the next route. See schema validation for what your handler receives after coercion.
GraphQL has already checked the arguments against the types in your API schema by this point, so what a schema here adds is everything those types cannot say: a range, a format, a default or one field depending on another.
Responses
What a handler returns is what AppSync gets back, and it becomes the value of the field being resolved. Nothing wraps it and nothing is added to it.
Nothing links that return type to the field's type in your API schema either, so the compiler will not tell you when the two drift apart. AppSync will, at runtime, as an error on the field.
A throw lands the same way: the client gets null for the field and an entry in the response's errors array, and the rest of the query still resolves. Where the field is non-null, the null propagates up to the nearest parent that allows one.
A batched resolver answers differently. See Batched resolvers.
Middleware
Router and route middleware are both typed AppSyncResolverMiddleware, and the chain runs once per invocation, which is once per field resolved.
import type { AppSyncResolverMiddleware } from '@lambda-event-router/appsync'
import { logger } from '@lambda-event-router/base'
export const withResolverContext: AppSyncResolverMiddleware = async (request, next) => {
logger.appendKeys({ field: `${request.info.parentTypeName}.${request.info.fieldName}` })
return next(request)
}const appSyncRouter = createAppSyncRouter({ middleware: [withResolverContext] })
appSyncRouter.route({
filters: { parentTypeName: 'Mutation', fieldName: 'createOrder' },
middleware: [withOrderContext],
handler: createOrder,
})The arguments schema runs before either level, so a route whose arguments fail validation never reaches its middleware. See middleware for the execution order and the three levels it attaches at.
Types
All exported from @lambda-event-router/appsync.
| Type | Description |
|---|---|
AppSyncResolverRequest<TArgs> | The handler argument |
AppSyncResolverFilters | The filters object |
AppSyncResolverFilterInput | What custom receives |
AppSyncResolverFieldFilters | The filters object on the convenience methods, which is custom on its own |
AppSyncResolverRouteDefinition<TArgs> | A full route passed to route() |
AppSyncResolverFieldInput<TArgs> | A route passed to one of the convenience methods |
AppSyncQueryInput<TArgs>, AppSyncMutationInput<TArgs>, AppSyncSubscriptionInput<TArgs> | Aliases of AppSyncResolverFieldInput<TArgs>, one per method |
AppSyncResolverMiddleware<TArgs> | Router and route middleware |
AppSyncRouterOptions | Options for createAppSyncRouter |
AppSyncBatchResult | One entry of the list a batched resolver returns |
The AppSyncRouter class and the createAppSyncRouter and defineRoute functions come from the same place. Most of the type names carry Resolver, which is what keeps them apart from the other two routers in the package, and the three aliases are named after their method instead.
There is no response type, since a handler's return type is yours to name.
Generic parameters
Every type above that takes a parameter takes the same one.
| Parameter | Types | Default |
|---|---|---|
TArgs | request.arguments | Record<string, unknown> |
AppSyncResolverRequest<CreateOrderArgs> types the arguments and leaves the rest of the request alone. You only need this for annotated handlers. Inference covers it.
Nested field resolvers
A resolver on a field of one of your own types arrives with parentTypeName set to that type rather than to Query or Mutation, which is why the convenience methods cannot register one.
appSyncRouter.route({
filters: { parentTypeName: 'Order', fieldName: 'lines' },
handler: getOrderLines,
})source holds the object the parent resolver returned, so that is where the key you need comes from. It is typed Record<string, unknown> | null, so check the field before you use it.
export async function getOrderLines({ source }: AppSyncResolverRequest): Promise<OrderLine[]> {
const orderId = source?.id
if (typeof orderId !== 'string') return []
return lines.forOrder(orderId)
}info.selectionSetList is the list of fields the client asked for on this one, which is worth reading before a nested resolver goes and fetches all of them.
Batched resolvers
A nested field resolver runs once per parent object, so a query returning 20 orders invokes it 20 times. Set maxBatchSize above 0 on the resolver and AppSync sends one invocation holding up to that many fields.
new Resolver(this, 'OrderLinesResolver', {
api,
dataSource,
typeName: 'Order',
fieldName: 'lines',
maxBatchSize: 10,
})The event is then a list of contexts rather than one. The router routes each entry on its own, so filters, schemas, middleware and handlers all work as they do for a single field.
AppSync requires the reply to match the request list in size and order, and to carry each value under data. The router does that wrapping, so a handler still returns the field's value and nothing else.
// Two orders in, two results out
[{ data: [{ id: 'line-1' }] }, { data: [] }]An entry that throws fails the whole batch by default. Every field in the batch resolves to null, and every one of them carries the failing entry's message, which reads as though they all failed for that reason. Set batchItemFailures to report it on its own instead.
const appSyncRouter = createAppSyncRouter({ batchItemFailures: true })The failing entry becomes an error in place, and the rest of the batch resolves as normal. The client gets null for that one field and an entry in the response's errors array.
[
{ data: [{ id: 'line-1' }] },
{ data: null, errorMessage: 'Order 42 is archived', errorType: 'ArchivedOrderError' },
]errorType is the thrown error's name, so a named error class is what the client sees. A schema failure arrives as SchemaValidationError, and a field with no route as Error.
Note: a non-null field cannot show a per-entry error on its own. The null propagates up to the nearest parent that allows one, which takes the rest of that parent with it.
Pipeline resolvers
A pipeline resolver runs several functions in order over one field, and two request fields carry state between them. prev.result is what the function before yours returned, and stash is an object every function in the pipeline shares.
export async function chargeOrder({ prev, stash }: AppSyncResolverRequest): Promise<Payment> {
const order = prev?.result
if (!order) throw new Error('chargeOrder runs after loadOrder and needs its result')
stash.chargedAt = new Date().toISOString()
return payments.charge(order)
}Nothing in info says which function of the pipeline is running. Two of them resolving the same field report the same parentTypeName and fieldName, so filters cannot tell them apart. Route on a custom reading stash, or give the second function its own Lambda.
Code example
An orders API with two queries, a mutation whose input is validated and a nested resolver for the lines on an Order.
Open a file: index.ts | AppSync router | handlers | schemas
Every route matches a different type and field pair, so no invocation can match two of them and the order they are registered in makes no difference.
Order.lines goes through route() because the convenience methods only set the three top level type names. source there is the order getOrder returned, and a client asking for lines on a list of orders gets one invocation of getOrderLines per order in it.
index.ts hands the router to LambdaRouter, which is what AWS invokes and what every router in the Lambda gets registered on. See routers for how the two levels of matching fit together.