-
Notifications
You must be signed in to change notification settings - Fork 1k
New pattern - appsync-events-lambda-cdk #3106
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1c19545
7dd7f3d
10a2753
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,65 @@ | ||
| # AppSync Events — In-Flight Event Interception with AWS Lambda | ||
|
|
||
| This pattern deploys an AWS AppSync Events API with a **Direct Lambda OnPublish interceptor** — a Lambda function that intercepts, validates, and enriches events in-flight before they reach subscribers. Unlike the existing `appsync-events-bedrock-cdk` and `appsync-events-lambda-agentcore-cdk` patterns (which use Lambda as a *publisher*), this pattern uses Lambda as a real-time event *processor* wired directly into the AppSync channel namespace. | ||
|
|
||
| Use cases: payload validation, PII redaction, event enrichment with external data, rate limiting per publisher, and schema enforcement before delivery. | ||
|
|
||
| Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/appsync-events-lambda-cdk | ||
|
|
||
| Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. | ||
|
|
||
| ## Requirements | ||
|
|
||
| * [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured | ||
| * [Node.js 22+](https://nodejs.org/en/download/) installed | ||
| * [AWS CDK v2](https://docs.aws.amazon.com/cdk/v2/guide/getting-started.html) installed | ||
|
|
||
| ## Architecture | ||
|
|
||
|  | ||
|
|
||
| ## How it works | ||
|
|
||
| 1. Publishers send events via HTTP POST to the AWS AppSync Events endpoint. | ||
| 2. The AWS Lambda function processes and enriches events before delivery. | ||
| 3. AWS AppSync Events delivers messages to all WebSocket subscribers on that channel. | ||
| 4. Channel namespaces (`notifications`, `alerts`) organize topics. | ||
|
|
||
| ## Deployment | ||
|
|
||
| 1. Clone the repository and navigate to the pattern directory: | ||
| ```bash | ||
| git clone https://github.com/aws-samples/serverless-patterns | ||
| cd serverless-patterns/appsync-events-lambda-cdk | ||
| ``` | ||
|
|
||
| 2. Install dependencies: | ||
| ```bash | ||
| npm install | ||
| ``` | ||
|
|
||
| 3. Bootstrap CDK (one-time per account/region): | ||
| ```bash | ||
| cdk bootstrap | ||
| ``` | ||
|
|
||
| 4. Deploy the stack: | ||
| ```bash | ||
| cdk deploy | ||
| ``` | ||
|
|
||
| ## Testing | ||
|
|
||
| ```bash | ||
| # Publish an event (replace values from cdk deploy output) | ||
| curl -X POST "https://<HttpEndpoint>/event" \ | ||
| -H "x-api-key: <ApiKeyValue>" \ | ||
| -H "Content-Type: application/json" \ | ||
| -d '{"channel":"notifications/general","events":["{\"message\":\"Hello from CDK\"}"]}' | ||
| ``` | ||
|
|
||
| ## Cleanup | ||
|
|
||
| ```bash | ||
| cdk destroy | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| #!/usr/bin/env node | ||
| import 'source-map-support/register'; | ||
| import * as cdk from 'aws-cdk-lib'; | ||
| import { AppsyncEventsLambdaStack } from '../lib/appsync-events-lambda-stack'; | ||
| const app = new cdk.App(); | ||
| new AppsyncEventsLambdaStack(app, 'AppsyncEventsLambdaStack'); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"app":"npx ts-node --prefer-ts-exts bin/app.ts"} |
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add a pretty-JSON
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. Pretty-printed |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,50 @@ | ||
| { | ||
| "title": "AWS AppSync Events with AWS Lambda handler", | ||
| "description": "Deploy an AWS AppSync Events API with an AWS Lambda handler for real-time pub/sub message processing.", | ||
| "language": "TypeScript", | ||
| "level": "300", | ||
| "framework": "CDK", | ||
| "introBox": { | ||
| "headline": "How it works", | ||
| "text": [ | ||
| "AWS AppSync Events provides WebSocket-based real-time pub/sub. An AWS Lambda handler processes published messages before delivery to subscribers." | ||
| ] | ||
| }, | ||
| "gitHub": { | ||
| "template": { | ||
| "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/appsync-events-lambda-cdk", | ||
| "templateURL": "serverless-patterns/appsync-events-lambda-cdk", | ||
| "projectFolder": "appsync-events-lambda-cdk" | ||
| } | ||
| }, | ||
| "resources": { | ||
| "bullets": [ | ||
| { | ||
| "text": "AWS AppSync Events", | ||
| "link": "https://docs.aws.amazon.com/appsync/latest/eventapi/event-api-welcome.html" | ||
| } | ||
| ] | ||
| }, | ||
| "deploy": { | ||
| "text": ["cdk deploy"], | ||
| "commands": ["npm install", "cdk bootstrap", "cdk deploy"] | ||
| }, | ||
| "testing": { | ||
| "text": ["Publish events via HTTP endpoint"] | ||
| }, | ||
| "cleanup": { | ||
| "text": ["cdk destroy"], | ||
| "commands": ["cdk destroy"] | ||
| }, | ||
| "authors": [ | ||
| { | ||
| "name": "Nithin Chandran R", | ||
| "bio": "Technical Account Manager at AWS", | ||
| "linkedin": "nithin-chandran-r" | ||
| } | ||
| ], | ||
| "services": { | ||
| "from": [{"service": "appsync"}], | ||
| "to": [{"service": "lambda"}] | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| import * as cdk from 'aws-cdk-lib'; | ||
| import * as appsync from 'aws-cdk-lib/aws-appsync'; | ||
| import * as lambda from 'aws-cdk-lib/aws-lambda'; | ||
| import * as logs from 'aws-cdk-lib/aws-logs'; | ||
| import * as iam from 'aws-cdk-lib/aws-iam'; | ||
| import { Construct } from 'constructs'; | ||
|
|
||
| export class AppsyncEventsLambdaStack extends cdk.Stack { | ||
| constructor(scope: Construct, id: string, props?: cdk.StackProps) { | ||
| super(scope, id, props); | ||
|
|
||
| // Lambda handler for event processing | ||
| const eventFn = new lambda.Function(this, 'EventHandlerFn', { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The lambda.Function construct is created without specifying logRetention. The default behaviour creates the log group implicitly with Never expire retention, possible leading to unbounded storage cost and noise in CloudWatch over time.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. Added |
||
| runtime: lambda.Runtime.NODEJS_22_X, | ||
| handler: 'index.handler', | ||
| code: lambda.Code.fromAsset('src'), | ||
| timeout: cdk.Duration.seconds(10), | ||
| logRetention: logs.RetentionDays.ONE_WEEK, | ||
| }); | ||
|
|
||
| // Service role AppSync Events assumes to invoke the Lambda data source | ||
| const appsyncRole = new iam.Role(this, 'AppSyncLambdaRole', { | ||
| assumedBy: new iam.ServicePrincipal('appsync.amazonaws.com'), | ||
| }); | ||
| eventFn.grantInvoke(appsyncRole); | ||
|
|
||
| // IAM role for AppSync to push logs to CloudWatch | ||
| const logsRole = new iam.Role(this, 'ApiLogsRole', { | ||
| assumedBy: new iam.ServicePrincipal('appsync.amazonaws.com'), | ||
| managedPolicies: [ | ||
| iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSAppSyncPushToCloudWatchLogs'), | ||
| ], | ||
| }); | ||
|
|
||
| // AppSync Events API with unique name derived from stack name | ||
| const api = new appsync.CfnApi(this, 'EventsApi', { | ||
| name: `${cdk.Aws.STACK_NAME}-EventsApi`, | ||
| eventConfig: { | ||
| authProviders: [{ authType: 'API_KEY' }], | ||
| connectionAuthModes: [{ authType: 'API_KEY' }], | ||
| defaultPublishAuthModes: [{ authType: 'API_KEY' }], | ||
| defaultSubscribeAuthModes: [{ authType: 'API_KEY' }], | ||
| logConfig: { | ||
| logLevel: 'INFO', | ||
| cloudWatchLogsRoleArn: logsRole.roleArn, | ||
| }, | ||
| }, | ||
| }); | ||
|
Comment on lines
+36
to
+48
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. Added |
||
|
|
||
| // API key with 365-day expiry | ||
| const apiKey = new appsync.CfnApiKey(this, 'EventsApiKey', { | ||
| apiId: api.attrApiId, | ||
| expires: Math.floor(Date.now() / 1000) + 365 * 24 * 60 * 60, | ||
| }); | ||
|
|
||
| // Lambda data source configured on the Events API. The OnPublish handler | ||
| // references this data source by name; the ARN lives here, not inline. | ||
| const lambdaDataSource = new appsync.CfnDataSource(this, 'LambdaDataSource', { | ||
| apiId: api.attrApiId, | ||
| name: 'LambdaEventHandler', | ||
| type: 'AWS_LAMBDA', | ||
| serviceRoleArn: appsyncRole.roleArn, | ||
| lambdaConfig: { | ||
| lambdaFunctionArn: eventFn.functionArn, | ||
| }, | ||
| }); | ||
|
|
||
| // Channel namespace with a Direct Lambda OnPublish interceptor. Every event | ||
| // published to this namespace is sent to the Lambda (REQUEST_RESPONSE) for | ||
| // validation/enrichment before AppSync broadcasts it to subscribers. | ||
| const notificationsChannel = new appsync.CfnChannelNamespace(this, 'NotificationsChannel', { | ||
| apiId: api.attrApiId, | ||
| name: 'notifications', | ||
| publishAuthModes: [{ authType: 'API_KEY' }], | ||
| subscribeAuthModes: [{ authType: 'API_KEY' }], | ||
| handlerConfigs: { | ||
| onPublish: { | ||
| behavior: 'DIRECT', | ||
| integration: { | ||
| dataSourceName: lambdaDataSource.name, | ||
| lambdaConfig: { | ||
| invokeType: 'REQUEST_RESPONSE', | ||
| }, | ||
| }, | ||
| }, | ||
| }, | ||
| }); | ||
| // dataSourceName is a string reference, so enforce creation ordering explicitly. | ||
| notificationsChannel.addDependency(lambdaDataSource); | ||
|
|
||
| // Second channel namespace | ||
| new appsync.CfnChannelNamespace(this, 'AlertsChannel', { | ||
| apiId: api.attrApiId, | ||
|
Comment on lines
+55
to
+93
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The pattern provisions a Lambda function (
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. Added |
||
| name: 'alerts', | ||
| publishAuthModes: [{ authType: 'API_KEY' }], | ||
| subscribeAuthModes: [{ authType: 'API_KEY' }], | ||
| }); | ||
|
|
||
| new cdk.CfnOutput(this, 'HttpEndpoint', { value: cdk.Fn.getAtt(api.logicalId, 'Dns.Http').toString() }); | ||
| new cdk.CfnOutput(this, 'RealtimeEndpoint', { value: cdk.Fn.getAtt(api.logicalId, 'Dns.Realtime').toString() }); | ||
| new cdk.CfnOutput(this, 'ApiId', { value: api.attrApiId }); | ||
| new cdk.CfnOutput(this, 'ApiKeyValue', { value: apiKey.attrApiKey }); | ||
| new cdk.CfnOutput(this, 'FunctionName', { value: eventFn.functionName }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| { | ||
| "name": "appsync-events-lambda-cdk", | ||
| "version": "1.0.0", | ||
| "bin": { "app": "bin/app.js" }, | ||
| "scripts": { "build": "tsc", "cdk": "cdk" }, | ||
| "dependencies": { | ||
| "aws-cdk-lib": "^2.180.0", | ||
| "constructs": "^10.0.0" | ||
| }, | ||
| "devDependencies": { | ||
| "typescript": "~5.4.0", | ||
| "@types/node": "^20.0.0" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,34 @@ | ||
| exports.handler = async (event) => { | ||
| const { events } = event; | ||
|
|
||
| if (!events || !Array.isArray(events)) { | ||
| return { error: 'No events received' }; | ||
| } | ||
|
|
||
| console.log(`OnPublish interceptor invoked for ${events.length} event(s)`); | ||
|
|
||
| // AppSync Events OnPublish (REQUEST_RESPONSE) expects { events: [{ id, payload }] }. | ||
| // payload must be a JSON value (object), not a re-stringified string, and each | ||
| // outgoing event must echo back the original event id. | ||
| const outgoing = events.map((e) => { | ||
| let payload = e.payload; | ||
| if (typeof payload === 'string') { | ||
| try { | ||
| payload = JSON.parse(payload); | ||
| } catch (err) { | ||
| return { id: e.id, payload: { error: 'Invalid JSON payload' } }; | ||
| } | ||
| } | ||
| return { | ||
| id: e.id, | ||
| payload: { | ||
| ...payload, | ||
| processedAt: new Date().toISOString(), | ||
| enriched: true, | ||
| messageId: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, | ||
| }, | ||
| }; | ||
| }); | ||
|
Comment on lines
+7
to
+31
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The handler maps every event with JSON.parse(e.payload) and no try/catch. Per the AppSync Events publish format, each event in events is "a stringified valid JSON value", but a publisher could legitimately send a non-JSON string (e.g., the publisher uses JSON.stringify("hello") or sends a malformed payload). A single bad event throws and the entire batch fails. AppSync expects per-event error handling (set event.error to skip broadcast for one event without failing the rest).
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Fixed. Added try/catch around |
||
|
|
||
| return { events: outgoing }; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| {"compilerOptions":{"target":"ES2020","module":"commonjs","lib":["es2020"],"declaration":true,"strict":true,"noImplicitAny":true,"strictNullChecks":true,"noEmit":false,"resolveJsonModule":true,"esModuleInterop":true,"outDir":"./build","rootDir":"."}} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
curl example uses "channel":"/notifications/general" (leading slash). Per the Publish events via HTTP documentation the channel value is shaped namespace/path (e.g., default/channel), not /namespace/path. A leading slash could result in a parsing/authorization failure at the API. The template defines a namespace named notifications, so the correct value is notifications/genera
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed. Removed leading slash - channel path is now
"channel":"notifications/general"per AppSync Events docs.