diff --git a/apigw-python-cdk-lambda-snapstart/CarHandler/handler.py b/apigw-python-cdk-lambda-snapstart/CarHandler/handler.py new file mode 100644 index 000000000..c266f4d9c --- /dev/null +++ b/apigw-python-cdk-lambda-snapstart/CarHandler/handler.py @@ -0,0 +1,140 @@ +import json +import os +import uuid +from decimal import Decimal +from typing import Any + +import boto3 +from botocore.exceptions import ClientError + +from aws_lambda_powertools import Logger +from aws_lambda_powertools.event_handler import ( + APIGatewayRestResolver, + Response, + content_types, +) +from aws_lambda_powertools.event_handler.exceptions import BadRequestError, NotFoundError +from aws_lambda_powertools.logging import correlation_paths +from aws_lambda_powertools.utilities.typing import LambdaContext + +logger = Logger(level=os.getenv("LOG_LEVEL", "INFO")) +app = APIGatewayRestResolver() + +table_name = os.environ["CAR_TABLE_NAME"] +dynamodb = boto3.resource("dynamodb") +table = dynamodb.Table(table_name) + +def _json_default(value: Any) -> Any: + if isinstance(value, Decimal): + if value % 1 == 0: + return int(value) + return float(value) + raise TypeError(f"Object of type {type(value)} is not JSON serializable") + + +def _json_body() -> dict: + """Parse request body as JSON object; empty or missing body returns {}.""" + raw = app.current_event.json_body + if raw is None: + return {} + if not isinstance(raw, dict): + raise BadRequestError("Request body must be a JSON object") + return raw + + +@app.post("/cars") +def create_car() -> Response: + body = _json_body() + car_id = str(uuid.uuid4()) + car = { + "id": car_id, + "make": body.get("make"), + "model": body.get("model"), + "year": body.get("year"), + "color": body.get("color"), + } + table.put_item(Item=car) + return Response( + status_code=201, + content_type=content_types.APPLICATION_JSON, + body=json.dumps(car, default=_json_default), + ) + + +@app.get("/cars/") +def get_car(car_id: str) -> Response: + item = table.get_item(Key={"id": car_id}).get("Item") + if not item: + raise NotFoundError(f"Car with id {car_id} not found") + return Response( + status_code=200, + content_type=content_types.APPLICATION_JSON, + body=json.dumps(item, default=_json_default), + ) + + +@app.put("/cars/") +def update_car(car_id: str) -> Response: + body = _json_body() + existing = table.get_item(Key={"id": car_id}).get("Item") + if not existing: + raise NotFoundError(f"Car with id {car_id} not found") + updated = { + "id": car_id, + "make": body.get("make", existing.get("make")), + "model": body.get("model", existing.get("model")), + "year": body.get("year", existing.get("year")), + "color": body.get("color", existing.get("color")), + } + table.put_item(Item=updated) + return Response( + status_code=200, + content_type=content_types.APPLICATION_JSON, + body=json.dumps(updated, default=_json_default), + ) + + +@app.delete("/cars/") +def delete_car(car_id: str) -> Response: + try: + table.delete_item( + Key={"id": car_id}, + ConditionExpression="attribute_exists(id)", + ) + except ClientError as exc: + if exc.response["Error"]["Code"] == "ConditionalCheckFailedException": + raise NotFoundError(f"Car with id {car_id} not found") from exc + raise + return Response(status_code=204, body="") + + +@app.exception_handler(NotFoundError) +def handle_not_found(exc: NotFoundError) -> Response: + return Response( + status_code=404, + content_type=content_types.APPLICATION_JSON, + body=json.dumps({"message": str(exc)}), + ) + + +@app.exception_handler(BadRequestError) +def handle_bad_request(exc: BadRequestError) -> Response: + return Response( + status_code=400, + content_type=content_types.APPLICATION_JSON, + body=json.dumps({"message": str(exc)}), + ) + + +@app.not_found +def handle_route_not_found(_exc: Exception) -> Response: + return Response( + status_code=404, + content_type=content_types.APPLICATION_JSON, + body=json.dumps({"message": "Route not found"}), + ) + + +@logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_REST) +def handler(event: dict, context: LambdaContext) -> dict: + return app.resolve(event, context) diff --git a/apigw-python-cdk-lambda-snapstart/CarHandler/requirements.txt b/apigw-python-cdk-lambda-snapstart/CarHandler/requirements.txt new file mode 100644 index 000000000..b8cad7ceb --- /dev/null +++ b/apigw-python-cdk-lambda-snapstart/CarHandler/requirements.txt @@ -0,0 +1 @@ +aws-lambda-powertools==3.25.0 \ No newline at end of file diff --git a/apigw-python-cdk-lambda-snapstart/README.md b/apigw-python-cdk-lambda-snapstart/README.md new file mode 100644 index 000000000..8dc862d54 --- /dev/null +++ b/apigw-python-cdk-lambda-snapstart/README.md @@ -0,0 +1,72 @@ +# Amazon API Gateway + AWS Lambda SnapStart + Amazon DynamoDB + +This pattern demonstrates how to create a REST API using Amazon API Gateway, AWS Lambda and Amazon DynamoDB. +It's built with [Python 3.12](https://www.python.org/downloads/release/python-3128/), together with +[AWS Cloud Development Kit (CDK)](https://docs.aws.amazon.com/cdk/v2/guide/work-with-cdk-python.html) as the Infrastructure as Code solution. This pattern also implements the usage of [AWS Lambda SnapStart](https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html) +to improve initialization performance of the Lambda function. + +## Architecture + +- API Gateway REST API (`prod` stage) +- AWS Lambda functon(Python 3.12) +- Lambda SnapStart enabled on published versions +- Lambda `live` alias integrated with API Gateway +- DynamoDB table with partition key `id` + +## Endpoints + +- `POST /cars` +- `GET /cars/{carId}` +- `PUT /cars/{carId}` +- `DELETE /cars/{carId}` + +## Requirements + +- Python 3.12+ +- AWS CDK v2 +- AWS credentials configured + +## Deploy + +To deploy this stack, run the following commands from the root of the `serverless-patterns` repository: + +```bash +# Move to the pattern directory and create a Python virtual environment +cd apigw-python-cdk-lambda-snapstart +python3 -m venv .venv +source .venv/bin/activate + +# Install the AWS CDK for Python +pip3 install -r requirements.txt + +# Install AWS Lambda Powertools library for the CarHandler Lambda +pip3 install -r CarHandler/requirements.txt -t CarHandler/ + +# Bootstrap your environment and deploy +cdk bootstrap +cdk deploy +``` + +## Test + +Get endpoint URL from stack outputs (`CarEndpoint`), then run: + +```bash +ENDPOINT="" + +# Create a car (use the returned "id" in the response for GET/PUT/DELETE below) +curl --location --request POST "$ENDPOINT/cars" \ + --header 'Content-Type: application/json' \ + --data-raw '{"make":"Porsche","model":"992","year":"2022","color":"White"}' + +# Get a car by id +curl --location "$ENDPOINT/cars/" + +# Update a car +curl --location --request PUT "$ENDPOINT/cars/" \ + --header 'Content-Type: application/json' \ + --data-raw '{"make":"Porsche","model":"992","year":"2023","color":"Racing Yellow"}' + +# Delete a car +curl --location --request DELETE "$ENDPOINT/cars/" +``` \ No newline at end of file diff --git a/apigw-python-cdk-lambda-snapstart/app.py b/apigw-python-cdk-lambda-snapstart/app.py new file mode 100644 index 000000000..8a5cedb2d --- /dev/null +++ b/apigw-python-cdk-lambda-snapstart/app.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +from aws_cdk import ( + App, CfnOutput, + Duration, + Stack, + RemovalPolicy, + aws_apigateway as apigw, + aws_dynamodb as dynamodb, + aws_lambda as _lambda, +) +from constructs import Construct + +class CarStoreStack(Stack): + def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: + super().__init__(scope, construct_id, **kwargs) + + car_table = dynamodb.Table( + self, + "CarTable", + partition_key=dynamodb.Attribute(name="id", type=dynamodb.AttributeType.STRING), + billing_mode=dynamodb.BillingMode.PAY_PER_REQUEST, + removal_policy=RemovalPolicy.DESTROY, + ) + + car_function = _lambda.Function( + self, + "CarStoreFunction", + runtime=_lambda.Runtime.PYTHON_3_12, + handler="handler.handler", + code=_lambda.Code.from_asset("CarHandler/"), + timeout=Duration.seconds(10), + snap_start=_lambda.SnapStartConf.ON_PUBLISHED_VERSIONS, + memory_size=256, + environment={ + "CAR_TABLE_NAME": car_table.table_name, + "LOG_LEVEL": "INFO", + } + ) + car_table.grant_read_write_data(car_function) + + live_alias = _lambda.Alias( + self, + "CarStoreLiveAlias", + alias_name="live", + version=car_function.current_version, + ) + + car_api = apigw.RestApi( + self, + "CarStoreApi", + deploy_options=apigw.StageOptions(stage_name="prod"), + ) + + integration = apigw.LambdaIntegration(live_alias, proxy=True) + car_api.root.add_method("ANY", integration) + car_api.root.add_resource("{proxy+}").add_method("ANY", integration) + + CfnOutput( + self, + "CarEndpoint", + description="API Gateway Car Endpoint", + value=car_api.url, + ) + CfnOutput(self, "CarTableName", value=car_table.table_name) + +app = App() +CarStoreStack(app, "CarStoreStack") +app.synth() diff --git a/apigw-python-cdk-lambda-snapstart/cdk.json b/apigw-python-cdk-lambda-snapstart/cdk.json new file mode 100644 index 000000000..c8091b7f4 --- /dev/null +++ b/apigw-python-cdk-lambda-snapstart/cdk.json @@ -0,0 +1,15 @@ +{ + "app": ".venv/bin/python app.py", + "watch": { + "include": [ + "**" + ], + "exclude": [ + "README.md", + "cdk*.json", + "requirements*.txt", + "**/__pycache__", + "tests" + ] + } +} \ No newline at end of file diff --git a/apigw-python-cdk-lambda-snapstart/example-pattern.json b/apigw-python-cdk-lambda-snapstart/example-pattern.json new file mode 100644 index 000000000..b7fa38ff7 --- /dev/null +++ b/apigw-python-cdk-lambda-snapstart/example-pattern.json @@ -0,0 +1,73 @@ +{ + "title": "API Gateway with Lambda SnapStart and DynamoDB using Python CDK", + "description": "This pattern demonstrates how to create a REST API using API Gateway, AWS Lambda with SnapStart, and DynamoDB. Built with Python 3.12 and AWS CDK, it implements Lambda SnapStart to improve initialization performance for faster cold starts.", + "language": "Python", + "level": "200", + "framework": "AWS CDK", + "introBox": { + "headline": "How it works", + "text": [ + "This pattern creates a REST API for managing car records using API Gateway and Lambda with SnapStart enabled.", + "The Lambda function is Python 3.12 based and includes a live alias that's integrated with API Gateway for seamless deployments.", + "Lambda SnapStart persists the initialized state of the Lambda runtime, significantly reducing cold start times for function initialization.", + "DynamoDB stores car records with a partition key of 'id', providing a scalable NoSQL database backend for the REST API." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/apigw-python-cdk-lambda-snapstart", + "templateURL": "serverless-patterns/apigw-python-cdk-lambda-snapstart", + "projectFolder": "apigw-python-cdk-lambda-snapstart", + "templateFile": "apigw-python-cdk-lambda-snapstart/app.py" + } + }, + "resources": { + "bullets": [ + { + "text": "AWS Lambda SnapStart", + "link": "https://docs.aws.amazon.com/lambda/latest/dg/snapstart.html" + }, + { + "text": "API Gateway REST API", + "link": "https://docs.aws.amazon.com/apigateway/latest/developerguide/apigateway-rest-api.html" + }, + { + "text": "AWS CDK Python Reference", + "link": "https://docs.aws.amazon.com/cdk/v2/guide/work-with-cdk-python.html" + } + ] + }, + "deploy": { + "text": [ + "python3 -m venv .venv", + "source .venv/bin/activate", + "pip install -r requirements.txt", + "cdk bootstrap", + "cdk deploy" + ] + }, + "testing": { + "text": [ + "Get the CarEndpoint from stack outputs, then test the endpoint to create a new car record:", + "curl --location --request POST \"$ENDPOINT/cars\" --header 'Content-Type: application/json' --data-raw '{\"make\":\"Porsche\",\"model\":\"992\",\"year\":\"2022\",\"color\":\"White\"}'", + "Change the endpoint and HTTP method to test other operations:", + "GET /cars/{carId} - Retrieve a car", + "PUT /cars/{carId} - Update a car", + "DELETE /cars/{carId} - Delete a car" + ] + }, + "cleanup": { + "text": [ + "Delete the stack: cdk destroy." + ] + }, + "authors": [ + { + "name": "Matia Rasetina", + "image": "https://media.licdn.com/dms/image/v2/C4D03AQEpZLzvymfGyA/profile-displayphoto-shrink_800_800/profile-displayphoto-shrink_800_800/0/1612951581132?e=1772668800&v=beta&t=m8AkoSUFICMRk5-Gd0hEAji0N4gFSfFGuv4lbBuXcJY", + "bio": "Senior Software Engineer @ Elixirr Digital", + "linkedin": "https://www.linkedin.com/in/matiarasetina/", + "twitter": "" + } + ] +} \ No newline at end of file diff --git a/apigw-python-cdk-lambda-snapstart/requirements.txt b/apigw-python-cdk-lambda-snapstart/requirements.txt new file mode 100644 index 000000000..7b6a920fd --- /dev/null +++ b/apigw-python-cdk-lambda-snapstart/requirements.txt @@ -0,0 +1,2 @@ +aws-cdk-lib>=2.170.0 +constructs>=10.0.0,<11.0.0 \ No newline at end of file