Skip to content

Commit 5c3c432

Browse files
authored
Merge branch 'main' into main
2 parents b7123ec + e5794f0 commit 5c3c432

File tree

11 files changed

+438
-1
lines changed

11 files changed

+438
-1
lines changed

.github/workflows/build-pull-request.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ jobs:
3333
# Get the list of changed files, excluding Markdown files
3434
- name: Get changed files
3535
id: changed-files
36-
uses: tj-actions/changed-files@c3a1bb2c992d77180ae65be6ae6c166cf40f857c
36+
uses: tj-actions/changed-files@4edd678ac3f81e2dc578756871e4d00c19191daf
3737
with:
3838
files: ${{ matrix.language }}/**
3939
files_ignore: '**/*.md'
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
*.js
2+
!jest.config.js
3+
*.d.ts
4+
node_modules
5+
package-lock.json
6+
7+
# CDK asset staging directory
8+
.cdk.staging
9+
cdk.out
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
*.ts
2+
!*.d.ts
3+
4+
# CDK asset staging directory
5+
.cdk.staging
6+
cdk.out
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
# API Gateway Asynchronous Lambda Invocation
2+
3+
Sample architecture to process events asynchronously using API Gateway and Lambda and store result in DynamoDB.
4+
5+
## Architecture
6+
![architecture](./images/architecture.png)
7+
8+
## Background:
9+
10+
In Lambda non-proxy (custom) integration, the backend Lambda function is invoked synchronously by default. This is the desired behavior for most REST API operations.
11+
Some applications, however, require work to be performed asynchronously (as a batch operation or a long-latency operation), typically by a separate backend component.
12+
In this case, the backend Lambda function is invoked asynchronously, and the front-end REST API method doesn't return the result.
13+
14+
## Solution:
15+
16+
### API Gateway:
17+
18+
- `POST` `/job`: Integrates with the Lambda function for job submission.
19+
- `GET` `/job/{jobId}`: Direct DynamoDB integration to fetch the job status by jobId.
20+
21+
### DynamoDB Integration:
22+
23+
- The stack includes the DynamoDB table for storing job statuses with jobId as the partition key.
24+
- The Lambda function has permissions to write to the DynamoDB table.
25+
26+
### IAM Role:
27+
- An IAM role is created for API Gateway with permissions to access the DynamoDB table for the GET /job/{jobId} method
28+
29+
### Example structure:
30+
```
31+
/api-gateway-async-lambda-invocation
32+
├── /assets
33+
│ └── /lambda-functions
34+
│ └── job_handler.js
35+
├── /lib
36+
| |-- app.ts
37+
│ └── api-gateway-async-lambda-invocation-stack.ts
38+
├── node_modules
39+
├── package.json
40+
├── cdk.json
41+
└── ...
42+
```
43+
44+
## Test:
45+
- `POST` curl command:
46+
```shell
47+
curl -X POST https://<API-ID>.execute-api.<REGION>.amazonaws.com/<stage>/job \
48+
-H "X-Amz-Invocation-Type: Event" \
49+
-H "Content-Type: application/json" \
50+
-d '{}'
51+
```
52+
53+
- `GET` curl command to get job details:
54+
```shell
55+
# jobId refers the output of the POST curl command.
56+
curl https://<API-ID>.execute-api.<REGION>.amazonaws.com/<stage>/job/<jobId>
57+
```
58+
59+
## Reference:
60+
[1] Set up asynchronous invocation of the backend Lambda function
61+
https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-integration-async.html
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// Import necessary modules from AWS SDK v3
2+
const { DynamoDBClient } = require('@aws-sdk/client-dynamodb');
3+
const { PutCommand } = require('@aws-sdk/lib-dynamodb'); // Import PutCommand
4+
5+
// Create a DynamoDB client
6+
const dynamoDBClient = new DynamoDBClient({});
7+
8+
exports.handler = async (event) => {
9+
const jobId = event.jobId;
10+
const status = 'Processed'; // Initial job status
11+
const createdAt = new Date().toISOString(); // Current timestamp
12+
13+
// Job item to be saved in DynamoDB
14+
const jobItem = {
15+
jobId,
16+
status,
17+
createdAt,
18+
};
19+
20+
const params = {
21+
TableName: process.env.JOB_TABLE,
22+
Item: jobItem,
23+
};
24+
25+
try {
26+
// Insert the job into the DynamoDB table
27+
const command = new PutCommand(params);
28+
await dynamoDBClient.send(command);
29+
30+
// Return the jobId to the client immediately
31+
const response = {
32+
statusCode: 200,
33+
body: JSON.stringify({ jobId }), // Return jobId to the client
34+
};
35+
36+
// Return jobId immediately
37+
return response;
38+
} catch (error) {
39+
console.error('Error processing job:', error);
40+
return {
41+
statusCode: 500,
42+
body: JSON.stringify({ error: 'Could not process job' }),
43+
};
44+
}
45+
};
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
{
2+
"app": "npx ts-node --prefer-ts-exts lib/app.ts",
3+
"watch": {
4+
"include": [
5+
"**"
6+
],
7+
"exclude": [
8+
"README.md",
9+
"cdk*.json",
10+
"**/*.d.ts",
11+
"**/*.js",
12+
"tsconfig.json",
13+
"package*.json",
14+
"yarn.lock",
15+
"node_modules",
16+
"test"
17+
]
18+
},
19+
"context": {
20+
"@aws-cdk/aws-lambda:recognizeLayerVersion": true,
21+
"@aws-cdk/core:checkSecretUsage": true,
22+
"@aws-cdk/core:target-partitions": [
23+
"aws",
24+
"aws-cn"
25+
],
26+
"@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true,
27+
"@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true,
28+
"@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true,
29+
"@aws-cdk/aws-iam:minimizePolicies": true,
30+
"@aws-cdk/core:validateSnapshotRemovalPolicy": true,
31+
"@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true,
32+
"@aws-cdk/aws-s3:createDefaultLoggingPolicy": true,
33+
"@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true,
34+
"@aws-cdk/aws-apigateway:disableCloudWatchRole": true,
35+
"@aws-cdk/core:enablePartitionLiterals": true,
36+
"@aws-cdk/aws-events:eventsTargetQueueSameAccount": true,
37+
"@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true,
38+
"@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true,
39+
"@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true,
40+
"@aws-cdk/aws-route53-patters:useCertificate": true,
41+
"@aws-cdk/customresources:installLatestAwsSdkDefault": false,
42+
"@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true,
43+
"@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true,
44+
"@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true,
45+
"@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true,
46+
"@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true,
47+
"@aws-cdk/aws-redshift:columnId": true,
48+
"@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true,
49+
"@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true,
50+
"@aws-cdk/aws-apigateway:requestValidatorUniqueId": true,
51+
"@aws-cdk/aws-kms:aliasNameRef": true,
52+
"@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true,
53+
"@aws-cdk/core:includePrefixInUniqueNameGeneration": true,
54+
"@aws-cdk/aws-efs:denyAnonymousAccess": true,
55+
"@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true,
56+
"@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true,
57+
"@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true,
58+
"@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true,
59+
"@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true,
60+
"@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true,
61+
"@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true,
62+
"@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true,
63+
"@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true,
64+
"@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true,
65+
"@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true,
66+
"@aws-cdk/aws-eks:nodegroupNameAttribute": true,
67+
"@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true,
68+
"@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true,
69+
"@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false,
70+
"@aws-cdk/aws-s3:keepNotificationInImportedBucket": false,
71+
"@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true,
72+
"@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true,
73+
"@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true,
74+
"@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true,
75+
"@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true,
76+
"@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true
77+
}
78+
}
27.7 KB
Loading
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import * as cdk from 'aws-cdk-lib';
2+
import { AccessLogFormat, AwsIntegration, LambdaIntegration, LambdaRestApi, LogGroupLogDestination, MethodLoggingLevel } from 'aws-cdk-lib/aws-apigateway';
3+
import { AttributeType, Table } from 'aws-cdk-lib/aws-dynamodb';
4+
import { PolicyDocument, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam';
5+
import { Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda';
6+
import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs';
7+
import { Construct } from 'constructs';
8+
import path = require('path');
9+
10+
export interface Properties extends cdk.StackProps {
11+
readonly prefix: string;
12+
}
13+
14+
export class ApiGatewayAsyncLambdaStack extends cdk.Stack {
15+
constructor(scope: Construct, id: string, props: Properties) {
16+
super(scope, id, props);
17+
18+
// DynamoDB table for job status
19+
const jobTable = new Table(this, `${props.prefix}-table`, {
20+
partitionKey: { name: 'jobId', type: AttributeType.STRING },
21+
tableName: `${props.prefix}-job-table`,
22+
removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production; Set `cdk.RemovalPolicy.RETAIN` for production
23+
});
24+
25+
// Create a Log Group for API Gateway logs
26+
const fnLogGroup = new LogGroup(this, `${props.prefix}-fn-log-group`, {
27+
retention: RetentionDays.ONE_WEEK, // Customize the retention period as needed
28+
});
29+
30+
// create a lambda function
31+
const jobHandler = new Function(this, `${props.prefix}-fn`, {
32+
runtime: Runtime.NODEJS_20_X,
33+
handler: 'job_handler.handler',
34+
code: Code.fromAsset(path.join(__dirname, '../assets/lambda-functions')),
35+
environment: {
36+
JOB_TABLE: jobTable.tableName,
37+
},
38+
logGroup: fnLogGroup,
39+
});
40+
// Grant Lambda permission to write to DynamoDB
41+
jobTable.grantWriteData(jobHandler);
42+
43+
// Create a Log Group for API Gateway logs
44+
const apiLogGroup = new LogGroup(this, `${props.prefix}-apigw-log-group`, {
45+
retention: RetentionDays.ONE_WEEK, // Customize the retention period as needed
46+
});
47+
48+
// API Gateway: Create a REST API with Lambda integration for POST /job
49+
const api = new LambdaRestApi(this, `${props.prefix}-apigw`, {
50+
restApiName: `${props.prefix}-job-service`,
51+
handler: jobHandler,
52+
proxy: false,
53+
cloudWatchRole: true,
54+
deployOptions: {
55+
metricsEnabled: true,
56+
dataTraceEnabled: true,
57+
accessLogDestination: new LogGroupLogDestination(apiLogGroup),
58+
accessLogFormat: AccessLogFormat.jsonWithStandardFields(),
59+
loggingLevel: MethodLoggingLevel.ERROR,
60+
}
61+
});
62+
63+
// POST /job method (Lambda integration)
64+
const job = api.root.addResource('job')
65+
66+
// POST /job method with asynchronous invocation
67+
job.addMethod("POST",
68+
new LambdaIntegration(jobHandler,{
69+
proxy:false,
70+
requestParameters:{
71+
'integration.request.header.X-Amz-Invocation-Type': "'Event'",
72+
},
73+
requestTemplates: {
74+
'application/json': `{
75+
"jobId": "$context.requestId",
76+
"body": $input.json('$')
77+
}`,
78+
},
79+
integrationResponses: [
80+
{
81+
statusCode: '200',
82+
responseTemplates: {
83+
'application/json': `{"jobId": "$context.requestId"}`
84+
}
85+
},
86+
{
87+
statusCode: '500',
88+
responseTemplates: {
89+
'application/json': `{
90+
"error": "An error occurred while processing the request.",
91+
"details": "$context.integrationErrorMessage"
92+
}`
93+
}
94+
}
95+
]
96+
}),
97+
{
98+
methodResponses: [
99+
{
100+
statusCode: '200',
101+
},
102+
{
103+
statusCode: '500',
104+
}
105+
]
106+
}
107+
);
108+
109+
// GET method to check the status of a job by jobId (direct DynamoDB integration)
110+
const jobId = job.addResource('{jobId}');
111+
jobId.addMethod("GET",
112+
new AwsIntegration({
113+
service: 'dynamodb',
114+
action: 'GetItem',
115+
options: {
116+
credentialsRole: new Role(this, 'ApiGatewayDynamoRole',{
117+
assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
118+
inlinePolicies: {
119+
dynamoPolicy: new PolicyDocument({
120+
statements: [
121+
new PolicyStatement({
122+
actions: ['dynamodb:GetItem'],
123+
resources: [jobTable.tableArn],
124+
}),
125+
],
126+
})
127+
}
128+
}),
129+
requestTemplates: {
130+
'application/json': `{
131+
"TableName": "${jobTable.tableName}",
132+
"Key": {
133+
"jobId": {
134+
"S": "$input.params('jobId')"
135+
}
136+
}
137+
}`,
138+
},
139+
integrationResponses: [{
140+
statusCode: '200',
141+
responseTemplates: {
142+
'application/json': `{
143+
"jobId": "$input.path('$.Item.jobId.S')",
144+
"status": "$input.path('$.Item.status.S')",
145+
"createdAt": "$input.path('$.Item.createdAt.S')"
146+
}`
147+
}
148+
},
149+
{
150+
statusCode: '404',
151+
selectionPattern: '.*"Item":null.*',
152+
responseTemplates: {
153+
'application/json': '{"error": "Job not found"}'
154+
}
155+
}
156+
]
157+
}
158+
}),
159+
{
160+
methodResponses:[
161+
{
162+
statusCode: '200'
163+
},
164+
{
165+
statusCode: '404'
166+
}
167+
]
168+
});
169+
}
170+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
#!/usr/bin/env node
2+
import 'source-map-support/register';
3+
import * as cdk from 'aws-cdk-lib';
4+
import { ApiGatewayAsyncLambdaStack } from '../lib/api-gateway-async-lambda-invocation-stack';
5+
6+
const app = new cdk.App();
7+
const env = { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }
8+
const prefix = 'apigw-async-lambda';
9+
10+
const apigw_async_lambda = new ApiGatewayAsyncLambdaStack(app, 'ApiGatewayAsyncLambdaStack', {
11+
env,
12+
stackName: `${prefix}-stack`,
13+
prefix,
14+
});

0 commit comments

Comments
 (0)