A type-safe framework for AWS AppSync Direct Lambda resolvers — generates Rust types, operation dispatch, and Lambda runtime glue from a GraphQL schema at compile time.
Table of Contents
The lambda-appsync crate provides procedural macros that read a GraphQL schema file at compile time and generate:
Operation enum covering every query, mutation, and subscription field, with argument extraction and dispatch logicHandlers trait and DefaultHandlers struct for wiring up the AWS Lambda runtimeYou write resolver functions annotated with #[appsync_operation(...)] and the framework validates their signatures against the schema, handles deserialization, and produces properly formatted AppSync responses.
make_appsync! for simple setups or make_types! / make_operation! / make_handlers! individually for multi-crate projectsHandlers trait (authentication hooks, logging, etc.)log/env_logger and tracing supportconst fn enum utilities (all(), index(), COUNT) for generated enumsIf your project requires union or interface support, or you have ideas on how the macro could use field arguments for regular types, please open a GitHub issue detailing your use case.
Add this dependency to your Cargo.toml:
[dependencies]
lambda-appsync = "0.10.0"
Or using cargo:
cargo add lambda-appsync
Note: Starting with v0.10.0, the default feature set is empty. The crate works out of the box without any features. Enable
env_logger,tracing, orlogonly if you need their re-exports or logging integration. See Feature Flags for details.
schema.graphql):type Query {
players: [Player!]!
gameStatus: GameStatus!
}
type Player {
id: ID!
name: String!
team: Team!
}
enum Team {
RUST
PYTHON
JS
}
enum GameStatus {
STARTED
STOPPED
}
use lambda_appsync::{make_appsync, appsync_operation, AppsyncError};
// Generate everything from the schema
make_appsync!("schema.graphql");
// Implement resolver functions:
#[appsync_operation(query(players))]
async fn get_players() -> Result<Vec<Player>, AppsyncError> {
todo!()
}
#[appsync_operation(query(gameStatus))]
async fn get_game_status() -> Result<GameStatus, AppsyncError> {
todo!()
}
// Wire up the Lambda runtime:
#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
lambda_runtime::run(
lambda_runtime::service_fn(DefaultHandlers::service_fn)
).await
}
When in a workspace context, all relative schema paths are resolved relative to the workspace root directory.
The framework's macros verify that your function signatures match the GraphQL schema at compile time and automatically wire everything up to handle AWS AppSync requests.
Check out the benchmark-game sample project that demonstrates lambda-appsync in action. This full-featured demo implements a GraphQL API for a mini-game web application using AWS AppSync and Lambda, showcasing:
Clone the repo to get started with a production-ready template for building serverless GraphQL APIs with Rust.
Override the Handlers trait to add pre-processing logic such as authentication checks:
use lambda_appsync::{make_appsync, appsync_operation, AppsyncError};
use lambda_appsync::{AppsyncEvent, AppsyncResponse, AppsyncIdentity};
make_appsync!("schema.graphql");
struct MyHandlers;
impl Handlers for MyHandlers {
async fn appsync_handler(event: AppsyncEvent<Operation>) -> AppsyncResponse {
// Custom authentication check
if let AppsyncIdentity::ApiKey = &event.identity {
return AppsyncResponse::unauthorized();
}
// Delegate to the default operation dispatch
event.info.operation.execute(event).await
}
}
#[appsync_operation(query(players))]
async fn get_players() -> Result<Vec<Player>, AppsyncError> {
todo!()
}
#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
lambda_runtime::run(
lambda_runtime::service_fn(MyHandlers::service_fn)
).await
}
This replaces the old hook parameter from appsync_lambda_main!. See the make_handlers! documentation for the full migration table.
For larger projects where you share GraphQL types across multiple Lambda functions, use the individual macros:
use lambda_appsync::{make_types, make_operation, make_handlers, appsync_operation, AppsyncError};
// Step 1: Generate types (could live in a shared lib crate)
make_types!("schema.graphql");
// Step 2: Generate Operation enum and dispatch logic
make_operation!("schema.graphql");
// Step 3: Generate Handlers trait and DefaultHandlers
make_handlers!();
#[appsync_operation(query(players))]
async fn get_players() -> Result<Vec<Player>, AppsyncError> {
todo!()
}
#[tokio::main]
async fn main() -> Result<(), lambda_runtime::Error> {
lambda_runtime::run(
lambda_runtime::service_fn(DefaultHandlers::service_fn)
).await
}
A typical multi-crate layout:
my-project/
├── schema.graphql
├── shared-types/ # lib crate using make_types!
│ └── src/lib.rs
├── lambda-a/ # bin crate using make_operation! + make_handlers!
│ └── src/main.rs
└── lambda-b/ # another bin crate
└── src/main.rs
When types live in a different module, use the type_module parameter on make_operation!:
make_operation!(
"schema.graphql",
type_module = crate::types,
);
Override generated Rust types or names for specific GraphQL elements:
make_appsync!(
"schema.graphql",
// Override a struct field type
type_override = Player.id: String,
// Override operation argument and return types to match
type_override = Query.player.id: String,
type_override = Mutation.deletePlayer.id: String,
// Rename a type (must also override operation return types!)
name_override = Player: GqlPlayer,
type_override = Query.players: GqlPlayer,
type_override = Query.player: GqlPlayer,
type_override = Mutation.createPlayer: GqlPlayer,
type_override = Mutation.deletePlayer: GqlPlayer,
);
Control which traits are derived on generated types:
make_appsync!(
"schema.graphql",
// Disable all default traits for Player — you implement them yourself
default_traits = Player: false,
// Add specific derives back
derive = Player: Debug,
derive = Player: serde::Serialize,
// Add extra derives on top of defaults for another type
derive = Team: Default,
);
Default traits for structs: Debug, Clone, Serialize, Deserialize.
Default traits for enums: Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, PartialOrd, Ord, Hash, Display, FromStr.
Build type-safe enhanced subscription filters:
use lambda_appsync::{appsync_operation, AppsyncError};
use lambda_appsync::subscription_filters::{FieldPath, FilterGroup};
#[appsync_operation(subscription(onCreatePlayer))]
async fn on_create_player(name: String) -> Result<Option<FilterGroup>, AppsyncError> {
Ok(Some(FieldPath::new("name")?.contains(name).into()))
}
Important: When using enhanced subscription filters, your AppSync Response mapping template must contain: ```vtl
if($context.result.data)
$extensions.setSubscriptionFilter($context.result.data)
end
null ```
Access the full AppSync event context in operation handlers with the with_appsync_event flag:
use lambda_appsync::{appsync_operation, AppsyncError, AppsyncEvent, AppsyncIdentity};
#[appsync_operation(mutation(createPlayer), with_appsync_event)]
async fn create_player(
name: String,
event: &AppsyncEvent<Operation>
) -> Result<Player, AppsyncError> {
let user_id = if let AppsyncIdentity::Cognito(cognito) = &event.identity {
cognito.sub.clone()
} else {
return Err(AppsyncError::new("Unauthorized", "Must be Cognito authenticated"));
};
todo!()
}
By default, #[appsync_operation] preserves the original function alongside the generated impl Operation method. You can call it directly elsewhere in your code:
#[appsync_operation(query(players))]
async fn fetch_players() -> Result<Vec<Player>, AppsyncError> {
todo!()
}
// fetch_players() is still available as a regular function
If you want the function to be removed (its body is inlined into the generated method), use inline_and_remove. This can be handy when generating handlers with macro_rules!, where you don't want the function name to collide with itself:
macro_rules! game_status_mut {
($mut_name:ident, $status:path) => {
#[appsync_operation(mutation($mut_name), inline_and_remove)]
pub async fn _discarded() -> Result<GameStatus, AppsyncError> {
dynamodb_set_game_status($status).await?;
Ok($status)
}
};
}
game_status_mut!(startGame, GameStatus::Started);
game_status_mut!(stopGame, GameStatus::Stopped);
game_status_mut!(resetGame, GameStatus::Reset);
Note: When the
compatfeature is enabled, the old default behavior is restored (inline and remove), and thekeep_original_function_nameparameter is available to opt back into preservation.
AWS SDK errors are automatically converted to AppsyncError, allowing use of the ? operator:
async fn store_item(item: Item, client: &aws_sdk_dynamodb::Client) -> Result<(), AppsyncError> {
client.put_item()
.table_name("my-table")
.item("id", AttributeValue::S(item.id.to_string()))
.item("data", AttributeValue::S(item.data))
.send()
.await?;
Ok(())
}
Combine multiple errors using the pipe operator:
let err = AppsyncError::new("ValidationError", "Invalid email")
| AppsyncError::new("DatabaseError", "User not found");
// error_type: "ValidationError|DatabaseError"
// error_message: "Invalid email\nUser not found in database"
| Macro | Kind | Purpose |
|---|---|---|
make_appsync! |
All-in-one | Generate types, Operation enum, and Handlers trait from a schema |
make_types! |
Composable | Generate Rust structs and enums from schema type definitions |
make_operation! |
Composable | Generate the Operation enum and dispatch logic |
make_handlers! |
C |
$ claude mcp add lambda-appsync \
-- python -m otcore.mcp_server <graph>