diff --git a/apigw-rest-api-dynamodb-lambda-cdk/.gitignore b/apigw-rest-api-dynamodb-lambda-cdk/.gitignore new file mode 100644 index 000000000..f60797b6a --- /dev/null +++ b/apigw-rest-api-dynamodb-lambda-cdk/.gitignore @@ -0,0 +1,8 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/apigw-rest-api-dynamodb-lambda-cdk/.npmignore b/apigw-rest-api-dynamodb-lambda-cdk/.npmignore new file mode 100644 index 000000000..c1d6d45dc --- /dev/null +++ b/apigw-rest-api-dynamodb-lambda-cdk/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/apigw-rest-api-dynamodb-lambda-cdk/README.md b/apigw-rest-api-dynamodb-lambda-cdk/README.md new file mode 100644 index 000000000..485206b4e --- /dev/null +++ b/apigw-rest-api-dynamodb-lambda-cdk/README.md @@ -0,0 +1,158 @@ +# Amazon API Gateway REST API to Amazon DynamoDB + +This pattern creates an Amazon API Gateway REST API that integrates with an Amazon DynamoDB table. + +Learn more about this pattern at Serverless Land Patterns: http://serverlessland.com/patterns/apigw-dynamodb-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. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## Requirements + +* [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured +* [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +* [AWS Serverless Application Model](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/serverless-sam-cli-install.html) (AWS SAM) installed + +## Architecture +The following diagram illustrates the solutions architect +![Architecture Diagram](img/concept.png) +## Deployment Instructions + +1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repository: + ``` + git clone https://github.com/aws-samples/serverless-patterns + ``` +1. Change directory to the pattern directory: + ``` + cd apigw-rest-api-dynamodb-lambda-cdk + ``` + +1. Install dependencies + ``` + npm install + cd src + pip install aws-xray-sdk --target . + pip install aws_lambda_powertools --target . + ``` + +1. Deploy the stack to your default AWS account and region. The output of this command should give you the HTTP API URL. + ``` + cd .. + cdk deploy + ``` + +## How it works + +This pattern creates an Amazon API Gateway REST API that integrates with an Amazon DynamoDB table. The API integrates directly with the DynamoDB API and supports [PutItem](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_PutItem.html) and [Query](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_Query.html) actions. The API Integration has read and write access to the DynamoDB. There is no authentication on the API endpoint + +## Testing + +Upon deployment, you will see the API endpoint URL in the output. It will take the format: + +`https://${API_ID}.execute-api.${REGION_NAME}.amazonaws.com` + +Once the application is deployed, use [Postman](https://www.postman.com/) to test the API using the following instructions. + +1. Launch Postman + +1. Invoke the DynamoDB **PutItem** action to add a new item to the DynamoDB table: + * Enter the API URL with the **prod** stage as the path:. + ``` + https://${API_ID}.execute-api.${REGION_NAME}.amazonaws.com/prod/awsomedynamodb + ``` + * Select **POST** as the HTTP method from the drop-down list to the left of the address bar. + * Choose the **Body** tab. Choose **raw** and select **JSON** from the drop-down list. Enter the following into the text box: + ``` + { + "id":"14", + "name":"Do some thing", + "description":"Do some thing", + "customer":"John" + } + ``` + * Choose **Send** to submit the request and receive a "200 OK" response. + * Open the DynamoDB console and select the table which was created to confirm that the item has been added. + * Change the values for `pk` or `data` in the POST body and repeat this process to add multiple items to the DynamoDB table. + +1. Invoke the DynamoDB **Query** action to query all items by artist in the DynamoDB table: + * Enter the Invoke URL in the address bar. Add **/prod/foo** to the URL path. + * Add **/foo** to the URL path. This defines the ID that you want to query. + ``` + https://${API_ID}.execute-api.${REGION_NAME}.amazonaws.com/prod/awsomedynamodb + ``` + * Select **GET** as the HTTP method from the drop-down list to the left of the address bar. + * Choose the **Body** tab. Choose **none**. + * Choose **Send** to submit the request and receive a "200 OK" response with a list of the matching results. Example: + ``` + { + "Count": 1, + "Items": [ + { + "AWSomeDynamoDBId": { + "S": "foo" + }, + "name": { + "S": "blah blah blah" + }, + "description": { + "S": "blah blah blah" + }, + "Id": { + "S": "string" + } + } + ], + "ScannedCount": 1 + } + ``` + +1. Invoke the DynamoDB **Query** action to query specific item by artist in the DynamoDB table: + * Enter the Invoke URL in the address bar. Add **/prod/foo** to the URL path. + * Add **/foo** to the URL path. This defines the ID that you want to query. + ``` + https://${API_ID}.execute-api.${REGION_NAME}.amazonaws.com/prod/awsomedynamodb/{id} + ``` + * Select **GET** as the HTTP method from the drop-down list to the left of the address bar. + * Choose the **Body** tab. Choose **none**. + * Choose **Send** to submit the request and receive a "200 OK" response with a list of the matching results. Example: + ``` + { + "Count": x, + "Items": [ + { + "AWSomeDynamoDBId": { + "S": "foo" + }, + "name": { + "S": "blah blah blah" + }, + "description": { + "S": "blah blah blah" + }, + "Id": { + "S": "string" + } + } + ], + "ScannedCount": 1 + } + ``` +## Documentation +- [Tutorial: Build an API Gateway REST API with AWS integration](https://docs.aws.amazon.com/apigateway/latest/developerguide/getting-started-aws-proxy.html) +- [How do I use API Gateway as a proxy for another AWS service?](https://aws.amazon.com/premiumsupport/knowledge-center/api-gateway-proxy-integrate-service/) +- [Using Amazon API Gateway as a proxy for DynamoDB](https://aws.amazon.com/blogs/compute/using-amazon-api-gateway-as-a-proxy-for-dynamodb/) +- [Setting up data transformations for REST APIs](https://docs.aws.amazon.com/apigateway/latest/developerguide/rest-api-data-transformations.html) +- [Amazon API Gateway API request and response data mapping reference](https://docs.aws.amazon.com/apigateway/latest/developerguide/request-response-data-mappings.html) +- [API Gateway mapping template and access logging variable reference](https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html) + +## Cleanup + +Run the given command to delete the resources that were created. It might take some time for the CloudFormation stack to get deleted. +``` +cdk destroy +``` + +---- +Copyright 2021 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/apigw-rest-api-dynamodb-lambda-cdk/bin/ApiDynamoDB.ts b/apigw-rest-api-dynamodb-lambda-cdk/bin/ApiDynamoDB.ts new file mode 100644 index 000000000..5955ad9c6 --- /dev/null +++ b/apigw-rest-api-dynamodb-lambda-cdk/bin/ApiDynamoDB.ts @@ -0,0 +1,21 @@ +#!/usr/bin/env node +import 'source-map-support/register'; +import * as cdk from 'aws-cdk-lib'; +import { ApiDynamoDBStack } from '../lib/ApiDynamoDBStack'; + +const app = new cdk.App(); +new ApiDynamoDBStack(app, 'ApiDynamoDBStack', { + /* If you don't specify 'env', this stack will be environment-agnostic. + * Account/Region-dependent features and context lookups will not work, + * but a single synthesized template can be deployed anywhere. */ + + /* Uncomment the next line to specialize this stack for the AWS Account + * and Region that are implied by the current CLI configuration. */ + // env: { account: process.env.CDK_DEFAULT_ACCOUNT, region: process.env.CDK_DEFAULT_REGION }, + + /* Uncomment the next line if you know exactly what Account and Region you + * want to deploy the stack to. */ + // env: { account: '123456789012', region: 'us-east-1' }, + + /* For more information, see https://docs.aws.amazon.com/cdk/latest/guide/environments.html */ +}); \ No newline at end of file diff --git a/apigw-rest-api-dynamodb-lambda-cdk/cdk.json b/apigw-rest-api-dynamodb-lambda-cdk/cdk.json new file mode 100644 index 000000000..cbc12db91 --- /dev/null +++ b/apigw-rest-api-dynamodb-lambda-cdk/cdk.json @@ -0,0 +1,39 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/ApiDynamoDB.ts", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "**/*.d.ts", + "**/*.js", + "tsconfig.json", + "package*.json", + "yarn.lock", + "node_modules", + "test" + ] + }, + "context": { + "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true, + "@aws-cdk/core:stackRelativeExports": true, + "@aws-cdk/aws-rds:lowercaseDbIdentifier": true, + "@aws-cdk/aws-lambda:recognizeVersionProps": true, + "@aws-cdk/aws-lambda:recognizeLayerVersion": true, + "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, + "@aws-cdk-containers/ecs-service-extensions:enableDefaultLogDriver": true, + "@aws-cdk/aws-ec2:uniqueImdsv2TemplateName": true, + "@aws-cdk/core:checkSecretUsage": 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/core:target-partitions": [ + "aws", + "aws-cn" + ] + } +} diff --git a/apigw-rest-api-dynamodb-lambda-cdk/example-pattern.json b/apigw-rest-api-dynamodb-lambda-cdk/example-pattern.json new file mode 100644 index 000000000..52b99a43f --- /dev/null +++ b/apigw-rest-api-dynamodb-lambda-cdk/example-pattern.json @@ -0,0 +1,61 @@ +{ + "title": "Amazon API Gateway to Amazon DynamoDB and Trigger Event to AWS Lambda as a consumer", + "description": "Create a REST API Gateway which performs PutRecords API to save to DynamoDB and the stream is consumed by a Lambda function", + "language": "Python", + "level": "300", + "framework": "SAM", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern will help you to deploy Amazon API Gateway API integration with Amazon DynamoDB and it will deploy Lambda function as a consumer for deployed Amazon DynamoDB Stream.", + "In this pattern, Lambda function is logging the decoded message which was received from the Amazon Kinesis Stream." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/apigw-rest-dynamodb-lambda-cdk", + "templateURL": "serverless-patterns/apigw-rest-dynamodb-lambda-cdk", + "projectFolder": "apigw-rest-dynamodb-lambda-cdk", + "templateFile": "apigw-rest-dynamodb-lambda-cdk/template.yaml" + } + }, + "resources": { + "bullets": [ + { + "text": "Tutorial: Create a REST API as an Amazon Kinesis proxy in API Gateway", + "link": "https://docs.aws.amazon.com/apigateway/latest/developerguide/integrating-api-with-aws-services-kinesis.html" + }, + { + "text": "Kinesis Data Stream PutRecord API", + "link": "https://docs.aws.amazon.com/kinesis/latest/APIReference/API_PutRecord.html" + }, + { + "text": "Using AWS Lambda with Amazon Kinesis", + "link": "https://docs.aws.amazon.com/lambda/latest/dg/with-kinesis.html" + } + ] + }, + "deploy": { + "text": [ + "sam deploy --guided" + ] + }, + "testing": { + "text": [ + "See the Github repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: sam delete." + ] + }, + "authors": [ + { + "name": "Thi Nguyen", + "image": "https://drive.google.com/file/d/188LpzUvUmHt1o7vzbwKw32S-fYabL-qY/view?usp=sharing", + "bio": "Solutions Architect @ AWS", + "linkedin": "https://www.linkedin.com/in/ndthi" + } + ] + } diff --git a/apigw-rest-api-dynamodb-lambda-cdk/img/concept.png b/apigw-rest-api-dynamodb-lambda-cdk/img/concept.png new file mode 100644 index 000000000..bbfe0e41c Binary files /dev/null and b/apigw-rest-api-dynamodb-lambda-cdk/img/concept.png differ diff --git a/apigw-rest-api-dynamodb-lambda-cdk/lib/ApiDynamoDBStack.ts b/apigw-rest-api-dynamodb-lambda-cdk/lib/ApiDynamoDBStack.ts new file mode 100644 index 000000000..86fabb491 --- /dev/null +++ b/apigw-rest-api-dynamodb-lambda-cdk/lib/ApiDynamoDBStack.ts @@ -0,0 +1,202 @@ +import { RemovalPolicy, Stack, StackProps } from 'aws-cdk-lib'; +import { AwsIntegration, Cors, RestApi } from 'aws-cdk-lib/aws-apigateway'; +import { Table, BillingMode, AttributeType, StreamViewType } from 'aws-cdk-lib/aws-dynamodb'; +import { Effect, Policy, PolicyStatement, Role, ServicePrincipal } from 'aws-cdk-lib/aws-iam'; +import { Construct } from 'constructs'; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import { DynamoEventSource } from 'aws-cdk-lib/aws-lambda-event-sources'; +import * as path from 'path'; + +export class ApiDynamoDBStack extends Stack { + constructor(scope: Construct, id: string, props?: StackProps) { + super(scope, id, props); + + const modelName = 'AWSomeDynamoDB'; + const dynamoDBTable = new Table(this, modelName, { + billingMode: BillingMode.PAY_PER_REQUEST, + partitionKey: { + name:`${modelName}Id`, + type: AttributeType.STRING + }, + removalPolicy: RemovalPolicy.DESTROY, + tableName: modelName, + stream: StreamViewType.NEW_AND_OLD_IMAGES + }); + + const lambdaFunction = new lambda.Function(this, 'Function', { + code: lambda.Code.fromAsset(path.join(__dirname, '../src')), + handler: 'messageHandler.lambda_handler', + functionName: 'TableStreamHandler', + runtime: lambda.Runtime.PYTHON_3_10, + }); + + lambdaFunction.addEventSource(new DynamoEventSource(dynamoDBTable, { + startingPosition: lambda.StartingPosition.LATEST, + })); + + dynamoDBTable.grantStreamRead(lambdaFunction); + + const getPolicy = new Policy(this, 'getPolicy', { + statements: [ + new PolicyStatement({ + actions: ['dynamodb:GetItem'], + effect: Effect.ALLOW, + resources: [dynamoDBTable.tableArn], + }), + ], + }); + + const putPolicy = new Policy(this, 'putPolicy', { + statements: [ + new PolicyStatement({ + actions: ['dynamodb:PutItem'], + effect: Effect.ALLOW, + resources: [dynamoDBTable.tableArn], + }), + ], + }); + + const scanPolicy = new Policy(this, 'scanPolicy', { + statements: [ + new PolicyStatement({ + actions: ['dynamodb:Scan'], + effect: Effect.ALLOW, + resources: [dynamoDBTable.tableArn], + }), + ], + }); + + const api = new RestApi(this, `${modelName}Api`,{ + defaultCorsPreflightOptions:{ + allowOrigins: Cors.ALL_ORIGINS + }, + restApiName:`${modelName} Service` + }) + + const getRole = new Role(this, 'getRole', { + assumedBy: new ServicePrincipal('apigateway.amazonaws.com'), + }); + getRole.attachInlinePolicy(getPolicy); + + const putRole = new Role(this, 'putRole', { + assumedBy: new ServicePrincipal('apigateway.amazonaws.com'), + }); + putRole.attachInlinePolicy(putPolicy); + + const scanRole = new Role(this, 'scanRole', { + assumedBy: new ServicePrincipal('apigateway.amazonaws.com'), + }); + scanRole.attachInlinePolicy(scanPolicy); + + + const errorResponses = [ + { + selectionPattern: '400', + statusCode: '400', + responseTemplates: { + 'application/json': `{ + "error": "Bad input!" + }`, + }, + }, + { + selectionPattern: '5\\d{2}', + statusCode: '500', + responseTemplates: { + 'application/json': `{ + "error": "Internal Service Error!" + }`, + }, + }, + ]; + + const integrationResponses = [ + { + statusCode: '200', + }, + ...errorResponses, + ]; + + const getIntegration = new AwsIntegration({ + action: 'GetItem', + options: { + credentialsRole: getRole, + integrationResponses, + requestTemplates: { + 'application/json': `{ + "Key": { + "${modelName}Id": { + "S": "$method.request.path.id" + } + }, + "TableName": "${modelName}" + }`, + }, + }, + service: 'dynamodb', + }); + + const createIntegration = new AwsIntegration({ + action: 'PutItem', + options: { + credentialsRole: putRole, + integrationResponses: [ + { + statusCode: '200', + responseTemplates: { + 'application/json': `{ + "requestId": "$context.requestId" + }`, + }, + }, + ...errorResponses, + ], + requestTemplates: { + 'application/json': `{ + "Item": { + "${modelName}Id": { + "S": "$context.requestId" + }, + "Name": { + "S": "$input.path('$.name')" + }, + "Description": { + "S": "$input.path('$.description')" + }, + "Customer": { + "S": "$input.path('$.customer')" + }, + "Id": { + "S": "$input.path('$.id')" + } + }, + "TableName": "${modelName}" + }`, + }, + }, + service: 'dynamodb', + }); + + const getAllIntegration = new AwsIntegration({ + action: 'Scan', + options: { + credentialsRole: scanRole, + integrationResponses, + requestTemplates: { + 'application/json': `{ + "TableName": "${modelName}" + }`, + }, + }, + service: 'dynamodb', + }); + + const methodOptions = { methodResponses: [{ statusCode: '200' }, { statusCode: '400' }, { statusCode: '500' }] }; + const allResources = api.root.addResource(modelName.toLocaleLowerCase()); + const oneResource = allResources.addResource('{id}'); + + allResources.addMethod('GET', getAllIntegration, methodOptions); + allResources.addMethod('POST', createIntegration, methodOptions); + oneResource.addMethod('GET', getIntegration, methodOptions); + } +} diff --git a/apigw-rest-api-dynamodb-lambda-cdk/package.json b/apigw-rest-api-dynamodb-lambda-cdk/package.json new file mode 100644 index 000000000..4a055035b --- /dev/null +++ b/apigw-rest-api-dynamodb-lambda-cdk/package.json @@ -0,0 +1,30 @@ +{ + "name": "cdk", + "version": "0.1.0", + "bin": { + "cdk": "bin/cdk.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "lint": "eslint . --ext .ts", + "cdk": "cdk" + }, + "devDependencies": { + "@types/jest": "^27.5.2", + "@types/node": "10.17.27", + "@types/prettier": "2.6.0", + "aws-cdk": "2.88.0", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "ts-node": "^10.9.1", + "typescript": "~4.6.3" + }, + "dependencies": { + "@typescript-eslint/eslint-plugin": "^6.1.0", + "aws-cdk-lib": "2.88.0", + "constructs": "^10.0.0", + "eslint": "^8.45.0", + "source-map-support": "^0.5.21" + } +} diff --git a/apigw-rest-api-dynamodb-lambda-cdk/src/messageHandler.py b/apigw-rest-api-dynamodb-lambda-cdk/src/messageHandler.py new file mode 100644 index 000000000..d070fbda7 --- /dev/null +++ b/apigw-rest-api-dynamodb-lambda-cdk/src/messageHandler.py @@ -0,0 +1,10 @@ +# Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +# SPDX-License-Identifier: MIT-0 +import os +import boto3 +from aws_lambda_powertools.utilities.data_classes import DynamoDBStreamEvent +from aws_lambda_powertools.utilities.typing import LambdaContext + +def lambda_handler(event: DynamoDBStreamEvent, context: LambdaContext): + records = event["Records"] + print(records) diff --git a/apigw-rest-api-dynamodb-lambda-cdk/src/requirements.txt b/apigw-rest-api-dynamodb-lambda-cdk/src/requirements.txt new file mode 100644 index 000000000..8e0dc717e --- /dev/null +++ b/apigw-rest-api-dynamodb-lambda-cdk/src/requirements.txt @@ -0,0 +1,2 @@ +aws-xray-sdk +aws_lambda_powertools \ No newline at end of file diff --git a/apigw-rest-api-dynamodb-lambda-cdk/tsconfig.json b/apigw-rest-api-dynamodb-lambda-cdk/tsconfig.json new file mode 100644 index 000000000..9f8e8beab --- /dev/null +++ b/apigw-rest-api-dynamodb-lambda-cdk/tsconfig.json @@ -0,0 +1,30 @@ +{ + "compilerOptions": { + "target": "ES2018", + "module": "commonjs", + "lib": [ + "es2018" + ], + "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" + ] +}