diff --git a/typescript/api-gateway-async-lambda-invocation/.gitignore b/typescript/api-gateway-async-lambda-invocation/.gitignore new file mode 100644 index 000000000..3a6e9d9eb --- /dev/null +++ b/typescript/api-gateway-async-lambda-invocation/.gitignore @@ -0,0 +1,9 @@ +*.js +!jest.config.js +*.d.ts +node_modules +package-lock.json + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/typescript/api-gateway-async-lambda-invocation/.npmignore b/typescript/api-gateway-async-lambda-invocation/.npmignore new file mode 100644 index 000000000..c1d6d45dc --- /dev/null +++ b/typescript/api-gateway-async-lambda-invocation/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/typescript/api-gateway-async-lambda-invocation/README.md b/typescript/api-gateway-async-lambda-invocation/README.md new file mode 100644 index 000000000..bccbed46a --- /dev/null +++ b/typescript/api-gateway-async-lambda-invocation/README.md @@ -0,0 +1,61 @@ +# API Gateway Asynchronous Lambda Invocation + +Sample architecture to process events asynchronously using API Gateway and Lambda and store result in DynamoDB. + +## Architecture +![architecture](./images/architecture.png) + +## Background: + +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. +Some applications, however, require work to be performed asynchronously (as a batch operation or a long-latency operation), typically by a separate backend component. +In this case, the backend Lambda function is invoked asynchronously, and the front-end REST API method doesn't return the result. + +## Solution: + +### API Gateway: + +- `POST` `/job`: Integrates with the Lambda function for job submission. +- `GET` `/job/{jobId}`: Direct DynamoDB integration to fetch the job status by jobId. + +### DynamoDB Integration: + +- The stack includes the DynamoDB table for storing job statuses with jobId as the partition key. +- The Lambda function has permissions to write to the DynamoDB table. + +### IAM Role: +- An IAM role is created for API Gateway with permissions to access the DynamoDB table for the GET /job/{jobId} method + +### Example structure: +``` +/api-gateway-async-lambda-invocation + ├── /assets + │ └── /lambda-functions + │ └── job_handler.js + ├── /lib + | |-- app.ts + │ └── api-gateway-async-lambda-invocation-stack.ts + ├── node_modules + ├── package.json + ├── cdk.json + └── ... +``` + +## Test: +- `POST` curl command: +```shell +curl -X POST https://.execute-api..amazonaws.com//job \ + -H "X-Amz-Invocation-Type: Event" \ + -H "Content-Type: application/json" \ + -d '{}' +``` + +- `GET` curl command to get job details: +```shell +# jobId refers the output of the POST curl command. +curl https://.execute-api..amazonaws.com//job/ +``` + +## Reference: +[1] Set up asynchronous invocation of the backend Lambda function +https://docs.aws.amazon.com/apigateway/latest/developerguide/set-up-lambda-integration-async.html diff --git a/typescript/api-gateway-async-lambda-invocation/assets/lambda-functions/job_handler.js b/typescript/api-gateway-async-lambda-invocation/assets/lambda-functions/job_handler.js new file mode 100644 index 000000000..6f3ef11b6 --- /dev/null +++ b/typescript/api-gateway-async-lambda-invocation/assets/lambda-functions/job_handler.js @@ -0,0 +1,45 @@ +// Import necessary modules from AWS SDK v3 +const { DynamoDBClient } = require('@aws-sdk/client-dynamodb'); +const { PutCommand } = require('@aws-sdk/lib-dynamodb'); // Import PutCommand + +// Create a DynamoDB client +const dynamoDBClient = new DynamoDBClient({}); + +exports.handler = async (event) => { + const jobId = event.jobId; + const status = 'Processed'; // Initial job status + const createdAt = new Date().toISOString(); // Current timestamp + + // Job item to be saved in DynamoDB + const jobItem = { + jobId, + status, + createdAt, + }; + + const params = { + TableName: process.env.JOB_TABLE, + Item: jobItem, + }; + + try { + // Insert the job into the DynamoDB table + const command = new PutCommand(params); + await dynamoDBClient.send(command); + + // Return the jobId to the client immediately + const response = { + statusCode: 200, + body: JSON.stringify({ jobId }), // Return jobId to the client + }; + + // Return jobId immediately + return response; + } catch (error) { + console.error('Error processing job:', error); + return { + statusCode: 500, + body: JSON.stringify({ error: 'Could not process job' }), + }; + } +}; diff --git a/typescript/api-gateway-async-lambda-invocation/cdk.json b/typescript/api-gateway-async-lambda-invocation/cdk.json new file mode 100644 index 000000000..158ae8358 --- /dev/null +++ b/typescript/api-gateway-async-lambda-invocation/cdk.json @@ -0,0 +1,78 @@ +{ + "app": "npx ts-node --prefer-ts-exts lib/app.ts", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "yarn.lock", + "node_modules", + "test" + ] + }, + "context": { + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/core:checkSecretUsage": true, + "@aws-cdk/core:target-partitions": [ + "aws", + "aws-cn" + ], + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/aws-ecs:arnFormatIncludesClusterName": true, + "@aws-cdk/aws-iam:minimizePolicies": true, + "@aws-cdk/core:validateSnapshotRemovalPolicy": true, + "@aws-cdk/aws-codepipeline:crossAccountKeyAliasStackSafeResourceName": true, + "@aws-cdk/aws-s3:createDefaultLoggingPolicy": true, + "@aws-cdk/aws-sns-subscriptions:restrictSqsDescryption": true, + "@aws-cdk/aws-apigateway:disableCloudWatchRole": true, + "@aws-cdk/core:enablePartitionLiterals": true, + "@aws-cdk/aws-events:eventsTargetQueueSameAccount": true, + "@aws-cdk/aws-ecs:disableExplicitDeploymentControllerForCircuitBreaker": true, + "@aws-cdk/aws-iam:importedRoleStackSafeDefaultPolicyName": true, + "@aws-cdk/aws-s3:serverAccessLogsUseBucketPolicy": true, + "@aws-cdk/aws-route53-patters:useCertificate": true, + "@aws-cdk/customresources:installLatestAwsSdkDefault": false, + "@aws-cdk/aws-rds:databaseProxyUniqueResourceName": true, + "@aws-cdk/aws-codedeploy:removeAlarmsFromDeploymentGroup": true, + "@aws-cdk/aws-apigateway:authorizerChangeDeploymentLogicalId": true, + "@aws-cdk/aws-ec2:launchTemplateDefaultUserData": true, + "@aws-cdk/aws-secretsmanager:useAttachedSecretResourcePolicyForSecretTargetAttachments": true, + "@aws-cdk/aws-redshift:columnId": true, + "@aws-cdk/aws-stepfunctions-tasks:enableEmrServicePolicyV2": true, + "@aws-cdk/aws-ec2:restrictDefaultSecurityGroup": true, + "@aws-cdk/aws-apigateway:requestValidatorUniqueId": true, + "@aws-cdk/aws-kms:aliasNameRef": true, + "@aws-cdk/aws-autoscaling:generateLaunchTemplateInsteadOfLaunchConfig": true, + "@aws-cdk/core:includePrefixInUniqueNameGeneration": true, + "@aws-cdk/aws-efs:denyAnonymousAccess": true, + "@aws-cdk/aws-opensearchservice:enableOpensearchMultiAzWithStandby": true, + "@aws-cdk/aws-lambda-nodejs:useLatestRuntimeVersion": true, + "@aws-cdk/aws-efs:mountTargetOrderInsensitiveLogicalId": true, + "@aws-cdk/aws-rds:auroraClusterChangeScopeOfInstanceParameterGroupWithEachParameters": true, + "@aws-cdk/aws-appsync:useArnForSourceApiAssociationIdentifier": true, + "@aws-cdk/aws-rds:preventRenderingDeprecatedCredentials": true, + "@aws-cdk/aws-codepipeline-actions:useNewDefaultBranchForCodeCommitSource": true, + "@aws-cdk/aws-cloudwatch-actions:changeLambdaPermissionLogicalIdForLambdaAction": true, + "@aws-cdk/aws-codepipeline:crossAccountKeysDefaultValueToFalse": true, + "@aws-cdk/aws-codepipeline:defaultPipelineTypeToV2": true, + "@aws-cdk/aws-kms:reduceCrossAccountRegionPolicyScope": true, + "@aws-cdk/aws-eks:nodegroupNameAttribute": true, + "@aws-cdk/aws-ec2:ebsDefaultGp3Volume": true, + "@aws-cdk/aws-ecs:removeDefaultDeploymentAlarm": true, + "@aws-cdk/custom-resources:logApiResponseDataPropertyTrueDefault": false, + "@aws-cdk/aws-s3:keepNotificationInImportedBucket": false, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, + "@aws-cdk/aws-ec2:ec2SumTImeoutEnabled": true, + "@aws-cdk/aws-appsync:appSyncGraphQLAPIScopeLambdaPermission": true, + "@aws-cdk/aws-rds:setCorrectValueForDatabaseInstanceReadReplicaInstanceResourceId": true, + "@aws-cdk/core:cfnIncludeRejectComplexResourceUpdateCreatePolicyIntrinsics": true, + "@aws-cdk/aws-lambda-nodejs:sdkV3ExcludeSmithyPackages": true + } +} diff --git a/typescript/api-gateway-async-lambda-invocation/images/architecture.png b/typescript/api-gateway-async-lambda-invocation/images/architecture.png new file mode 100644 index 000000000..ba86b8ed9 Binary files /dev/null and b/typescript/api-gateway-async-lambda-invocation/images/architecture.png differ diff --git a/typescript/api-gateway-async-lambda-invocation/lib/api-gateway-async-lambda-invocation-stack.ts b/typescript/api-gateway-async-lambda-invocation/lib/api-gateway-async-lambda-invocation-stack.ts new file mode 100644 index 000000000..28c4a696a --- /dev/null +++ b/typescript/api-gateway-async-lambda-invocation/lib/api-gateway-async-lambda-invocation-stack.ts @@ -0,0 +1,170 @@ +import * as cdk from 'aws-cdk-lib'; +import { AccessLogFormat, AwsIntegration, LambdaIntegration, LambdaRestApi, LogGroupLogDestination, MethodLoggingLevel } from 'aws-cdk-lib/aws-apigateway'; +import { AttributeType, Table } from 'aws-cdk-lib/aws-dynamodb'; +import { PolicyDocument, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { Code, Function, Runtime } from 'aws-cdk-lib/aws-lambda'; +import { LogGroup, RetentionDays } from 'aws-cdk-lib/aws-logs'; +import { Construct } from 'constructs'; +import path = require('path'); + +export interface Properties extends cdk.StackProps { + readonly prefix: string; +} + +export class ApiGatewayAsyncLambdaStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: Properties) { + super(scope, id, props); + + // DynamoDB table for job status + const jobTable = new Table(this, `${props.prefix}-table`, { + partitionKey: { name: 'jobId', type: AttributeType.STRING }, + tableName: `${props.prefix}-job-table`, + removalPolicy: cdk.RemovalPolicy.DESTROY, // NOT recommended for production; Set `cdk.RemovalPolicy.RETAIN` for production + }); + + // Create a Log Group for API Gateway logs + const fnLogGroup = new LogGroup(this, `${props.prefix}-fn-log-group`, { + retention: RetentionDays.ONE_WEEK, // Customize the retention period as needed + }); + + // create a lambda function + const jobHandler = new Function(this, `${props.prefix}-fn`, { + runtime: Runtime.NODEJS_20_X, + handler: 'job_handler.handler', + code: Code.fromAsset(path.join(__dirname, '../assets/lambda-functions')), + environment: { + JOB_TABLE: jobTable.tableName, + }, + logGroup: fnLogGroup, + }); + // Grant Lambda permission to write to DynamoDB + jobTable.grantWriteData(jobHandler); + + // Create a Log Group for API Gateway logs + const apiLogGroup = new LogGroup(this, `${props.prefix}-apigw-log-group`, { + retention: RetentionDays.ONE_WEEK, // Customize the retention period as needed + }); + + // API Gateway: Create a REST API with Lambda integration for POST /job + const api = new LambdaRestApi(this, `${props.prefix}-apigw`, { + restApiName: `${props.prefix}-job-service`, + handler: jobHandler, + proxy: false, + cloudWatchRole: true, + deployOptions: { + metricsEnabled: true, + dataTraceEnabled: true, + accessLogDestination: new LogGroupLogDestination(apiLogGroup), + accessLogFormat: AccessLogFormat.jsonWithStandardFields(), + loggingLevel: MethodLoggingLevel.ERROR, + } + }); + + // POST /job method (Lambda integration) + const job = api.root.addResource('job') + + // POST /job method with asynchronous invocation + job.addMethod("POST", + new LambdaIntegration(jobHandler,{ + proxy:false, + requestParameters:{ + 'integration.request.header.X-Amz-Invocation-Type': "'Event'", + }, + requestTemplates: { + 'application/json': `{ + "jobId": "$context.requestId", + "body": $input.json('$') + }`, + }, + integrationResponses: [ + { + statusCode: '200', + responseTemplates: { + 'application/json': `{"jobId": "$context.requestId"}` + } + }, + { + statusCode: '500', + responseTemplates: { + 'application/json': `{ + "error": "An error occurred while processing the request.", + "details": "$context.integrationErrorMessage" + }` + } + } + ] + }), + { + methodResponses: [ + { + statusCode: '200', + }, + { + statusCode: '500', + } + ] + } + ); + + // GET method to check the status of a job by jobId (direct DynamoDB integration) + const jobId = job.addResource('{jobId}'); + jobId.addMethod("GET", + new AwsIntegration({ + service: 'dynamodb', + action: 'GetItem', + options: { + credentialsRole: new Role(this, 'ApiGatewayDynamoRole',{ + assumedBy: new ServicePrincipal('apigateway.amazonaws.com'), + inlinePolicies: { + dynamoPolicy: new PolicyDocument({ + statements: [ + new PolicyStatement({ + actions: ['dynamodb:GetItem'], + resources: [jobTable.tableArn], + }), + ], + }) + } + }), + requestTemplates: { + 'application/json': `{ + "TableName": "${jobTable.tableName}", + "Key": { + "jobId": { + "S": "$input.params('jobId')" + } + } + }`, + }, + integrationResponses: [{ + statusCode: '200', + responseTemplates: { + 'application/json': `{ + "jobId": "$input.path('$.Item.jobId.S')", + "status": "$input.path('$.Item.status.S')", + "createdAt": "$input.path('$.Item.createdAt.S')" + }` + } + }, + { + statusCode: '404', + selectionPattern: '.*"Item":null.*', + responseTemplates: { + 'application/json': '{"error": "Job not found"}' + } + } + ] + } + }), + { + methodResponses:[ + { + statusCode: '200' + }, + { + statusCode: '404' + } + ] + }); + } +} \ No newline at end of file diff --git a/typescript/api-gateway-async-lambda-invocation/lib/app.ts b/typescript/api-gateway-async-lambda-invocation/lib/app.ts new file mode 100644 index 000000000..08f1dc829 --- /dev/null +++ b/typescript/api-gateway-async-lambda-invocation/lib/app.ts @@ -0,0 +1,14 @@ +#!/usr/bin/env node +import 'source-map-support/register'; +import * as cdk from 'aws-cdk-lib'; +import { ApiGatewayAsyncLambdaStack } from '../lib/api-gateway-async-lambda-invocation-stack'; + +const app = new cdk.App(); +const env = { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION } +const prefix = 'apigw-async-lambda'; + +const apigw_async_lambda = new ApiGatewayAsyncLambdaStack(app, 'ApiGatewayAsyncLambdaStack', { + env, + stackName: `${prefix}-stack`, + prefix, +}); diff --git a/typescript/api-gateway-async-lambda-invocation/package.json b/typescript/api-gateway-async-lambda-invocation/package.json new file mode 100644 index 000000000..ea1fcf4f1 --- /dev/null +++ b/typescript/api-gateway-async-lambda-invocation/package.json @@ -0,0 +1,23 @@ +{ + "name": "api-gateway-async-lambda-invocation", + "version": "0.1.0", + "bin": { + "api-gateway-async-lambda-invocation": "bin/api-gateway-async-lambda-invocation.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "cdk": "cdk" + }, + "devDependencies": { + "@types/node": "22.5.4", + "aws-cdk": "2.163.0", + "ts-node": "^10.9.2", + "typescript": "~5.6.2" + }, + "dependencies": { + "aws-cdk-lib": "2.163.0", + "constructs": "^10.0.0", + "source-map-support": "^0.5.21" + } +} diff --git a/typescript/api-gateway-async-lambda-invocation/tsconfig.json b/typescript/api-gateway-async-lambda-invocation/tsconfig.json new file mode 100644 index 000000000..aaa7dc510 --- /dev/null +++ b/typescript/api-gateway-async-lambda-invocation/tsconfig.json @@ -0,0 +1,31 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": [ + "es2020", + "dom" + ], + "declaration": true, + "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "noImplicitThis": true, + "alwaysStrict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": false, + "inlineSourceMap": true, + "inlineSources": true, + "experimentalDecorators": true, + "strictPropertyInitialization": false, + "typeRoots": [ + "./node_modules/@types" + ] + }, + "exclude": [ + "node_modules", + "cdk.out" + ] +}