From 304505f5b0a56fe06e07dea3a3f76b57b8ea8bb5 Mon Sep 17 00:00:00 2001 From: Eleni Dimitropoulou <12170229+eldimi@users.noreply.github.com> Date: Wed, 16 Apr 2025 19:21:53 +0300 Subject: [PATCH 01/21] Add REST API GW to Lambda --- rest-api-gw-lambda-node-cdk/.gitignore | 9 + rest-api-gw-lambda-node-cdk/README.md | 60 +++ .../bin/rest-api-gw-lambda-nodejs-cdk.ts | 10 + rest-api-gw-lambda-node-cdk/cdk.json | 87 ++++ .../example-pattern.json | 61 +++ rest-api-gw-lambda-node-cdk/jest.config.js | 8 + .../lib/api-gateway-stack.ts | 69 +++ .../lib/lambda-stack.ts | 72 +++ .../lib/lambda/handle/index.ts | 279 ++++++++++++ .../lib/lambda/search/index.ts | 165 +++++++ .../lib/openapi/openapi.json | 431 ++++++++++++++++++ .../lib/rest-api-gateway-lambda-stack.ts | 36 ++ .../lib/secrets-stack.ts | 25 + rest-api-gw-lambda-node-cdk/package.json | 33 ++ rest-api-gw-lambda-node-cdk/test/cdk.test.ts | 17 + .../test/sample_create_order.json | 47 ++ .../test/sample_order_response.json | 8 + .../test/sample_search_order.json | 9 + .../test/sample_update_order.json | 9 + rest-api-gw-lambda-node-cdk/tsconfig.json | 32 ++ 20 files changed, 1467 insertions(+) create mode 100644 rest-api-gw-lambda-node-cdk/.gitignore create mode 100644 rest-api-gw-lambda-node-cdk/README.md create mode 100644 rest-api-gw-lambda-node-cdk/bin/rest-api-gw-lambda-nodejs-cdk.ts create mode 100644 rest-api-gw-lambda-node-cdk/cdk.json create mode 100644 rest-api-gw-lambda-node-cdk/example-pattern.json create mode 100644 rest-api-gw-lambda-node-cdk/jest.config.js create mode 100644 rest-api-gw-lambda-node-cdk/lib/api-gateway-stack.ts create mode 100644 rest-api-gw-lambda-node-cdk/lib/lambda-stack.ts create mode 100644 rest-api-gw-lambda-node-cdk/lib/lambda/handle/index.ts create mode 100644 rest-api-gw-lambda-node-cdk/lib/lambda/search/index.ts create mode 100644 rest-api-gw-lambda-node-cdk/lib/openapi/openapi.json create mode 100644 rest-api-gw-lambda-node-cdk/lib/rest-api-gateway-lambda-stack.ts create mode 100644 rest-api-gw-lambda-node-cdk/lib/secrets-stack.ts create mode 100644 rest-api-gw-lambda-node-cdk/package.json create mode 100644 rest-api-gw-lambda-node-cdk/test/cdk.test.ts create mode 100644 rest-api-gw-lambda-node-cdk/test/sample_create_order.json create mode 100644 rest-api-gw-lambda-node-cdk/test/sample_order_response.json create mode 100644 rest-api-gw-lambda-node-cdk/test/sample_search_order.json create mode 100644 rest-api-gw-lambda-node-cdk/test/sample_update_order.json create mode 100644 rest-api-gw-lambda-node-cdk/tsconfig.json diff --git a/rest-api-gw-lambda-node-cdk/.gitignore b/rest-api-gw-lambda-node-cdk/.gitignore new file mode 100644 index 000000000..6b891cfc8 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/.gitignore @@ -0,0 +1,9 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out +cdk.out/* diff --git a/rest-api-gw-lambda-node-cdk/README.md b/rest-api-gw-lambda-node-cdk/README.md new file mode 100644 index 000000000..3ca759d23 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/README.md @@ -0,0 +1,60 @@ +# AWS Service 1 to AWS Service 2 + +This pattern << explain usage >> + +Learn more about this pattern at Serverless Land Patterns: << Add the live URL here >> + +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 + +## 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 _patterns-model + ``` +1. From the command line, use AWS SAM to deploy the AWS resources for the pattern as specified in the template.yml file: + ``` + sam deploy --guided + ``` +1. During the prompts: + * Enter a stack name + * Enter the desired AWS Region + * Allow SAM CLI to create IAM roles with the required permissions. + + Once you have run `sam deploy --guided` mode once and saved arguments to a configuration file (samconfig.toml), you can use `sam deploy` in future to use these defaults. + +1. Note the outputs from the SAM deployment process. These contain the resource names and/or ARNs which are used for testing. + +## How it works + +Explain how the service interaction works. + +## Testing + +Provide steps to trigger the integration and show what should be observed if successful. + +## Cleanup + +1. Delete the stack + ```bash + aws cloudformation delete-stack --stack-name STACK_NAME + ``` +1. Confirm the stack has been deleted + ```bash + aws cloudformation list-stacks --query "StackSummaries[?contains(StackName,'STACK_NAME')].StackStatus" + ``` +---- +Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/rest-api-gw-lambda-node-cdk/bin/rest-api-gw-lambda-nodejs-cdk.ts b/rest-api-gw-lambda-node-cdk/bin/rest-api-gw-lambda-nodejs-cdk.ts new file mode 100644 index 000000000..11da793e4 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/bin/rest-api-gw-lambda-nodejs-cdk.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import * as cdk from 'aws-cdk-lib'; +import { RestApiGwLambdaStack } from '../lib/rest-api-gateway-lambda-stack'; + +const app = new cdk.App(); + +new RestApiGwLambdaStack(app, 'RestApiGwLambdaStack', { + stageName: 'v1', + description: 'REST API Gateway with Lambda integration using openapi spec', +}); \ No newline at end of file diff --git a/rest-api-gw-lambda-node-cdk/cdk.json b/rest-api-gw-lambda-node-cdk/cdk.json new file mode 100644 index 000000000..39cb85fc4 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/cdk.json @@ -0,0 +1,87 @@ +{ + "app": "npx ts-node --prefer-ts-exts bin/rest-api-gw-lambda-nodejs-cdk.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:enableImdsBlockingDeprecatedFeature": false, + "@aws-cdk/aws-ecs:disableEcsImdsBlocking": true, + "@aws-cdk/aws-ecs:reduceEc2FargateCloudWatchPermissions": true, + "@aws-cdk/aws-dynamodb:resourcePolicyPerReplica": 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, + "@aws-cdk/aws-stepfunctions-tasks:fixRunEcsTaskPolicy": true, + "@aws-cdk/aws-ec2:bastionHostUseAmazonLinux2023ByDefault": true, + "@aws-cdk/aws-route53-targets:userPoolDomainNameMethodWithoutCustomResource": true, + "@aws-cdk/aws-elasticloadbalancingV2:albDualstackWithoutPublicIpv4SecurityGroupRulesDefault": true, + "@aws-cdk/aws-iam:oidcRejectUnauthorizedConnections": true, + "@aws-cdk/core:enableAdditionalMetadataCollection": true + } +} diff --git a/rest-api-gw-lambda-node-cdk/example-pattern.json b/rest-api-gw-lambda-node-cdk/example-pattern.json new file mode 100644 index 000000000..7f70a64e6 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/example-pattern.json @@ -0,0 +1,61 @@ + +///TODO Fix before deploying +{ + "title": "Step Functions to Athena", + "description": "Create a Step Functions workflow to query Amazon Athena.", + "language": "Python", + "level": "200", + "framework": "AWS CDK", + "introBox": { + "headline": "How it works", + "text": [ + "This sample project demonstrates how to use an AWS Step Functions state machine to query Athena and get the results. This pattern is leveraging the native integration between these 2 services which means only JSON-based, structured language is used to define the implementation.", + "With Amazon Athena you can get up to 1000 results per invocation of the GetQueryResults method and this is the reason why the Step Function has a loop to get more results. The results are sent to a Map which can be configured to handle (the DoSomething state) the items in parallel or one by one by modifying the max_concurrency parameter.", + "This pattern deploys one Step Functions, two S3 Buckets, one Glue table and one Glue database." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/sfn-athena-cdk-python", + "templateURL": "serverless-patterns/sfn-athena-cdk-python", + "projectFolder": "sfn-athena-cdk-python", + "templateFile": "sfn_athena_cdk_python_stack.py" + } + }, + "resources": { + "bullets": [ + { + "text": "Call Athena with Step Functions", + "link": "https://docs.aws.amazon.com/step-functions/latest/dg/connect-athena.html" + }, + { + "text": "Amazon Athena - Serverless Interactive Query Service", + "link": "https://aws.amazon.com/athena/" + } + ] + }, + "deploy": { + "text": [ + "sam deploy" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: cdk delete." + ] + }, + "authors": [ + { + "name": "Your name", + "image": "link-to-your-photo.jpg", + "bio": "Your bio.", + "linkedin": "linked-in-ID", + "twitter": "twitter-handle" + } + ] +} diff --git a/rest-api-gw-lambda-node-cdk/jest.config.js b/rest-api-gw-lambda-node-cdk/jest.config.js new file mode 100644 index 000000000..08263b895 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + } +}; diff --git a/rest-api-gw-lambda-node-cdk/lib/api-gateway-stack.ts b/rest-api-gw-lambda-node-cdk/lib/api-gateway-stack.ts new file mode 100644 index 000000000..5dfc3cf3a --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/lib/api-gateway-stack.ts @@ -0,0 +1,69 @@ +// lib/api-gateway-stack.ts +import * as cdk from 'aws-cdk-lib'; +import * as apigateway from 'aws-cdk-lib/aws-apigateway'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import { Construct } from 'constructs'; +import path = require('path'); +import * as fs from 'fs'; + +interface ApiGatewayStackProps extends cdk.NestedStackProps { + handleLambda: lambda.Function; + searchLambda: lambda.Function; + stageName: string; +} + +export class ApiGatewayStack extends cdk.NestedStack { + public readonly api: apigateway.SpecRestApi; + + constructor(scope: Construct, id: string, props: ApiGatewayStackProps) { + super(scope, id, props); + + const openApiSpecPath = path.join(__dirname, 'openapi/openapi.json'); + const openApiSpecContent= fs.readFileSync(openApiSpecPath, 'utf8'); + + // Parse JSON and replace placeholders + const openApiSpec = JSON.parse( + openApiSpecContent.replaceAll( + '${lambdaArn}', props.handleLambda.functionArn + ).replaceAll( + '${region}', cdk.Stack.of(this).region + ).replaceAll( + '${searchLambdaArn}', props.searchLambda.functionArn, + ) + ); + + this.api = new apigateway.SpecRestApi(this, 'SampleApiGatewayLambda2Api', { + restApiName: 'My API Service', + description: 'API Gateway with Lambda integration', + apiDefinition: apigateway.ApiDefinition.fromInline(openApiSpec), + deployOptions: { + tracingEnabled: true, + dataTraceEnabled: true, + stageName: props.stageName, + }, + cloudWatchRole: false, + }); + + // TODO: is it too permissive? Should we define method and stage instead of **? + new lambda.CfnPermission(this, 'LambdaHandlerPermission', { + action: 'lambda:InvokeFunction', + functionName: props.handleLambda.functionName, + principal: 'apigateway.amazonaws.com', + sourceArn: `arn:aws:execute-api:${this.region}:${this.account}:${this.api.restApiId}/*/*/order*`, + }); + + new lambda.CfnPermission(this, 'LambdaSearchPermission', { + action: 'lambda:InvokeFunction', + functionName: props.searchLambda.functionName, + principal: 'apigateway.amazonaws.com', + sourceArn: `arn:aws:execute-api:${this.region}:${this.account}:${this.api.restApiId}/*/*/orders/search*`, + }); + + // Output the API URL + new cdk.CfnOutput(this, 'ApiUrl', { + value: this.api.url, + description: 'API Gateway URL', + }); + + } +} diff --git a/rest-api-gw-lambda-node-cdk/lib/lambda-stack.ts b/rest-api-gw-lambda-node-cdk/lib/lambda-stack.ts new file mode 100644 index 000000000..ae2311217 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/lib/lambda-stack.ts @@ -0,0 +1,72 @@ +// lib/lambda-stack.ts +import * as cdk from 'aws-cdk-lib'; +import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import { Construct } from 'constructs'; + +export interface LambdaStackProps extends cdk.NestedStackProps { + stageName: string; + apiKey: secretsmanager.Secret; +} + +export class LambdaStack extends cdk.NestedStack { + public readonly handleLambda: lambda.Function; + public readonly searchLambda: lambda.Function; + + constructor(scope: Construct, id: string, props: LambdaStackProps) { + super(scope, id, props); + + const powertoolsLayer = lambda.LayerVersion.fromLayerVersionArn( + this, + 'powertools-layer', + `arn:aws:lambda:${cdk.Stack.of(this).region}:094274105915:layer:AWSLambdaPowertoolsTypeScriptV2:3` + ); + + this.handleLambda = new lambda.Function(this, 'Handle', { + runtime: lambda.Runtime.NODEJS_20_X, + handler: 'index.handler', + code: lambda.Code.fromAsset('lib/lambda/handle'), + environment: { + POWERTOOLS_SERVICE_NAME: 'handleOrders', + POWERTOOLS_LOG_LEVEL: 'INFO', + POWERTOOLS_METRICS_NAMESPACE: 'RestAPIGWLambda', + POWERTOOLS_TRACE_ENABLED: 'true', + POWERTOOLS_METRICS_FUNCTION_NAME: 'handle', + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: 'true', + POWERTOOLS_TRACER_CAPTURE_RESPONSE: 'true', + POWERTOOLS_DEV: 'false', + STAGE: props.stageName, + API_KEY_SECRET_ARN: props.apiKey.secretArn + }, + tracing: lambda.Tracing.ACTIVE, + layers: [powertoolsLayer], + timeout: cdk.Duration.seconds(30), + }); + + props.apiKey.grantRead(this.handleLambda); + + this.searchLambda = new lambda.Function(this, 'Search', { + runtime: lambda.Runtime.NODEJS_20_X, + handler: 'index.handler', + code: lambda.Code.fromAsset('lib/lambda/search'), + environment: { + POWERTOOLS_SERVICE_NAME: 'searchOrders', + POWERTOOLS_LOG_LEVEL: 'INFO', + POWERTOOLS_METRICS_NAMESPACE: 'RestAPIGWLambda', + POWERTOOLS_TRACE_ENABLED: 'true', + POWERTOOLS_METRICS_FUNCTION_NAME: 'search', + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: 'true', + POWERTOOLS_TRACER_CAPTURE_RESPONSE: 'true', + POWERTOOLS_DEV: 'false', + STAGE: props.stageName, + }, + tracing: lambda.Tracing.ACTIVE, + layers: [powertoolsLayer], + timeout: cdk.Duration.seconds(30), + }); + + + + } + +} diff --git a/rest-api-gw-lambda-node-cdk/lib/lambda/handle/index.ts b/rest-api-gw-lambda-node-cdk/lib/lambda/handle/index.ts new file mode 100644 index 000000000..4b679622d --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/lib/lambda/handle/index.ts @@ -0,0 +1,279 @@ +import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; +import { Logger } from '@aws-lambda-powertools/logger'; +import { Metrics, MetricUnit } from '@aws-lambda-powertools/metrics'; +import { Tracer } from '@aws-lambda-powertools/tracer'; +import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import { GetSecretValueCommand, SecretsManager } from '@aws-sdk/client-secrets-manager'; +import type { Context } from 'aws-lambda'; +import { v4 as uuidv4 } from 'uuid'; + +const orderCache = new Map(); + +// Sample data +const sampleOrders = [ + { + orderId: 'ORD-2024-001', + customerId: 'CUST123', + items: [ + { + productId: 'PROD789', + productName: 'Nike Air Max 2024', + quantity: 1, + price: 129.99, + sku: 'NK-AM24-BLK-42', + variantId: 'SIZE-42-BLACK' + } + ], + status: 'DELIVERED', + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + shippingAddress: { + street: '123 Main Street', + city: 'Seattle', + state: 'WA', + postalCode: '98101', + country: 'USA' + }, + trackingNumber: '1Z999AA1234567890' + }, + { + orderId: 'ORD-2024-002', + customerId: 'CUST456', + items: [ + { + productId: 'PROD456', + productName: 'Adidas Running Shorts', + quantity: 2, + price: 34.99, + sku: 'AD-RS-BLU-M', + variantId: 'SIZE-M-BLUE' + } + ], + status: 'PROCESSING', + createdAt: '2024-01-16T15:30:00Z', + updatedAt: '2024-01-16T15:30:00Z', + shippingAddress: { + street: '456 Pine Street', + city: 'Portland', + state: 'OR', + postalCode: '97201', + country: 'USA' + } + } +]; + +// Initialize cache with sample data +sampleOrders.forEach(order => orderCache.set(order.orderId, order)); + + +const logger = new Logger(); +const metrics = new Metrics(); +const tracer = new Tracer(); +const secretsManager = new SecretsManager(); +let apiKey: string | undefined; + +interface ErrorResponse { + message: string; +} + +class HandleOrderLambda implements LambdaInterface { + @tracer.captureLambdaHandler() + @metrics.logMetrics() + @logger.injectLambdaContext() + public async handler(_event: APIGatewayProxyEvent, _context: Context): Promise { + logger.appendKeys({ + stage: process.env.STAGE, + }); + logger.info('Processing event', { _event }); + metrics.addMetric('ProcessedEvents', MetricUnit.Count, 1); + tracer.getSegment(); + const apiKey = await getApiKey(); + // TODO test both branches: exists or not + if (apiKey) { + logger.debug("API key found") + + } + // use api key to call external service + try { + switch (_event.httpMethod) { + case 'POST': + if (_event.path === '/order') { + return await createOrder(_event); + } + break; + case 'GET': + if (_event.path.match(/^\/order\/[^/]+$/)) { + return await getOrder(_event); + } + break; + case 'PUT': + if (_event.path.match(/^\/order\/[^/]+$/)) { + return await updateOrder(_event); + } + break; + case 'DELETE': + if (_event.path.match(/^\/order\/[^/]+$/)) { + return await deleteOrder(_event); + } + break; + } + metrics.publishStoredMetrics(); + + return errorResponse(404, { message: 'Not Found' }); + } catch (error) { + console.error('Error:', error); + return errorResponse(500, { message: 'Internal Server Error' }); + } + } +} + + +async function getApiKey(): Promise { + + if (apiKey) { + return apiKey; + } + const secretArn = process.env.API_KEY_SECRET_ARN; + if (!secretArn) { + throw new Error('API_KEY_SECRET_ARN environment variable is not set'); + } + + const command = new GetSecretValueCommand({ + SecretId: secretArn, + }); + + const response = await secretsManager.send(command); + + if (!response.SecretString) { + throw new Error('Secret string is empty'); + } + + const secretData = response.SecretString; + if (!secretData){ + throw new Error('API KEY does not exist'); + } + apiKey = secretData; + return apiKey + +} + +async function createOrder(event: APIGatewayProxyEvent): Promise { + try { + // TODO add validation + const order = JSON.parse(event.body || '{}'); + const timestamp = new Date().toISOString(); + + const newOrder = { + ...order, + orderId: `ORD-${uuidv4()}`, + status: 'PENDING', + createdAt: timestamp, + updatedAt: timestamp + }; + + orderCache.set(newOrder.orderId, newOrder); + + const response = { + orderId: newOrder.orderId, + status: newOrder.status, + createdAt: newOrder.createdAt, + }; + + return { + statusCode: 201, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(response) + }; + } catch (error) { + console.error('Create Order Error:', error); + return errorResponse(400, { message: 'Invalid request body' }); + } +} + +async function getOrder(event: APIGatewayProxyEvent): Promise { + const orderId = event.pathParameters?.orderId; + const order = orderCache.get(orderId || ''); + + if (!order) { + return errorResponse(404, { message: 'Order not found' }); + } + + const response = { + orderId: order.orderId, + status: order.status, + createdAt: order.createdAt, + trackingNumber: order.trackingNumber + }; + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(response) + }; +} + +async function updateOrder(event: APIGatewayProxyEvent): Promise { + const orderId = event.pathParameters?.orderId; + const updates = JSON.parse(event.body || '{}'); + + const existingOrder = orderCache.get(orderId || ''); + if (!existingOrder) { + return errorResponse(404, { message: 'Order not found' }); + } + + const updatedOrder = { + ...existingOrder, + ...updates, + updatedAt: new Date().toISOString() + }; + + orderCache.set(orderId!, updatedOrder); + + const response = { + orderId: updatedOrder.orderId, + status: updatedOrder.status, + createdAt: updatedOrder.createdAt, + updatedAt: updatedOrder.updatedAt, + trackingNumber: updatedOrder.trackingNumber + }; + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(response) + }; +} + +async function deleteOrder(event: APIGatewayProxyEvent): Promise { + const orderId = event.pathParameters?.orderId; + + if (!orderCache.has(orderId || '')) { + return errorResponse(404, { message: 'Order not found' }); + } + + orderCache.delete(orderId!); + + return { + statusCode: 204, + body: '' + }; +} + +function errorResponse(statusCode: number, error: ErrorResponse): APIGatewayProxyResult { + return { + statusCode, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(error) + }; +} + +const handlerClass = new HandleOrderLambda(); +export const handler = handlerClass.handler.bind(handlerClass); \ No newline at end of file diff --git a/rest-api-gw-lambda-node-cdk/lib/lambda/search/index.ts b/rest-api-gw-lambda-node-cdk/lib/lambda/search/index.ts new file mode 100644 index 000000000..5e7ecce38 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/lib/lambda/search/index.ts @@ -0,0 +1,165 @@ +import { Logger } from '@aws-lambda-powertools/logger'; +import { Metrics, MetricUnit } from '@aws-lambda-powertools/metrics'; +import { Tracer } from '@aws-lambda-powertools/tracer'; +import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; +import type { Context } from 'aws-lambda'; +import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; + +const logger = new Logger(); +const metrics = new Metrics(); +const tracer = new Tracer(); + + +interface SearchCriteria { + customerIds?: string[]; + statuses?: string[]; + productIds?: string[]; + page?: number; + limit?: number; + sortBy?: 'createdAt' | 'total' | 'status'; + sortOrder?: 'asc' | 'desc'; +} + +// Sample data +const sampleOrders = [ + { + orderId: 'ORD-2024-001', + customerId: 'CUST123', + items: [ + { + productId: 'PROD789', + productName: 'Nike Air Max 2024', + quantity: 1, + price: 129.99, + sku: 'NK-AM24-BLK-42', + variantId: 'SIZE-42-BLACK' + } + ], + status: 'DELIVERED', + createdAt: '2024-01-15T10:00:00Z', + updatedAt: '2024-01-15T10:00:00Z', + total: 129.99, + shippingAddress: { + street: '123 Main Street', + city: 'Seattle', + state: 'WA', + postalCode: '98101', + country: 'USA' + }, + shippingMethod: 'EXPRESS', + trackingNumber: '1Z999AA1234567890' + }, + // Add more sample orders here +]; + + +class SearchOrderLambda implements LambdaInterface { + @tracer.captureLambdaHandler() + @metrics.logMetrics() + @logger.injectLambdaContext() + public async handler(_event: APIGatewayProxyEvent, _context: Context): Promise { + logger.appendKeys({ + stage: process.env.STAGE, + }); + logger.info('Processing event', { _event }); + metrics.addMetric('ProcessedEvents', MetricUnit.Count, 1); + tracer.getSegment(); + + try { + // TODO validate object + const criteria: SearchCriteria = JSON.parse(_event.body || '{}'); + let filteredOrders = [...sampleOrders]; + + // Apply filters + if (criteria.customerIds?.length) { + filteredOrders = filteredOrders.filter(order => + criteria.customerIds!.includes(order.customerId) + ); + } + + if (criteria.statuses?.length) { + filteredOrders = filteredOrders.filter(order => + criteria.statuses!.includes(order.status) + ); + } + + if (criteria.productIds?.length) { + filteredOrders = filteredOrders.filter(order => + order.items.some(item => criteria.productIds!.includes(item.productId)) + ); + } + + // Sort results + const sortBy = criteria.sortBy || 'createdAt'; + const sortOrder = criteria.sortOrder || 'desc'; + + filteredOrders.sort((a, b) => { + let comparison = 0; + switch (sortBy) { + case 'total': + comparison = a.total - b.total; + break; + case 'status': + comparison = a.status.localeCompare(b.status); + break; + case 'createdAt': + default: + comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + break; + } + return sortOrder === 'desc' ? -comparison : comparison; + }); + + // Handle pagination + const page = Math.max(1, criteria.page || 1); + const limit = Math.min(Math.max(1, criteria.limit || 20), 100); + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedOrders = filteredOrders.slice(startIndex, endIndex); + + const response = { + items: paginatedOrders.map(order => ({ + orderId: order.orderId, + customerId: order.customerId, + status: order.status, + createdAt: order.createdAt, + total: order.total, + items: order.items.map(item => ({ + productId: item.productId, + productName: item.productName, + quantity: item.quantity, + price: item.price + })), + shippingMethod: order.shippingMethod, + trackingNumber: order.trackingNumber + })), + pagination: { + total: filteredOrders.length, + pages: Math.ceil(filteredOrders.length / limit), + currentPage: page, + limit + } + }; + + return { + statusCode: 200, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify(response) + }; + } catch (error) { + console.error('Search Orders Error:', error); + return { + statusCode: 400, + headers: { + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ message: 'Invalid search criteria' }) + }; + } + } +} + +const searchClass = new SearchOrderLambda(); +export const handler = searchClass.handler.bind(searchClass); \ No newline at end of file diff --git a/rest-api-gw-lambda-node-cdk/lib/openapi/openapi.json b/rest-api-gw-lambda-node-cdk/lib/openapi/openapi.json new file mode 100644 index 000000000..9dd59d358 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/lib/openapi/openapi.json @@ -0,0 +1,431 @@ +{ + "openapi": "3.0.1", + "info": { + "title": "SampleApiGatewayLambda", + "version": "1.0.0" + }, + "paths": { + "/order": { + "post": { + "summary": "Create order", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Order" + } + } + } + }, + "responses": { + "201": { + "description": "Order created successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderResponse" + } + } + } + }, + "400": { + "description": "Bad Request" + } + }, + "x-amazon-apigateway-integration": { + "type": "AWS_PROXY", + "httpMethod": "POST", + "uri": "arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${lambdaArn}/invocations", + "passthroughBehavior": "when_no_match", + "timeoutInMillis": 29000 + } + } + }, + "/order/{orderId}": { + "get": { + "summary": "Get order by ID", + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of the order to retrieve" + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderResponse" + } + } + } + }, + "404": { + "description": "Order not found" + } + }, + "x-amazon-apigateway-integration": { + "type": "AWS_PROXY", + "httpMethod": "POST", + "uri": "arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${lambdaArn}/invocations", + "passthroughBehavior": "when_no_match", + "timeoutInMillis": 29000 + } + }, + "put": { + "summary": "Update order", + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of the order to update" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderUpdate" + } + } + } + }, + "responses": { + "200": { + "description": "Order updated successfully", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrderResponse" + } + } + } + }, + "400": { + "description": "Bad Request" + }, + "404": { + "description": "Order not found" + } + }, + "x-amazon-apigateway-integration": { + "type": "AWS_PROXY", + "httpMethod": "POST", + "uri": "arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${lambdaArn}/invocations", + "passthroughBehavior": "when_no_match", + "timeoutInMillis": 29000 + } + }, + "delete": { + "summary": "Delete order", + "parameters": [ + { + "name": "orderId", + "in": "path", + "required": true, + "schema": { + "type": "string" + }, + "description": "ID of the order to delete" + } + ], + "responses": { + "204": { + "description": "Order deleted successfully" + }, + "404": { + "description": "Order not found" + } + }, + "x-amazon-apigateway-integration": { + "type": "AWS_PROXY", + "httpMethod": "POST", + "uri": "arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${lambdaArn}/invocations", + "passthroughBehavior": "when_no_match", + "timeoutInMillis": 29000 + } + } + }, + "/orders/search": { + "post": { + "summary": "Search orders", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "customerIds": { + "type": "array", + "items": { "type": "string" } + }, + "statuses": { + "type": "array", + "items": { + "type": "string", + "enum": ["PENDING", "CONFIRMED", "PROCESSING", "SHIPPED", "DELIVERED", "CANCELLED"] + } + }, + "productIds": { + "type": "array", + "items": { "type": "string" } + }, + "page": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "sortBy": { + "type": "string", + "enum": ["createdAt", "total", "status"], + "default": "createdAt" + }, + "sortOrder": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Successful search results", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrderResponse" + } + }, + "pagination": { + "type": "object", + "properties": { + "total": { "type": "integer" }, + "pages": { "type": "integer" }, + "currentPage": { "type": "integer" }, + "limit": { "type": "integer" } + } + } + } + } + } + } + }, + "400": { + "description": "Invalid search criteria" + } + }, + "x-amazon-apigateway-integration": { + "type": "AWS_PROXY", + "httpMethod": "POST", + "uri": "arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${searchLambdaArn}/invocations", + "passthroughBehavior": "when_no_match" + } + } + } + }, + "components": { + "schemas": { + "Address": { + "type": "object", + "required": ["street", "city", "state", "postalCode", "country"], + "properties": { + "street": { + "type": "string", + "description": "Street address including house number" + }, + "city": { + "type": "string" + }, + "state": { + "type": "string" + }, + "postalCode": { + "type": "string" + }, + "country": { + "type": "string" + }, + "apartment": { + "type": "string", + "description": "Apartment, suite, or unit number" + } + } + }, + "OrderItem": { + "type": "object", + "required": ["productId", "quantity", "price"], + "properties": { + "productId": { + "type": "string" + }, + "productName": { + "type": "string" + }, + "quantity": { + "type": "integer", + "minimum": 1 + }, + "price": { + "type": "number", + "format": "float", + "minimum": 0 + }, + "sku": { + "type": "string" + }, + "variantId": { + "type": "string", + "description": "ID for specific product variant (size, color, etc.)" + } + } + }, + "Order": { + "type": "object", + "required": ["customerId", "items", "shippingAddress", "billingAddress"], + "properties": { + "customerId": { + "type": "string" + }, + "items": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrderItem" + }, + "minItems": 1 + }, + "shippingAddress": { + "$ref": "#/components/schemas/Address" + }, + "billingAddress": { + "$ref": "#/components/schemas/Address" + }, + "subtotal": { + "type": "number", + "format": "float", + "minimum": 0 + }, + "tax": { + "type": "number", + "format": "float", + "minimum": 0 + }, + "shippingCost": { + "type": "number", + "format": "float", + "minimum": 0 + }, + "total": { + "type": "number", + "format": "float", + "minimum": 0 + }, + "paymentMethod": { + "type": "string", + "enum": ["CREDIT_CARD", "DEBIT_CARD", "PAYPAL", "BANK_TRANSFER"] + }, + "shippingMethod": { + "type": "string", + "enum": ["STANDARD", "EXPRESS", "NEXT_DAY"] + }, + "customerNotes": { + "type": "string", + "maxLength": 500 + }, + "giftWrapping": { + "type": "boolean", + "default": false + }, + "couponCode": { + "type": "string" + }, + "discountAmount": { + "type": "number", + "format": "float", + "minimum": 0 + } + } + }, + "OrderUpdate": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["PENDING", "CONFIRMED", "PROCESSING", "SHIPPED", "DELIVERED", "CANCELLED"] + }, + "shippingAddress": { + "$ref": "#/components/schemas/Address" + }, + "shippingMethod": { + "type": "string", + "enum": ["STANDARD", "EXPRESS", "NEXT_DAY"] + }, + "trackingNumber": { + "type": "string" + }, + "customerNotes": { + "type": "string", + "maxLength": 500 + } + } + }, + "OrderResponse": { + "type": "object", + "required": ["orderId", "status", "createdAt"], + "properties": { + "orderId": { + "type": "string" + }, + "status": { + "type": "string", + "enum": ["PENDING", "CONFIRMED", "PROCESSING", "SHIPPED", "DELIVERED", "CANCELLED"] + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "estimatedDeliveryDate": { + "type": "string", + "format": "date" + }, + "trackingNumber": { + "type": "string" + } + } + } + } + }, + "x-amazon-apigateway-request-validators": { + "basic": { + "validateRequestBody": true, + "validateRequestParameters": true + } + }, + "x-amazon-apigateway-request-validator": "basic" +} diff --git a/rest-api-gw-lambda-node-cdk/lib/rest-api-gateway-lambda-stack.ts b/rest-api-gw-lambda-node-cdk/lib/rest-api-gateway-lambda-stack.ts new file mode 100644 index 000000000..c558fd0d3 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/lib/rest-api-gateway-lambda-stack.ts @@ -0,0 +1,36 @@ +// lib/rest-api-gateway-lambda-stack.ts +import * as cdk from 'aws-cdk-lib'; +import { Construct } from 'constructs'; +import { LambdaStack } from './lambda-stack'; +import { ApiGatewayStack } from './api-gateway-stack'; +import { SecretsStack } from './secrets-stack'; + +interface RestApiGwLambdaStackProps extends cdk.StackProps { + stageName: string; +} + +export class RestApiGwLambdaStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: RestApiGwLambdaStackProps) { + super(scope, id, props); + + // Create Secrets Stack + const secretsStack = new SecretsStack(this, 'OrdersSecretsStack', { + crossRegionReferences: true + }); + + // Create Lambda nested stack + const lambdaStack = new LambdaStack(this, 'OrdersLambdaStack', { + stageName: props.stageName, + apiKey: secretsStack.apiKey, + description: 'Lambda functions for the API', + }); + + // Create API Gateway nested stack + new ApiGatewayStack(this, 'OrdersApiStack', { + stageName: props.stageName, + handleLambda: lambdaStack.handleLambda, + searchLambda: lambdaStack.searchLambda, + description: 'API Gateway with Lambda integration', + }); + } +} diff --git a/rest-api-gw-lambda-node-cdk/lib/secrets-stack.ts b/rest-api-gw-lambda-node-cdk/lib/secrets-stack.ts new file mode 100644 index 000000000..f6dc19d90 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/lib/secrets-stack.ts @@ -0,0 +1,25 @@ +// lib/secrets-stack.ts +import * as cdk from 'aws-cdk-lib'; +import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; +import { Construct } from 'constructs'; + +export class SecretsStack extends cdk.NestedStack { + public readonly apiKey: secretsmanager.Secret; + + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + // Create the secret + this.apiKey = new secretsmanager.Secret(this, 'ExternalServiceApiKey', { + secretName: 'orders/api-key', + description: 'API Key for External Payment Service', + removalPolicy: cdk.RemovalPolicy.RETAIN, + }); + + // Output the secret ARN + new cdk.CfnOutput(this, 'ExtenralServiceApiKeySecretArn', { + value: this.apiKey.secretArn, + description: 'ARN of the API Key secret' + }); + } +} \ No newline at end of file diff --git a/rest-api-gw-lambda-node-cdk/package.json b/rest-api-gw-lambda-node-cdk/package.json new file mode 100644 index 000000000..4377f996c --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/package.json @@ -0,0 +1,33 @@ +{ + "name": "cdk", + "version": "0.1.0", + "bin": { + "cdk": "bin/cdk.js" + }, + "scripts": { + "build": "tsc", + "watch": "tsc -w", + "test": "jest", + "cdk": "cdk" + }, + "devDependencies": { + "@aws-lambda-powertools/metrics": "^2.18.0", + "@aws-lambda-powertools/tracer": "^2.18.0", + "@aws-sdk/client-secrets-manager": "^3.787.0", + "@types/aws-lambda": "^8.10.149", + "@types/jest": "^29.5.14", + "@types/js-yaml": "^4.0.9", + "@types/node": "22.14.0", + "aws-cdk": "2.178.2", + "jest": "^29.7.0", + "js-yaml": "^4.1.0", + "ts-jest": "^29.3.2", + "ts-node": "^10.9.2", + "typescript": "~5.8.3" + }, + "dependencies": { + "@aws-lambda-powertools/logger": "^2.18.0", + "aws-cdk-lib": "2.189.1", + "constructs": "^10.4.2" + } +} diff --git a/rest-api-gw-lambda-node-cdk/test/cdk.test.ts b/rest-api-gw-lambda-node-cdk/test/cdk.test.ts new file mode 100644 index 000000000..1e6b29c85 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/test/cdk.test.ts @@ -0,0 +1,17 @@ +// import * as cdk from 'aws-cdk-lib'; +// import { Template } from 'aws-cdk-lib/assertions'; +// import * as Cdk from '../lib/cdk-stack'; + +// example test. To run these tests, uncomment this file along with the +// example resource in lib/cdk-stack.ts +test('SQS Queue Created', () => { +// const app = new cdk.App(); +// // WHEN +// const stack = new Cdk.CdkStack(app, 'MyTestStack'); +// // THEN +// const template = Template.fromStack(stack); + +// template.hasResourceProperties('AWS::SQS::Queue', { +// VisibilityTimeout: 300 +// }); +}); diff --git a/rest-api-gw-lambda-node-cdk/test/sample_create_order.json b/rest-api-gw-lambda-node-cdk/test/sample_create_order.json new file mode 100644 index 000000000..1031cba02 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/test/sample_create_order.json @@ -0,0 +1,47 @@ +{ + "customerId": "CUST123456", + "items": [ + { + "productId": "PROD789", + "productName": "Nike Air Max 2024", + "quantity": 1, + "price": 129.99, + "sku": "NK-AM24-BLK-42", + "variantId": "SIZE-42-BLACK" + }, + { + "productId": "PROD456", + "productName": "Adidas Running Shorts", + "quantity": 2, + "price": 34.99, + "sku": "AD-RS-BLU-M", + "variantId": "SIZE-M-BLUE" + } + ], + "shippingAddress": { + "street": "123 Main Street", + "city": "Seattle", + "state": "WA", + "postalCode": "98101", + "country": "USA", + "apartment": "Unit 45" + }, + "billingAddress": { + "street": "123 Main Street", + "city": "Seattle", + "state": "WA", + "postalCode": "98101", + "country": "USA", + "apartment": "Unit 45" + }, + "subtotal": 199.97, + "tax": 20.00, + "shippingCost": 9.99, + "total": 229.96, + "paymentMethod": "CREDIT_CARD", + "shippingMethod": "EXPRESS", + "customerNotes": "Please leave the package at the front desk if no one is home", + "giftWrapping": true, + "couponCode": "SUMMER20", + "discountAmount": 40.00 + } \ No newline at end of file diff --git a/rest-api-gw-lambda-node-cdk/test/sample_order_response.json b/rest-api-gw-lambda-node-cdk/test/sample_order_response.json new file mode 100644 index 000000000..21b487add --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/test/sample_order_response.json @@ -0,0 +1,8 @@ +{ + "orderId": "ORD-2024-123456", + "status": "CONFIRMED", + "createdAt": "2024-01-20T15:30:00Z", + "estimatedDeliveryDate": "2024-01-22", + "trackingNumber": "1Z999AA1234567890" + } + \ No newline at end of file diff --git a/rest-api-gw-lambda-node-cdk/test/sample_search_order.json b/rest-api-gw-lambda-node-cdk/test/sample_search_order.json new file mode 100644 index 000000000..f685efa84 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/test/sample_search_order.json @@ -0,0 +1,9 @@ +{ + "customerIds": ["CUST123", "CUST456"], + "statuses": ["PROCESSING", "DELIVERED"], + "productIds": ["PROD789"], + "page": 1, + "limit": 20, + "sortBy": "total", + "sortOrder": "desc" + } \ No newline at end of file diff --git a/rest-api-gw-lambda-node-cdk/test/sample_update_order.json b/rest-api-gw-lambda-node-cdk/test/sample_update_order.json new file mode 100644 index 000000000..d6c727e74 --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/test/sample_update_order.json @@ -0,0 +1,9 @@ +{ + "status": "SHIPPED", + "shippingAddress": { + "street": "456 New St", + "city": "Seattle" + }, + "trackingNumber": "1Z999AA1234567890", + "customerNotes": "Leave at door" +} \ No newline at end of file diff --git a/rest-api-gw-lambda-node-cdk/tsconfig.json b/rest-api-gw-lambda-node-cdk/tsconfig.json new file mode 100644 index 000000000..9880d6e3c --- /dev/null +++ b/rest-api-gw-lambda-node-cdk/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "ES2024", + "module": "commonjs", + "lib": [ + "es2024", + "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, + "emitDecoratorMetadata": true, + "strictPropertyInitialization": false, + "typeRoots": [ + "./node_modules/@types" + ] + }, + "exclude": [ + "node_modules", + "cdk.out" + ] +} From 73844ff44fc654848b99918eb4f11a81bd4a344d Mon Sep 17 00:00:00 2001 From: Eleni Dimitropoulou <12170229+eldimi@users.noreply.github.com> Date: Thu, 17 Apr 2025 11:00:20 +0300 Subject: [PATCH 02/21] Remove unnessary metrics invocation --- rest-api-gw-lambda-node-cdk/lib/lambda/handle/index.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/rest-api-gw-lambda-node-cdk/lib/lambda/handle/index.ts b/rest-api-gw-lambda-node-cdk/lib/lambda/handle/index.ts index 4b679622d..d9554c325 100644 --- a/rest-api-gw-lambda-node-cdk/lib/lambda/handle/index.ts +++ b/rest-api-gw-lambda-node-cdk/lib/lambda/handle/index.ts @@ -116,9 +116,7 @@ class HandleOrderLambda implements LambdaInterface { return await deleteOrder(_event); } break; - } - metrics.publishStoredMetrics(); - + } return errorResponse(404, { message: 'Not Found' }); } catch (error) { console.error('Error:', error); From ef95c71a47f54cfdd7b91d086d277b8dae229a96 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Thu, 17 Apr 2025 15:43:01 +0200 Subject: [PATCH 03/21] rename to adhere to common sample dir convention --- {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/.gitignore | 0 {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/README.md | 0 .../bin/rest-api-gw-lambda-nodejs-cdk.ts | 0 {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/cdk.json | 0 .../example-pattern.json | 0 .../jest.config.js | 0 .../lib/api-gateway-stack.ts | 0 .../lib/lambda-stack.ts | 0 .../lib/lambda/handle/index.ts | 0 .../lib/lambda/search/index.ts | 0 .../lib/openapi/openapi.json | 0 .../lib/rest-api-gateway-lambda-stack.ts | 0 .../lib/secrets-stack.ts | 0 .../package.json | 0 .../test/cdk.test.ts | 0 .../test/sample_create_order.json | 0 .../test/sample_order_response.json | 0 .../test/sample_search_order.json | 0 .../test/sample_update_order.json | 0 .../tsconfig.json | 0 20 files changed, 0 insertions(+), 0 deletions(-) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/.gitignore (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/README.md (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/bin/rest-api-gw-lambda-nodejs-cdk.ts (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/cdk.json (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/example-pattern.json (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/jest.config.js (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/lib/api-gateway-stack.ts (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/lib/lambda-stack.ts (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/lib/lambda/handle/index.ts (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/lib/lambda/search/index.ts (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/lib/openapi/openapi.json (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/lib/rest-api-gateway-lambda-stack.ts (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/lib/secrets-stack.ts (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/package.json (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/test/cdk.test.ts (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/test/sample_create_order.json (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/test/sample_order_response.json (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/test/sample_search_order.json (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/test/sample_update_order.json (100%) rename {rest-api-gw-lambda-node-cdk => apigw-lambda-node-cdk}/tsconfig.json (100%) diff --git a/rest-api-gw-lambda-node-cdk/.gitignore b/apigw-lambda-node-cdk/.gitignore similarity index 100% rename from rest-api-gw-lambda-node-cdk/.gitignore rename to apigw-lambda-node-cdk/.gitignore diff --git a/rest-api-gw-lambda-node-cdk/README.md b/apigw-lambda-node-cdk/README.md similarity index 100% rename from rest-api-gw-lambda-node-cdk/README.md rename to apigw-lambda-node-cdk/README.md diff --git a/rest-api-gw-lambda-node-cdk/bin/rest-api-gw-lambda-nodejs-cdk.ts b/apigw-lambda-node-cdk/bin/rest-api-gw-lambda-nodejs-cdk.ts similarity index 100% rename from rest-api-gw-lambda-node-cdk/bin/rest-api-gw-lambda-nodejs-cdk.ts rename to apigw-lambda-node-cdk/bin/rest-api-gw-lambda-nodejs-cdk.ts diff --git a/rest-api-gw-lambda-node-cdk/cdk.json b/apigw-lambda-node-cdk/cdk.json similarity index 100% rename from rest-api-gw-lambda-node-cdk/cdk.json rename to apigw-lambda-node-cdk/cdk.json diff --git a/rest-api-gw-lambda-node-cdk/example-pattern.json b/apigw-lambda-node-cdk/example-pattern.json similarity index 100% rename from rest-api-gw-lambda-node-cdk/example-pattern.json rename to apigw-lambda-node-cdk/example-pattern.json diff --git a/rest-api-gw-lambda-node-cdk/jest.config.js b/apigw-lambda-node-cdk/jest.config.js similarity index 100% rename from rest-api-gw-lambda-node-cdk/jest.config.js rename to apigw-lambda-node-cdk/jest.config.js diff --git a/rest-api-gw-lambda-node-cdk/lib/api-gateway-stack.ts b/apigw-lambda-node-cdk/lib/api-gateway-stack.ts similarity index 100% rename from rest-api-gw-lambda-node-cdk/lib/api-gateway-stack.ts rename to apigw-lambda-node-cdk/lib/api-gateway-stack.ts diff --git a/rest-api-gw-lambda-node-cdk/lib/lambda-stack.ts b/apigw-lambda-node-cdk/lib/lambda-stack.ts similarity index 100% rename from rest-api-gw-lambda-node-cdk/lib/lambda-stack.ts rename to apigw-lambda-node-cdk/lib/lambda-stack.ts diff --git a/rest-api-gw-lambda-node-cdk/lib/lambda/handle/index.ts b/apigw-lambda-node-cdk/lib/lambda/handle/index.ts similarity index 100% rename from rest-api-gw-lambda-node-cdk/lib/lambda/handle/index.ts rename to apigw-lambda-node-cdk/lib/lambda/handle/index.ts diff --git a/rest-api-gw-lambda-node-cdk/lib/lambda/search/index.ts b/apigw-lambda-node-cdk/lib/lambda/search/index.ts similarity index 100% rename from rest-api-gw-lambda-node-cdk/lib/lambda/search/index.ts rename to apigw-lambda-node-cdk/lib/lambda/search/index.ts diff --git a/rest-api-gw-lambda-node-cdk/lib/openapi/openapi.json b/apigw-lambda-node-cdk/lib/openapi/openapi.json similarity index 100% rename from rest-api-gw-lambda-node-cdk/lib/openapi/openapi.json rename to apigw-lambda-node-cdk/lib/openapi/openapi.json diff --git a/rest-api-gw-lambda-node-cdk/lib/rest-api-gateway-lambda-stack.ts b/apigw-lambda-node-cdk/lib/rest-api-gateway-lambda-stack.ts similarity index 100% rename from rest-api-gw-lambda-node-cdk/lib/rest-api-gateway-lambda-stack.ts rename to apigw-lambda-node-cdk/lib/rest-api-gateway-lambda-stack.ts diff --git a/rest-api-gw-lambda-node-cdk/lib/secrets-stack.ts b/apigw-lambda-node-cdk/lib/secrets-stack.ts similarity index 100% rename from rest-api-gw-lambda-node-cdk/lib/secrets-stack.ts rename to apigw-lambda-node-cdk/lib/secrets-stack.ts diff --git a/rest-api-gw-lambda-node-cdk/package.json b/apigw-lambda-node-cdk/package.json similarity index 100% rename from rest-api-gw-lambda-node-cdk/package.json rename to apigw-lambda-node-cdk/package.json diff --git a/rest-api-gw-lambda-node-cdk/test/cdk.test.ts b/apigw-lambda-node-cdk/test/cdk.test.ts similarity index 100% rename from rest-api-gw-lambda-node-cdk/test/cdk.test.ts rename to apigw-lambda-node-cdk/test/cdk.test.ts diff --git a/rest-api-gw-lambda-node-cdk/test/sample_create_order.json b/apigw-lambda-node-cdk/test/sample_create_order.json similarity index 100% rename from rest-api-gw-lambda-node-cdk/test/sample_create_order.json rename to apigw-lambda-node-cdk/test/sample_create_order.json diff --git a/rest-api-gw-lambda-node-cdk/test/sample_order_response.json b/apigw-lambda-node-cdk/test/sample_order_response.json similarity index 100% rename from rest-api-gw-lambda-node-cdk/test/sample_order_response.json rename to apigw-lambda-node-cdk/test/sample_order_response.json diff --git a/rest-api-gw-lambda-node-cdk/test/sample_search_order.json b/apigw-lambda-node-cdk/test/sample_search_order.json similarity index 100% rename from rest-api-gw-lambda-node-cdk/test/sample_search_order.json rename to apigw-lambda-node-cdk/test/sample_search_order.json diff --git a/rest-api-gw-lambda-node-cdk/test/sample_update_order.json b/apigw-lambda-node-cdk/test/sample_update_order.json similarity index 100% rename from rest-api-gw-lambda-node-cdk/test/sample_update_order.json rename to apigw-lambda-node-cdk/test/sample_update_order.json diff --git a/rest-api-gw-lambda-node-cdk/tsconfig.json b/apigw-lambda-node-cdk/tsconfig.json similarity index 100% rename from rest-api-gw-lambda-node-cdk/tsconfig.json rename to apigw-lambda-node-cdk/tsconfig.json From eaa54e44d14efff2a7844af01ef95015ff316c94 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Thu, 17 Apr 2025 16:13:56 +0200 Subject: [PATCH 04/21] convert lambda.Function to lambdaNodejs.NodejsFunction --- apigw-lambda-node-cdk/lib/lambda-stack.ts | 49 +++++++++++------------ apigw-lambda-node-cdk/package.json | 3 +- 2 files changed, 26 insertions(+), 26 deletions(-) diff --git a/apigw-lambda-node-cdk/lib/lambda-stack.ts b/apigw-lambda-node-cdk/lib/lambda-stack.ts index ae2311217..66282c38d 100644 --- a/apigw-lambda-node-cdk/lib/lambda-stack.ts +++ b/apigw-lambda-node-cdk/lib/lambda-stack.ts @@ -1,6 +1,7 @@ // lib/lambda-stack.ts import * as cdk from 'aws-cdk-lib'; import * as lambda from 'aws-cdk-lib/aws-lambda'; +import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs'; import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; import { Construct } from 'constructs'; @@ -22,10 +23,10 @@ export class LambdaStack extends cdk.NestedStack { `arn:aws:lambda:${cdk.Stack.of(this).region}:094274105915:layer:AWSLambdaPowertoolsTypeScriptV2:3` ); - this.handleLambda = new lambda.Function(this, 'Handle', { - runtime: lambda.Runtime.NODEJS_20_X, - handler: 'index.handler', - code: lambda.Code.fromAsset('lib/lambda/handle'), + this.handleLambda = new lambdaNodejs.NodejsFunction(this, 'Handle', { + runtime: lambda.Runtime.NODEJS_22_X, + handler: 'handler', + entry: 'lib/lambda/handle/index.ts', environment: { POWERTOOLS_SERVICE_NAME: 'handleOrders', POWERTOOLS_LOG_LEVEL: 'INFO', @@ -45,27 +46,25 @@ export class LambdaStack extends cdk.NestedStack { props.apiKey.grantRead(this.handleLambda); - this.searchLambda = new lambda.Function(this, 'Search', { - runtime: lambda.Runtime.NODEJS_20_X, - handler: 'index.handler', - code: lambda.Code.fromAsset('lib/lambda/search'), - environment: { - POWERTOOLS_SERVICE_NAME: 'searchOrders', - POWERTOOLS_LOG_LEVEL: 'INFO', - POWERTOOLS_METRICS_NAMESPACE: 'RestAPIGWLambda', - POWERTOOLS_TRACE_ENABLED: 'true', - POWERTOOLS_METRICS_FUNCTION_NAME: 'search', - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: 'true', - POWERTOOLS_TRACER_CAPTURE_RESPONSE: 'true', - POWERTOOLS_DEV: 'false', - STAGE: props.stageName, - }, - tracing: lambda.Tracing.ACTIVE, - layers: [powertoolsLayer], - timeout: cdk.Duration.seconds(30), - }); - - + this.searchLambda = new lambdaNodejs.NodejsFunction(this, 'Search', { + runtime: lambda.Runtime.NODEJS_22_X, + handler: 'handler', + entry: 'lib/lambda/search/index.ts', + environment: { + POWERTOOLS_SERVICE_NAME: 'searchOrders', + POWERTOOLS_LOG_LEVEL: 'INFO', + POWERTOOLS_METRICS_NAMESPACE: 'RestAPIGWLambda', + POWERTOOLS_TRACE_ENABLED: 'true', + POWERTOOLS_METRICS_FUNCTION_NAME: 'search', + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: 'true', + POWERTOOLS_TRACER_CAPTURE_RESPONSE: 'true', + POWERTOOLS_DEV: 'false', + STAGE: props.stageName, + }, + tracing: lambda.Tracing.ACTIVE, + layers: [powertoolsLayer], + timeout: cdk.Duration.seconds(30), + }); } diff --git a/apigw-lambda-node-cdk/package.json b/apigw-lambda-node-cdk/package.json index 4377f996c..4ec81d6aa 100644 --- a/apigw-lambda-node-cdk/package.json +++ b/apigw-lambda-node-cdk/package.json @@ -28,6 +28,7 @@ "dependencies": { "@aws-lambda-powertools/logger": "^2.18.0", "aws-cdk-lib": "2.189.1", - "constructs": "^10.4.2" + "constructs": "^10.4.2", + "esbuild": "^0.25.2" } } From 9b63bedecee9870c1c9c69be8d1e239d799825f2 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Thu, 17 Apr 2025 17:35:52 +0200 Subject: [PATCH 05/21] renamings --- ...i-gw-lambda-nodejs-cdk.ts => apigw-lambda-node-cdk.ts} | 4 ++-- apigw-lambda-node-cdk/cdk.json | 2 +- apigw-lambda-node-cdk/lib/api-gateway-stack.ts | 2 +- ...way-lambda-stack.ts => apigw-lambda-node-cdk-stack.ts} | 6 +++--- apigw-lambda-node-cdk/lib/lambda-stack.ts | 8 ++++---- .../lib/lambda/{handle => ordersCRUD}/index.ts | 8 +++++--- .../lib/lambda/{search => ordersSearch}/index.ts | 0 7 files changed, 16 insertions(+), 14 deletions(-) rename apigw-lambda-node-cdk/bin/{rest-api-gw-lambda-nodejs-cdk.ts => apigw-lambda-node-cdk.ts} (58%) rename apigw-lambda-node-cdk/lib/{rest-api-gateway-lambda-stack.ts => apigw-lambda-node-cdk-stack.ts} (83%) rename apigw-lambda-node-cdk/lib/lambda/{handle => ordersCRUD}/index.ts (99%) rename apigw-lambda-node-cdk/lib/lambda/{search => ordersSearch}/index.ts (100%) diff --git a/apigw-lambda-node-cdk/bin/rest-api-gw-lambda-nodejs-cdk.ts b/apigw-lambda-node-cdk/bin/apigw-lambda-node-cdk.ts similarity index 58% rename from apigw-lambda-node-cdk/bin/rest-api-gw-lambda-nodejs-cdk.ts rename to apigw-lambda-node-cdk/bin/apigw-lambda-node-cdk.ts index 11da793e4..963aa331d 100644 --- a/apigw-lambda-node-cdk/bin/rest-api-gw-lambda-nodejs-cdk.ts +++ b/apigw-lambda-node-cdk/bin/apigw-lambda-node-cdk.ts @@ -1,10 +1,10 @@ #!/usr/bin/env node import * as cdk from 'aws-cdk-lib'; -import { RestApiGwLambdaStack } from '../lib/rest-api-gateway-lambda-stack'; +import { ApigwLambdaNodeStack } from '../lib/apigw-lambda-node-cdk-stack'; const app = new cdk.App(); -new RestApiGwLambdaStack(app, 'RestApiGwLambdaStack', { +new ApigwLambdaNodeStack(app, 'ApigwLambdaStack', { stageName: 'v1', description: 'REST API Gateway with Lambda integration using openapi spec', }); \ No newline at end of file diff --git a/apigw-lambda-node-cdk/cdk.json b/apigw-lambda-node-cdk/cdk.json index 39cb85fc4..e18bd21dd 100644 --- a/apigw-lambda-node-cdk/cdk.json +++ b/apigw-lambda-node-cdk/cdk.json @@ -1,5 +1,5 @@ { - "app": "npx ts-node --prefer-ts-exts bin/rest-api-gw-lambda-nodejs-cdk.ts", + "app": "npx ts-node --prefer-ts-exts bin/apigw-lambda-node-cdk.ts", "watch": { "include": [ "**" diff --git a/apigw-lambda-node-cdk/lib/api-gateway-stack.ts b/apigw-lambda-node-cdk/lib/api-gateway-stack.ts index 5dfc3cf3a..4e04a2f07 100644 --- a/apigw-lambda-node-cdk/lib/api-gateway-stack.ts +++ b/apigw-lambda-node-cdk/lib/api-gateway-stack.ts @@ -33,7 +33,7 @@ export class ApiGatewayStack extends cdk.NestedStack { ); this.api = new apigateway.SpecRestApi(this, 'SampleApiGatewayLambda2Api', { - restApiName: 'My API Service', + restApiName: 'OrdersAPI', description: 'API Gateway with Lambda integration', apiDefinition: apigateway.ApiDefinition.fromInline(openApiSpec), deployOptions: { diff --git a/apigw-lambda-node-cdk/lib/rest-api-gateway-lambda-stack.ts b/apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts similarity index 83% rename from apigw-lambda-node-cdk/lib/rest-api-gateway-lambda-stack.ts rename to apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts index c558fd0d3..10bc157bd 100644 --- a/apigw-lambda-node-cdk/lib/rest-api-gateway-lambda-stack.ts +++ b/apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts @@ -5,12 +5,12 @@ import { LambdaStack } from './lambda-stack'; import { ApiGatewayStack } from './api-gateway-stack'; import { SecretsStack } from './secrets-stack'; -interface RestApiGwLambdaStackProps extends cdk.StackProps { +interface ApigwLambdaNodeStackProps extends cdk.StackProps { stageName: string; } -export class RestApiGwLambdaStack extends cdk.Stack { - constructor(scope: Construct, id: string, props: RestApiGwLambdaStackProps) { +export class ApigwLambdaNodeStack extends cdk.Stack { + constructor(scope: Construct, id: string, props: ApigwLambdaNodeStackProps) { super(scope, id, props); // Create Secrets Stack diff --git a/apigw-lambda-node-cdk/lib/lambda-stack.ts b/apigw-lambda-node-cdk/lib/lambda-stack.ts index 66282c38d..9f36d991a 100644 --- a/apigw-lambda-node-cdk/lib/lambda-stack.ts +++ b/apigw-lambda-node-cdk/lib/lambda-stack.ts @@ -26,9 +26,9 @@ export class LambdaStack extends cdk.NestedStack { this.handleLambda = new lambdaNodejs.NodejsFunction(this, 'Handle', { runtime: lambda.Runtime.NODEJS_22_X, handler: 'handler', - entry: 'lib/lambda/handle/index.ts', + entry: 'lib/lambda/ordersCRUD/index.ts', environment: { - POWERTOOLS_SERVICE_NAME: 'handleOrders', + POWERTOOLS_SERVICE_NAME: 'ordersCRUD', POWERTOOLS_LOG_LEVEL: 'INFO', POWERTOOLS_METRICS_NAMESPACE: 'RestAPIGWLambda', POWERTOOLS_TRACE_ENABLED: 'true', @@ -49,9 +49,9 @@ export class LambdaStack extends cdk.NestedStack { this.searchLambda = new lambdaNodejs.NodejsFunction(this, 'Search', { runtime: lambda.Runtime.NODEJS_22_X, handler: 'handler', - entry: 'lib/lambda/search/index.ts', + entry: 'lib/lambda/ordersSearch/index.ts', environment: { - POWERTOOLS_SERVICE_NAME: 'searchOrders', + POWERTOOLS_SERVICE_NAME: 'ordersSearch', POWERTOOLS_LOG_LEVEL: 'INFO', POWERTOOLS_METRICS_NAMESPACE: 'RestAPIGWLambda', POWERTOOLS_TRACE_ENABLED: 'true', diff --git a/apigw-lambda-node-cdk/lib/lambda/handle/index.ts b/apigw-lambda-node-cdk/lib/lambda/ordersCRUD/index.ts similarity index 99% rename from apigw-lambda-node-cdk/lib/lambda/handle/index.ts rename to apigw-lambda-node-cdk/lib/lambda/ordersCRUD/index.ts index d9554c325..f338b4656 100644 --- a/apigw-lambda-node-cdk/lib/lambda/handle/index.ts +++ b/apigw-lambda-node-cdk/lib/lambda/ordersCRUD/index.ts @@ -67,6 +67,10 @@ sampleOrders.forEach(order => orderCache.set(order.orderId, order)); const logger = new Logger(); +logger.appendKeys({ + stage: process.env.STAGE, +}); + const metrics = new Metrics(); const tracer = new Tracer(); const secretsManager = new SecretsManager(); @@ -81,9 +85,7 @@ class HandleOrderLambda implements LambdaInterface { @metrics.logMetrics() @logger.injectLambdaContext() public async handler(_event: APIGatewayProxyEvent, _context: Context): Promise { - logger.appendKeys({ - stage: process.env.STAGE, - }); + logger.info('Processing event', { _event }); metrics.addMetric('ProcessedEvents', MetricUnit.Count, 1); tracer.getSegment(); diff --git a/apigw-lambda-node-cdk/lib/lambda/search/index.ts b/apigw-lambda-node-cdk/lib/lambda/ordersSearch/index.ts similarity index 100% rename from apigw-lambda-node-cdk/lib/lambda/search/index.ts rename to apigw-lambda-node-cdk/lib/lambda/ordersSearch/index.ts From 7ddc904b63f1a29208d86e2989da4031e9b6d26e Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Sat, 19 Apr 2025 08:35:56 +0200 Subject: [PATCH 06/21] enums used multiple times to own schema --- .../lib/openapi/openapi.json | 33 ++++++++++++++----- 1 file changed, 25 insertions(+), 8 deletions(-) diff --git a/apigw-lambda-node-cdk/lib/openapi/openapi.json b/apigw-lambda-node-cdk/lib/openapi/openapi.json index 9dd59d358..437b6a0fb 100644 --- a/apigw-lambda-node-cdk/lib/openapi/openapi.json +++ b/apigw-lambda-node-cdk/lib/openapi/openapi.json @@ -278,6 +278,27 @@ } } }, + "OrderStatus": { + "type": "string", + "enum": [ + "PENDING", + "CONFIRMED", + "PROCESSING", + "SHIPPED", + "DELIVERED", + "CANCELLED" + ], + "description": "The current status of the order" + }, + "ShippingMethod": { + "type": "string", + "enum": [ + "STANDARD", + "EXPRESS", + "NEXT_DAY" + ], + "description": "Available shipping methods for orders" + }, "OrderItem": { "type": "object", "required": ["productId", "quantity", "price"], @@ -351,8 +372,7 @@ "enum": ["CREDIT_CARD", "DEBIT_CARD", "PAYPAL", "BANK_TRANSFER"] }, "shippingMethod": { - "type": "string", - "enum": ["STANDARD", "EXPRESS", "NEXT_DAY"] + "$ref": "#/components/schemas/ShippingMethod" }, "customerNotes": { "type": "string", @@ -376,15 +396,13 @@ "type": "object", "properties": { "status": { - "type": "string", - "enum": ["PENDING", "CONFIRMED", "PROCESSING", "SHIPPED", "DELIVERED", "CANCELLED"] + "$ref": "#/components/schemas/OrderStatus" }, "shippingAddress": { "$ref": "#/components/schemas/Address" }, "shippingMethod": { - "type": "string", - "enum": ["STANDARD", "EXPRESS", "NEXT_DAY"] + "$ref": "#/components/schemas/ShippingMethod" }, "trackingNumber": { "type": "string" @@ -403,8 +421,7 @@ "type": "string" }, "status": { - "type": "string", - "enum": ["PENDING", "CONFIRMED", "PROCESSING", "SHIPPED", "DELIVERED", "CANCELLED"] + "$ref": "#/components/schemas/OrderStatus" }, "createdAt": { "type": "string", From 2b6953b745573aa190a42cee599f2ace1e238dd4 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Sun, 20 Apr 2025 12:17:50 +0200 Subject: [PATCH 07/21] adjust to pattern demo, simplify, formatting --- .../lib/openapi/openapi.json | 102 +++++++++--------- 1 file changed, 53 insertions(+), 49 deletions(-) diff --git a/apigw-lambda-node-cdk/lib/openapi/openapi.json b/apigw-lambda-node-cdk/lib/openapi/openapi.json index 437b6a0fb..82eff87d0 100644 --- a/apigw-lambda-node-cdk/lib/openapi/openapi.json +++ b/apigw-lambda-node-cdk/lib/openapi/openapi.json @@ -13,7 +13,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/Order" + "$ref": "#/components/schemas/OrderCreationInput" } } } @@ -24,7 +24,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderResponse" + "$ref": "#/components/schemas/Order" } } } @@ -62,7 +62,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderResponse" + "$ref": "#/components/schemas/Order" } } } @@ -108,7 +108,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/OrderResponse" + "$ref": "#/components/schemas/Order" } } } @@ -175,8 +175,7 @@ "statuses": { "type": "array", "items": { - "type": "string", - "enum": ["PENDING", "CONFIRMED", "PROCESSING", "SHIPPED", "DELIVERED", "CANCELLED"] + "$ref": "#/components/schemas/OrderStatus" } }, "productIds": { @@ -220,7 +219,7 @@ "items": { "type": "array", "items": { - "$ref": "#/components/schemas/OrderResponse" + "$ref": "#/components/schemas/Order" } }, "pagination": { @@ -292,13 +291,13 @@ }, "ShippingMethod": { "type": "string", - "enum": [ - "STANDARD", - "EXPRESS", - "NEXT_DAY" - ], + "enum": ["STANDARD", "EXPRESS", "NEXT_DAY"], "description": "Available shipping methods for orders" }, + "PaymentMethod": { + "type": "string", + "enum": ["CREDIT_CARD", "DEBIT_CARD", "PAYPAL", "BANK_TRANSFER"] + }, "OrderItem": { "type": "object", "required": ["productId", "quantity", "price"], @@ -327,9 +326,14 @@ } } }, - "Order": { + "OrderCreationInput": { "type": "object", - "required": ["customerId", "items", "shippingAddress", "billingAddress"], + "required": [ + "customerId", + "items", + "shippingAddress", + "billingAddress" + ], "properties": { "customerId": { "type": "string" @@ -347,11 +351,6 @@ "billingAddress": { "$ref": "#/components/schemas/Address" }, - "subtotal": { - "type": "number", - "format": "float", - "minimum": 0 - }, "tax": { "type": "number", "format": "float", @@ -362,14 +361,8 @@ "format": "float", "minimum": 0 }, - "total": { - "type": "number", - "format": "float", - "minimum": 0 - }, "paymentMethod": { - "type": "string", - "enum": ["CREDIT_CARD", "DEBIT_CARD", "PAYPAL", "BANK_TRANSFER"] + "$ref": "#/components/schemas/PaymentMethod" }, "shippingMethod": { "$ref": "#/components/schemas/ShippingMethod" @@ -392,6 +385,40 @@ } } }, + "Order": { + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/OrderCreationInput" + }, + { + "type": "object", + "required": ["orderId", "status", "createdAt"], + "properties": { + "orderId": { + "type": "string", + "readOnly": true + }, + "status": { + "$ref": "#/components/schemas/OrderStatus", + "readOnly": true + }, + "createdAt": { + "type": "string", + "format": "date-time", + "readOnly": true + }, + "estimatedDeliveryDate": { + "type": "string", + "format": "date" + }, + "trackingNumber": { + "type": "string" + } + } + } + ] + }, "OrderUpdate": { "type": "object", "properties": { @@ -412,29 +439,6 @@ "maxLength": 500 } } - }, - "OrderResponse": { - "type": "object", - "required": ["orderId", "status", "createdAt"], - "properties": { - "orderId": { - "type": "string" - }, - "status": { - "$ref": "#/components/schemas/OrderStatus" - }, - "createdAt": { - "type": "string", - "format": "date-time" - }, - "estimatedDeliveryDate": { - "type": "string", - "format": "date" - }, - "trackingNumber": { - "type": "string" - } - } } } }, From b0f7c513010bc80c5dd92ced645bcd3c56878a40 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Tue, 22 Apr 2025 09:12:56 +0200 Subject: [PATCH 08/21] add orders common code --- apigw-lambda-node-cdk/lib/lambda-stack.ts | 63 ++-- .../lib/openapi/openapi.json | 2 +- .../lib/ordersCommonCode/types.ts | 320 ++++++++++++++++++ apigw-lambda-node-cdk/package.json | 3 +- apigw-lambda-node-cdk/tsconfig.json | 12 +- 5 files changed, 369 insertions(+), 31 deletions(-) create mode 100644 apigw-lambda-node-cdk/lib/ordersCommonCode/types.ts diff --git a/apigw-lambda-node-cdk/lib/lambda-stack.ts b/apigw-lambda-node-cdk/lib/lambda-stack.ts index 9f36d991a..90c753694 100644 --- a/apigw-lambda-node-cdk/lib/lambda-stack.ts +++ b/apigw-lambda-node-cdk/lib/lambda-stack.ts @@ -19,50 +19,59 @@ export class LambdaStack extends cdk.NestedStack { const powertoolsLayer = lambda.LayerVersion.fromLayerVersionArn( this, - 'powertools-layer', - `arn:aws:lambda:${cdk.Stack.of(this).region}:094274105915:layer:AWSLambdaPowertoolsTypeScriptV2:3` + "powertools-layer", + `arn:aws:lambda:${ + cdk.Stack.of(this).region + }:094274105915:layer:AWSLambdaPowertoolsTypeScriptV2:3` ); - this.handleLambda = new lambdaNodejs.NodejsFunction(this, 'Handle', { + const ordersLayer = new lambda.LayerVersion(this, "orders-layer", { + code: lambda.Code.fromAsset("lib/ordersCommonCode"), + compatibleRuntimes: [lambda.Runtime.NODEJS_22_X], + description: "Common orders code layer", + layerVersionName: "orders-layer", + }); + + this.handleLambda = new lambdaNodejs.NodejsFunction(this, "Handle", { runtime: lambda.Runtime.NODEJS_22_X, - handler: 'handler', - entry: 'lib/lambda/ordersCRUD/index.ts', + handler: "handler", + entry: "lib/lambda/ordersCRUD/index.ts", environment: { - POWERTOOLS_SERVICE_NAME: 'ordersCRUD', - POWERTOOLS_LOG_LEVEL: 'INFO', - POWERTOOLS_METRICS_NAMESPACE: 'RestAPIGWLambda', - POWERTOOLS_TRACE_ENABLED: 'true', - POWERTOOLS_METRICS_FUNCTION_NAME: 'handle', - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: 'true', - POWERTOOLS_TRACER_CAPTURE_RESPONSE: 'true', - POWERTOOLS_DEV: 'false', + POWERTOOLS_SERVICE_NAME: "ordersCRUD", + POWERTOOLS_LOG_LEVEL: "INFO", + POWERTOOLS_METRICS_NAMESPACE: "RestAPIGWLambda", + POWERTOOLS_TRACE_ENABLED: "true", + POWERTOOLS_METRICS_FUNCTION_NAME: "handle", + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: "true", + POWERTOOLS_TRACER_CAPTURE_RESPONSE: "true", + POWERTOOLS_DEV: "false", STAGE: props.stageName, - API_KEY_SECRET_ARN: props.apiKey.secretArn + API_KEY_SECRET_ARN: props.apiKey.secretArn, }, tracing: lambda.Tracing.ACTIVE, - layers: [powertoolsLayer], + layers: [powertoolsLayer, ordersLayer], timeout: cdk.Duration.seconds(30), }); props.apiKey.grantRead(this.handleLambda); - this.searchLambda = new lambdaNodejs.NodejsFunction(this, 'Search', { + this.searchLambda = new lambdaNodejs.NodejsFunction(this, "Search", { runtime: lambda.Runtime.NODEJS_22_X, - handler: 'handler', - entry: 'lib/lambda/ordersSearch/index.ts', + handler: "handler", + entry: "lib/lambda/ordersSearch/index.ts", environment: { - POWERTOOLS_SERVICE_NAME: 'ordersSearch', - POWERTOOLS_LOG_LEVEL: 'INFO', - POWERTOOLS_METRICS_NAMESPACE: 'RestAPIGWLambda', - POWERTOOLS_TRACE_ENABLED: 'true', - POWERTOOLS_METRICS_FUNCTION_NAME: 'search', - POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: 'true', - POWERTOOLS_TRACER_CAPTURE_RESPONSE: 'true', - POWERTOOLS_DEV: 'false', + POWERTOOLS_SERVICE_NAME: "ordersSearch", + POWERTOOLS_LOG_LEVEL: "INFO", + POWERTOOLS_METRICS_NAMESPACE: "RestAPIGWLambda", + POWERTOOLS_TRACE_ENABLED: "true", + POWERTOOLS_METRICS_FUNCTION_NAME: "search", + POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: "true", + POWERTOOLS_TRACER_CAPTURE_RESPONSE: "true", + POWERTOOLS_DEV: "false", STAGE: props.stageName, }, tracing: lambda.Tracing.ACTIVE, - layers: [powertoolsLayer], + layers: [powertoolsLayer, ordersLayer], timeout: cdk.Duration.seconds(30), }); diff --git a/apigw-lambda-node-cdk/lib/openapi/openapi.json b/apigw-lambda-node-cdk/lib/openapi/openapi.json index 82eff87d0..600007141 100644 --- a/apigw-lambda-node-cdk/lib/openapi/openapi.json +++ b/apigw-lambda-node-cdk/lib/openapi/openapi.json @@ -195,7 +195,7 @@ }, "sortBy": { "type": "string", - "enum": ["createdAt", "total", "status"], + "enum": ["createdAt", "status"], "default": "createdAt" }, "sortOrder": { diff --git a/apigw-lambda-node-cdk/lib/ordersCommonCode/types.ts b/apigw-lambda-node-cdk/lib/ordersCommonCode/types.ts new file mode 100644 index 000000000..5232c8f8c --- /dev/null +++ b/apigw-lambda-node-cdk/lib/ordersCommonCode/types.ts @@ -0,0 +1,320 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + "/order": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Create order */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OrderCreationInput"]; + }; + }; + responses: { + /** @description Order created successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Order"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/order/{orderId}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get order by ID */ + get: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the order to retrieve */ + orderId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Order"]; + }; + }; + /** @description Order not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + /** Update order */ + put: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the order to update */ + orderId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["OrderUpdate"]; + }; + }; + responses: { + /** @description Order updated successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Order"]; + }; + }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Order not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + post?: never; + /** Delete order */ + delete: { + parameters: { + query?: never; + header?: never; + path: { + /** @description ID of the order to delete */ + orderId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Order deleted successfully */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Order not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/orders/search": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Search orders */ + post: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": { + customerIds?: string[]; + statuses?: components["schemas"]["OrderStatus"][]; + productIds?: string[]; + /** @default 1 */ + page?: number; + /** @default 20 */ + limit?: number; + /** + * @default createdAt + * @enum {string} + */ + sortBy?: "createdAt" | "status"; + /** + * @default desc + * @enum {string} + */ + sortOrder?: "asc" | "desc"; + }; + }; + }; + responses: { + /** @description Successful search results */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": { + items?: components["schemas"]["Order"][]; + pagination?: { + total?: number; + pages?: number; + currentPage?: number; + limit?: number; + }; + }; + }; + }; + /** @description Invalid search criteria */ + 400: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + Address: { + /** @description Street address including house number */ + street: string; + city: string; + state: string; + postalCode: string; + country: string; + /** @description Apartment, suite, or unit number */ + apartment?: string; + }; + /** + * @description The current status of the order + * @enum {string} + */ + OrderStatus: "PENDING" | "CONFIRMED" | "PROCESSING" | "SHIPPED" | "DELIVERED" | "CANCELLED"; + /** + * @description Available shipping methods for orders + * @enum {string} + */ + ShippingMethod: "STANDARD" | "EXPRESS" | "NEXT_DAY"; + /** @enum {string} */ + PaymentMethod: "CREDIT_CARD" | "DEBIT_CARD" | "PAYPAL" | "BANK_TRANSFER"; + OrderItem: { + productId: string; + productName?: string; + quantity: number; + /** Format: float */ + price: number; + sku?: string; + /** @description ID for specific product variant (size, color, etc.) */ + variantId?: string; + }; + OrderCreationInput: { + customerId: string; + items: components["schemas"]["OrderItem"][]; + shippingAddress: components["schemas"]["Address"]; + billingAddress: components["schemas"]["Address"]; + /** Format: float */ + tax?: number; + /** Format: float */ + shippingCost?: number; + paymentMethod?: components["schemas"]["PaymentMethod"]; + shippingMethod?: components["schemas"]["ShippingMethod"]; + customerNotes?: string; + /** @default false */ + giftWrapping: boolean; + couponCode?: string; + /** Format: float */ + discountAmount?: number; + }; + Order: components["schemas"]["OrderCreationInput"] & { + readonly orderId: string; + readonly status: components["schemas"]["OrderStatus"]; + /** Format: date-time */ + readonly createdAt: string; + /** Format: date */ + estimatedDeliveryDate?: string; + trackingNumber?: string; + }; + OrderUpdate: { + status?: components["schemas"]["OrderStatus"]; + shippingAddress?: components["schemas"]["Address"]; + shippingMethod?: components["schemas"]["ShippingMethod"]; + trackingNumber?: string; + customerNotes?: string; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export type operations = Record; diff --git a/apigw-lambda-node-cdk/package.json b/apigw-lambda-node-cdk/package.json index 4ec81d6aa..9e8dde2d2 100644 --- a/apigw-lambda-node-cdk/package.json +++ b/apigw-lambda-node-cdk/package.json @@ -29,6 +29,7 @@ "@aws-lambda-powertools/logger": "^2.18.0", "aws-cdk-lib": "2.189.1", "constructs": "^10.4.2", - "esbuild": "^0.25.2" + "esbuild": "^0.25.2", + "openapi-typescript": "^7.6.1" } } diff --git a/apigw-lambda-node-cdk/tsconfig.json b/apigw-lambda-node-cdk/tsconfig.json index 9880d6e3c..e400e82b4 100644 --- a/apigw-lambda-node-cdk/tsconfig.json +++ b/apigw-lambda-node-cdk/tsconfig.json @@ -1,7 +1,9 @@ { "compilerOptions": { "target": "ES2024", - "module": "commonjs", + "module": "CommonJS", + "moduleResolution": "node", + "noUncheckedIndexedAccess": true, "lib": [ "es2024", "dom" @@ -23,7 +25,13 @@ "strictPropertyInitialization": false, "typeRoots": [ "./node_modules/@types" - ] + ], + "baseUrl": ".", + "paths": { + "/opt/ordersCommonCode/*": [ + "lib/ordersCommonCode/*" + ] + } }, "exclude": [ "node_modules", From fdff121a995570dc8a2343c1e66963138aba2841 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Tue, 22 Apr 2025 09:25:30 +0200 Subject: [PATCH 09/21] add dynamodb table --- .../lib/apigw-lambda-node-cdk-stack.ts | 4 +++ apigw-lambda-node-cdk/lib/dynamodb-stack.ts | 25 +++++++++++++++++++ apigw-lambda-node-cdk/lib/lambda-stack.ts | 22 +++++++++------- 3 files changed, 42 insertions(+), 9 deletions(-) create mode 100644 apigw-lambda-node-cdk/lib/dynamodb-stack.ts diff --git a/apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts b/apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts index 10bc157bd..9d6a8f131 100644 --- a/apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts +++ b/apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts @@ -1,6 +1,7 @@ // lib/rest-api-gateway-lambda-stack.ts import * as cdk from 'aws-cdk-lib'; import { Construct } from 'constructs'; +import { DynamoDBStack } from './dynamodb-stack'; import { LambdaStack } from './lambda-stack'; import { ApiGatewayStack } from './api-gateway-stack'; import { SecretsStack } from './secrets-stack'; @@ -18,11 +19,14 @@ export class ApigwLambdaNodeStack extends cdk.Stack { crossRegionReferences: true }); + const dynamoDbStack = new DynamoDBStack(this, 'OrdersDynamoDBStack') + // Create Lambda nested stack const lambdaStack = new LambdaStack(this, 'OrdersLambdaStack', { stageName: props.stageName, apiKey: secretsStack.apiKey, description: 'Lambda functions for the API', + table: dynamoDbStack.table }); // Create API Gateway nested stack diff --git a/apigw-lambda-node-cdk/lib/dynamodb-stack.ts b/apigw-lambda-node-cdk/lib/dynamodb-stack.ts new file mode 100644 index 000000000..72056e614 --- /dev/null +++ b/apigw-lambda-node-cdk/lib/dynamodb-stack.ts @@ -0,0 +1,25 @@ +// lib/dynamodb-stack.ts +import * as cdk from "aws-cdk-lib"; +import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; +import { Construct } from "constructs"; + +export class DynamoDBStack extends cdk.NestedStack { + public readonly table: dynamodb.TableV2; + + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + this.table = new dynamodb.TableV2(this, "Table", { + partitionKey: { + name: "p", + type: dynamodb.AttributeType.STRING, + }, + sortKey: { + name: "s", + type: dynamodb.AttributeType.STRING, + }, + billing: dynamodb.Billing.onDemand(), + removalPolicy: cdk.RemovalPolicy.DESTROY, // For development - change for production + }); + } +} diff --git a/apigw-lambda-node-cdk/lib/lambda-stack.ts b/apigw-lambda-node-cdk/lib/lambda-stack.ts index 90c753694..46575dd7a 100644 --- a/apigw-lambda-node-cdk/lib/lambda-stack.ts +++ b/apigw-lambda-node-cdk/lib/lambda-stack.ts @@ -1,13 +1,15 @@ // lib/lambda-stack.ts -import * as cdk from 'aws-cdk-lib'; -import * as lambda from 'aws-cdk-lib/aws-lambda'; -import * as lambdaNodejs from 'aws-cdk-lib/aws-lambda-nodejs'; -import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; -import { Construct } from 'constructs'; +import * as cdk from "aws-cdk-lib"; +import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import * as lambdaNodejs from "aws-cdk-lib/aws-lambda-nodejs"; +import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; +import { Construct } from "constructs"; export interface LambdaStackProps extends cdk.NestedStackProps { - stageName: string; - apiKey: secretsmanager.Secret; + stageName: string; + apiKey: secretsmanager.Secret; + table: dynamodb.TableV2; } export class LambdaStack extends cdk.NestedStack { @@ -47,6 +49,7 @@ export class LambdaStack extends cdk.NestedStack { POWERTOOLS_DEV: "false", STAGE: props.stageName, API_KEY_SECRET_ARN: props.apiKey.secretArn, + DYNAMODB_TABLE_NAME: props.table.tableName, }, tracing: lambda.Tracing.ACTIVE, layers: [powertoolsLayer, ordersLayer], @@ -54,6 +57,7 @@ export class LambdaStack extends cdk.NestedStack { }); props.apiKey.grantRead(this.handleLambda); + props.table.grantReadWriteData(this.handleLambda); this.searchLambda = new lambdaNodejs.NodejsFunction(this, "Search", { runtime: lambda.Runtime.NODEJS_22_X, @@ -69,12 +73,12 @@ export class LambdaStack extends cdk.NestedStack { POWERTOOLS_TRACER_CAPTURE_RESPONSE: "true", POWERTOOLS_DEV: "false", STAGE: props.stageName, + DYNAMODB_TABLE_NAME: props.table.tableName, }, tracing: lambda.Tracing.ACTIVE, layers: [powertoolsLayer, ordersLayer], timeout: cdk.Duration.seconds(30), }); - + props.table.grantReadData(this.searchLambda); } - } From ca0989316b4adc463dbe0f6675ab8cb01251edaf Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Tue, 22 Apr 2025 10:23:52 +0200 Subject: [PATCH 10/21] add cognito --- .../lib/api-gateway-stack.ts | 8 ++++- .../lib/apigw-lambda-node-cdk-stack.ts | 7 ++++- apigw-lambda-node-cdk/lib/cognito-stack.ts | 31 +++++++++++++++++++ .../lib/openapi/openapi.json | 19 ++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 apigw-lambda-node-cdk/lib/cognito-stack.ts diff --git a/apigw-lambda-node-cdk/lib/api-gateway-stack.ts b/apigw-lambda-node-cdk/lib/api-gateway-stack.ts index 4e04a2f07..dc974b8d0 100644 --- a/apigw-lambda-node-cdk/lib/api-gateway-stack.ts +++ b/apigw-lambda-node-cdk/lib/api-gateway-stack.ts @@ -1,15 +1,17 @@ // lib/api-gateway-stack.ts import * as cdk from 'aws-cdk-lib'; import * as apigateway from 'aws-cdk-lib/aws-apigateway'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; import * as lambda from 'aws-cdk-lib/aws-lambda'; import { Construct } from 'constructs'; -import path = require('path'); +import * as path from 'path'; import * as fs from 'fs'; interface ApiGatewayStackProps extends cdk.NestedStackProps { handleLambda: lambda.Function; searchLambda: lambda.Function; stageName: string; + userPool: cognito.UserPool } export class ApiGatewayStack extends cdk.NestedStack { @@ -29,6 +31,10 @@ export class ApiGatewayStack extends cdk.NestedStack { '${region}', cdk.Stack.of(this).region ).replaceAll( '${searchLambdaArn}', props.searchLambda.functionArn, + ).replaceAll( + '${accountId}', cdk.Stack.of(this).account + ).replaceAll( + '${userPoolId}', props.userPool.userPoolId ) ); diff --git a/apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts b/apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts index 9d6a8f131..e7842d4f2 100644 --- a/apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts +++ b/apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts @@ -5,6 +5,7 @@ import { DynamoDBStack } from './dynamodb-stack'; import { LambdaStack } from './lambda-stack'; import { ApiGatewayStack } from './api-gateway-stack'; import { SecretsStack } from './secrets-stack'; +import { CognitoStack } from './cognito-stack'; interface ApigwLambdaNodeStackProps extends cdk.StackProps { stageName: string; @@ -21,6 +22,8 @@ export class ApigwLambdaNodeStack extends cdk.Stack { const dynamoDbStack = new DynamoDBStack(this, 'OrdersDynamoDBStack') + const cognitoStack = new CognitoStack(this, 'OrdersCognitoStack'); + // Create Lambda nested stack const lambdaStack = new LambdaStack(this, 'OrdersLambdaStack', { stageName: props.stageName, @@ -30,11 +33,13 @@ export class ApigwLambdaNodeStack extends cdk.Stack { }); // Create API Gateway nested stack - new ApiGatewayStack(this, 'OrdersApiStack', { + const apiGatewayStack = new ApiGatewayStack(this, 'OrdersApiStack', { stageName: props.stageName, handleLambda: lambdaStack.handleLambda, searchLambda: lambdaStack.searchLambda, description: 'API Gateway with Lambda integration', + userPool: cognitoStack.userPool }); + apiGatewayStack.node.addDependency(cognitoStack); } } diff --git a/apigw-lambda-node-cdk/lib/cognito-stack.ts b/apigw-lambda-node-cdk/lib/cognito-stack.ts new file mode 100644 index 000000000..8f2024ecb --- /dev/null +++ b/apigw-lambda-node-cdk/lib/cognito-stack.ts @@ -0,0 +1,31 @@ +// lib/cognito-stack.ts +import * as cdk from "aws-cdk-lib"; +import * as cognito from "aws-cdk-lib/aws-cognito"; +import { Construct } from "constructs"; + +export class CognitoStack extends cdk.NestedStack { + public readonly userPool: cognito.UserPool; + public readonly userPoolClient: cognito.UserPoolClient; + + constructor(scope: Construct, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + this.userPool = new cognito.UserPool(this, "UserPool", { + removalPolicy: cdk.RemovalPolicy.DESTROY, // For development - change for production + }); + + this.userPoolClient = new cognito.UserPoolClient(this, "UserPoolClient", { + userPool: this.userPool, + }); + + + // Outputs + new cdk.CfnOutput(this, "UserPoolId", { + value: this.userPool.userPoolId, + }); + + new cdk.CfnOutput(this, "UserPoolClientId", { + value: this.userPoolClient.userPoolClientId, + }); + } +} diff --git a/apigw-lambda-node-cdk/lib/openapi/openapi.json b/apigw-lambda-node-cdk/lib/openapi/openapi.json index 600007141..3dc14c1c6 100644 --- a/apigw-lambda-node-cdk/lib/openapi/openapi.json +++ b/apigw-lambda-node-cdk/lib/openapi/openapi.json @@ -4,6 +4,11 @@ "title": "SampleApiGatewayLambda", "version": "1.0.0" }, + "security": [ + { + "cognito-authorizer": [] + } + ], "paths": { "/order": { "post": { @@ -250,6 +255,20 @@ } }, "components": { + "securitySchemes": { + "cognito-authorizer": { + "type": "apiKey", + "name": "Authorization", + "in": "header", + "x-amazon-apigateway-authtype": "cognito_user_pools", + "x-amazon-apigateway-authorizer": { + "type": "cognito_user_pools", + "providerARNs": [ + "arn:aws:cognito-idp:${region}:${accountId}:userpool/${userPoolId}" + ] + } + } + }, "schemas": { "Address": { "type": "object", From 04900470a82d824c8ef73e29f21558ed22a2b5c6 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Thu, 24 Apr 2025 19:01:27 +0200 Subject: [PATCH 11/21] lots of restructuring and implementation --- apigw-lambda-node-cdk/README.md | 60 ---- .../lib/api-gateway-stack.ts | 75 ----- .../lib/apigw-lambda-node-cdk-stack.ts | 45 --- .../lib/lambda/ordersCRUD/index.ts | 279 ------------------ .../lib/lambda/ordersSearch/index.ts | 165 ----------- apigw-lambda-node-cdk/lib/secrets-stack.ts | 25 -- apigw-lambda-node-cdk/test/cdk.test.ts | 17 -- .../test/sample_create_order.json | 47 --- .../test/sample_order_response.json | 8 - .../test/sample_search_order.json | 9 - .../test/sample_update_order.json | 9 - .../.gitignore | 0 apigw-lambda-powertools-openapi-cdk/README.md | 173 +++++++++++ apigw-lambda-powertools-openapi-cdk/auth.json | 10 + .../apigw-lambda-powertools-openapi-cdk.ts | 6 +- .../cdk.json | 2 +- .../example-pattern.json | 0 .../jest.config.js | 0 .../lib/api-gateway-stack.ts | 64 ++++ .../lib/cognito-stack.ts | 25 +- .../lib/dynamodb-stack.ts | 12 +- .../lib/lambda-stack.ts | 31 +- .../lib/lambda/ordersCRUD/ddb.ts | 226 ++++++++++++++ .../lib/lambda/ordersCRUD/index.ts | 246 +++++++++++++++ .../lib/lambda/ordersCRUD/powertools.ts | 14 + .../lib/lambda/ordersSearch/ddb.ts | 178 +++++++++++ .../lib/lambda/ordersSearch/index.ts | 78 +++++ .../lib/lambda/ordersSearch/powertools.ts | 12 + .../lib/openapi/openapi.json | 117 ++++---- .../lib/ordersCommonCode/README.md | 1 + .../lib/ordersCommonCode/customer.ts | 20 ++ .../lib/ordersCommonCode/order.ts | 181 ++++++++++++ .../lib/ordersCommonCode/types.ts | 52 ++-- .../lib/parent-stack.ts | 63 ++++ .../lib/secrets-stack.ts | 26 ++ .../package.json | 3 + .../pattern.png | Bin 0 -> 81284 bytes .../test/sample_create_order.json | 41 +++ .../test/sample_create_order2.json | 46 +++ .../tsconfig.json | 2 +- 40 files changed, 1501 insertions(+), 867 deletions(-) delete mode 100644 apigw-lambda-node-cdk/README.md delete mode 100644 apigw-lambda-node-cdk/lib/api-gateway-stack.ts delete mode 100644 apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts delete mode 100644 apigw-lambda-node-cdk/lib/lambda/ordersCRUD/index.ts delete mode 100644 apigw-lambda-node-cdk/lib/lambda/ordersSearch/index.ts delete mode 100644 apigw-lambda-node-cdk/lib/secrets-stack.ts delete mode 100644 apigw-lambda-node-cdk/test/cdk.test.ts delete mode 100644 apigw-lambda-node-cdk/test/sample_create_order.json delete mode 100644 apigw-lambda-node-cdk/test/sample_order_response.json delete mode 100644 apigw-lambda-node-cdk/test/sample_search_order.json delete mode 100644 apigw-lambda-node-cdk/test/sample_update_order.json rename {apigw-lambda-node-cdk => apigw-lambda-powertools-openapi-cdk}/.gitignore (100%) create mode 100644 apigw-lambda-powertools-openapi-cdk/README.md create mode 100644 apigw-lambda-powertools-openapi-cdk/auth.json rename apigw-lambda-node-cdk/bin/apigw-lambda-node-cdk.ts => apigw-lambda-powertools-openapi-cdk/bin/apigw-lambda-powertools-openapi-cdk.ts (51%) rename {apigw-lambda-node-cdk => apigw-lambda-powertools-openapi-cdk}/cdk.json (98%) rename {apigw-lambda-node-cdk => apigw-lambda-powertools-openapi-cdk}/example-pattern.json (100%) rename {apigw-lambda-node-cdk => apigw-lambda-powertools-openapi-cdk}/jest.config.js (100%) create mode 100644 apigw-lambda-powertools-openapi-cdk/lib/api-gateway-stack.ts rename {apigw-lambda-node-cdk => apigw-lambda-powertools-openapi-cdk}/lib/cognito-stack.ts (58%) rename {apigw-lambda-node-cdk => apigw-lambda-powertools-openapi-cdk}/lib/dynamodb-stack.ts (64%) rename {apigw-lambda-node-cdk => apigw-lambda-powertools-openapi-cdk}/lib/lambda-stack.ts (79%) create mode 100644 apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersCRUD/ddb.ts create mode 100644 apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersCRUD/index.ts create mode 100644 apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersCRUD/powertools.ts create mode 100644 apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/ddb.ts create mode 100644 apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/index.ts create mode 100644 apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/powertools.ts rename {apigw-lambda-node-cdk => apigw-lambda-powertools-openapi-cdk}/lib/openapi/openapi.json (85%) create mode 100644 apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/README.md create mode 100644 apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/customer.ts create mode 100644 apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/order.ts rename {apigw-lambda-node-cdk => apigw-lambda-powertools-openapi-cdk}/lib/ordersCommonCode/types.ts (90%) create mode 100644 apigw-lambda-powertools-openapi-cdk/lib/parent-stack.ts create mode 100644 apigw-lambda-powertools-openapi-cdk/lib/secrets-stack.ts rename {apigw-lambda-node-cdk => apigw-lambda-powertools-openapi-cdk}/package.json (85%) create mode 100644 apigw-lambda-powertools-openapi-cdk/pattern.png create mode 100644 apigw-lambda-powertools-openapi-cdk/test/sample_create_order.json create mode 100644 apigw-lambda-powertools-openapi-cdk/test/sample_create_order2.json rename {apigw-lambda-node-cdk => apigw-lambda-powertools-openapi-cdk}/tsconfig.json (96%) diff --git a/apigw-lambda-node-cdk/README.md b/apigw-lambda-node-cdk/README.md deleted file mode 100644 index 3ca759d23..000000000 --- a/apigw-lambda-node-cdk/README.md +++ /dev/null @@ -1,60 +0,0 @@ -# AWS Service 1 to AWS Service 2 - -This pattern << explain usage >> - -Learn more about this pattern at Serverless Land Patterns: << Add the live URL here >> - -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 - -## 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 _patterns-model - ``` -1. From the command line, use AWS SAM to deploy the AWS resources for the pattern as specified in the template.yml file: - ``` - sam deploy --guided - ``` -1. During the prompts: - * Enter a stack name - * Enter the desired AWS Region - * Allow SAM CLI to create IAM roles with the required permissions. - - Once you have run `sam deploy --guided` mode once and saved arguments to a configuration file (samconfig.toml), you can use `sam deploy` in future to use these defaults. - -1. Note the outputs from the SAM deployment process. These contain the resource names and/or ARNs which are used for testing. - -## How it works - -Explain how the service interaction works. - -## Testing - -Provide steps to trigger the integration and show what should be observed if successful. - -## Cleanup - -1. Delete the stack - ```bash - aws cloudformation delete-stack --stack-name STACK_NAME - ``` -1. Confirm the stack has been deleted - ```bash - aws cloudformation list-stacks --query "StackSummaries[?contains(StackName,'STACK_NAME')].StackStatus" - ``` ----- -Copyright 2024 Amazon.com, Inc. or its affiliates. All Rights Reserved. - -SPDX-License-Identifier: MIT-0 diff --git a/apigw-lambda-node-cdk/lib/api-gateway-stack.ts b/apigw-lambda-node-cdk/lib/api-gateway-stack.ts deleted file mode 100644 index dc974b8d0..000000000 --- a/apigw-lambda-node-cdk/lib/api-gateway-stack.ts +++ /dev/null @@ -1,75 +0,0 @@ -// lib/api-gateway-stack.ts -import * as cdk from 'aws-cdk-lib'; -import * as apigateway from 'aws-cdk-lib/aws-apigateway'; -import * as cognito from 'aws-cdk-lib/aws-cognito'; -import * as lambda from 'aws-cdk-lib/aws-lambda'; -import { Construct } from 'constructs'; -import * as path from 'path'; -import * as fs from 'fs'; - -interface ApiGatewayStackProps extends cdk.NestedStackProps { - handleLambda: lambda.Function; - searchLambda: lambda.Function; - stageName: string; - userPool: cognito.UserPool -} - -export class ApiGatewayStack extends cdk.NestedStack { - public readonly api: apigateway.SpecRestApi; - - constructor(scope: Construct, id: string, props: ApiGatewayStackProps) { - super(scope, id, props); - - const openApiSpecPath = path.join(__dirname, 'openapi/openapi.json'); - const openApiSpecContent= fs.readFileSync(openApiSpecPath, 'utf8'); - - // Parse JSON and replace placeholders - const openApiSpec = JSON.parse( - openApiSpecContent.replaceAll( - '${lambdaArn}', props.handleLambda.functionArn - ).replaceAll( - '${region}', cdk.Stack.of(this).region - ).replaceAll( - '${searchLambdaArn}', props.searchLambda.functionArn, - ).replaceAll( - '${accountId}', cdk.Stack.of(this).account - ).replaceAll( - '${userPoolId}', props.userPool.userPoolId - ) - ); - - this.api = new apigateway.SpecRestApi(this, 'SampleApiGatewayLambda2Api', { - restApiName: 'OrdersAPI', - description: 'API Gateway with Lambda integration', - apiDefinition: apigateway.ApiDefinition.fromInline(openApiSpec), - deployOptions: { - tracingEnabled: true, - dataTraceEnabled: true, - stageName: props.stageName, - }, - cloudWatchRole: false, - }); - - // TODO: is it too permissive? Should we define method and stage instead of **? - new lambda.CfnPermission(this, 'LambdaHandlerPermission', { - action: 'lambda:InvokeFunction', - functionName: props.handleLambda.functionName, - principal: 'apigateway.amazonaws.com', - sourceArn: `arn:aws:execute-api:${this.region}:${this.account}:${this.api.restApiId}/*/*/order*`, - }); - - new lambda.CfnPermission(this, 'LambdaSearchPermission', { - action: 'lambda:InvokeFunction', - functionName: props.searchLambda.functionName, - principal: 'apigateway.amazonaws.com', - sourceArn: `arn:aws:execute-api:${this.region}:${this.account}:${this.api.restApiId}/*/*/orders/search*`, - }); - - // Output the API URL - new cdk.CfnOutput(this, 'ApiUrl', { - value: this.api.url, - description: 'API Gateway URL', - }); - - } -} diff --git a/apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts b/apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts deleted file mode 100644 index e7842d4f2..000000000 --- a/apigw-lambda-node-cdk/lib/apigw-lambda-node-cdk-stack.ts +++ /dev/null @@ -1,45 +0,0 @@ -// lib/rest-api-gateway-lambda-stack.ts -import * as cdk from 'aws-cdk-lib'; -import { Construct } from 'constructs'; -import { DynamoDBStack } from './dynamodb-stack'; -import { LambdaStack } from './lambda-stack'; -import { ApiGatewayStack } from './api-gateway-stack'; -import { SecretsStack } from './secrets-stack'; -import { CognitoStack } from './cognito-stack'; - -interface ApigwLambdaNodeStackProps extends cdk.StackProps { - stageName: string; -} - -export class ApigwLambdaNodeStack extends cdk.Stack { - constructor(scope: Construct, id: string, props: ApigwLambdaNodeStackProps) { - super(scope, id, props); - - // Create Secrets Stack - const secretsStack = new SecretsStack(this, 'OrdersSecretsStack', { - crossRegionReferences: true - }); - - const dynamoDbStack = new DynamoDBStack(this, 'OrdersDynamoDBStack') - - const cognitoStack = new CognitoStack(this, 'OrdersCognitoStack'); - - // Create Lambda nested stack - const lambdaStack = new LambdaStack(this, 'OrdersLambdaStack', { - stageName: props.stageName, - apiKey: secretsStack.apiKey, - description: 'Lambda functions for the API', - table: dynamoDbStack.table - }); - - // Create API Gateway nested stack - const apiGatewayStack = new ApiGatewayStack(this, 'OrdersApiStack', { - stageName: props.stageName, - handleLambda: lambdaStack.handleLambda, - searchLambda: lambdaStack.searchLambda, - description: 'API Gateway with Lambda integration', - userPool: cognitoStack.userPool - }); - apiGatewayStack.node.addDependency(cognitoStack); - } -} diff --git a/apigw-lambda-node-cdk/lib/lambda/ordersCRUD/index.ts b/apigw-lambda-node-cdk/lib/lambda/ordersCRUD/index.ts deleted file mode 100644 index f338b4656..000000000 --- a/apigw-lambda-node-cdk/lib/lambda/ordersCRUD/index.ts +++ /dev/null @@ -1,279 +0,0 @@ -import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; -import { Logger } from '@aws-lambda-powertools/logger'; -import { Metrics, MetricUnit } from '@aws-lambda-powertools/metrics'; -import { Tracer } from '@aws-lambda-powertools/tracer'; -import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; -import { GetSecretValueCommand, SecretsManager } from '@aws-sdk/client-secrets-manager'; -import type { Context } from 'aws-lambda'; -import { v4 as uuidv4 } from 'uuid'; - -const orderCache = new Map(); - -// Sample data -const sampleOrders = [ - { - orderId: 'ORD-2024-001', - customerId: 'CUST123', - items: [ - { - productId: 'PROD789', - productName: 'Nike Air Max 2024', - quantity: 1, - price: 129.99, - sku: 'NK-AM24-BLK-42', - variantId: 'SIZE-42-BLACK' - } - ], - status: 'DELIVERED', - createdAt: '2024-01-15T10:00:00Z', - updatedAt: '2024-01-15T10:00:00Z', - shippingAddress: { - street: '123 Main Street', - city: 'Seattle', - state: 'WA', - postalCode: '98101', - country: 'USA' - }, - trackingNumber: '1Z999AA1234567890' - }, - { - orderId: 'ORD-2024-002', - customerId: 'CUST456', - items: [ - { - productId: 'PROD456', - productName: 'Adidas Running Shorts', - quantity: 2, - price: 34.99, - sku: 'AD-RS-BLU-M', - variantId: 'SIZE-M-BLUE' - } - ], - status: 'PROCESSING', - createdAt: '2024-01-16T15:30:00Z', - updatedAt: '2024-01-16T15:30:00Z', - shippingAddress: { - street: '456 Pine Street', - city: 'Portland', - state: 'OR', - postalCode: '97201', - country: 'USA' - } - } -]; - -// Initialize cache with sample data -sampleOrders.forEach(order => orderCache.set(order.orderId, order)); - - -const logger = new Logger(); -logger.appendKeys({ - stage: process.env.STAGE, -}); - -const metrics = new Metrics(); -const tracer = new Tracer(); -const secretsManager = new SecretsManager(); -let apiKey: string | undefined; - -interface ErrorResponse { - message: string; -} - -class HandleOrderLambda implements LambdaInterface { - @tracer.captureLambdaHandler() - @metrics.logMetrics() - @logger.injectLambdaContext() - public async handler(_event: APIGatewayProxyEvent, _context: Context): Promise { - - logger.info('Processing event', { _event }); - metrics.addMetric('ProcessedEvents', MetricUnit.Count, 1); - tracer.getSegment(); - const apiKey = await getApiKey(); - // TODO test both branches: exists or not - if (apiKey) { - logger.debug("API key found") - - } - // use api key to call external service - try { - switch (_event.httpMethod) { - case 'POST': - if (_event.path === '/order') { - return await createOrder(_event); - } - break; - case 'GET': - if (_event.path.match(/^\/order\/[^/]+$/)) { - return await getOrder(_event); - } - break; - case 'PUT': - if (_event.path.match(/^\/order\/[^/]+$/)) { - return await updateOrder(_event); - } - break; - case 'DELETE': - if (_event.path.match(/^\/order\/[^/]+$/)) { - return await deleteOrder(_event); - } - break; - } - return errorResponse(404, { message: 'Not Found' }); - } catch (error) { - console.error('Error:', error); - return errorResponse(500, { message: 'Internal Server Error' }); - } - } -} - - -async function getApiKey(): Promise { - - if (apiKey) { - return apiKey; - } - const secretArn = process.env.API_KEY_SECRET_ARN; - if (!secretArn) { - throw new Error('API_KEY_SECRET_ARN environment variable is not set'); - } - - const command = new GetSecretValueCommand({ - SecretId: secretArn, - }); - - const response = await secretsManager.send(command); - - if (!response.SecretString) { - throw new Error('Secret string is empty'); - } - - const secretData = response.SecretString; - if (!secretData){ - throw new Error('API KEY does not exist'); - } - apiKey = secretData; - return apiKey - -} - -async function createOrder(event: APIGatewayProxyEvent): Promise { - try { - // TODO add validation - const order = JSON.parse(event.body || '{}'); - const timestamp = new Date().toISOString(); - - const newOrder = { - ...order, - orderId: `ORD-${uuidv4()}`, - status: 'PENDING', - createdAt: timestamp, - updatedAt: timestamp - }; - - orderCache.set(newOrder.orderId, newOrder); - - const response = { - orderId: newOrder.orderId, - status: newOrder.status, - createdAt: newOrder.createdAt, - }; - - return { - statusCode: 201, - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(response) - }; - } catch (error) { - console.error('Create Order Error:', error); - return errorResponse(400, { message: 'Invalid request body' }); - } -} - -async function getOrder(event: APIGatewayProxyEvent): Promise { - const orderId = event.pathParameters?.orderId; - const order = orderCache.get(orderId || ''); - - if (!order) { - return errorResponse(404, { message: 'Order not found' }); - } - - const response = { - orderId: order.orderId, - status: order.status, - createdAt: order.createdAt, - trackingNumber: order.trackingNumber - }; - - return { - statusCode: 200, - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(response) - }; -} - -async function updateOrder(event: APIGatewayProxyEvent): Promise { - const orderId = event.pathParameters?.orderId; - const updates = JSON.parse(event.body || '{}'); - - const existingOrder = orderCache.get(orderId || ''); - if (!existingOrder) { - return errorResponse(404, { message: 'Order not found' }); - } - - const updatedOrder = { - ...existingOrder, - ...updates, - updatedAt: new Date().toISOString() - }; - - orderCache.set(orderId!, updatedOrder); - - const response = { - orderId: updatedOrder.orderId, - status: updatedOrder.status, - createdAt: updatedOrder.createdAt, - updatedAt: updatedOrder.updatedAt, - trackingNumber: updatedOrder.trackingNumber - }; - - return { - statusCode: 200, - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(response) - }; -} - -async function deleteOrder(event: APIGatewayProxyEvent): Promise { - const orderId = event.pathParameters?.orderId; - - if (!orderCache.has(orderId || '')) { - return errorResponse(404, { message: 'Order not found' }); - } - - orderCache.delete(orderId!); - - return { - statusCode: 204, - body: '' - }; -} - -function errorResponse(statusCode: number, error: ErrorResponse): APIGatewayProxyResult { - return { - statusCode, - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(error) - }; -} - -const handlerClass = new HandleOrderLambda(); -export const handler = handlerClass.handler.bind(handlerClass); \ No newline at end of file diff --git a/apigw-lambda-node-cdk/lib/lambda/ordersSearch/index.ts b/apigw-lambda-node-cdk/lib/lambda/ordersSearch/index.ts deleted file mode 100644 index 5e7ecce38..000000000 --- a/apigw-lambda-node-cdk/lib/lambda/ordersSearch/index.ts +++ /dev/null @@ -1,165 +0,0 @@ -import { Logger } from '@aws-lambda-powertools/logger'; -import { Metrics, MetricUnit } from '@aws-lambda-powertools/metrics'; -import { Tracer } from '@aws-lambda-powertools/tracer'; -import { APIGatewayProxyEvent, APIGatewayProxyResult } from 'aws-lambda'; -import type { Context } from 'aws-lambda'; -import type { LambdaInterface } from '@aws-lambda-powertools/commons/types'; - -const logger = new Logger(); -const metrics = new Metrics(); -const tracer = new Tracer(); - - -interface SearchCriteria { - customerIds?: string[]; - statuses?: string[]; - productIds?: string[]; - page?: number; - limit?: number; - sortBy?: 'createdAt' | 'total' | 'status'; - sortOrder?: 'asc' | 'desc'; -} - -// Sample data -const sampleOrders = [ - { - orderId: 'ORD-2024-001', - customerId: 'CUST123', - items: [ - { - productId: 'PROD789', - productName: 'Nike Air Max 2024', - quantity: 1, - price: 129.99, - sku: 'NK-AM24-BLK-42', - variantId: 'SIZE-42-BLACK' - } - ], - status: 'DELIVERED', - createdAt: '2024-01-15T10:00:00Z', - updatedAt: '2024-01-15T10:00:00Z', - total: 129.99, - shippingAddress: { - street: '123 Main Street', - city: 'Seattle', - state: 'WA', - postalCode: '98101', - country: 'USA' - }, - shippingMethod: 'EXPRESS', - trackingNumber: '1Z999AA1234567890' - }, - // Add more sample orders here -]; - - -class SearchOrderLambda implements LambdaInterface { - @tracer.captureLambdaHandler() - @metrics.logMetrics() - @logger.injectLambdaContext() - public async handler(_event: APIGatewayProxyEvent, _context: Context): Promise { - logger.appendKeys({ - stage: process.env.STAGE, - }); - logger.info('Processing event', { _event }); - metrics.addMetric('ProcessedEvents', MetricUnit.Count, 1); - tracer.getSegment(); - - try { - // TODO validate object - const criteria: SearchCriteria = JSON.parse(_event.body || '{}'); - let filteredOrders = [...sampleOrders]; - - // Apply filters - if (criteria.customerIds?.length) { - filteredOrders = filteredOrders.filter(order => - criteria.customerIds!.includes(order.customerId) - ); - } - - if (criteria.statuses?.length) { - filteredOrders = filteredOrders.filter(order => - criteria.statuses!.includes(order.status) - ); - } - - if (criteria.productIds?.length) { - filteredOrders = filteredOrders.filter(order => - order.items.some(item => criteria.productIds!.includes(item.productId)) - ); - } - - // Sort results - const sortBy = criteria.sortBy || 'createdAt'; - const sortOrder = criteria.sortOrder || 'desc'; - - filteredOrders.sort((a, b) => { - let comparison = 0; - switch (sortBy) { - case 'total': - comparison = a.total - b.total; - break; - case 'status': - comparison = a.status.localeCompare(b.status); - break; - case 'createdAt': - default: - comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); - break; - } - return sortOrder === 'desc' ? -comparison : comparison; - }); - - // Handle pagination - const page = Math.max(1, criteria.page || 1); - const limit = Math.min(Math.max(1, criteria.limit || 20), 100); - const startIndex = (page - 1) * limit; - const endIndex = startIndex + limit; - const paginatedOrders = filteredOrders.slice(startIndex, endIndex); - - const response = { - items: paginatedOrders.map(order => ({ - orderId: order.orderId, - customerId: order.customerId, - status: order.status, - createdAt: order.createdAt, - total: order.total, - items: order.items.map(item => ({ - productId: item.productId, - productName: item.productName, - quantity: item.quantity, - price: item.price - })), - shippingMethod: order.shippingMethod, - trackingNumber: order.trackingNumber - })), - pagination: { - total: filteredOrders.length, - pages: Math.ceil(filteredOrders.length / limit), - currentPage: page, - limit - } - }; - - return { - statusCode: 200, - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify(response) - }; - } catch (error) { - console.error('Search Orders Error:', error); - return { - statusCode: 400, - headers: { - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ message: 'Invalid search criteria' }) - }; - } - } -} - -const searchClass = new SearchOrderLambda(); -export const handler = searchClass.handler.bind(searchClass); \ No newline at end of file diff --git a/apigw-lambda-node-cdk/lib/secrets-stack.ts b/apigw-lambda-node-cdk/lib/secrets-stack.ts deleted file mode 100644 index f6dc19d90..000000000 --- a/apigw-lambda-node-cdk/lib/secrets-stack.ts +++ /dev/null @@ -1,25 +0,0 @@ -// lib/secrets-stack.ts -import * as cdk from 'aws-cdk-lib'; -import * as secretsmanager from 'aws-cdk-lib/aws-secretsmanager'; -import { Construct } from 'constructs'; - -export class SecretsStack extends cdk.NestedStack { - public readonly apiKey: secretsmanager.Secret; - - constructor(scope: Construct, id: string, props?: cdk.StackProps) { - super(scope, id, props); - - // Create the secret - this.apiKey = new secretsmanager.Secret(this, 'ExternalServiceApiKey', { - secretName: 'orders/api-key', - description: 'API Key for External Payment Service', - removalPolicy: cdk.RemovalPolicy.RETAIN, - }); - - // Output the secret ARN - new cdk.CfnOutput(this, 'ExtenralServiceApiKeySecretArn', { - value: this.apiKey.secretArn, - description: 'ARN of the API Key secret' - }); - } -} \ No newline at end of file diff --git a/apigw-lambda-node-cdk/test/cdk.test.ts b/apigw-lambda-node-cdk/test/cdk.test.ts deleted file mode 100644 index 1e6b29c85..000000000 --- a/apigw-lambda-node-cdk/test/cdk.test.ts +++ /dev/null @@ -1,17 +0,0 @@ -// import * as cdk from 'aws-cdk-lib'; -// import { Template } from 'aws-cdk-lib/assertions'; -// import * as Cdk from '../lib/cdk-stack'; - -// example test. To run these tests, uncomment this file along with the -// example resource in lib/cdk-stack.ts -test('SQS Queue Created', () => { -// const app = new cdk.App(); -// // WHEN -// const stack = new Cdk.CdkStack(app, 'MyTestStack'); -// // THEN -// const template = Template.fromStack(stack); - -// template.hasResourceProperties('AWS::SQS::Queue', { -// VisibilityTimeout: 300 -// }); -}); diff --git a/apigw-lambda-node-cdk/test/sample_create_order.json b/apigw-lambda-node-cdk/test/sample_create_order.json deleted file mode 100644 index 1031cba02..000000000 --- a/apigw-lambda-node-cdk/test/sample_create_order.json +++ /dev/null @@ -1,47 +0,0 @@ -{ - "customerId": "CUST123456", - "items": [ - { - "productId": "PROD789", - "productName": "Nike Air Max 2024", - "quantity": 1, - "price": 129.99, - "sku": "NK-AM24-BLK-42", - "variantId": "SIZE-42-BLACK" - }, - { - "productId": "PROD456", - "productName": "Adidas Running Shorts", - "quantity": 2, - "price": 34.99, - "sku": "AD-RS-BLU-M", - "variantId": "SIZE-M-BLUE" - } - ], - "shippingAddress": { - "street": "123 Main Street", - "city": "Seattle", - "state": "WA", - "postalCode": "98101", - "country": "USA", - "apartment": "Unit 45" - }, - "billingAddress": { - "street": "123 Main Street", - "city": "Seattle", - "state": "WA", - "postalCode": "98101", - "country": "USA", - "apartment": "Unit 45" - }, - "subtotal": 199.97, - "tax": 20.00, - "shippingCost": 9.99, - "total": 229.96, - "paymentMethod": "CREDIT_CARD", - "shippingMethod": "EXPRESS", - "customerNotes": "Please leave the package at the front desk if no one is home", - "giftWrapping": true, - "couponCode": "SUMMER20", - "discountAmount": 40.00 - } \ No newline at end of file diff --git a/apigw-lambda-node-cdk/test/sample_order_response.json b/apigw-lambda-node-cdk/test/sample_order_response.json deleted file mode 100644 index 21b487add..000000000 --- a/apigw-lambda-node-cdk/test/sample_order_response.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "orderId": "ORD-2024-123456", - "status": "CONFIRMED", - "createdAt": "2024-01-20T15:30:00Z", - "estimatedDeliveryDate": "2024-01-22", - "trackingNumber": "1Z999AA1234567890" - } - \ No newline at end of file diff --git a/apigw-lambda-node-cdk/test/sample_search_order.json b/apigw-lambda-node-cdk/test/sample_search_order.json deleted file mode 100644 index f685efa84..000000000 --- a/apigw-lambda-node-cdk/test/sample_search_order.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "customerIds": ["CUST123", "CUST456"], - "statuses": ["PROCESSING", "DELIVERED"], - "productIds": ["PROD789"], - "page": 1, - "limit": 20, - "sortBy": "total", - "sortOrder": "desc" - } \ No newline at end of file diff --git a/apigw-lambda-node-cdk/test/sample_update_order.json b/apigw-lambda-node-cdk/test/sample_update_order.json deleted file mode 100644 index d6c727e74..000000000 --- a/apigw-lambda-node-cdk/test/sample_update_order.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "status": "SHIPPED", - "shippingAddress": { - "street": "456 New St", - "city": "Seattle" - }, - "trackingNumber": "1Z999AA1234567890", - "customerNotes": "Leave at door" -} \ No newline at end of file diff --git a/apigw-lambda-node-cdk/.gitignore b/apigw-lambda-powertools-openapi-cdk/.gitignore similarity index 100% rename from apigw-lambda-node-cdk/.gitignore rename to apigw-lambda-powertools-openapi-cdk/.gitignore diff --git a/apigw-lambda-powertools-openapi-cdk/README.md b/apigw-lambda-powertools-openapi-cdk/README.md new file mode 100644 index 000000000..a7a7f76ea --- /dev/null +++ b/apigw-lambda-powertools-openapi-cdk/README.md @@ -0,0 +1,173 @@ +# AWS Service 1 to AWS Service 2 + +This pattern << explain usage >> + +Learn more about this pattern at Serverless Land Patterns: << Add the live URL here >> + +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 + +## 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-lambda-powertools-openapi-cdk + ``` +1. Authenticate to the AWS account you want to deploy in. +1. From the command line, use AWS SAM to deploy the AWS resources for the pattern as specified in the template.yml file: + ``` + cdk deploy + ``` + +1. With successful deployment, cdk will print three Outputs to the terminal ("ApiGatewayEndpoint", "UserPoolClientId" and "UserPoolId"). Copy them to your text editor for later user. + +## How it works + +![Diagram of pattern](pattern.png) + +Explain how the service interaction works. + +## Testing + +You will create an Amazon Cogntio user for authenticating against the API. Then, you will execute some requests against the Order API to generate events. Finally, you will observe the Logging, Tracing, Metrics and Parameters functionalities of the Powertools for AWS Lambda in the AWS console. + +1. Set environment variables for the following commands. You will need the values of the Outputs you copied as last step of the Deployment Instructions: + + ```bash + API_GATEWAY_ENDPOINT= + USER_POOL_ID= + USER_POOL_CLIENT_ID= + USER_NAME=testuser + USER_EMAIL=user@example.com + USER_PASSWORD=MyUserPassword123! + ``` + +1. Create a user in Cognito that will be used for authenticating test requests: + + ```bash + aws cognito-idp admin-create-user \ + --user-pool-id $USER_POOL_ID \ + --username $USER_NAME \ + --user-attributes Name=email,Value=$USER_EMAIL \ + --temporary-password $USER_PASSWORD \ + --message-action SUPPRESS + aws cognito-idp admin-set-user-password \ + --user-pool-id $USER_POOL_ID \ + --username $USER_NAME \ + --password $USER_PASSWORD \ + --permanent + ``` + +1. Generate a Cognito IdToken for the user that will be sent as the Authorization header. Store it in the ID_TOKEN environment variable: + + ```bash + ID_TOKEN=$(aws cognito-idp admin-initiate-auth \ + --user-pool-id $USER_POOL_ID \ + --client-id $USER_POOL_CLIENT_ID \ + --auth-flow ADMIN_USER_PASSWORD_AUTH \ + --auth-parameters USERNAME=$USER_NAME,PASSWORD=$USER_PASSWORD \ + --query 'AuthenticationResult.IdToken' \ + --output text) + ``` + +1. Send a POST request that will create an order with the body being the content of the file `test/sample_create_order.json`. Store the order ID in the environment variable ORDER_ID for further use: + + ```bash + ORDER_ID=$(curl -sS --header "Authorization: Bearer $ID_TOKEN" \ + $API_GATEWAY_ENDPOINT/order -X POST \ + -H "Content-Type: application/json" \ + --data "@./test/sample_create_order.json" | tee /dev/tty | jq -r .orderId) + ``` + + You will get the Order json as response. + +1. Update the order with new shipping information using a PUT request: + + ```bash + curl -sS --header "Authorization: Bearer $ID_TOKEN" \ + $API_GATEWAY_ENDPOINT/order/$ORDER_ID -X PUT \ + -H "Content-Type: application/json" \ + --data '{ + "shippingMethod": "NEXT_DAY", + "customerNotes": "Please deliver before noon", + "shippingAddress": { + "street": "777 Main Street", + "city": "Anytown", + "state": "WA", + "postalCode": "31415", + "country": "USA" + } + }' + ``` + + You will get the updated Order json as response. + +1. Retrieve the order using a GET request: + + ```bash + curl -sS --header "Authorization: Bearer $ID_TOKEN" \ + $API_GATEWAY_ENDPOINT/order/$ORDER_ID -X GET + ``` + + You will again the the Order json as response. + +1. Send a POST request that will create a second order with the body being the content of the file `test/sample_create_order2.json`. + + ```bash + curl -sS --header "Authorization: Bearer $ID_TOKEN" \ + $API_GATEWAY_ENDPOINT/order -X POST \ + -H "Content-Type: application/json" \ + --data "@./test/sample_create_order2.json" + ``` + + You will get the second Order json as response. + +1. Send a request to the `/orders/search` endpoint, looking for orders containing the product ID "PROD111". + + ```bash + curl -sS --header "Authorization: Bearer $ID_TOKEN" \ + $API_GATEWAY_ENDPOINT/orders/search -X POST \ + -H "Content-Type: application/json" \ + --data '{ + "productIds": ["PROD111"], + "page": 1, + "limit": 20, + "sortBy": "createdAt", + "sortOrder": "desc" + }' + ``` + + Only the second Order json will be returned as the first one does not include PROD111. + +1. Delete the first order + + ```bash + curl -sS --header "Authorization: Bearer $ID_TOKEN" \ + $API_GATEWAY_ENDPOINT/order/$ORDER_ID -X DELETE + ``` + + There will be no response. + +## Cleanup + +1. Delete the stacks + ```bash + cdk destroy + ``` + +---- +Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 diff --git a/apigw-lambda-powertools-openapi-cdk/auth.json b/apigw-lambda-powertools-openapi-cdk/auth.json new file mode 100644 index 000000000..877deffb1 --- /dev/null +++ b/apigw-lambda-powertools-openapi-cdk/auth.json @@ -0,0 +1,10 @@ +{ + "ChallengeParameters": {}, + "AuthenticationResult": { + "AccessToken": "eyJraWQiOiIxUXFiRVB3elJaaWhMSHZjdlwvallqRGd6UGI3QlVsMm04XC9seWhwN3dJOEE9IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJiNDg4YzQwOC02MDMxLTcwMWEtN2NmNi1lMDY1NTI4NDdjYTEiLCJpc3MiOiJodHRwczpcL1wvY29nbml0by1pZHAudXMtZWFzdC0xLmFtYXpvbmF3cy5jb21cL3VzLWVhc3QtMV9oY1NlUEFXdTIiLCJjbGllbnRfaWQiOiIxdTZkOTdyMnQxMWpxdGFoYjUzNjB2a2t0YiIsIm9yaWdpbl9qdGkiOiJjY2Y1ZDEwNS0xYmU1LTRhODctYWI0NS04ZDI2NzM5MDdiZjYiLCJldmVudF9pZCI6ImQzMDgwNGU0LTc3OWUtNGQ5ZC1hZGVmLWUxYjBjZmUwNTVmNSIsInRva2VuX3VzZSI6ImFjY2VzcyIsInNjb3BlIjoiYXdzLmNvZ25pdG8uc2lnbmluLnVzZXIuYWRtaW4iLCJhdXRoX3RpbWUiOjE3NDUzODY5ODIsImV4cCI6MTc0NTM5MDU4MiwiaWF0IjoxNzQ1Mzg2OTgyLCJqdGkiOiJmM2YzMTYyYS0wY2E0LTQxYmQtYjJmNS0wYzc3NzAzMDc1NjQiLCJ1c2VybmFtZSI6InRlc3R1c2VyIn0.Z1KBS9B6iOIRuMu-uUZtPzoqrR0p4DpXJYP6iH5W25I3EcJVzTPzIOQGBwdIvd9Fa2qbez_P5omOQBqGLB8rqipAJm4bZCToSY5cbkMyLxD4xqJ-2cUpVnY1UMDR2CL2vJgAMc9H14ZZ_LnWRAsmNUgFRm_xpJ1vbXaqtUf5EmYqAawRXFdnQwDXHBHh_VwnA-KVk9ZMIGTS0U_x91_qFNEzPPG3yTOrHNR3pqnVzNCIh83tm2U8YZrGEPMgUS_05swUfDR3o98y3nKhZzA2AcFz8reorbi5__LWMMAt2euPxsQ1QRI-oKAycTlRymGE56VttuhnP0Eq1LYpNgxexA", + "ExpiresIn": 3600, + "TokenType": "Bearer", + "RefreshToken": "eyJjdHkiOiJKV1QiLCJlbmMiOiJBMjU2R0NNIiwiYWxnIjoiUlNBLU9BRVAifQ.BX4SMAlXdmzDb6RsNJ7k7de63J6EoaKm6ZU-6sDW_fZa0kXxDzZdtqqGTEWSjVzVjA4SUantOUKXtJquPN9EPbDCJlucdQmezt59pDo8SXX3dEN5DvQ9UI9S63OQdxTlBm5SN-q3-tc4UhcorQtQWfQXmIjVs80LCZMVkrynDasMBnlcjGmbZcoKi4dTE5eoKQ4y3i6Upfz7WplPTzXESMSMA4OTneNPAmxHRO2DqTzFbKDG2sa93kvP9DTuYnWjDVKH1uAuLkg2Bxz4ZsG8BUSC7XG17fCs73tlUPMQLamXwmKTvjZnuuzEyqe9XSRTfU6bLfMeN2bQEl_yWG6JhQ.zsoMyJJ-OpxVQCvB.6NBTGdDfFLTFkJyDPbzgKy8zkxuhvsRPtmzHJyEB9TmpUIXhpnUxb2nPq7fcHO59wmZWvr3clN8o661YYtJ9rIE3Cnt2-mqMg123YQkO7Np2TOoCTml-RmY1j77JgdEz8uGiaCDqq59m_0GkgRh0qOKqUReuWodxsTHksO5QJ_lBjiYjeJsgVTXITWDpx0FEutfqgST10pXBxMN0OMeybOolJqqT5BZeMq9SjDIfdATEhMNge6a1PbekNGqiS9XijmDAuyB7JWTeI2Q9j0zkt2qy91l4hZlmmc9V2Zih8aFSCyT6-2F30l9HYS0vlR7HkpeVCFPdDYKavFq0tjQVS2ch9MQUtSEMpIwngl2tdnjQpljjfXECMy8GFN3QAla8vpgEKfc8-JC7mfJwfMXaQgJlLIvel692bpnBOZWFB6rCy2R_Nnqew3fik_LU1MOaJuL45YA5LX0HvyFuO0Pr-SSzyLFBOkKEc9wk35PJLmBbqlxhADOsclsvKNJoop_AD5u8vWKv81na3SXF45sZXcfyB87q6stJwItWHnLGvvrgec_LqzI30gFnAXgs7wIGumDJzwqlCPh5Bpe2UtcpUIxVHY-swCa_pDHCPCu-xVsV9qEjdITo_rMbRJrS2kAS7HSEFdbIAVC-ps7k9nQ9h8iE4CiWeCql5ElPV0ulrfnCQE_BR5Um-IHXDrJPUDw2SsOPJHxlLJ4TInl9kg1bPTKWBdOxggKXQezTZbx-QNGyx_xhLs9fZjyPpp73mRCACmjfyV5YZ5lfY7_MxPtIPp0NWWRerOoai69X7_YIeM9BXJUaYyGwpOrIXMMlfJQuy0yQ08fpB3LTceBx4cbaPvw4agTlV4ACFRbtivJKkX5TaDzYTfQf83l-BPxPV6n7S9W_ovUJObqU7IOYVn0yi4MnpozKfHCsNC5ZVGvEUTsp3eO1nAALaQchp4LMyUYoWDeii3qE08OwnyMV0oDOfa_Gz3Xd-jhR45tiXocs4Zd5VIe-JXD17rjKQxvU5dpjPsWjWo9cirZhtee2abrFNKt2JkcoFsUtfzJEz61sNHsX1alxOCOf9SYgxZef15mYma4ujzp-DB7vYSYDV-MXf5lNDdsQoh0iug0yWQDwLnotMPrPFHcwI8KwwIZygAJeLgjU9Iubbkf20RPp68EhF8sMKEUXNm1AvPtA3IGZjJqrA6UrnKyJAwVKZSWdplfY5ve7UGJEV2XWjdfXKa6iZ8iPR5uvBNgtF_gI6v0U1-1wWXpt8AVcrp58Aw19y3M8eAjdmcFj.KtApjHBH3LdCM8BEyJDaUw", + "IdToken": "eyJraWQiOiI4bWFEQXZRak5tRW1EUGZhRGlDa3pJQkYyYXFsaHVGYnVYeGR5dVpIdFd3PSIsImFsZyI6IlJTMjU2In0.eyJzdWIiOiJiNDg4YzQwOC02MDMxLTcwMWEtN2NmNi1lMDY1NTI4NDdjYTEiLCJpc3MiOiJodHRwczpcL1wvY29nbml0by1pZHAudXMtZWFzdC0xLmFtYXpvbmF3cy5jb21cL3VzLWVhc3QtMV9oY1NlUEFXdTIiLCJjb2duaXRvOnVzZXJuYW1lIjoidGVzdHVzZXIiLCJvcmlnaW5fanRpIjoiY2NmNWQxMDUtMWJlNS00YTg3LWFiNDUtOGQyNjczOTA3YmY2IiwiYXVkIjoiMXU2ZDk3cjJ0MTFqcXRhaGI1MzYwdmtrdGIiLCJldmVudF9pZCI6ImQzMDgwNGU0LTc3OWUtNGQ5ZC1hZGVmLWUxYjBjZmUwNTVmNSIsInRva2VuX3VzZSI6ImlkIiwiYXV0aF90aW1lIjoxNzQ1Mzg2OTgyLCJleHAiOjE3NDUzOTA1ODIsImlhdCI6MTc0NTM4Njk4MiwianRpIjoiMDZlMjM0NDgtNjlkYi00OTE0LTkxZjUtYzI0YzlmNjZmNmQxIiwiZW1haWwiOiJ1c2VyQGV4YW1wbGUuY29tIn0.EBoVUdF5mEbaiwgFFWeWRGelUNsvdk-jXl5N3VBDfH_P6ndtRYWQt3xl1PZL2lboUz2-YeyjDWY1aR6k01A3zbxJdy0iyzZZ5amm_tZPOPVZCrjI_5pjtEm7mjJxM-yFrN45ND-BhHxo1ESO5SvLbMR0554yRB2jKLlvZIydThrKn-Snv6swDC-7q834_fIv2-rpKliXmjMye3BM7_5lbs8ZIIu2TQDoQe4lVKBxqypPXeU0hmrg7gT184F-zJqATTPCqis4x2l8ZhFnt3jHq85SE4DJetCTDZaQ_WLXfpKXrVQKqmke9-zPRXs4-LQMMz0OGt9VWqufoqrGwAomhg" + } +} diff --git a/apigw-lambda-node-cdk/bin/apigw-lambda-node-cdk.ts b/apigw-lambda-powertools-openapi-cdk/bin/apigw-lambda-powertools-openapi-cdk.ts similarity index 51% rename from apigw-lambda-node-cdk/bin/apigw-lambda-node-cdk.ts rename to apigw-lambda-powertools-openapi-cdk/bin/apigw-lambda-powertools-openapi-cdk.ts index 963aa331d..368a79d67 100644 --- a/apigw-lambda-node-cdk/bin/apigw-lambda-node-cdk.ts +++ b/apigw-lambda-powertools-openapi-cdk/bin/apigw-lambda-powertools-openapi-cdk.ts @@ -1,10 +1,10 @@ #!/usr/bin/env node import * as cdk from 'aws-cdk-lib'; -import { ApigwLambdaNodeStack } from '../lib/apigw-lambda-node-cdk-stack'; +import { ApigwLambdaPowertoolsOpenapiStack } from '../lib/parent-stack'; const app = new cdk.App(); -new ApigwLambdaNodeStack(app, 'ApigwLambdaStack', { - stageName: 'v1', +new ApigwLambdaPowertoolsOpenapiStack(app, 'ApigwLambdaStack', { + stageName: 'dev', description: 'REST API Gateway with Lambda integration using openapi spec', }); \ No newline at end of file diff --git a/apigw-lambda-node-cdk/cdk.json b/apigw-lambda-powertools-openapi-cdk/cdk.json similarity index 98% rename from apigw-lambda-node-cdk/cdk.json rename to apigw-lambda-powertools-openapi-cdk/cdk.json index e18bd21dd..6229819ae 100644 --- a/apigw-lambda-node-cdk/cdk.json +++ b/apigw-lambda-powertools-openapi-cdk/cdk.json @@ -1,5 +1,5 @@ { - "app": "npx ts-node --prefer-ts-exts bin/apigw-lambda-node-cdk.ts", + "app": "npx ts-node --prefer-ts-exts bin/apigw-lambda-powertools-openapi-cdk.ts", "watch": { "include": [ "**" diff --git a/apigw-lambda-node-cdk/example-pattern.json b/apigw-lambda-powertools-openapi-cdk/example-pattern.json similarity index 100% rename from apigw-lambda-node-cdk/example-pattern.json rename to apigw-lambda-powertools-openapi-cdk/example-pattern.json diff --git a/apigw-lambda-node-cdk/jest.config.js b/apigw-lambda-powertools-openapi-cdk/jest.config.js similarity index 100% rename from apigw-lambda-node-cdk/jest.config.js rename to apigw-lambda-powertools-openapi-cdk/jest.config.js diff --git a/apigw-lambda-powertools-openapi-cdk/lib/api-gateway-stack.ts b/apigw-lambda-powertools-openapi-cdk/lib/api-gateway-stack.ts new file mode 100644 index 000000000..308630ff9 --- /dev/null +++ b/apigw-lambda-powertools-openapi-cdk/lib/api-gateway-stack.ts @@ -0,0 +1,64 @@ +// lib/api-gateway-stack.ts +import * as cdk from "aws-cdk-lib"; +import * as apigateway from "aws-cdk-lib/aws-apigateway"; +import * as cognito from "aws-cdk-lib/aws-cognito"; +import * as lambda from "aws-cdk-lib/aws-lambda"; +import { Construct } from "constructs"; +import * as path from "path"; +import * as fs from "fs"; + +interface ApiGatewayStackProps extends cdk.NestedStackProps { + handleLambda: lambda.Function; + searchLambda: lambda.Function; + stageName: string; + userPool: cognito.UserPool; +} + +export class ApiGatewayStack extends cdk.NestedStack { + public readonly api: apigateway.SpecRestApi; + + constructor(scope: Construct, id: string, props: ApiGatewayStackProps) { + super(scope, id, props); + + const openApiSpecContent = fs.readFileSync( + path.join(__dirname, "openapi/openapi.json"), + "utf8" + ); + + const openApiSpec = JSON.parse( + openApiSpecContent + .replaceAll("${lambdaArn}", props.handleLambda.functionArn) + .replaceAll("${region}", cdk.Stack.of(this).region) + .replaceAll("${searchLambdaArn}", props.searchLambda.functionArn) + .replaceAll("${accountId}", cdk.Stack.of(this).account) + .replaceAll("${userPoolId}", props.userPool.userPoolId) + ); + + this.api = new apigateway.SpecRestApi(this, "SampleApiGatewayLambda2Api", { + restApiName: "OrdersAPI", + description: "API Gateway with Lambda integration", + apiDefinition: apigateway.ApiDefinition.fromInline(openApiSpec), + deployOptions: { + tracingEnabled: true, + dataTraceEnabled: true, + stageName: props.stageName, + }, + cloudWatchRole: false, + }); + + // TODO: is it too permissive? Should we define method and stage instead of **? + new lambda.CfnPermission(this, "LambdaHandlerPermission", { + action: "lambda:InvokeFunction", + functionName: props.handleLambda.functionName, + principal: "apigateway.amazonaws.com", + sourceArn: `arn:aws:execute-api:${this.region}:${this.account}:${this.api.restApiId}/*/*/order*`, + }); + + new lambda.CfnPermission(this, "LambdaSearchPermission", { + action: "lambda:InvokeFunction", + functionName: props.searchLambda.functionName, + principal: "apigateway.amazonaws.com", + sourceArn: `arn:aws:execute-api:${this.region}:${this.account}:${this.api.restApiId}/*/*/orders/search*`, + }); + } +} diff --git a/apigw-lambda-node-cdk/lib/cognito-stack.ts b/apigw-lambda-powertools-openapi-cdk/lib/cognito-stack.ts similarity index 58% rename from apigw-lambda-node-cdk/lib/cognito-stack.ts rename to apigw-lambda-powertools-openapi-cdk/lib/cognito-stack.ts index 8f2024ecb..69534bb4a 100644 --- a/apigw-lambda-node-cdk/lib/cognito-stack.ts +++ b/apigw-lambda-powertools-openapi-cdk/lib/cognito-stack.ts @@ -3,29 +3,30 @@ import * as cdk from "aws-cdk-lib"; import * as cognito from "aws-cdk-lib/aws-cognito"; import { Construct } from "constructs"; +export interface CognitoStackProps extends cdk.NestedStackProps { + stageName: string; +} + export class CognitoStack extends cdk.NestedStack { public readonly userPool: cognito.UserPool; public readonly userPoolClient: cognito.UserPoolClient; - constructor(scope: Construct, id: string, props?: cdk.StackProps) { + constructor(scope: Construct, id: string, props: CognitoStackProps) { super(scope, id, props); this.userPool = new cognito.UserPool(this, "UserPool", { - removalPolicy: cdk.RemovalPolicy.DESTROY, // For development - change for production + removalPolicy: + props.stageName === "dev" + ? cdk.RemovalPolicy.DESTROY + : cdk.RemovalPolicy.RETAIN, }); this.userPoolClient = new cognito.UserPoolClient(this, "UserPoolClient", { userPool: this.userPool, - }); - - - // Outputs - new cdk.CfnOutput(this, "UserPoolId", { - value: this.userPool.userPoolId, - }); - - new cdk.CfnOutput(this, "UserPoolClientId", { - value: this.userPoolClient.userPoolClientId, + authFlows: { + userPassword: true, + adminUserPassword: true, + }, }); } } diff --git a/apigw-lambda-node-cdk/lib/dynamodb-stack.ts b/apigw-lambda-powertools-openapi-cdk/lib/dynamodb-stack.ts similarity index 64% rename from apigw-lambda-node-cdk/lib/dynamodb-stack.ts rename to apigw-lambda-powertools-openapi-cdk/lib/dynamodb-stack.ts index 72056e614..e5d45c1b5 100644 --- a/apigw-lambda-node-cdk/lib/dynamodb-stack.ts +++ b/apigw-lambda-powertools-openapi-cdk/lib/dynamodb-stack.ts @@ -3,13 +3,18 @@ import * as cdk from "aws-cdk-lib"; import * as dynamodb from "aws-cdk-lib/aws-dynamodb"; import { Construct } from "constructs"; +export interface DynamoDBStackProps extends cdk.NestedStackProps { + stageName: string; +} + export class DynamoDBStack extends cdk.NestedStack { public readonly table: dynamodb.TableV2; - constructor(scope: Construct, id: string, props?: cdk.StackProps) { + constructor(scope: Construct, id: string, props: DynamoDBStackProps) { super(scope, id, props); this.table = new dynamodb.TableV2(this, "Table", { + tableName: "orders", partitionKey: { name: "p", type: dynamodb.AttributeType.STRING, @@ -19,7 +24,10 @@ export class DynamoDBStack extends cdk.NestedStack { type: dynamodb.AttributeType.STRING, }, billing: dynamodb.Billing.onDemand(), - removalPolicy: cdk.RemovalPolicy.DESTROY, // For development - change for production + removalPolicy: + props.stageName === "dev" + ? cdk.RemovalPolicy.DESTROY + : cdk.RemovalPolicy.RETAIN, }); } } diff --git a/apigw-lambda-node-cdk/lib/lambda-stack.ts b/apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts similarity index 79% rename from apigw-lambda-node-cdk/lib/lambda-stack.ts rename to apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts index 46575dd7a..3c9517938 100644 --- a/apigw-lambda-node-cdk/lib/lambda-stack.ts +++ b/apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts @@ -27,13 +27,6 @@ export class LambdaStack extends cdk.NestedStack { }:094274105915:layer:AWSLambdaPowertoolsTypeScriptV2:3` ); - const ordersLayer = new lambda.LayerVersion(this, "orders-layer", { - code: lambda.Code.fromAsset("lib/ordersCommonCode"), - compatibleRuntimes: [lambda.Runtime.NODEJS_22_X], - description: "Common orders code layer", - layerVersionName: "orders-layer", - }); - this.handleLambda = new lambdaNodejs.NodejsFunction(this, "Handle", { runtime: lambda.Runtime.NODEJS_22_X, handler: "handler", @@ -41,19 +34,21 @@ export class LambdaStack extends cdk.NestedStack { environment: { POWERTOOLS_SERVICE_NAME: "ordersCRUD", POWERTOOLS_LOG_LEVEL: "INFO", - POWERTOOLS_METRICS_NAMESPACE: "RestAPIGWLambda", - POWERTOOLS_TRACE_ENABLED: "true", + POWERTOOLS_METRICS_NAMESPACE: "OrderService", POWERTOOLS_METRICS_FUNCTION_NAME: "handle", + POWERTOOLS_TRACE_ENABLED: "true", POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: "true", POWERTOOLS_TRACER_CAPTURE_RESPONSE: "true", - POWERTOOLS_DEV: "false", + POWERTOOLS_TRACER_CAPTURE_ERROR: "true", + POWERTOOLS_DEV: String(props.stageName === "dev"), + POWERTOOLS_LOGGER_LOG_EVENT: String(props.stageName === "dev"), STAGE: props.stageName, API_KEY_SECRET_ARN: props.apiKey.secretArn, DYNAMODB_TABLE_NAME: props.table.tableName, }, tracing: lambda.Tracing.ACTIVE, - layers: [powertoolsLayer, ordersLayer], - timeout: cdk.Duration.seconds(30), + layers: [powertoolsLayer], + timeout: cdk.Duration.seconds(5), }); props.apiKey.grantRead(this.handleLambda); @@ -66,18 +61,20 @@ export class LambdaStack extends cdk.NestedStack { environment: { POWERTOOLS_SERVICE_NAME: "ordersSearch", POWERTOOLS_LOG_LEVEL: "INFO", - POWERTOOLS_METRICS_NAMESPACE: "RestAPIGWLambda", - POWERTOOLS_TRACE_ENABLED: "true", + POWERTOOLS_METRICS_NAMESPACE: "OrderService", POWERTOOLS_METRICS_FUNCTION_NAME: "search", + POWERTOOLS_TRACE_ENABLED: "true", POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: "true", POWERTOOLS_TRACER_CAPTURE_RESPONSE: "true", - POWERTOOLS_DEV: "false", + POWERTOOLS_TRACER_CAPTURE_ERROR: "true", + POWERTOOLS_DEV: String(props.stageName === "dev"), + POWERTOOLS_LOGGER_LOG_EVENT: String(props.stageName === "dev"), STAGE: props.stageName, DYNAMODB_TABLE_NAME: props.table.tableName, }, tracing: lambda.Tracing.ACTIVE, - layers: [powertoolsLayer, ordersLayer], - timeout: cdk.Duration.seconds(30), + layers: [powertoolsLayer], + timeout: cdk.Duration.seconds(5), }); props.table.grantReadData(this.searchLambda); } diff --git a/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersCRUD/ddb.ts b/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersCRUD/ddb.ts new file mode 100644 index 000000000..9d913a5ab --- /dev/null +++ b/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersCRUD/ddb.ts @@ -0,0 +1,226 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { + DynamoDBDocumentClient, + PutCommand, + DeleteCommand, + QueryCommand, + PutCommandInput, +} from "@aws-sdk/lib-dynamodb"; + +import { + convertDdbToOrder, + convertOrderToDdb, + DdbOrder, + DdbOrderItem, + Order, + OrderUpdate, +} from "@ordersCommonCode/order"; +import { tracer } from "./powertools"; + +const DYNAMODB_TABLE_NAME = process.env["DYNAMODB_TABLE_NAME"]; +if (!DYNAMODB_TABLE_NAME) { + throw new Error("DYNAMODB_TABLE_NAME is not defined"); +} +const client = tracer.captureAWSv3Client(new DynamoDBClient({})); +const docClient = DynamoDBDocumentClient.from(client); + +/** + * Writes or updates an order and its items to DynamoDB + * @async + * @param {Object} params - The parameters object + * @param {Order} params.order - The order to write to DynamoDB + * @param {boolean} params.update - If true, updates existing order; if false, creates new order + * @throws {Error} If DynamoDB operation fails + * @returns {Promise} + */ +export async function writeOrder({ + order, + update, +}: { + order: Order; + update: boolean; +}): Promise { + const { ddbOrder, ddbOrderItems } = convertOrderToDdb({ order }); + + const putCommandInput: PutCommandInput = { + TableName: DYNAMODB_TABLE_NAME, + Item: ddbOrder, + }; + if (!update) { + putCommandInput.ConditionExpression = + "attribute_not_exists(p) AND attribute_not_exists(s)"; + } + + const promises = [docClient.send(new PutCommand(putCommandInput))]; + + for (const item of ddbOrderItems) { + promises.push( + docClient.send( + new PutCommand({ + TableName: DYNAMODB_TABLE_NAME, + Item: item, + }) + ) + ); + } + await Promise.all(promises); +} + +/** + * Retrieves an order and its items from DynamoDB + * @async + * @param {Object} params - The parameters for retrieving the order + * @param {string} params.customerId - The ID of the customer who owns the order + * @param {string} params.orderId - The ID of the order to retrieve + * @returns {Promise} The order if found + * @throws {Error} If order not found or query fails + */ +export async function getOrder({ + customerId, + orderId, +}: { + customerId: string; + orderId: string; +}): Promise { + try { + const result = await docClient.send( + new QueryCommand({ + TableName: DYNAMODB_TABLE_NAME, + KeyConditionExpression: "p = :p AND begins_with(s, :s)", + ExpressionAttributeValues: { + ":p": `ORDERS#${customerId}`, + ":s": `${orderId}`, + }, + }) + ); + if (!result.Items || result.Items.length === 0) { + throw new Error(`Order not found for orderId: ${orderId}`); + } + let orderRecord: DdbOrder | null = null; + const orderItems: DdbOrderItem[] = []; + + for (const item of result.Items) { + if (item.s === orderId) { + orderRecord = item as DdbOrder; + } else if (item.s.startsWith(`${orderId}#ITEMS#`)) { + orderItems.push(item as DdbOrderItem); + } + } + + if (!orderRecord) { + throw new Error(`Order record not found for orderId: ${orderId}`); + } + + return convertDdbToOrder({ + ddbItem: orderRecord, + ddbOrderItems: orderItems, + }); + } catch (error) { + throw new Error(`Failed to get order: ${(error as Error).message}`); + } +} + +/** + * Retrieves all order items for a specific order from DynamoDB + * @async + * @param {Object} params - The parameters for retrieving order items + * @param {string} params.customerId - The ID of the customer who owns the order + * @param {string} params.orderId - The ID of the order whose items to retrieve + * @returns {Promise} Array of order items + * @throws {Error} If DynamoDB query fails + */ +export async function getDdbOrderItems({ + customerId, + orderId, +}: { + customerId: string; + orderId: string; +}): Promise { + const queryResult = await docClient.send( + new QueryCommand({ + TableName: DYNAMODB_TABLE_NAME, + KeyConditionExpression: "p = :p AND begins_with(s, :s)", + ExpressionAttributeValues: { + ":p": `ORDERS#${customerId}`, + ":s": `${orderId}#ITEMS#`, + }, + }) + ); + if (!queryResult.Items) { + return []; + } else { + return queryResult.Items as DdbOrderItem[]; + } +} + +/** + * Deletes an order and all its items from DynamoDB + * @async + * @param {Object} params - The parameters for deleting the order + * @param {string} params.customerId - The ID of the customer who owns the order + * @param {string} params.orderId - The ID of the order to delete + * @throws {Error} If DynamoDB operation fails + * @returns {Promise} + */ +export async function deleteOrder({ + customerId, + orderId, +}: { + customerId: string; + orderId: string; +}) { + const ddbOrderItems = await getDdbOrderItems({ customerId, orderId }); + const promises = [ + docClient.send( + new DeleteCommand({ + TableName: DYNAMODB_TABLE_NAME, + Key: { + p: `ORDERS#${customerId}`, + s: orderId, + }, + }) + ), + ]; + for (const item of ddbOrderItems) { + promises.push( + docClient.send( + new DeleteCommand({ + TableName: DYNAMODB_TABLE_NAME, + Key: { + p: item.p, + s: item.s, + }, + }) + ) + ); + } + await Promise.all(promises); +} + +/** + * Updates an existing order in DynamoDB + * @async + * @param {Object} params - The parameters for updating the order + * @param {OrderUpdate} params.orderUpdate - The update data for the order + * @param {string} params.orderId - The ID of the order to update + * @param {string} params.customerId - The ID of the customer who owns the order + * @returns {Promise} The updated order + * @throws {Error} If order not found or update fails + */ +export async function updateOrder({ + orderUpdate, + orderId, + customerId, +}: { + orderUpdate: OrderUpdate; + orderId: string; + customerId: string; +}): Promise { + const order = await getOrder({ customerId, orderId }); + const updatedOrder = { + ...order, + ...orderUpdate, + }; + await writeOrder({ order: updatedOrder, update: true }); + return updatedOrder; +} diff --git a/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersCRUD/index.ts b/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersCRUD/index.ts new file mode 100644 index 000000000..6288ca51e --- /dev/null +++ b/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersCRUD/index.ts @@ -0,0 +1,246 @@ +import type { LambdaInterface } from "@aws-lambda-powertools/commons/types"; +import { MetricUnit } from "@aws-lambda-powertools/metrics"; +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; + +import type { Context } from "aws-lambda"; +import { v4 as uuidv4 } from "uuid"; +import { + Order, + OrderCreationInput, + OrderUpdate, +} from "@ordersCommonCode/order"; +import { getCustomerIdFromAuthInfo } from "@ordersCommonCode/customer"; +import { writeOrder, getOrder, updateOrder, deleteOrder } from "./ddb"; +import { logger, metrics, secretsProvider, tracer } from "./powertools"; + +/** + * Simulates an external payment processing operation with X-Ray tracing + * @async + * @throws {Error} If secret retrieval fails + * @returns {Promise} + */ +async function simulateExternalPaymentProcessing() { + const segment = tracer.getSegment(); + const subsegment = segment?.addNewSubsegment("### payment processing"); + await secretsProvider.get(process.env.API_KEY_SECRET_ARN!, { + maxAge: 300, + }); + await new Promise((resolve) => + setTimeout(resolve, Math.floor(Math.random() * 100) + 100) + ); + subsegment?.close(); +} + +/** + * Handles the creation of a new order + * @async + * @param {Object} params - The parameters object + * @param {APIGatewayProxyEvent} params.event - The API Gateway event + * @param {string} params.customerId - The customer's unique identifier + * @returns {Promise} The API response with the created order + * @throws {Error} If order creation fails + */ +async function handleOrderCreationEvent({ + event, + customerId, +}: { + event: APIGatewayProxyEvent; + customerId: string; +}) { + const orderCreationInput: OrderCreationInput = JSON.parse(event.body || "{}"); + const order: Order = { + ...orderCreationInput, + customerId, + orderId: `ORD-${uuidv4()}`, + status: "PENDING", + createdAt: new Date().toISOString(), + }; + await simulateExternalPaymentProcessing(); + await writeOrder({ + order, + update: false, + }); + return { + statusCode: 201, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(order), + }; +} + +/** + * Retrieves an order by ID for a specific customer + * @async + * @param {Object} params - The parameters object + * @param {string} params.customerId - The customer's unique identifier + * @param {string} params.orderId - The order's unique identifier + * @returns {Promise} The API response with the order details + * @throws {Error} If order retrieval fails + */ +async function handleOrderGetEvent({ + customerId, + orderId, +}: { + customerId: string; + orderId: string; +}) { + const order = await getOrder({ + customerId, + orderId: orderId, + }); + return { + statusCode: 200, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(order), + }; +} + +/** + * Updates an existing order + * @async + * @param {Object} params - The parameters object + * @param {APIGatewayProxyEvent} params.event - The API Gateway event + * @param {string} params.customerId - The customer's unique identifier + * @param {string} params.orderId - The order's unique identifier + * @returns {Promise} The API response with the updated order + * @throws {Error} If order update fails + */ +async function handleOrderUpdateEvent({ + event, + customerId, + orderId, +}: { + event: APIGatewayProxyEvent; + customerId: string; + orderId: string; +}) { + const orderUpdateInput: OrderUpdate = JSON.parse(event.body || "{}"); + const order = await updateOrder({ + orderUpdate: orderUpdateInput, + orderId: orderId, + customerId, + }); + return { + statusCode: 200, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(order), + }; +} + +/** + * Deletes an order by ID + * @async + * @param {Object} params - The parameters object + * @param {string} params.customerId - The customer's unique identifier + * @param {string} params.orderId - The order's unique identifier + * @returns {Promise} The API response confirming deletion + * @throws {Error} If order deletion fails + */ +async function handleOrderDeleteEvent({ + customerId, + orderId, +}: { + customerId: string; + orderId: string; +}) { + await deleteOrder({ + customerId, + orderId: orderId, + }); + return { + statusCode: 204, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({}), + }; +} + +/** + * Main Lambda handler class implementing CRUD operations for orders + * @class + * @implements {LambdaInterface} + */ +class HandleOrderLambda implements LambdaInterface { + @tracer.captureLambdaHandler() + @metrics.logMetrics() + @logger.injectLambdaContext({ + flushBufferOnUncaughtError: true, + }) + public async handler( + event: APIGatewayProxyEvent, + _context: Context + ): Promise { + logger.logEventIfEnabled(event); + + const segment = tracer.getSegment(); + + const customerId = getCustomerIdFromAuthInfo({ event }); + const orderId = event.pathParameters?.orderId; + segment?.addAnnotation("customerId", customerId); + if (orderId) { + segment?.addAnnotation("orderId", orderId); + } + + try { + switch (event.httpMethod) { + case "POST": + if (event.path === "/order") { + metrics.addMetric("OrdersCreated", MetricUnit.Count, 1); + return await handleOrderCreationEvent({ event, customerId }); + } + break; + case "GET": + if (event.path.match(/^\/order\/[^/]+$/)) { + metrics.addMetric("OrdersGotten", MetricUnit.Count, 1); + return await handleOrderGetEvent({ customerId, orderId: orderId! }); + } + break; + case "PUT": + if (event.path.match(/^\/order\/[^/]+$/)) { + metrics.addMetric("OrdersUpdated", MetricUnit.Count, 1); + return await handleOrderUpdateEvent({ + event, + customerId, + orderId: orderId!, + }); + } + break; + case "DELETE": + if (event.path.match(/^\/order\/[^/]+$/)) { + metrics.addMetric("OrdersDeleted", MetricUnit.Count, 1); + return await handleOrderDeleteEvent({ + customerId, + orderId: orderId!, + }); + } + break; + } + return { + statusCode: 400, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ message: "Invalid request" }), + }; + } catch (error) { + segment?.addError(error as Error); + console.error("Error:", error); + return { + statusCode: 500, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ message: "Internal Server Error" }), + }; + } + } +} + +const handlerClass = new HandleOrderLambda(); +export const handler = handlerClass.handler.bind(handlerClass); diff --git a/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersCRUD/powertools.ts b/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersCRUD/powertools.ts new file mode 100644 index 000000000..a7ea05bc9 --- /dev/null +++ b/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersCRUD/powertools.ts @@ -0,0 +1,14 @@ +import { Logger } from "@aws-lambda-powertools/logger"; +import { Metrics } from "@aws-lambda-powertools/metrics"; +import { Tracer } from "@aws-lambda-powertools/tracer"; +import { SecretsProvider } from "@aws-lambda-powertools/parameters/secrets"; + +// objects created here for import and reuse in other files +export const logger = new Logger(); +export const metrics = new Metrics(); +export const secretsProvider = new SecretsProvider({}); +export const tracer = new Tracer(); + +logger.appendKeys({ + stage: process.env.STAGE, +}); diff --git a/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/ddb.ts b/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/ddb.ts new file mode 100644 index 000000000..5b02c98da --- /dev/null +++ b/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/ddb.ts @@ -0,0 +1,178 @@ +import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; +import { DynamoDBDocumentClient, QueryCommand } from "@aws-sdk/lib-dynamodb"; + +import { + convertDdbToOrder, + DdbOrder, + DdbOrderItem, + Order, + OrderSearchCriteria, + OrderSearchResponse, +} from "@ordersCommonCode/order"; +import { tracer } from "./powertools"; + +const DYNAMODB_TABLE_NAME = process.env["DYNAMODB_TABLE_NAME"]; +if (!DYNAMODB_TABLE_NAME) { + throw new Error("DYNAMODB_TABLE_NAME is not defined"); +} +const client = tracer.captureAWSv3Client(new DynamoDBClient({})); +const docClient = DynamoDBDocumentClient.from(client); + +/** + * Sorts and paginates a list of orders based on search criteria + * @param {Object} params - The parameters object + * @param {Order[]} params.orders - Array of orders to sort and paginate + * @param {OrderSearchCriteria} params.orderSearchCriteria - Criteria for sorting and pagination + * @returns {OrderSearchResponse} Paginated and sorted orders with pagination metadata + */ +function sortAndPageLimitOrders({ + orders, + orderSearchCriteria, +}: { + orders: Order[]; + orderSearchCriteria: OrderSearchCriteria; +}): OrderSearchResponse { + // default order: descending by createdAt + if (orderSearchCriteria.sortBy === "status") { + if (orderSearchCriteria.sortOrder === "asc") { + orders.sort((a, b) => a.status.localeCompare(b.status)); + } else { + orders.sort((a, b) => b.status.localeCompare(a.status)); + } + } else { + if (orderSearchCriteria.sortOrder === "asc") { + orders.sort( + (a, b) => + new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + } else { + orders.sort( + (a, b) => + new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + } + } + + const page = Math.max(1, orderSearchCriteria.page || 1); + const limit = Math.max(1, orderSearchCriteria.limit || 20); + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const paginatedOrders = orders.slice(startIndex, endIndex); + + return { + items: paginatedOrders, + pagination: { + total: orders.length, + pages: Math.ceil(orders.length / limit), + currentPage: page, + limit, + }, + }; +} + +/** + * Filters orders based on search criteria + * @param {Object} params - The parameters object + * @param {DdbOrder[]} params.ddbOrders - Array of DynamoDB orders + * @param {DdbOrderItem[]} params.ddbOrderItems - Array of DynamoDB order items + * @param {OrderSearchCriteria} params.orderSearchCriteria - Criteria for filtering orders + * @returns {Order[]} Filtered array of orders + */ +function getFilteredOrders({ + ddbOrders, + ddbOrderItems, + orderSearchCriteria, +}: { + ddbOrders: DdbOrder[]; + ddbOrderItems: DdbOrderItem[]; + orderSearchCriteria: OrderSearchCriteria; +}): Order[] { + const orders: Order[] = []; + for (const ddbOrder of ddbOrders) { + if ( + orderSearchCriteria.statuses && + !orderSearchCriteria.statuses.includes(ddbOrder.status) + ) { + continue; + } + + const orderItems = ddbOrderItems.filter((item) => + item.s.startsWith(`${ddbOrder.s}#ITEMS#`) + ); + + const order = convertDdbToOrder({ + ddbItem: ddbOrder, + ddbOrderItems: orderItems, + }); + + if ( + orderSearchCriteria.productIds && + !order.items.find((orderItem) => + orderSearchCriteria.productIds?.includes(orderItem.productId) + ) + ) { + continue; + } + + orders.push(order); + } + + return orders; +} + +/** + * Searches for orders in DynamoDB based on customer ID and search criteria + * @param {Object} params - The parameters object + * @param {OrderSearchCriteria} params.orderSearchCriteria - Search criteria for filtering and sorting orders + * @param {string} params.customerId - Customer ID to search orders for + * @returns {Promise} Filtered, sorted, and paginated orders with metadata + * @throws {Error} If DynamoDB query fails + */ +export async function searchOrders({ + orderSearchCriteria, + customerId, +}: { + orderSearchCriteria: OrderSearchCriteria; + customerId: string; +}): Promise { + const queryResult = await docClient.send( + new QueryCommand({ + TableName: DYNAMODB_TABLE_NAME, + KeyConditionExpression: "p = :p", + ExpressionAttributeValues: { + ":p": `ORDERS#${customerId}`, + }, + }) + ); + if (!queryResult.Items) { + return { + items: [], + pagination: { + total: 0, + pages: 0, + currentPage: 1, + limit: orderSearchCriteria.limit || 20, + }, + }; + } + const ddbOrders: DdbOrder[] = []; + const ddbOrderItems: DdbOrderItem[] = []; + + for (const item of queryResult.Items) { + if (item.s.includes("#ITEMS#")) { + ddbOrderItems.push(item as DdbOrderItem); + } else { + ddbOrders.push(item as DdbOrder); + } + } + + const filteredOrders = getFilteredOrders({ + ddbOrders, + ddbOrderItems, + orderSearchCriteria, + }); + return sortAndPageLimitOrders({ + orders: filteredOrders, + orderSearchCriteria, + }); +} diff --git a/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/index.ts b/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/index.ts new file mode 100644 index 000000000..4efb0524a --- /dev/null +++ b/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/index.ts @@ -0,0 +1,78 @@ +import { Metrics, MetricUnit } from "@aws-lambda-powertools/metrics"; +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; +import type { Context } from "aws-lambda"; +import type { LambdaInterface } from "@aws-lambda-powertools/commons/types"; +import { logger, tracer } from "./powertools"; +import { OrderSearchCriteria } from "@ordersCommonCode/order"; +import { searchOrders } from "./ddb"; +import { getCustomerIdFromAuthInfo } from "@ordersCommonCode/customer"; + +const metrics = new Metrics(); + +/** + * Lambda class handling order search operations through API Gateway + * @class + * @implements {LambdaInterface} + */ +class SearchOrderLambda implements LambdaInterface { + /** + * Main Lambda handler method for processing order search requests + * @async + * @param {APIGatewayProxyEvent} event - The API Gateway event containing search criteria + * @param {Context} _context - AWS Lambda context + * @returns {Promise} API response with search results or error + * @throws {Error} If search operation fails + */ + @tracer.captureLambdaHandler() + @metrics.logMetrics() + @logger.injectLambdaContext({ + flushBufferOnUncaughtError: true, + }) + public async handler( + event: APIGatewayProxyEvent, + _context: Context + ): Promise { + logger.appendKeys({ + stage: process.env.STAGE, + }); + + logger.logEventIfEnabled(event); + + metrics.addMetric("ProcessedEvents", MetricUnit.Count, 1); + const segment = tracer.getSegment(); + + const customerId = getCustomerIdFromAuthInfo({ event }); + segment?.addAnnotation("customerId", customerId); + + try { + const orderSearchCriteria: OrderSearchCriteria = JSON.parse( + event.body || "{}" + ); + + const response = await searchOrders({ + customerId, + orderSearchCriteria, + }); + + return { + statusCode: 200, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(response), + }; + } catch (error) { + console.error("Search Orders Error:", error); + return { + statusCode: 400, + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ message: "Invalid search criteria" }), + }; + } + } +} + +const searchClass = new SearchOrderLambda(); +export const handler = searchClass.handler.bind(searchClass); diff --git a/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/powertools.ts b/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/powertools.ts new file mode 100644 index 000000000..aac284ea9 --- /dev/null +++ b/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/powertools.ts @@ -0,0 +1,12 @@ +import { Logger } from "@aws-lambda-powertools/logger"; +import { Metrics } from "@aws-lambda-powertools/metrics"; +import { Tracer } from "@aws-lambda-powertools/tracer"; + +// objects created here for import and reuse in other files +export const logger = new Logger(); +export const metrics = new Metrics(); +export const tracer = new Tracer(); + +logger.appendKeys({ + stage: process.env.STAGE, +}); diff --git a/apigw-lambda-node-cdk/lib/openapi/openapi.json b/apigw-lambda-powertools-openapi-cdk/lib/openapi/openapi.json similarity index 85% rename from apigw-lambda-node-cdk/lib/openapi/openapi.json rename to apigw-lambda-powertools-openapi-cdk/lib/openapi/openapi.json index 3dc14c1c6..c457b189c 100644 --- a/apigw-lambda-node-cdk/lib/openapi/openapi.json +++ b/apigw-lambda-powertools-openapi-cdk/lib/openapi/openapi.json @@ -171,44 +171,7 @@ "content": { "application/json": { "schema": { - "type": "object", - "properties": { - "customerIds": { - "type": "array", - "items": { "type": "string" } - }, - "statuses": { - "type": "array", - "items": { - "$ref": "#/components/schemas/OrderStatus" - } - }, - "productIds": { - "type": "array", - "items": { "type": "string" } - }, - "page": { - "type": "integer", - "minimum": 1, - "default": 1 - }, - "limit": { - "type": "integer", - "minimum": 1, - "maximum": 100, - "default": 20 - }, - "sortBy": { - "type": "string", - "enum": ["createdAt", "status"], - "default": "createdAt" - }, - "sortOrder": { - "type": "string", - "enum": ["asc", "desc"], - "default": "desc" - } - } + "$ref": "#/components/schemas/OrderSearchCriteria" } } } @@ -348,15 +311,11 @@ "OrderCreationInput": { "type": "object", "required": [ - "customerId", "items", "shippingAddress", "billingAddress" ], "properties": { - "customerId": { - "type": "string" - }, "items": { "type": "array", "items": { @@ -370,16 +329,6 @@ "billingAddress": { "$ref": "#/components/schemas/Address" }, - "tax": { - "type": "number", - "format": "float", - "minimum": 0 - }, - "shippingCost": { - "type": "number", - "format": "float", - "minimum": 0 - }, "paymentMethod": { "$ref": "#/components/schemas/PaymentMethod" }, @@ -396,11 +345,6 @@ }, "couponCode": { "type": "string" - }, - "discountAmount": { - "type": "number", - "format": "float", - "minimum": 0 } } }, @@ -412,8 +356,11 @@ }, { "type": "object", - "required": ["orderId", "status", "createdAt"], + "required": ["customerId", "orderId", "status", "createdAt"], "properties": { + "customerId": { + "type": "string" + }, "orderId": { "type": "string", "readOnly": true @@ -433,6 +380,16 @@ }, "trackingNumber": { "type": "string" + }, + "shippingCost": { + "type": "number", + "format": "float", + "minimum": 0 + }, + "discountAmount": { + "type": "number", + "format": "float", + "minimum": 0 } } } @@ -441,23 +398,55 @@ "OrderUpdate": { "type": "object", "properties": { - "status": { - "$ref": "#/components/schemas/OrderStatus" - }, "shippingAddress": { "$ref": "#/components/schemas/Address" }, "shippingMethod": { "$ref": "#/components/schemas/ShippingMethod" }, - "trackingNumber": { - "type": "string" - }, "customerNotes": { "type": "string", "maxLength": 500 } } + }, + "OrderSearchCriteria": { + "type": "object", + "properties": { + "statuses": { + "type": "array", + "items": { + "$ref": "#/components/schemas/OrderStatus" + } + }, + "productIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "page": { + "type": "integer", + "minimum": 1, + "default": 1 + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "default": 20 + }, + "sortBy": { + "type": "string", + "enum": ["createdAt", "status"], + "default": "createdAt" + }, + "sortOrder": { + "type": "string", + "enum": ["asc", "desc"], + "default": "desc" + } + } } } }, diff --git a/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/README.md b/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/README.md new file mode 100644 index 000000000..dfc4b8b45 --- /dev/null +++ b/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/README.md @@ -0,0 +1 @@ +The file `types.ts` must not be modified directly. It is automatically created by `openapi-typescript` taking `openapi/openapi.json` as input. Refer to the main `README.md` for more info. diff --git a/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/customer.ts b/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/customer.ts new file mode 100644 index 000000000..cbed67988 --- /dev/null +++ b/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/customer.ts @@ -0,0 +1,20 @@ +import { + APIGatewayProxyCognitoAuthorizer, + APIGatewayProxyEvent, +} from "aws-lambda"; + +/** + * Extracts the customer ID from the Cognito authentication information in the API Gateway event + * @param {Object} params - The parameters object + * @param {APIGatewayProxyEvent} params.event - The API Gateway event containing Cognito authorizer context + * @returns {string} The customer ID (Cognito sub claim) + * @throws {Error} If the authorizer or sub claim is not present + */ +export function getCustomerIdFromAuthInfo({ + event, +}: { + event: APIGatewayProxyEvent; +}): string { + return (event.requestContext.authorizer as APIGatewayProxyCognitoAuthorizer) + .claims["sub"]!; +} diff --git a/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/order.ts b/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/order.ts new file mode 100644 index 000000000..cd383e984 --- /dev/null +++ b/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/order.ts @@ -0,0 +1,181 @@ +import { components, paths } from "./types"; + +export type Address = components["schemas"]["Address"]; +export type Order = components["schemas"]["Order"]; +export type OrderCreationInput = components["schemas"]["OrderCreationInput"]; +export type OrderItem = components["schemas"]["OrderItem"]; +export type OrderResponse = components["schemas"]["Order"]; +export type OrderSearchCriteria = components["schemas"]["OrderSearchCriteria"]; +export type OrderSearchResponse = paths["/orders/search"]["post"]["responses"]["200"]["content"]["application/json"] +export type OrderStatus = components["schemas"]["OrderStatus"]; +export type OrderUpdate = components["schemas"]["OrderUpdate"]; +export type PaymentMethod = components["schemas"]["PaymentMethod"]; +export type ShippingMethod = components["schemas"]["ShippingMethod"]; + +/** + * Represents an order record in DynamoDB + * @interface DdbOrder + * @property {string} p - Partition key in format "ORDERS#{customerId}" + * @property {string} s - Sort key containing the orderId + * @property {string} customerId - Unique identifier for the customer + * @property {Address} shippingAddress - Shipping address details + * @property {Address} billingAddress - Billing address details + * @property {number} [tax] - Tax amount applied to the order + * @property {number} [shippingCost] - Shipping cost for the order + * @property {PaymentMethod} [paymentMethod] - Method of payment + * @property {ShippingMethod} [shippingMethod] - Method of shipping + * @property {string} [customerNotes] - Additional notes from the customer + * @property {boolean} giftWrapping - Whether gift wrapping was requested + * @property {string} [couponCode] - Applied coupon code + * @property {number} [discountAmount] - Discount amount applied + * @property {OrderStatus} status - Current status of the order + * @property {string} createdAt - Timestamp of order creation + */ +export interface DdbOrder { + p: string; + s: string; + customerId: string; + shippingAddress: Address; + billingAddress: Address; + tax?: number; + shippingCost?: number; + paymentMethod?: PaymentMethod; + shippingMethod?: ShippingMethod; + customerNotes?: string; + giftWrapping: boolean; + couponCode?: string; + discountAmount?: number; + status: OrderStatus; + createdAt: string; +} + +/** + * Represents an order item record in DynamoDB + * @interface DdbOrderItem + * @property {string} p - Partition key in format "ORDERS#{customerId}#{orderId}" + * @property {string} s - Sort key in format "{orderId}#{productId}" + * @property {string} [productName] - Name of the product + * @property {number} quantity - Quantity ordered + * @property {number} price - Price per unit + * @property {string} [sku] - Stock Keeping Unit + * @property {string} [variantId] - Identifier for product variant + */ +export interface DdbOrderItem { + p: string; + s: string; + productName?: string; + quantity: number; + price: number; + sku?: string; + variantId?: string; +} + +/** + * Converts an Order object to DynamoDB format + * @param {Order} order - The order to convert + * @returns {Object} Object containing the converted order and its items + * @property {DdbOrder} ddbOrder - The main order record for DynamoDB + * @property {DdbOrderItem[]} ddbOrderItems - Array of order item records for DynamoDB + */ +export function convertOrderToDdb({ order }: { order: Order }): { + ddbOrder: DdbOrder; + ddbOrderItems: DdbOrderItem[]; +} { + const ddbItem: DdbOrder = { + p: `ORDERS#${order.customerId}`, + s: order.orderId, + customerId: order.customerId, + shippingAddress: order.shippingAddress, + billingAddress: order.billingAddress, + tax: order.tax, + shippingCost: order.shippingCost, + paymentMethod: order.paymentMethod, + shippingMethod: order.shippingMethod, + customerNotes: order.customerNotes, + giftWrapping: order.giftWrapping, + couponCode: order.couponCode, + discountAmount: order.discountAmount, + status: order.status, + createdAt: order.createdAt, + }; + const ddbItems: DdbOrderItem[] = order.items.map((item) => ({ + p: `ORDERS#${order.customerId}`, + s: `${order.orderId}#ITEMS#${item.productId}`, + productName: item.productName, + quantity: item.quantity, + price: item.price, + sku: item.sku, + variantId: item.variantId, + })); + Object.keys(ddbItem).forEach((key) => { + if (ddbItem[key as keyof DdbOrder] === undefined) { + delete ddbItem[key as keyof DdbOrder]; + } + }); + ddbItems.forEach((item) => { + Object.keys(item).forEach((key) => { + if (item[key as keyof DdbOrderItem] === undefined) { + delete item[key as keyof DdbOrderItem]; + } + }); + }); + return { + ddbOrder: ddbItem, + ddbOrderItems: ddbItems, + }; +} + +/** + * Converts a DynamoDB order item record to an OrderItem object + * @param {DdbOrderItem} ddbOrderItem - The order item record from DynamoDB + * @returns {OrderItem} The converted OrderItem object + */ +export function convertDdbToOrderItem({ + ddbOrderItem, +}: { + ddbOrderItem: DdbOrderItem; +}): OrderItem { + return { + productId: ddbOrderItem.s.split("#ITEMS#")[1]!, + productName: ddbOrderItem.productName, + quantity: ddbOrderItem.quantity, + price: ddbOrderItem.price, + sku: ddbOrderItem.sku, + variantId: ddbOrderItem.variantId, + }; +} + +/** + * Converts DynamoDB order records back to an Order object + * @param {DdbOrder} ddbItem - The main order record from DynamoDB + * @param {DdbOrderItem[]} ddbOrderItems - Array of order item records from DynamoDB + * @returns {Order} The reconstructed Order object + */ +export function convertDdbToOrder({ + ddbItem, + ddbOrderItems, +}: { + ddbItem: DdbOrder; + ddbOrderItems: DdbOrderItem[]; +}): Order { + const order: Order = { + orderId: ddbItem.s, + customerId: ddbItem.customerId, + items: ddbOrderItems.map((item) => + convertDdbToOrderItem({ ddbOrderItem: item }) + ), + shippingAddress: ddbItem.shippingAddress, + billingAddress: ddbItem.billingAddress, + tax: ddbItem.tax, + shippingCost: ddbItem.shippingCost, + paymentMethod: ddbItem.paymentMethod, + shippingMethod: ddbItem.shippingMethod, + customerNotes: ddbItem.customerNotes, + giftWrapping: ddbItem.giftWrapping, + couponCode: ddbItem.couponCode, + discountAmount: ddbItem.discountAmount, + status: ddbItem.status, + createdAt: ddbItem.createdAt, + }; + return order; +} diff --git a/apigw-lambda-node-cdk/lib/ordersCommonCode/types.ts b/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/types.ts similarity index 90% rename from apigw-lambda-node-cdk/lib/ordersCommonCode/types.ts rename to apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/types.ts index 5232c8f8c..390084c69 100644 --- a/apigw-lambda-node-cdk/lib/ordersCommonCode/types.ts +++ b/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/types.ts @@ -185,25 +185,7 @@ export interface paths { }; requestBody: { content: { - "application/json": { - customerIds?: string[]; - statuses?: components["schemas"]["OrderStatus"][]; - productIds?: string[]; - /** @default 1 */ - page?: number; - /** @default 20 */ - limit?: number; - /** - * @default createdAt - * @enum {string} - */ - sortBy?: "createdAt" | "status"; - /** - * @default desc - * @enum {string} - */ - sortOrder?: "asc" | "desc"; - }; + "application/json": components["schemas"]["OrderSearchCriteria"]; }; }; responses: { @@ -276,24 +258,18 @@ export interface components { variantId?: string; }; OrderCreationInput: { - customerId: string; items: components["schemas"]["OrderItem"][]; shippingAddress: components["schemas"]["Address"]; billingAddress: components["schemas"]["Address"]; - /** Format: float */ - tax?: number; - /** Format: float */ - shippingCost?: number; paymentMethod?: components["schemas"]["PaymentMethod"]; shippingMethod?: components["schemas"]["ShippingMethod"]; customerNotes?: string; /** @default false */ giftWrapping: boolean; couponCode?: string; - /** Format: float */ - discountAmount?: number; }; Order: components["schemas"]["OrderCreationInput"] & { + customerId: string; readonly orderId: string; readonly status: components["schemas"]["OrderStatus"]; /** Format: date-time */ @@ -301,14 +277,34 @@ export interface components { /** Format: date */ estimatedDeliveryDate?: string; trackingNumber?: string; + /** Format: float */ + shippingCost?: number; + /** Format: float */ + discountAmount?: number; }; OrderUpdate: { - status?: components["schemas"]["OrderStatus"]; shippingAddress?: components["schemas"]["Address"]; shippingMethod?: components["schemas"]["ShippingMethod"]; - trackingNumber?: string; customerNotes?: string; }; + OrderSearchCriteria: { + statuses?: components["schemas"]["OrderStatus"][]; + productIds?: string[]; + /** @default 1 */ + page: number; + /** @default 20 */ + limit: number; + /** + * @default createdAt + * @enum {string} + */ + sortBy: "createdAt" | "status"; + /** + * @default desc + * @enum {string} + */ + sortOrder: "asc" | "desc"; + }; }; responses: never; parameters: never; diff --git a/apigw-lambda-powertools-openapi-cdk/lib/parent-stack.ts b/apigw-lambda-powertools-openapi-cdk/lib/parent-stack.ts new file mode 100644 index 000000000..ac46b1dfa --- /dev/null +++ b/apigw-lambda-powertools-openapi-cdk/lib/parent-stack.ts @@ -0,0 +1,63 @@ +import * as cdk from "aws-cdk-lib"; +import { Construct } from "constructs"; +import { DynamoDBStack } from "./dynamodb-stack"; +import { LambdaStack } from "./lambda-stack"; +import { ApiGatewayStack } from "./api-gateway-stack"; +import { SecretsStack } from "./secrets-stack"; +import { CognitoStack } from "./cognito-stack"; + +interface ApigwLambdaPowertoolsOpenapiStackProps extends cdk.StackProps { + stageName: string; +} + +export class ApigwLambdaPowertoolsOpenapiStack extends cdk.Stack { + constructor( + scope: Construct, + id: string, + props: ApigwLambdaPowertoolsOpenapiStackProps + ) { + super(scope, id, props); + + const secretsStack = new SecretsStack(this, "OrdersSecretsStack", { + stageName: props.stageName, + }); + + const dynamoDbStack = new DynamoDBStack(this, "OrdersDynamoDBStack", { + stageName: props.stageName, + }); + const cognitoStack = new CognitoStack(this, "OrdersCognitoStack", { + stageName: props.stageName, + }); + + const lambdaStack = new LambdaStack(this, "OrdersLambdaStack", { + stageName: props.stageName, + apiKey: secretsStack.apiKey, + description: "Lambda functions for the API", + table: dynamoDbStack.table, + }); + + const apiGatewayStack = new ApiGatewayStack(this, "OrdersApiStack", { + stageName: props.stageName, + handleLambda: lambdaStack.handleLambda, + searchLambda: lambdaStack.searchLambda, + description: "API Gateway with Lambda integration", + userPool: cognitoStack.userPool, + }); + + new cdk.CfnOutput(this, "UserPoolId", { + value: cognitoStack.userPool.userPoolId, + description: "UserPoolId", + exportName: "UserPoolId", + }); + new cdk.CfnOutput(this, "UserPoolClientId", { + value: cognitoStack.userPoolClient.userPoolClientId, + description: "UserPoolClientId", + exportName: "UserPoolClientId", + }); + new cdk.CfnOutput(this, "ApiGatewayEndpoint", { + value: apiGatewayStack.api.url, + description: "ApiGatewayEndpoint", + exportName: "ApiGatewayEndpoint", + }); + } +} diff --git a/apigw-lambda-powertools-openapi-cdk/lib/secrets-stack.ts b/apigw-lambda-powertools-openapi-cdk/lib/secrets-stack.ts new file mode 100644 index 000000000..afd943b1b --- /dev/null +++ b/apigw-lambda-powertools-openapi-cdk/lib/secrets-stack.ts @@ -0,0 +1,26 @@ +// lib/secrets-stack.ts +import * as cdk from "aws-cdk-lib"; +import * as secretsmanager from "aws-cdk-lib/aws-secretsmanager"; +import { Construct } from "constructs"; + +export interface SecretsStackProps extends cdk.NestedStackProps { + stageName: string; +} + +export class SecretsStack extends cdk.NestedStack { + public readonly apiKey: secretsmanager.Secret; + + constructor(scope: Construct, id: string, props: SecretsStackProps) { + super(scope, id, props); + + // Create the secret + this.apiKey = new secretsmanager.Secret(this, "ExternalServiceApiKey", { + secretName: "orders/payment-api-key", + description: "API Key for External Payment Service", + removalPolicy: + props.stageName === "dev" + ? cdk.RemovalPolicy.DESTROY + : cdk.RemovalPolicy.RETAIN, + }); + } +} diff --git a/apigw-lambda-node-cdk/package.json b/apigw-lambda-powertools-openapi-cdk/package.json similarity index 85% rename from apigw-lambda-node-cdk/package.json rename to apigw-lambda-powertools-openapi-cdk/package.json index 9e8dde2d2..78b532b88 100644 --- a/apigw-lambda-node-cdk/package.json +++ b/apigw-lambda-powertools-openapi-cdk/package.json @@ -27,6 +27,9 @@ }, "dependencies": { "@aws-lambda-powertools/logger": "^2.18.0", + "@aws-lambda-powertools/parameters": "^2.18.0", + "@aws-sdk/client-dynamodb": "^3.788.0", + "@aws-sdk/lib-dynamodb": "^3.789.0", "aws-cdk-lib": "2.189.1", "constructs": "^10.4.2", "esbuild": "^0.25.2", diff --git a/apigw-lambda-powertools-openapi-cdk/pattern.png b/apigw-lambda-powertools-openapi-cdk/pattern.png new file mode 100644 index 0000000000000000000000000000000000000000..beb022ac92b2b9075b8c46491d78e10ea31a3f2f GIT binary patch literal 81284 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 Date: Thu, 24 Apr 2025 19:16:33 +0200 Subject: [PATCH 12/21] use central metrics object --- .../lib/lambda/ordersSearch/index.ts | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/index.ts b/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/index.ts index 4efb0524a..f4f405c3b 100644 --- a/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/index.ts +++ b/apigw-lambda-powertools-openapi-cdk/lib/lambda/ordersSearch/index.ts @@ -1,14 +1,12 @@ -import { Metrics, MetricUnit } from "@aws-lambda-powertools/metrics"; +import { MetricUnit } from "@aws-lambda-powertools/metrics"; import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; import type { Context } from "aws-lambda"; import type { LambdaInterface } from "@aws-lambda-powertools/commons/types"; -import { logger, tracer } from "./powertools"; +import { logger, metrics, tracer } from "./powertools"; import { OrderSearchCriteria } from "@ordersCommonCode/order"; import { searchOrders } from "./ddb"; import { getCustomerIdFromAuthInfo } from "@ordersCommonCode/customer"; -const metrics = new Metrics(); - /** * Lambda class handling order search operations through API Gateway * @class From daa50f1a5112798c2ad6251177615be9de817387 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Thu, 24 Apr 2025 19:18:53 +0200 Subject: [PATCH 13/21] CDK instead of SAM --- apigw-lambda-powertools-openapi-cdk/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apigw-lambda-powertools-openapi-cdk/README.md b/apigw-lambda-powertools-openapi-cdk/README.md index a7a7f76ea..48eb67119 100644 --- a/apigw-lambda-powertools-openapi-cdk/README.md +++ b/apigw-lambda-powertools-openapi-cdk/README.md @@ -13,7 +13,7 @@ Important: this application uses various AWS services and there are costs associ * [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 +* [AWS Cloud Development Kit](https://docs.aws.amazon.com/cdk/v2/guide/getting-started.html) (AWS CDK) installed ## Deployment Instructions From 5f90a418c14b6af84ee9afe6b2b9732f8c9d6611 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Mon, 28 Apr 2025 08:17:35 +0200 Subject: [PATCH 14/21] renaming, formatting --- .../bin/apigw-lambda-powertools-openapi-cdk.ts | 12 ++++++------ .../lib/api-gateway-stack.ts | 1 - .../lib/lambda-stack.ts | 12 +++++++----- .../lib/parent-stack.ts | 8 ++++---- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/apigw-lambda-powertools-openapi-cdk/bin/apigw-lambda-powertools-openapi-cdk.ts b/apigw-lambda-powertools-openapi-cdk/bin/apigw-lambda-powertools-openapi-cdk.ts index 368a79d67..0238f5d23 100644 --- a/apigw-lambda-powertools-openapi-cdk/bin/apigw-lambda-powertools-openapi-cdk.ts +++ b/apigw-lambda-powertools-openapi-cdk/bin/apigw-lambda-powertools-openapi-cdk.ts @@ -1,10 +1,10 @@ #!/usr/bin/env node -import * as cdk from 'aws-cdk-lib'; -import { ApigwLambdaPowertoolsOpenapiStack } from '../lib/parent-stack'; +import * as cdk from "aws-cdk-lib"; +import { ParentStack } from "../lib/parent-stack"; const app = new cdk.App(); -new ApigwLambdaPowertoolsOpenapiStack(app, 'ApigwLambdaStack', { - stageName: 'dev', - description: 'REST API Gateway with Lambda integration using openapi spec', -}); \ No newline at end of file +new ParentStack(app, "ApigwLambdaPowertoolsOpenapiStack", { + stageName: "dev", + description: "REST API Gateway with Lambda integration using openapi spec", +}); diff --git a/apigw-lambda-powertools-openapi-cdk/lib/api-gateway-stack.ts b/apigw-lambda-powertools-openapi-cdk/lib/api-gateway-stack.ts index 308630ff9..cbec8ec22 100644 --- a/apigw-lambda-powertools-openapi-cdk/lib/api-gateway-stack.ts +++ b/apigw-lambda-powertools-openapi-cdk/lib/api-gateway-stack.ts @@ -46,7 +46,6 @@ export class ApiGatewayStack extends cdk.NestedStack { cloudWatchRole: false, }); - // TODO: is it too permissive? Should we define method and stage instead of **? new lambda.CfnPermission(this, "LambdaHandlerPermission", { action: "lambda:InvokeFunction", functionName: props.handleLambda.functionName, diff --git a/apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts b/apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts index 3c9517938..3ca280c6a 100644 --- a/apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts +++ b/apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts @@ -13,7 +13,7 @@ export interface LambdaStackProps extends cdk.NestedStackProps { } export class LambdaStack extends cdk.NestedStack { - public readonly handleLambda: lambda.Function; + public readonly crudLambda: lambda.Function; public readonly searchLambda: lambda.Function; constructor(scope: Construct, id: string, props: LambdaStackProps) { @@ -27,8 +27,9 @@ export class LambdaStack extends cdk.NestedStack { }:094274105915:layer:AWSLambdaPowertoolsTypeScriptV2:3` ); - this.handleLambda = new lambdaNodejs.NodejsFunction(this, "Handle", { + this.crudLambda = new lambdaNodejs.NodejsFunction(this, "crudLambda", { runtime: lambda.Runtime.NODEJS_22_X, + functionName: "orderCRUD", handler: "handler", entry: "lib/lambda/ordersCRUD/index.ts", environment: { @@ -51,11 +52,12 @@ export class LambdaStack extends cdk.NestedStack { timeout: cdk.Duration.seconds(5), }); - props.apiKey.grantRead(this.handleLambda); - props.table.grantReadWriteData(this.handleLambda); + props.apiKey.grantRead(this.crudLambda); + props.table.grantReadWriteData(this.crudLambda); - this.searchLambda = new lambdaNodejs.NodejsFunction(this, "Search", { + this.searchLambda = new lambdaNodejs.NodejsFunction(this, "searchLambda", { runtime: lambda.Runtime.NODEJS_22_X, + functionName: "orderSearch", handler: "handler", entry: "lib/lambda/ordersSearch/index.ts", environment: { diff --git a/apigw-lambda-powertools-openapi-cdk/lib/parent-stack.ts b/apigw-lambda-powertools-openapi-cdk/lib/parent-stack.ts index ac46b1dfa..9b6de5f75 100644 --- a/apigw-lambda-powertools-openapi-cdk/lib/parent-stack.ts +++ b/apigw-lambda-powertools-openapi-cdk/lib/parent-stack.ts @@ -6,15 +6,15 @@ import { ApiGatewayStack } from "./api-gateway-stack"; import { SecretsStack } from "./secrets-stack"; import { CognitoStack } from "./cognito-stack"; -interface ApigwLambdaPowertoolsOpenapiStackProps extends cdk.StackProps { +interface ParentStackProps extends cdk.StackProps { stageName: string; } -export class ApigwLambdaPowertoolsOpenapiStack extends cdk.Stack { +export class ParentStack extends cdk.Stack { constructor( scope: Construct, id: string, - props: ApigwLambdaPowertoolsOpenapiStackProps + props: ParentStackProps ) { super(scope, id, props); @@ -38,7 +38,7 @@ export class ApigwLambdaPowertoolsOpenapiStack extends cdk.Stack { const apiGatewayStack = new ApiGatewayStack(this, "OrdersApiStack", { stageName: props.stageName, - handleLambda: lambdaStack.handleLambda, + handleLambda: lambdaStack.crudLambda, searchLambda: lambdaStack.searchLambda, description: "API Gateway with Lambda integration", userPool: cognitoStack.userPool, From c5e403fc27184a9f1e0e9a86edfed84ea1ae5ab3 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Mon, 28 Apr 2025 08:17:50 +0200 Subject: [PATCH 15/21] schema simplification for pattern --- .../lib/ordersCommonCode/order.ts | 2 -- 1 file changed, 2 deletions(-) diff --git a/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/order.ts b/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/order.ts index cd383e984..805b63b83 100644 --- a/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/order.ts +++ b/apigw-lambda-powertools-openapi-cdk/lib/ordersCommonCode/order.ts @@ -87,7 +87,6 @@ export function convertOrderToDdb({ order }: { order: Order }): { customerId: order.customerId, shippingAddress: order.shippingAddress, billingAddress: order.billingAddress, - tax: order.tax, shippingCost: order.shippingCost, paymentMethod: order.paymentMethod, shippingMethod: order.shippingMethod, @@ -166,7 +165,6 @@ export function convertDdbToOrder({ ), shippingAddress: ddbItem.shippingAddress, billingAddress: ddbItem.billingAddress, - tax: ddbItem.tax, shippingCost: ddbItem.shippingCost, paymentMethod: ddbItem.paymentMethod, shippingMethod: ddbItem.shippingMethod, From 0adc3213d1caa90bf4ab1ee2c6caa75fdf09fd79 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Mon, 28 Apr 2025 08:18:46 +0200 Subject: [PATCH 16/21] documentation updates --- apigw-lambda-powertools-openapi-cdk/README.md | 280 +++++++++++------- 1 file changed, 167 insertions(+), 113 deletions(-) diff --git a/apigw-lambda-powertools-openapi-cdk/README.md b/apigw-lambda-powertools-openapi-cdk/README.md index 48eb67119..f1c40efdb 100644 --- a/apigw-lambda-powertools-openapi-cdk/README.md +++ b/apigw-lambda-powertools-openapi-cdk/README.md @@ -1,35 +1,48 @@ -# AWS Service 1 to AWS Service 2 +# AWS CDK Project with OpenAPI defined API Gateway and Powertools for AWS Lambda -This pattern << explain usage >> +This pattern demonstrates how to build a fully typed serverless API using AWS CDK, OpenAPI, and Powertools for AWS Lambda. The pattern leverages an OpenAPI specification to define the API Gateway configuration, including paths, methods, and Lambda integrations. The same OpenAPI specification is used to auto-generate TypeScript types, ensuring type safety and consistency across the API Gateway configuration and Lambda function implementations. -Learn more about this pattern at Serverless Land Patterns: << Add the live URL here >> +Key features of this pattern: -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. +- Uses OpenAPI specification as a single source of truth for API definition +- Auto-generates TypeScript types from OpenAPI spec for Lambda functions +- Implements AWS Lambda Powertools for: + - Structured logging to CloudWatch + - Custom metrics collection + - Distributed tracing with AWS X-Ray + - Secure parameter and secrets management +- Demonstrates end-to-end type safety from API definition to function implementation +- Showcases best practices for building serverless APIs with AWS CDK + +The pattern includes a sample Order API that demonstrates CRUD operations and search functionality, complete with authentication via Amazon Cognito. This architecture ensures that API contracts are always in sync between the API Gateway configuration and the Lambda function implementations, reducing runtime errors and improving developer experience. +Learn more about this pattern at Serverless Land Patterns: << Add the live URL here >> +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 Cloud Development Kit](https://docs.aws.amazon.com/cdk/v2/guide/getting-started.html) (AWS CDK) installed +- [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 Cloud Development Kit](https://docs.aws.amazon.com/cdk/v2/guide/getting-started.html) (AWS CDK) installed ## 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 - ``` + ``` + git clone https://github.com/aws-samples/serverless-patterns + ``` 1. Change directory to the pattern directory: - ``` - cd apigw-lambda-powertools-openapi-cdk - ``` + ``` + cd apigw-lambda-powertools-openapi-cdk + ``` 1. Authenticate to the AWS account you want to deploy in. 1. From the command line, use AWS SAM to deploy the AWS resources for the pattern as specified in the template.yml file: - ``` - cdk deploy - ``` + + ``` + cdk deploy + ``` 1. With successful deployment, cdk will print three Outputs to the terminal ("ApiGatewayEndpoint", "UserPoolClientId" and "UserPoolId"). Copy them to your text editor for later user. @@ -37,7 +50,42 @@ Important: this application uses various AWS services and there are costs associ ![Diagram of pattern](pattern.png) -Explain how the service interaction works. +The pattern demonstrates a modern approach to building type-safe serverless APIs: + +1. **API Definition** + + - The API is defined using OpenAPI 3.0 specification + - The specification describes all API paths, methods, request/response schemas, and security requirements + - API Gateway is configured directly from this OpenAPI specification using CDK + +2. **Type Generation** + + - TypeScript types are automatically generated from the OpenAPI specification + - These types are used in Lambda function implementations to ensure type safety + - Any changes to the API contract in the OpenAPI spec are easily synchronized to the shared code for the AWS Lambda functions by executing `npx openapi-typescript ./lib/openapi/openapi.json -o ./lib/ordersCommonCode/types.ts` + +3. **Lambda Function Implementation** + + - Lambda functions are implemented in TypeScript using the generated types + - AWS Lambda Powertools provides the following capabilities: + - Logger: Structured JSON logging with sampling and correlation IDs + - Tracer: Distributed tracing with AWS X-Ray for request flow visualization + - Metrics: Custom metrics collection for monitoring and alerting + - Parameters: Secure access to parameters and secrets + +4. **Authentication Flow** + + - Amazon Cognito User Pool handles user authentication + - API Gateway validates JWT tokens from Cognito + - Lambda functions receive authenticated requests with user context + +5. **Data Flow** + - Client makes authenticated requests to API Gateway + - API Gateway routes requests to appropriate Lambda functions + - Lambda functions process requests and interact with DynamoDB + - Responses flow back through API Gateway to the client + +The entire stack is deployed using AWS CDK, which creates and manages all required AWS resources including API Gateway, Lambda functions, DynamoDB tables, and Cognito User Pool. ## Testing @@ -45,129 +93,135 @@ You will create an Amazon Cogntio user for authenticating against the API. Then, 1. Set environment variables for the following commands. You will need the values of the Outputs you copied as last step of the Deployment Instructions: - ```bash - API_GATEWAY_ENDPOINT= - USER_POOL_ID= - USER_POOL_CLIENT_ID= - USER_NAME=testuser - USER_EMAIL=user@example.com - USER_PASSWORD=MyUserPassword123! - ``` + ```bash + API_GATEWAY_ENDPOINT= + USER_POOL_ID= + USER_POOL_CLIENT_ID= + USER_NAME=testuser + USER_EMAIL=user@example.com + USER_PASSWORD=MyUserPassword123! + ``` 1. Create a user in Cognito that will be used for authenticating test requests: - ```bash - aws cognito-idp admin-create-user \ - --user-pool-id $USER_POOL_ID \ - --username $USER_NAME \ - --user-attributes Name=email,Value=$USER_EMAIL \ - --temporary-password $USER_PASSWORD \ - --message-action SUPPRESS - aws cognito-idp admin-set-user-password \ - --user-pool-id $USER_POOL_ID \ - --username $USER_NAME \ - --password $USER_PASSWORD \ - --permanent - ``` + ```bash + aws cognito-idp admin-create-user \ + --user-pool-id $USER_POOL_ID \ + --username $USER_NAME \ + --user-attributes Name=email,Value=$USER_EMAIL \ + --temporary-password $USER_PASSWORD \ + --message-action SUPPRESS + aws cognito-idp admin-set-user-password \ + --user-pool-id $USER_POOL_ID \ + --username $USER_NAME \ + --password $USER_PASSWORD \ + --permanent + ``` 1. Generate a Cognito IdToken for the user that will be sent as the Authorization header. Store it in the ID_TOKEN environment variable: - ```bash - ID_TOKEN=$(aws cognito-idp admin-initiate-auth \ - --user-pool-id $USER_POOL_ID \ - --client-id $USER_POOL_CLIENT_ID \ - --auth-flow ADMIN_USER_PASSWORD_AUTH \ - --auth-parameters USERNAME=$USER_NAME,PASSWORD=$USER_PASSWORD \ - --query 'AuthenticationResult.IdToken' \ - --output text) - ``` + ```bash + ID_TOKEN=$(aws cognito-idp admin-initiate-auth \ + --user-pool-id $USER_POOL_ID \ + --client-id $USER_POOL_CLIENT_ID \ + --auth-flow ADMIN_USER_PASSWORD_AUTH \ + --auth-parameters USERNAME=$USER_NAME,PASSWORD=$USER_PASSWORD \ + --query 'AuthenticationResult.IdToken' \ + --output text) + ``` 1. Send a POST request that will create an order with the body being the content of the file `test/sample_create_order.json`. Store the order ID in the environment variable ORDER_ID for further use: - ```bash - ORDER_ID=$(curl -sS --header "Authorization: Bearer $ID_TOKEN" \ - $API_GATEWAY_ENDPOINT/order -X POST \ - -H "Content-Type: application/json" \ - --data "@./test/sample_create_order.json" | tee /dev/tty | jq -r .orderId) - ``` + ```bash + ORDER_ID=$(curl -sS -H "Authorization: Bearer $ID_TOKEN" \ + -H "Content-Type: application/json" \ + -X POST \ + --data "@./test/sample_create_order.json" \ + $API_GATEWAY_ENDPOINT/order | tee /dev/tty | jq -r .orderId) + ``` - You will get the Order json as response. + You will get the Order json as response. 1. Update the order with new shipping information using a PUT request: - ```bash - curl -sS --header "Authorization: Bearer $ID_TOKEN" \ - $API_GATEWAY_ENDPOINT/order/$ORDER_ID -X PUT \ - -H "Content-Type: application/json" \ - --data '{ - "shippingMethod": "NEXT_DAY", - "customerNotes": "Please deliver before noon", - "shippingAddress": { - "street": "777 Main Street", - "city": "Anytown", - "state": "WA", - "postalCode": "31415", - "country": "USA" - } - }' - ``` - - You will get the updated Order json as response. + ```bash + curl -sS -H "Authorization: Bearer $ID_TOKEN" \ + $API_GATEWAY_ENDPOINT/order/$ORDER_ID -X PUT \ + -H "Content-Type: application/json" \ + --data '{ + "shippingMethod": "NEXT_DAY", + "customerNotes": "Please deliver before noon", + "shippingAddress": { + "street": "777 Main Street", + "city": "Anytown", + "state": "WA", + "postalCode": "31415", + "country": "USA" + } + }' + ``` + + You will get the updated Order json as response. 1. Retrieve the order using a GET request: - ```bash - curl -sS --header "Authorization: Bearer $ID_TOKEN" \ - $API_GATEWAY_ENDPOINT/order/$ORDER_ID -X GET - ``` + ```bash + curl -sS -H "Authorization: Bearer $ID_TOKEN" \ + $API_GATEWAY_ENDPOINT/order/$ORDER_ID \ + -X GET + ``` - You will again the the Order json as response. + You will again the the Order json as response. 1. Send a POST request that will create a second order with the body being the content of the file `test/sample_create_order2.json`. - ```bash - curl -sS --header "Authorization: Bearer $ID_TOKEN" \ - $API_GATEWAY_ENDPOINT/order -X POST \ - -H "Content-Type: application/json" \ - --data "@./test/sample_create_order2.json" - ``` - - You will get the second Order json as response. - -1. Send a request to the `/orders/search` endpoint, looking for orders containing the product ID "PROD111". - - ```bash - curl -sS --header "Authorization: Bearer $ID_TOKEN" \ - $API_GATEWAY_ENDPOINT/orders/search -X POST \ - -H "Content-Type: application/json" \ - --data '{ - "productIds": ["PROD111"], - "page": 1, - "limit": 20, - "sortBy": "createdAt", - "sortOrder": "desc" - }' - ``` - - Only the second Order json will be returned as the first one does not include PROD111. + ```bash + curl -sS -H "Authorization: Bearer $ID_TOKEN" \ + $API_GATEWAY_ENDPOINT/order \ + -X POST \ + -H "Content-Type: application/json" \ + --data "@./test/sample_create_order2.json" + ``` + + You will get the second Order json as response. + +1. Send a request to the `/orders/search` endpoint, looking for orders containing the product ID "PROD111". + + ```bash + curl -sS -H "Authorization: Bearer $ID_TOKEN" \ + -X POST \ + -H "Content-Type: application/json" \ + --data '{ + "productIds": ["PROD111"], + "page": 1, + "limit": 20, + "sortBy": "createdAt", + "sortOrder": "desc" + }' \ + $API_GATEWAY_ENDPOINT/orders/search + ``` + + Only the second Order json will be returned as the first one does not include PROD111. 1. Delete the first order - ```bash - curl -sS --header "Authorization: Bearer $ID_TOKEN" \ - $API_GATEWAY_ENDPOINT/order/$ORDER_ID -X DELETE - ``` + ```bash + curl -sS -H "Authorization: Bearer $ID_TOKEN" \ + -X DELETE \ + $API_GATEWAY_ENDPOINT/order/$ORDER_ID + ``` - There will be no response. + There will be no response for this request. ## Cleanup - + 1. Delete the stacks - ```bash - cdk destroy - ``` + ```bash + cdk destroy + ``` + +--- ----- Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. SPDX-License-Identifier: MIT-0 From 0bacf56f6f7f6fd89e6f3521a8c35e669d6e2d29 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Mon, 28 Apr 2025 09:39:41 +0200 Subject: [PATCH 17/21] updated AWS console instructions --- apigw-lambda-powertools-openapi-cdk/README.md | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/apigw-lambda-powertools-openapi-cdk/README.md b/apigw-lambda-powertools-openapi-cdk/README.md index f1c40efdb..03c448b0a 100644 --- a/apigw-lambda-powertools-openapi-cdk/README.md +++ b/apigw-lambda-powertools-openapi-cdk/README.md @@ -91,6 +91,8 @@ The entire stack is deployed using AWS CDK, which creates and manages all requir You will create an Amazon Cogntio user for authenticating against the API. Then, you will execute some requests against the Order API to generate events. Finally, you will observe the Logging, Tracing, Metrics and Parameters functionalities of the Powertools for AWS Lambda in the AWS console. +### Generate events + 1. Set environment variables for the following commands. You will need the values of the Outputs you copied as last step of the Deployment Instructions: ```bash @@ -213,6 +215,36 @@ You will create an Amazon Cogntio user for authenticating against the API. Then, There will be no response for this request. +Repeat the requests to the API gateway as often as desired for generating more events to observe. A new order ID will be generated during creation in the backend, so you can reuse the same request payloads without risking a collision. + +### View results in the AWS console + +The console direct links in this section default to the `us-east-1` region. Ensure you change your region selection if you deployed into a different one. + +1. View the CloudWatch logs + + Open the [log group of the orderCRUD function](https://us-east-1.console.aws.amazon.com/cloudwatch/home?region=us-east-1#logsV2:log-groups/log-group/$252Faws$252Flambda$252ForderCRUD) and choose the first log stream. You will see the log format enhanced by the [Logger of Powertools for AWS Lambda](https://docs.powertools.aws.dev/lambda/python/latest/core/logger/#getting-started), e.g. adding a `cold_start` property to the JSON. With this, you could easily query CloudWatch Logs for the frequency and start time of new lambda environments. + +1. View the CloudWatch metrics + + + +1. View the X-Ray traces + + Open the [X-Ray traces](https://us-east-1.console.aws.amazon.com/cloudwatch/home?region=us-east-1#xray:traces/query) console. Ensure that you choose a long enough time frame on the top right to include the time of your test requests. + + In the **Query refiners** secion, open the **Refine query by** select item. + + Scrolling down, you see that you can filter by **customerId** and **orderId** as those were added as X-Ray annotations in the orderCRUD Lambda function. Choose **customerId**. The table right below will now allow you to choose the singular customer ID for the Cognito user you created. Activate the checkbox next to it, then choose the **Add to query** button. + + Next, choose **Service** in the **Refine query by** select item. In the table below, check the box next to **ordersCRUD** and again choose the **Add to query** button. + + Filter all traces to your selection by choosing the **Run query** buttons. + + The **Traces** table at the bottom of the page show you the operations you executed against the API. Opening them, you should e.g. observe that not all of them have the Lambda cold start "Init" phase as the environment could be reused if you executed the test requests in a close sequence. + + You will also see that if the environment was reused for multiple of the POST request for order creation, the **### payment processing** subsection will be considerably quicker for the subsequent reuses. This is due to the [Parameters functionality in Powertools for AWS Lambda](https://docs.powertools.aws.dev/lambda/python/latest/utilities/parameters/) caching the AWS Secrets Manager secret for the simulated payment processing operation. + ## Cleanup 1. Delete the stacks From 82d8580ca57c3b235b0853c6b5b810a2ee4f5df9 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Mon, 28 Apr 2025 09:49:07 +0200 Subject: [PATCH 18/21] typos --- apigw-lambda-powertools-openapi-cdk/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apigw-lambda-powertools-openapi-cdk/README.md b/apigw-lambda-powertools-openapi-cdk/README.md index 03c448b0a..7e6baedf0 100644 --- a/apigw-lambda-powertools-openapi-cdk/README.md +++ b/apigw-lambda-powertools-openapi-cdk/README.md @@ -239,9 +239,9 @@ The console direct links in this section default to the `us-east-1` region. Ensu Next, choose **Service** in the **Refine query by** select item. In the table below, check the box next to **ordersCRUD** and again choose the **Add to query** button. - Filter all traces to your selection by choosing the **Run query** buttons. + Filter all traces to your selection by choosing the **Run query** button. - The **Traces** table at the bottom of the page show you the operations you executed against the API. Opening them, you should e.g. observe that not all of them have the Lambda cold start "Init" phase as the environment could be reused if you executed the test requests in a close sequence. + The **Traces** table at the bottom of the page shows you the operations you executed against the API. Opening them, you should e.g. observe that not all of them have the Lambda cold start "Init" phase as the environment could be reused if you executed the test requests in a close sequence. You will also see that if the environment was reused for multiple of the POST request for order creation, the **### payment processing** subsection will be considerably quicker for the subsequent reuses. This is due to the [Parameters functionality in Powertools for AWS Lambda](https://docs.powertools.aws.dev/lambda/python/latest/utilities/parameters/) caching the AWS Secrets Manager secret for the simulated payment processing operation. From a2105296a7a99ef9cdccac5abdc87998f6a5604e Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Mon, 28 Apr 2025 09:53:38 +0200 Subject: [PATCH 19/21] add dependency installation --- apigw-lambda-powertools-openapi-cdk/README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apigw-lambda-powertools-openapi-cdk/README.md b/apigw-lambda-powertools-openapi-cdk/README.md index 7e6baedf0..5da68573e 100644 --- a/apigw-lambda-powertools-openapi-cdk/README.md +++ b/apigw-lambda-powertools-openapi-cdk/README.md @@ -37,6 +37,10 @@ Important: this application uses various AWS services and there are costs associ ``` cd apigw-lambda-powertools-openapi-cdk ``` +1. Install the dependencies + ``` + npm install + ``` 1. Authenticate to the AWS account you want to deploy in. 1. From the command line, use AWS SAM to deploy the AWS resources for the pattern as specified in the template.yml file: From cb4bf9eb80c8c06ddc9d804d2bbf50aaf5be3b84 Mon Sep 17 00:00:00 2001 From: Robert Hanuschke Date: Mon, 28 Apr 2025 12:10:11 +0200 Subject: [PATCH 20/21] add PutMetricData permission to lambda functions --- .../lib/lambda-stack.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts b/apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts index 3ca280c6a..7df19bb60 100644 --- a/apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts +++ b/apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts @@ -51,6 +51,12 @@ export class LambdaStack extends cdk.NestedStack { layers: [powertoolsLayer], timeout: cdk.Duration.seconds(5), }); + this.crudLambda.addToRolePolicy( + new cdk.aws_iam.PolicyStatement({ + actions: ["cloudwatch:PutMetricData"], + resources: ["*"], + }) + ); props.apiKey.grantRead(this.crudLambda); props.table.grantReadWriteData(this.crudLambda); @@ -78,6 +84,13 @@ export class LambdaStack extends cdk.NestedStack { layers: [powertoolsLayer], timeout: cdk.Duration.seconds(5), }); + this.searchLambda.addToRolePolicy( + new cdk.aws_iam.PolicyStatement({ + actions: ["cloudwatch:PutMetricData"], + resources: ["*"], + }) + ); + props.table.grantReadData(this.searchLambda); } } From a7a0c8bba20d69d7cf1cfc60973a8e396a82af1c Mon Sep 17 00:00:00 2001 From: Eleni Dimitropoulou <12170229+eldimi@users.noreply.github.com> Date: Wed, 7 May 2025 10:19:54 +0300 Subject: [PATCH 21/21] Remove POWERTOOLS_DEV env variable and add README for Cloudwarch metrics --- apigw-lambda-powertools-openapi-cdk/README.md | 7 ++++--- apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts | 2 -- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apigw-lambda-powertools-openapi-cdk/README.md b/apigw-lambda-powertools-openapi-cdk/README.md index 5da68573e..bc5c86964 100644 --- a/apigw-lambda-powertools-openapi-cdk/README.md +++ b/apigw-lambda-powertools-openapi-cdk/README.md @@ -100,9 +100,9 @@ You will create an Amazon Cogntio user for authenticating against the API. Then, 1. Set environment variables for the following commands. You will need the values of the Outputs you copied as last step of the Deployment Instructions: ```bash - API_GATEWAY_ENDPOINT= - USER_POOL_ID= - USER_POOL_CLIENT_ID= + API_GATEWAY_ENDPOINT=https://axisqktpfd.execute-api.us-east-1.amazonaws.com/dev/ + USER_POOL_ID=us-east-1_rvVtjsPQn + USER_POOL_CLIENT_ID=1u94umf8p7fjblbgite3rnvepj USER_NAME=testuser USER_EMAIL=user@example.com USER_PASSWORD=MyUserPassword123! @@ -231,6 +231,7 @@ The console direct links in this section default to the `us-east-1` region. Ensu 1. View the CloudWatch metrics + Open the [CloudWatch metrics](https://768942995475-qsphipsa.us-east-1.console.aws.amazon.com/cloudwatch/home?region=us-east-1#metricsV2?graph=~()) and notice the **Custom namespaces** on top of the **AWS namespaces**. You can select to graph the metrics from the Custom Namespace but also the managed metrics from the AWS namespaces that correspond to the orderCRUD and/or orderSearch AWS Lambda function(s) 1. View the X-Ray traces diff --git a/apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts b/apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts index 7df19bb60..7c71d4028 100644 --- a/apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts +++ b/apigw-lambda-powertools-openapi-cdk/lib/lambda-stack.ts @@ -41,7 +41,6 @@ export class LambdaStack extends cdk.NestedStack { POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: "true", POWERTOOLS_TRACER_CAPTURE_RESPONSE: "true", POWERTOOLS_TRACER_CAPTURE_ERROR: "true", - POWERTOOLS_DEV: String(props.stageName === "dev"), POWERTOOLS_LOGGER_LOG_EVENT: String(props.stageName === "dev"), STAGE: props.stageName, API_KEY_SECRET_ARN: props.apiKey.secretArn, @@ -75,7 +74,6 @@ export class LambdaStack extends cdk.NestedStack { POWERTOOLS_TRACER_CAPTURE_HTTPS_REQUESTS: "true", POWERTOOLS_TRACER_CAPTURE_RESPONSE: "true", POWERTOOLS_TRACER_CAPTURE_ERROR: "true", - POWERTOOLS_DEV: String(props.stageName === "dev"), POWERTOOLS_LOGGER_LOG_EVENT: String(props.stageName === "dev"), STAGE: props.stageName, DYNAMODB_TABLE_NAME: props.table.tableName,