From 9bd640aee3ec8dab984fe1d6a497cc1937ec8984 Mon Sep 17 00:00:00 2001 From: Jacky Fan Date: Tue, 22 Oct 2024 20:38:50 +1300 Subject: [PATCH 1/9] initial commit --- .../.gitignore | 9 + .../.npmignore | 6 + .../README.md | 14 ++ .../cdk.json | 78 ++++++++ .../jest.config.js | 8 + ...i-gateway-async-lambda-invocation-stack.ts | 170 ++++++++++++++++++ .../lib/app.ts | 14 ++ .../package.json | 27 +++ ...pi-gateway-async-lambda-invocation.test.ts | 17 ++ .../tsconfig.json | 31 ++++ 10 files changed, 374 insertions(+) create mode 100644 typescript/api-gateway-async-lambda-invocation/.gitignore create mode 100644 typescript/api-gateway-async-lambda-invocation/.npmignore create mode 100644 typescript/api-gateway-async-lambda-invocation/README.md create mode 100644 typescript/api-gateway-async-lambda-invocation/cdk.json create mode 100644 typescript/api-gateway-async-lambda-invocation/jest.config.js create mode 100644 typescript/api-gateway-async-lambda-invocation/lib/api-gateway-async-lambda-invocation-stack.ts create mode 100644 typescript/api-gateway-async-lambda-invocation/lib/app.ts create mode 100644 typescript/api-gateway-async-lambda-invocation/package.json create mode 100644 typescript/api-gateway-async-lambda-invocation/test/api-gateway-async-lambda-invocation.test.ts create mode 100644 typescript/api-gateway-async-lambda-invocation/tsconfig.json diff --git a/typescript/api-gateway-async-lambda-invocation/.gitignore b/typescript/api-gateway-async-lambda-invocation/.gitignore new file mode 100644 index 0000000000..3a6e9d9eb2 --- /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 0000000000..c1d6d45dcf --- /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 0000000000..9315fe5b9f --- /dev/null +++ b/typescript/api-gateway-async-lambda-invocation/README.md @@ -0,0 +1,14 @@ +# Welcome to your CDK TypeScript project + +This is a blank project for CDK development with TypeScript. + +The `cdk.json` file tells the CDK Toolkit how to execute your app. + +## Useful commands + +* `npm run build` compile typescript to js +* `npm run watch` watch for changes and compile +* `npm run test` perform the jest unit tests +* `npx cdk deploy` deploy this stack to your default AWS account/region +* `npx cdk diff` compare deployed stack with current state +* `npx cdk synth` emits the synthesized CloudFormation template 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 0000000000..158ae83585 --- /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/jest.config.js b/typescript/api-gateway-async-lambda-invocation/jest.config.js new file mode 100644 index 0000000000..08263b8954 --- /dev/null +++ b/typescript/api-gateway-async-lambda-invocation/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + } +}; 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 0000000000..28c4a696a5 --- /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 0000000000..08f1dc829a --- /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 0000000000..c5047329e3 --- /dev/null +++ b/typescript/api-gateway-async-lambda-invocation/package.json @@ -0,0 +1,27 @@ +{ + "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", + "test": "jest", + "cdk": "cdk" + }, + "devDependencies": { + "@types/jest": "^29.5.12", + "@types/node": "22.5.4", + "jest": "^29.7.0", + "ts-jest": "^29.2.5", + "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" + } +} \ No newline at end of file diff --git a/typescript/api-gateway-async-lambda-invocation/test/api-gateway-async-lambda-invocation.test.ts b/typescript/api-gateway-async-lambda-invocation/test/api-gateway-async-lambda-invocation.test.ts new file mode 100644 index 0000000000..62576aaa84 --- /dev/null +++ b/typescript/api-gateway-async-lambda-invocation/test/api-gateway-async-lambda-invocation.test.ts @@ -0,0 +1,17 @@ +// import * as cdk from 'aws-cdk-lib'; +// import { Template } from 'aws-cdk-lib/assertions'; +// import * as ApiGatewayAsyncLambdaInvocation from '../lib/api-gateway-async-lambda-invocation-stack'; + +// example test. To run these tests, uncomment this file along with the +// example resource in lib/api-gateway-async-lambda-invocation-stack.ts +test('SQS Queue Created', () => { +// const app = new cdk.App(); +// // WHEN +// const stack = new ApiGatewayAsyncLambdaInvocation.ApiGatewayAsyncLambdaInvocationStack(app, 'MyTestStack'); +// // THEN +// const template = Template.fromStack(stack); + +// template.hasResourceProperties('AWS::SQS::Queue', { +// VisibilityTimeout: 300 +// }); +}); 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 0000000000..aaa7dc510f --- /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" + ] +} From 6f53465b8d304f283a044918af64d09645d82265 Mon Sep 17 00:00:00 2001 From: Jacky Fan Date: Tue, 22 Oct 2024 20:40:25 +1300 Subject: [PATCH 2/9] updated README.md --- .../README.md | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/typescript/api-gateway-async-lambda-invocation/README.md b/typescript/api-gateway-async-lambda-invocation/README.md index 9315fe5b9f..3d697e0306 100644 --- a/typescript/api-gateway-async-lambda-invocation/README.md +++ b/typescript/api-gateway-async-lambda-invocation/README.md @@ -1,14 +1,33 @@ -# Welcome to your CDK TypeScript project +# API Gateway Asynchronous Lambda Invocation -This is a blank project for CDK development with TypeScript. +Sample architecture to process events asynchronously using API Gateway and Lambda and store result in DynamoDB. -The `cdk.json` file tells the CDK Toolkit how to execute your app. +## Architecture +![architecture](./images/architecture.png) -## Useful commands +## 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 '{}' +``` -* `npm run build` compile typescript to js -* `npm run watch` watch for changes and compile -* `npm run test` perform the jest unit tests -* `npx cdk deploy` deploy this stack to your default AWS account/region -* `npx cdk diff` compare deployed stack with current state -* `npx cdk synth` emits the synthesized CloudFormation template +- `GET` curl command to get job details: +```shell +# jobId refers the output of the POST curl command. +curl https://.execute-api..amazonaws.com//job/ +``` + + +``` +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. +``` + +### 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 From bcd73ab623e6f2df1eb2c709274c57a2e2f494e6 Mon Sep 17 00:00:00 2001 From: Jacky Fan Date: Tue, 22 Oct 2024 20:41:26 +1300 Subject: [PATCH 3/9] added architecture diagram --- .../images/architecture.png | Bin 0 -> 28316 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 typescript/api-gateway-async-lambda-invocation/images/architecture.png 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 0000000000000000000000000000000000000000..ba86b8ed9aa512ca05914fe8ce2f8a236acaf488 GIT binary patch literal 28316 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 Date: Tue, 22 Oct 2024 21:04:18 +1300 Subject: [PATCH 4/9] added background and solution in README.md --- .../README.md | 46 +++++++++++++++---- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/typescript/api-gateway-async-lambda-invocation/README.md b/typescript/api-gateway-async-lambda-invocation/README.md index 3d697e0306..bccbed46a0 100644 --- a/typescript/api-gateway-async-lambda-invocation/README.md +++ b/typescript/api-gateway-async-lambda-invocation/README.md @@ -5,6 +5,42 @@ Sample architecture to process events asynchronously using API Gateway and Lambd ## 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 @@ -20,14 +56,6 @@ curl -X POST https://.execute-api..amazonaws.com//job \ curl https://.execute-api..amazonaws.com//job/ ``` - -``` -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. -``` - -### Reference: +## 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 From cf0d3ff5d79b0a3f55ac1a0a0bd74ca17ead5c7f Mon Sep 17 00:00:00 2001 From: Michael Kaiser Date: Sat, 2 Nov 2024 09:23:58 -0500 Subject: [PATCH 5/9] Delete typescript/api-gateway-async-lambda-invocation/test/api-gateway-async-lambda-invocation.test.ts Does not implement test --- .../api-gateway-async-lambda-invocation.test.ts | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 typescript/api-gateway-async-lambda-invocation/test/api-gateway-async-lambda-invocation.test.ts diff --git a/typescript/api-gateway-async-lambda-invocation/test/api-gateway-async-lambda-invocation.test.ts b/typescript/api-gateway-async-lambda-invocation/test/api-gateway-async-lambda-invocation.test.ts deleted file mode 100644 index 62576aaa84..0000000000 --- a/typescript/api-gateway-async-lambda-invocation/test/api-gateway-async-lambda-invocation.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -// import * as cdk from 'aws-cdk-lib'; -// import { Template } from 'aws-cdk-lib/assertions'; -// import * as ApiGatewayAsyncLambdaInvocation from '../lib/api-gateway-async-lambda-invocation-stack'; - -// example test. To run these tests, uncomment this file along with the -// example resource in lib/api-gateway-async-lambda-invocation-stack.ts -test('SQS Queue Created', () => { -// const app = new cdk.App(); -// // WHEN -// const stack = new ApiGatewayAsyncLambdaInvocation.ApiGatewayAsyncLambdaInvocationStack(app, 'MyTestStack'); -// // THEN -// const template = Template.fromStack(stack); - -// template.hasResourceProperties('AWS::SQS::Queue', { -// VisibilityTimeout: 300 -// }); -}); From 3f9c8bb8791f2df4cb5c7eaa38b0c0f9ca484533 Mon Sep 17 00:00:00 2001 From: Michael Kaiser Date: Sat, 2 Nov 2024 09:24:24 -0500 Subject: [PATCH 6/9] Delete typescript/api-gateway-async-lambda-invocation/jest.config.js Does not implement test --- .../api-gateway-async-lambda-invocation/jest.config.js | 8 -------- 1 file changed, 8 deletions(-) delete mode 100644 typescript/api-gateway-async-lambda-invocation/jest.config.js diff --git a/typescript/api-gateway-async-lambda-invocation/jest.config.js b/typescript/api-gateway-async-lambda-invocation/jest.config.js deleted file mode 100644 index 08263b8954..0000000000 --- a/typescript/api-gateway-async-lambda-invocation/jest.config.js +++ /dev/null @@ -1,8 +0,0 @@ -module.exports = { - testEnvironment: 'node', - roots: ['/test'], - testMatch: ['**/*.test.ts'], - transform: { - '^.+\\.tsx?$': 'ts-jest' - } -}; From e9407ad40150ff664fc62e848df9aff690113623 Mon Sep 17 00:00:00 2001 From: Michael Kaiser Date: Sat, 2 Nov 2024 09:24:51 -0500 Subject: [PATCH 7/9] Update package.json --- typescript/api-gateway-async-lambda-invocation/package.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/typescript/api-gateway-async-lambda-invocation/package.json b/typescript/api-gateway-async-lambda-invocation/package.json index c5047329e3..b8cff06958 100644 --- a/typescript/api-gateway-async-lambda-invocation/package.json +++ b/typescript/api-gateway-async-lambda-invocation/package.json @@ -7,7 +7,6 @@ "scripts": { "build": "tsc", "watch": "tsc -w", - "test": "jest", "cdk": "cdk" }, "devDependencies": { @@ -24,4 +23,4 @@ "constructs": "^10.0.0", "source-map-support": "^0.5.21" } -} \ No newline at end of file +} From 6e019b5712ff85af323c46041cdaa6c71e683ac3 Mon Sep 17 00:00:00 2001 From: Michael Kaiser Date: Sat, 2 Nov 2024 09:25:20 -0500 Subject: [PATCH 8/9] Update package.json Does not implement test --- typescript/api-gateway-async-lambda-invocation/package.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/typescript/api-gateway-async-lambda-invocation/package.json b/typescript/api-gateway-async-lambda-invocation/package.json index b8cff06958..ea1fcf4f12 100644 --- a/typescript/api-gateway-async-lambda-invocation/package.json +++ b/typescript/api-gateway-async-lambda-invocation/package.json @@ -10,10 +10,7 @@ "cdk": "cdk" }, "devDependencies": { - "@types/jest": "^29.5.12", "@types/node": "22.5.4", - "jest": "^29.7.0", - "ts-jest": "^29.2.5", "aws-cdk": "2.163.0", "ts-node": "^10.9.2", "typescript": "~5.6.2" From 26b5bc3ce716d8cddc785fcaee65ec28091d038c Mon Sep 17 00:00:00 2001 From: Jacky Fan Date: Tue, 5 Nov 2024 13:06:00 +1300 Subject: [PATCH 9/9] added lambda handler: job_handler.js --- .../assets/lambda-functions/job_handler.js | 45 +++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 typescript/api-gateway-async-lambda-invocation/assets/lambda-functions/job_handler.js 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 0000000000..6f3ef11b6e --- /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' }), + }; + } +};