From f89d29c99354e596f42ab83726fb1b86ce9b1d1d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 16:30:14 +0000 Subject: [PATCH 1/7] Implement AWS Lambda support for Python cookiecutter template Add AWS Lambda with DynamoDB as the third cloud provider option for the Python template, alongside Azure Function App and GCP Cloud Function. - Add lambda_app.py entry point with API Gateway event routing - Add template.yaml SAM template for deployment and local development - Add DynamoDB repository implementation using boto3 Table resource - Add Lambda-specific controller methods for API Gateway event parsing - Add Lambda response format (dict with statusCode/headers/body) - Update post-generation hook to clean up unused cloud-specific files - Add AWS Lambda to CI pipeline matrix for automated testing - Update root README support matrix to mark Python/Lambda as complete https://claude.ai/code/session_01JmXGz7hA4WARPVez6ycB28 --- .github/workflows/build-python-pipeline.yaml | 1 + README.md | 2 +- python/cookiecutter.json | 3 +- python/hooks/post_gen_project.py | 16 +- .../Makefile | 4 + .../README.md | 75 +++++++++ .../blueprints/__init__.py | 4 + .../{{cookiecutter.project_slug}}_api.py | 46 +++++ ...{cookiecutter.project_slug}}_controller.py | 32 ++++ ...iecutter.project_slug}}_controller_test.py | 47 ++++++ .../lambda_app.py | 33 ++++ .../pyproject.toml | 3 + .../repositories/__init__.py | 5 + ...{cookiecutter.project_slug}}_repository.py | 59 ++++++- ...iecutter.project_slug}}_repository_test.py | 158 ++++++++++++++++-- .../template.yaml | 70 ++++++++ .../utils/detect_error.py | 7 + .../utils/detect_error_test.py | 20 +++ .../utils/response_generator.py | 11 +- .../utils/response_generator_test.py | 16 ++ 20 files changed, 582 insertions(+), 30 deletions(-) create mode 100644 python/{{cookiecutter.project_class_name}}/lambda_app.py create mode 100644 python/{{cookiecutter.project_class_name}}/template.yaml diff --git a/.github/workflows/build-python-pipeline.yaml b/.github/workflows/build-python-pipeline.yaml index bcbc7df..1470ecf 100644 --- a/.github/workflows/build-python-pipeline.yaml +++ b/.github/workflows/build-python-pipeline.yaml @@ -30,6 +30,7 @@ jobs: cloud-service: - "Azure Function App" - "GCP Cloud Function" + - "AWS Lambda" python-version: - "3.12" - "3.13" diff --git a/README.md b/README.md index ec32b95..13d6f3f 100644 --- a/README.md +++ b/README.md @@ -60,7 +60,7 @@ Follow the prompts to configure your project. - 📋 + diff --git a/python/cookiecutter.json b/python/cookiecutter.json index 30fd389..2ad2b7c 100644 --- a/python/cookiecutter.json +++ b/python/cookiecutter.json @@ -7,7 +7,8 @@ "author": "Code and Sorts (Colby Timm)", "cloud_service": [ "Azure Function App", - "GCP Cloud Function" + "GCP Cloud Function", + "AWS Lambda" ], "open_source_license": [ "MIT license", diff --git a/python/hooks/post_gen_project.py b/python/hooks/post_gen_project.py index 8408cde..f48a44d 100644 --- a/python/hooks/post_gen_project.py +++ b/python/hooks/post_gen_project.py @@ -6,15 +6,27 @@ # Files to remove based on cloud service if cloud_service == "Azure Function App": - # Remove GCP-specific files + # Remove GCP and AWS-specific files files_to_remove = [ "main.py", + "lambda_app.py", + "template.yaml", ] elif cloud_service == "GCP Cloud Function": - # Remove Azure-specific files + # Remove Azure and AWS-specific files files_to_remove = [ "function_app.py", "local.settings.json", + "lambda_app.py", + "template.yaml", + ] +elif cloud_service == "AWS Lambda": + # Remove Azure and GCP-specific files + files_to_remove = [ + "function_app.py", + "local.settings.json", + "main.py", + "host.json", ] else: files_to_remove = [] diff --git a/python/{{cookiecutter.project_class_name}}/Makefile b/python/{{cookiecutter.project_class_name}}/Makefile index 3e00ef2..d617860 100644 --- a/python/{{cookiecutter.project_class_name}}/Makefile +++ b/python/{{cookiecutter.project_class_name}}/Makefile @@ -15,6 +15,10 @@ run: ## Run function app. @echo "Use 'poetry run functions-framework --target= --source=main.py' to run specific functions" @poetry run functions-framework --target=get_list --source=main.py --port=8080 {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} + @echo "🚀 Running Lambda API locally via SAM" + @sam local start-api +{%- endif %} .PHONY: build build: clean-build ## Build wheel file using poetry diff --git a/python/{{cookiecutter.project_class_name}}/README.md b/python/{{cookiecutter.project_class_name}}/README.md index c6a3234..7e0142f 100644 --- a/python/{{cookiecutter.project_class_name}}/README.md +++ b/python/{{cookiecutter.project_class_name}}/README.md @@ -10,6 +10,9 @@ This project is a Python-based REST API built using [Azure Function Apps](https: {% if cookiecutter.cloud_service == 'GCP Cloud Function' -%} This project is a Python-based REST API built using [Google Cloud Functions](https://cloud.google.com/functions/docs). The API leverages GCP's serverless architecture, allowing you to deploy and scale functions effortlessly in the cloud. The HTTP-triggered functions serve as the endpoints for the API, providing a seamless way to handle client requests. {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} +This project is a Python-based REST API built using [AWS Lambda](https://docs.aws.amazon.com/lambda/) with API Gateway. The API leverages AWS's serverless architecture, allowing you to deploy and scale functions effortlessly in the cloud. The HTTP-triggered Lambda functions serve as the endpoints for the API, providing a seamless way to handle client requests. +{%- endif %} The REST API has the following endpoints: - GET (by ID) @@ -24,6 +27,9 @@ Dependency management is handled using [Poetry](https://python-poetry.org/), ens {% if cookiecutter.cloud_service == 'GCP Cloud Function' -%} Dependency management can be handled using either [Poetry](https://python-poetry.org/) for development or requirements.txt for GCP deployment. {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} +Dependency management is handled using [Poetry](https://python-poetry.org/), ensuring a streamlined and consistent environment for managing Python packages and their dependencies. +{%- endif %} ## Features @@ -45,6 +51,15 @@ Dependency management can be handled using either [Poetry](https://python-poetry - Firestore Database: This project uses Google Cloud Firestore as the NoSQL database. {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} +- AWS Lambda: Utilizes AWS's serverless platform to create scalable and efficient endpoints with API Gateway HTTP triggers. + +- Python-Based: Written entirely in Python, leveraging its rich ecosystem and libraries for rapid development. + +- Poetry for Dependency Management: Manages all Python dependencies with Poetry, making the development environment consistent and easy to set up. + +- DynamoDB: This project uses Amazon DynamoDB as the NoSQL database. +{%- endif %} ## Prerequisites @@ -72,6 +87,17 @@ Dependency management can be handled using either [Poetry](https://python-poetry - Firestore Database: Set up a Firestore database in your GCP project. {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} +- [AWS SAM CLI](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html): To run the Lambda functions locally. + +- [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html): To deploy and manage AWS resources. + +- [Poetry](https://python-poetry.org/): For dependency management and virtual environment setup. + +- AWS Account: An active AWS account for deploying Lambda functions. + +- DynamoDB Table: A DynamoDB table will be created automatically via the SAM template. +{%- endif %} ## Setup and Installation @@ -158,6 +184,40 @@ Dependency management can be handled using either [Poetry](https://python-poetry --set-env-vars GCP_PROJECT_ID=your-project-id,FIRESTORE_COLLECTION={{ cookiecutter.project_slug }} ``` {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} +1. Install AWS SAM CLI + + Follow the [documentation](https://docs.aws.amazon.com/serverless-application-model/latest/developerguide/install-sam-cli.html) to install the AWS SAM CLI based on your operating system. + +2. Install Poetry + + If you haven't already installed Poetry, you can do so by following the [official installation guide](https://python-poetry.org/docs/). + +3. Install Dependencies + + Install all dependencies and set up the virtual environment: + + ```console + make install + ``` + +4. Run the API Locally + + ```console + make run + ``` + + This command starts the local API Gateway using SAM CLI, where you can interact with your API endpoints. + +5. Deploy to AWS + + Build and deploy to AWS using SAM: + + ```console + sam build + sam deploy --guided + ``` +{%- endif %} ## Development Workflow @@ -223,6 +283,21 @@ This is also run automatically in CI on every PR and push to main. └── requirements.txt - Production dependencies for GCP deployment ``` {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} +```text +├── cookiecutter-template-python +│ ├── blueprints - Lambda handler methods +│ ├── controllers - Controllers +│ ├── errors - Custom errors +│ ├── models - Pydantic models +│ ├── repositories - DynamoDB repository +│ ├── services - Services +│ └── utils - Error detect & response generator utilities +│ +├── lambda_app.py - Lambda entry point with API Gateway routing +└── template.yaml - AWS SAM template for deployment +``` +{%- endif %} ## License diff --git a/python/{{cookiecutter.project_class_name}}/blueprints/__init__.py b/python/{{cookiecutter.project_class_name}}/blueprints/__init__.py index c956d98..e075b87 100644 --- a/python/{{cookiecutter.project_class_name}}/blueprints/__init__.py +++ b/python/{{cookiecutter.project_class_name}}/blueprints/__init__.py @@ -7,3 +7,7 @@ # GCP Cloud Functions don't use blueprints, see main.py for function exports __all__ = [] {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} +# AWS Lambda doesn't use blueprints, see lambda_app.py for function routing +__all__ = [] +{%- endif %} diff --git a/python/{{cookiecutter.project_class_name}}/blueprints/{{cookiecutter.project_slug}}_api.py b/python/{{cookiecutter.project_class_name}}/blueprints/{{cookiecutter.project_slug}}_api.py index 6dcc14b..11ab52b 100644 --- a/python/{{cookiecutter.project_class_name}}/blueprints/{{cookiecutter.project_slug}}_api.py +++ b/python/{{cookiecutter.project_class_name}}/blueprints/{{cookiecutter.project_slug}}_api.py @@ -31,6 +31,15 @@ collection = db.collection(collection_name) repository = {{ cookiecutter.project_class_name }}Repository(collection) {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} +import boto3 + +# Initialize DynamoDB table resource +dynamodb = boto3.resource("dynamodb") +table_name = os.getenv("DYNAMODB_TABLE_NAME", "{{ cookiecutter.project_slug }}") +table = dynamodb.Table(table_name) +repository = {{ cookiecutter.project_class_name }}Repository(table) +{%- endif %} from controllers import {{ cookiecutter.project_class_name }}Controller from services import {{ cookiecutter.project_class_name }}Service from repositories import {{ cookiecutter.project_class_name }}Repository @@ -46,6 +55,10 @@ async def get_by_id(req: func.HttpRequest) -> func.HttpResponse: {%- if cookiecutter.cloud_service == 'GCP Cloud Function' %} def get_by_id(request: Request): """HTTP Cloud Function to get item by ID.""" +{%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} +def get_by_id(event): + """Lambda handler to get item by ID.""" {%- endif %} logging.info("Get {{ cookiecutter.project_endpoint }} by ID processed a request.") @@ -55,6 +68,9 @@ def get_by_id(request: Request): {%- endif %} {%- if cookiecutter.cloud_service == 'GCP Cloud Function' %} item = controller.get_by_id(request) +{%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + item = controller.get_by_id(event) {%- endif %} return response_generator(item) @@ -69,6 +85,10 @@ async def get_list(req: func.HttpRequest) -> func.HttpResponse: {%- if cookiecutter.cloud_service == 'GCP Cloud Function' %} def get_list(request: Request): """HTTP Cloud Function to get all items.""" +{%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} +def get_list(event): + """Lambda handler to get all items.""" {%- endif %} logging.info("Get {{ cookiecutter.project_endpoint }} list processed a request.") @@ -87,6 +107,10 @@ async def create(req: func.HttpRequest) -> func.HttpResponse: {%- if cookiecutter.cloud_service == 'GCP Cloud Function' %} def create(request: Request): """HTTP Cloud Function to create a new item.""" +{%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} +def create(event): + """Lambda handler to create a new item.""" {%- endif %} logging.info("Create item processed a request.") @@ -96,6 +120,9 @@ def create(request: Request): {%- endif %} {%- if cookiecutter.cloud_service == 'GCP Cloud Function' %} created_item = controller.create(request) +{%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + created_item = controller.create(event) {%- endif %} return response_generator(created_item, 201) @@ -110,6 +137,10 @@ async def update(req: func.HttpRequest) -> func.HttpResponse: {%- if cookiecutter.cloud_service == 'GCP Cloud Function' %} def update(request: Request): """HTTP Cloud Function to update an existing item.""" +{%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} +def update(event): + """Lambda handler to update an existing item.""" {%- endif %} logging.info("Patch item processed a request.") @@ -119,6 +150,9 @@ def update(request: Request): {%- endif %} {%- if cookiecutter.cloud_service == 'GCP Cloud Function' %} updated_item = controller.update(request) +{%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + updated_item = controller.update(event) {%- endif %} return response_generator(updated_item, 201) @@ -133,6 +167,10 @@ async def delete(req: func.HttpRequest) -> func.HttpResponse: {%- if cookiecutter.cloud_service == 'GCP Cloud Function' %} def delete(request: Request): """HTTP Cloud Function to soft delete an item.""" +{%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} +def delete(event): + """Lambda handler to soft delete an item.""" {%- endif %} logging.info("Delete item processed a request.") @@ -148,6 +186,14 @@ def delete(request: Request): controller.soft_delete(request) return ("{{ cookiecutter.project_class_name }} deleted.", 200) {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + controller.soft_delete(event) + return { + "statusCode": 200, + "headers": {"Content-Type": "application/json"}, + "body": "{{ cookiecutter.project_class_name }} deleted." + } +{%- endif %} except Exception as error: return detect_error(error) diff --git a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py index 2e9f702..02637be 100644 --- a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py +++ b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py @@ -5,6 +5,9 @@ {% if cookiecutter.cloud_service == 'GCP Cloud Function' -%} from flask import Request {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} +import json +{%- endif %} from services import {{ cookiecutter.project_class_name }}Service from models import {{ cookiecutter.project_class_name }}Response, {{ cookiecutter.project_class_name }}, {{ cookiecutter.project_class_name }}IdValidation @@ -27,6 +30,13 @@ def get_by_id(self, request: Request) -> {{ cookiecutter.project_class_name }}Re {{ cookiecutter.project_class_name }}IdValidation(id=item_id) return self.service.get_by_id(item_id) {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + + def get_by_id(self, event: dict) -> {{ cookiecutter.project_class_name }}Response: + item_id: str = event.get("pathParameters", {}).get("item_id") + {{ cookiecutter.project_class_name }}IdValidation(id=item_id) + return self.service.get_by_id(item_id) +{%- endif %} def get_list(self) -> List[{{ cookiecutter.project_class_name }}Response]: return self.service.get_list() @@ -44,6 +54,13 @@ def create(self, request: Request) -> {{ cookiecutter.project_class_name }}Respo item = {{ cookiecutter.project_class_name }}(**item_json) return self.service.create(item) {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + + def create(self, event: dict) -> {{ cookiecutter.project_class_name }}Response: + item_json: dict = json.loads(event.get("body", "{}")) + item = {{ cookiecutter.project_class_name }}(**item_json) + return self.service.create(item) +{%- endif %} {%- if cookiecutter.cloud_service == 'Azure Function App' %} def update(self, req: func.HttpRequest) -> {{ cookiecutter.project_class_name }}Response: @@ -64,6 +81,15 @@ def update(self, request: Request) -> {{ cookiecutter.project_class_name }}Respo item.id = item_id return self.service.update(item) {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + + def update(self, event: dict) -> {{ cookiecutter.project_class_name }}Response: + item_id: str = event.get("pathParameters", {}).get("item_id") + item_data: dict = json.loads(event.get("body", "{}")) + item = {{ cookiecutter.project_class_name }}(**item_data) + item.id = item_id + return self.service.update(item) +{%- endif %} {%- if cookiecutter.cloud_service == 'Azure Function App' %} def soft_delete(self, req: func.HttpRequest) -> None: @@ -78,3 +104,9 @@ def soft_delete(self, request: Request) -> None: item_id: str = path_parts[-1] if len(path_parts) > 0 else None self.service.soft_delete(item_id) {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + + def soft_delete(self, event: dict) -> None: + item_id: str = event.get("pathParameters", {}).get("item_id") + self.service.soft_delete(item_id) +{%- endif %} diff --git a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller_test.py b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller_test.py index fa32981..322dd72 100644 --- a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller_test.py +++ b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller_test.py @@ -89,3 +89,50 @@ def test_value_error_invalid_uuid(controller, mock_service): Mock{{ cookiecutter.project_class_name }}IdValidation.assert_called_once_with(id='mockInvalidId') mock_service.get_by_id.assert_not_called() {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} +import pytest +from unittest.mock import AsyncMock, MagicMock +from models import {{ cookiecutter.project_class_name }}Response, {{ cookiecutter.project_class_name }}IdValidation +from controllers import {{ cookiecutter.project_class_name }}Controller +from services import {{ cookiecutter.project_class_name }}Service +from unittest.mock import patch + +def describe_item_controller(): + @pytest.fixture + def mock_service(): + service = AsyncMock({{ cookiecutter.project_class_name }}Service) + return service + + @pytest.fixture + def controller(mock_service): + return {{ cookiecutter.project_class_name }}Controller(service=mock_service) + + def describe_get_by_id(): + def test_ok_valid_uuid(controller, mock_service): + mock_event = { + "pathParameters": {"item_id": "ac1df01c-7ece-4a20-ab60-179829dad8f5"} + } + + expected_response = {{ cookiecutter.project_class_name }}Response(id="ac1df01c-7ece-4a20-ab60-179829dad8f5", name="mock{{ cookiecutter.project_class_name }}", type="mockType") + mock_service.get_by_id.return_value = expected_response + + with patch.object({{ cookiecutter.project_class_name }}IdValidation, '__init__', return_value=None) as Mock{{ cookiecutter.project_class_name }}IdValidation: + response = controller.get_by_id(mock_event) + + Mock{{ cookiecutter.project_class_name }}IdValidation.assert_called_once_with(id='ac1df01c-7ece-4a20-ab60-179829dad8f5') + mock_service.get_by_id.assert_called_once_with('ac1df01c-7ece-4a20-ab60-179829dad8f5') + assert response == expected_response + + def test_value_error_invalid_uuid(controller, mock_service): + mock_event = { + "pathParameters": {"item_id": "mockInvalidId"} + } + + with patch.object({{ cookiecutter.project_class_name }}IdValidation, '__init__', return_value=None) as Mock{{ cookiecutter.project_class_name }}IdValidation: + try: + controller.get_by_id(mock_event) + except Exception as error: + assert isinstance(error, ValueError) + Mock{{ cookiecutter.project_class_name }}IdValidation.assert_called_once_with(id='mockInvalidId') + mock_service.get_by_id.assert_not_called() +{%- endif %} diff --git a/python/{{cookiecutter.project_class_name}}/lambda_app.py b/python/{{cookiecutter.project_class_name}}/lambda_app.py new file mode 100644 index 0000000..031f6a0 --- /dev/null +++ b/python/{{cookiecutter.project_class_name}}/lambda_app.py @@ -0,0 +1,33 @@ +""" +Main entry point for AWS Lambda. +Routes API Gateway events to the appropriate handler. +""" +from blueprints.{{cookiecutter.project_slug}}_api import ( + get_by_id, + get_list, + create, + update, + delete +) + + +def lambda_handler(event, context): + http_method = event.get("httpMethod", "") + resource = event.get("resource", "") + + if http_method == "GET" and "{item_id}" in resource: + return get_by_id(event) + elif http_method == "GET": + return get_list(event) + elif http_method == "POST": + return create(event) + elif http_method == "PATCH": + return update(event) + elif http_method == "DELETE": + return delete(event) + else: + return { + "statusCode": 404, + "headers": {"Content-Type": "application/json"}, + "body": "Not Found" + } diff --git a/python/{{cookiecutter.project_class_name}}/pyproject.toml b/python/{{cookiecutter.project_class_name}}/pyproject.toml index c3e731e..0b45402 100644 --- a/python/{{cookiecutter.project_class_name}}/pyproject.toml +++ b/python/{{cookiecutter.project_class_name}}/pyproject.toml @@ -15,6 +15,9 @@ azure-cosmos = "^4.7.0" google-cloud-firestore = "^2.16.0" functions-framework = "^3.5.0" {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} +boto3 = "^1.35.0" +{%- endif %} pytest-mock = "^3.14.0" pytest-describe = "^3.0.0" diff --git a/python/{{cookiecutter.project_class_name}}/repositories/__init__.py b/python/{{cookiecutter.project_class_name}}/repositories/__init__.py index 8df9c12..07c7302 100644 --- a/python/{{cookiecutter.project_class_name}}/repositories/__init__.py +++ b/python/{{cookiecutter.project_class_name}}/repositories/__init__.py @@ -8,3 +8,8 @@ __all__ = ["{{ cookiecutter.project_class_name }}Repository"] {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} +from .{{cookiecutter.project_slug}}_repository import {{ cookiecutter.project_class_name }}Repository + +__all__ = ["{{ cookiecutter.project_class_name }}Repository"] +{%- endif %} diff --git a/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository.py b/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository.py index 9246645..288654e 100644 --- a/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository.py +++ b/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository.py @@ -5,6 +5,9 @@ {% if cookiecutter.cloud_service == 'GCP Cloud Function' -%} from google.cloud.firestore import Client as FirestoreClient, CollectionReference {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} +from boto3.dynamodb.conditions import Attr +{%- endif %} from typing import List, Optional from models import {{ cookiecutter.project_class_name }}, {{ cookiecutter.project_class_name }}Response from errors import NotFoundError @@ -33,6 +36,10 @@ def __init__(self, container_client: ContainerProxy): def __init__(self, collection: CollectionReference): self.collection = collection {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + def __init__(self, table): + self.table = table +{%- endif %} def get_by_id(self, item_id: str) -> Optional[{{ cookiecutter.project_class_name }}Response]: {%- if cookiecutter.cloud_service == 'Azure Function App' %} @@ -54,13 +61,22 @@ def get_by_id(self, item_id: str) -> Optional[{{ cookiecutter.project_class_name doc = self.collection.document(item_id).get() if not doc.exists: raise NotFoundError() - + data = doc.to_dict() if data.get('isDeleted', False): raise NotFoundError() - + return {{ cookiecutter.project_class_name }}Response.model_validate(data) {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + response = self.table.get_item(Key={"id": item_id}) + item = response.get("Item") + + if not item or item.get("isDeleted", False): + raise NotFoundError() + + return {{ cookiecutter.project_class_name }}Response.model_validate(item) +{%- endif %} def get_list(self) -> List[{{ cookiecutter.project_class_name }}Response | None]: {%- if cookiecutter.cloud_service == 'Azure Function App' %} @@ -79,6 +95,14 @@ def get_list(self) -> List[{{ cookiecutter.project_class_name }}Response | None] items.append({{ cookiecutter.project_class_name }}Response.model_validate(data)) return items {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + response = self.table.scan( + FilterExpression=Attr("isDeleted").eq(False) + ) + items = response.get("Items", []) + + return [{{ cookiecutter.project_class_name }}Response.model_validate(item) for item in items] +{%- endif %} def create(self, item: {{ cookiecutter.project_class_name }}) -> {{ cookiecutter.project_class_name }}Response: item_dict = item.model_dump(exclude_none=True) @@ -90,7 +114,12 @@ def create(self, item: {{ cookiecutter.project_class_name }}) -> {{ cookiecutter {%- if cookiecutter.cloud_service == 'GCP Cloud Function' %} doc_ref = self.collection.document(item.id) doc_ref.set(item_dict) - + + return {{ cookiecutter.project_class_name }}Response.model_validate(item_dict) +{%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + self.table.put_item(Item=item_dict) + return {{ cookiecutter.project_class_name }}Response.model_validate(item_dict) {%- endif %} @@ -109,7 +138,12 @@ def update(self, item: {{ cookiecutter.project_class_name }}) -> Optional[{{ coo {%- if cookiecutter.cloud_service == 'GCP Cloud Function' %} doc_ref = self.collection.document(item.id) doc_ref.update(patched_item) - + + return {{ cookiecutter.project_class_name }}Response.model_validate(patched_item) +{%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + self.table.put_item(Item=patched_item) + return {{ cookiecutter.project_class_name }}Response.model_validate(patched_item) {%- endif %} @@ -133,9 +167,22 @@ def delete(self, item_id: str): {%- if cookiecutter.cloud_service == 'GCP Cloud Function' %} doc_ref = self.collection.document(item_id) doc = doc_ref.get() - + if not doc.exists or doc.to_dict().get('isDeleted', False): raise NotFoundError() - + doc_ref.update({'isDeleted': True}) {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + response = self.table.get_item(Key={"id": item_id}) + item = response.get("Item") + + if not item or item.get("isDeleted", False): + raise NotFoundError() + + self.table.update_item( + Key={"id": item_id}, + UpdateExpression="SET isDeleted = :val", + ExpressionAttributeValues={":val": True} + ) +{%- endif %} diff --git a/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository_test.py b/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository_test.py index 0dc7da0..bff06dd 100644 --- a/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository_test.py +++ b/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository_test.py @@ -61,7 +61,7 @@ def test_successfully_call(mock_cosmos_client: ContainerProxy): enable_cross_partition_query=True ) assert result == mock_item_responses[0] - + def test_not_found_error(mock_cosmos_client: ContainerProxy): mock_cosmos_client.query_items.return_value = None try: @@ -81,7 +81,7 @@ def test_successfully_call(mock_cosmos_client: ContainerProxy): enable_cross_partition_query=True ) assert result == mock_item_responses - + def test_successfully_call_empty_result(mock_cosmos_client: ContainerProxy): mock_cosmos_client.query_items.return_value = [] repository = {{ cookiecutter.project_class_name }}Repository(mock_cosmos_client) @@ -165,20 +165,20 @@ def test_successfully_call(mock_firestore_collection): mock_doc_ref = MagicMock() mock_doc_ref.get.return_value = mock_doc mock_firestore_collection.document.return_value = mock_doc_ref - + repository = {{ cookiecutter.project_class_name }}Repository(mock_firestore_collection) result = repository.get_by_id(item_id='ac1df01c-7ece-4a20-ab60-179829dad8f5') - + mock_firestore_collection.document.assert_called_once_with('ac1df01c-7ece-4a20-ab60-179829dad8f5') assert result == mock_item_responses[0] - + def test_not_found_error(mock_firestore_collection): mock_doc = MagicMock() mock_doc.exists = False mock_doc_ref = MagicMock() mock_doc_ref.get.return_value = mock_doc mock_firestore_collection.document.return_value = mock_doc_ref - + repository = {{ cookiecutter.project_class_name }}Repository(mock_firestore_collection) try: repository.get_by_id(item_id='ac1df01c-7ece-4a20-ab60-179829dad8f5') @@ -200,25 +200,25 @@ def test_successfully_call(mock_firestore_collection): "name": "mockName2", "type": "mockType2" } - + mock_query = MagicMock() mock_query.stream.return_value = [mock_doc1, mock_doc2] mock_firestore_collection.where.return_value = mock_query - + repository = {{ cookiecutter.project_class_name }}Repository(mock_firestore_collection) result = repository.get_list() - + mock_firestore_collection.where.assert_called_once_with('isDeleted', '==', False) assert result == mock_item_responses - + def test_successfully_call_empty_result(mock_firestore_collection): mock_query = MagicMock() mock_query.stream.return_value = [] mock_firestore_collection.where.return_value = mock_query - + repository = {{ cookiecutter.project_class_name }}Repository(mock_firestore_collection) result = repository.get_list() - + mock_firestore_collection.where.assert_called_once_with('isDeleted', '==', False) assert result == [] @@ -226,7 +226,7 @@ def describe_create(): def test_successfully_call(mock_firestore_collection): mock_doc_ref = MagicMock() mock_firestore_collection.document.return_value = mock_doc_ref - + repository = {{ cookiecutter.project_class_name }}Repository(mock_firestore_collection) mock_item = {{ cookiecutter.project_class_name }}( name='mockName1', @@ -234,7 +234,7 @@ def test_successfully_call(mock_firestore_collection): id='ac1df01c-7ece-4a20-ab60-179829dad8f5' ) result = repository.create(item=mock_item) - + mock_firestore_collection.document.assert_called_once_with('ac1df01c-7ece-4a20-ab60-179829dad8f5') mock_doc_ref.set.assert_called_once() assert result.id == mock_item_responses[0].id @@ -246,10 +246,10 @@ def test_successfully_call(mock_firestore_collection): name='mockName1', type='mockType1' ) - + mock_doc_ref = MagicMock() mock_firestore_collection.document.return_value = mock_doc_ref - + repository = {{ cookiecutter.project_class_name }}Repository(mock_firestore_collection) with patch.object(repository, 'get_by_id', return_value=mock_item_response): result = repository.update( @@ -274,10 +274,132 @@ def test_successfully_call(mock_firestore_collection): mock_doc_ref = MagicMock() mock_doc_ref.get.return_value = mock_doc mock_firestore_collection.document.return_value = mock_doc_ref - + repository = {{ cookiecutter.project_class_name }}Repository(mock_firestore_collection) repository.delete(item_id='ac1df01c-7ece-4a20-ab60-179829dad8f5') - + mock_firestore_collection.document.assert_called_once_with('ac1df01c-7ece-4a20-ab60-179829dad8f5') mock_doc_ref.update.assert_called_once_with({'isDeleted': True}) {%- endif %} +{% if cookiecutter.cloud_service == 'AWS Lambda' -%} + + +def describe_item_service(): + @pytest.fixture + def mock_dynamodb_table(): + return MagicMock() + + def describe_get_by_id(): + def test_successfully_call(mock_dynamodb_table): + mock_dynamodb_table.get_item.return_value = { + "Item": { + "id": "ac1df01c-7ece-4a20-ab60-179829dad8f5", + "name": "mockName1", + "type": "mockType1", + "isDeleted": False + } + } + + repository = {{ cookiecutter.project_class_name }}Repository(mock_dynamodb_table) + result = repository.get_by_id(item_id='ac1df01c-7ece-4a20-ab60-179829dad8f5') + + mock_dynamodb_table.get_item.assert_called_once_with(Key={"id": "ac1df01c-7ece-4a20-ab60-179829dad8f5"}) + assert result == mock_item_responses[0] + + def test_not_found_error(mock_dynamodb_table): + mock_dynamodb_table.get_item.return_value = {} + + repository = {{ cookiecutter.project_class_name }}Repository(mock_dynamodb_table) + try: + repository.get_by_id(item_id='ac1df01c-7ece-4a20-ab60-179829dad8f5') + assert False, "Should have raised NotFoundError" + except NotFoundError: + pass + + def describe_get_list(): + def test_successfully_call(mock_dynamodb_table): + mock_dynamodb_table.scan.return_value = { + "Items": [ + { + "id": "ac1df01c-7ece-4a20-ab60-179829dad8f5", + "name": "mockName1", + "type": "mockType1", + "isDeleted": False + }, + { + "id": "de6cbc87-5969-458c-8444-3512a82250bc", + "name": "mockName2", + "type": "mockType2", + "isDeleted": False + } + ] + } + + repository = {{ cookiecutter.project_class_name }}Repository(mock_dynamodb_table) + result = repository.get_list() + + mock_dynamodb_table.scan.assert_called_once() + assert result == mock_item_responses + + def test_successfully_call_empty_result(mock_dynamodb_table): + mock_dynamodb_table.scan.return_value = {"Items": []} + + repository = {{ cookiecutter.project_class_name }}Repository(mock_dynamodb_table) + result = repository.get_list() + + mock_dynamodb_table.scan.assert_called_once() + assert result == [] + + def describe_create(): + def test_successfully_call(mock_dynamodb_table): + repository = {{ cookiecutter.project_class_name }}Repository(mock_dynamodb_table) + mock_item = {{ cookiecutter.project_class_name }}( + name='mockName1', + type='mockType1', + id='ac1df01c-7ece-4a20-ab60-179829dad8f5' + ) + result = repository.create(item=mock_item) + + mock_dynamodb_table.put_item.assert_called_once() + assert result.id == mock_item_responses[0].id + + def describe_update(): + def test_successfully_call(mock_dynamodb_table): + mock_item_response = {{ cookiecutter.project_class_name }}Response( + id='ac1df01c-7ece-4a20-ab60-179829dad8f5', + name='mockName1', + type='mockType1' + ) + + repository = {{ cookiecutter.project_class_name }}Repository(mock_dynamodb_table) + with patch.object(repository, 'get_by_id', return_value=mock_item_response): + result = repository.update( + item={{ cookiecutter.project_class_name }}( + id='ac1df01c-7ece-4a20-ab60-179829dad8f5', + name='mockName1-Update', + type='mockType1-Update' + ) + ) + assert result.id == 'ac1df01c-7ece-4a20-ab60-179829dad8f5' + assert result.name == 'mockName1-Update' + assert result.type == 'mockType1-Update' + + def describe_delete(): + def test_successfully_call(mock_dynamodb_table): + mock_dynamodb_table.get_item.return_value = { + "Item": { + "id": "ac1df01c-7ece-4a20-ab60-179829dad8f5", + "isDeleted": False + } + } + + repository = {{ cookiecutter.project_class_name }}Repository(mock_dynamodb_table) + repository.delete(item_id='ac1df01c-7ece-4a20-ab60-179829dad8f5') + + mock_dynamodb_table.get_item.assert_called_once_with(Key={"id": "ac1df01c-7ece-4a20-ab60-179829dad8f5"}) + mock_dynamodb_table.update_item.assert_called_once_with( + Key={"id": "ac1df01c-7ece-4a20-ab60-179829dad8f5"}, + UpdateExpression="SET isDeleted = :val", + ExpressionAttributeValues={":val": True} + ) +{%- endif %} diff --git a/python/{{cookiecutter.project_class_name}}/template.yaml b/python/{{cookiecutter.project_class_name}}/template.yaml new file mode 100644 index 0000000..99ce697 --- /dev/null +++ b/python/{{cookiecutter.project_class_name}}/template.yaml @@ -0,0 +1,70 @@ +AWSTemplateFormatVersion: '2010-09-09' +Transform: AWS::Serverless-2016-10-31 +Description: {{ cookiecutter.project_description }} + +Globals: + Function: + Timeout: 30 + Runtime: python3.12 + +Resources: + {{ cookiecutter.project_class_name }}Function: + Type: AWS::Serverless::Function + Properties: + Handler: lambda_app.lambda_handler + CodeUri: . + Description: {{ cookiecutter.project_description }} + Architectures: + - x86_64 + Environment: + Variables: + DYNAMODB_TABLE_NAME: !Ref {{ cookiecutter.project_class_name }}Table + Policies: + - DynamoDBCrudPolicy: + TableName: !Ref {{ cookiecutter.project_class_name }}Table + Events: + GetById: + Type: Api + Properties: + Path: /{{ cookiecutter.project_endpoint }}/{item_id} + Method: get + GetList: + Type: Api + Properties: + Path: /{{ cookiecutter.project_endpoint }} + Method: get + Create: + Type: Api + Properties: + Path: /{{ cookiecutter.project_endpoint }} + Method: post + Update: + Type: Api + Properties: + Path: /{{ cookiecutter.project_endpoint }}/{item_id} + Method: patch + Delete: + Type: Api + Properties: + Path: /{{ cookiecutter.project_endpoint }}/{item_id} + Method: delete + + {{ cookiecutter.project_class_name }}Table: + Type: AWS::DynamoDB::Table + Properties: + TableName: {{ cookiecutter.project_slug }} + AttributeDefinitions: + - AttributeName: id + AttributeType: S + KeySchema: + - AttributeName: id + KeyType: HASH + BillingMode: PAY_PER_REQUEST + +Outputs: + {{ cookiecutter.project_class_name }}Api: + Description: API Gateway endpoint URL + Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/{{ cookiecutter.project_endpoint }}/" + {{ cookiecutter.project_class_name }}Function: + Description: Lambda Function ARN + Value: !GetAtt {{ cookiecutter.project_class_name }}Function.Arn diff --git a/python/{{cookiecutter.project_class_name}}/utils/detect_error.py b/python/{{cookiecutter.project_class_name}}/utils/detect_error.py index f568f6b..c8a6650 100644 --- a/python/{{cookiecutter.project_class_name}}/utils/detect_error.py +++ b/python/{{cookiecutter.project_class_name}}/utils/detect_error.py @@ -25,6 +25,13 @@ def generate_error_response(message: str, type: str = None, status_code: int = 5 headers = {'Content-Type': 'application/json'} return (error_response.model_dump_json(exclude_none=True), status_code, headers) {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + return { + "statusCode": status_code, + "headers": {"Content-Type": "application/json"}, + "body": error_response.model_dump_json(exclude_none=True) + } +{%- endif %} def detect_error(error: Exception): if error and isinstance(error, BaseError): diff --git a/python/{{cookiecutter.project_class_name}}/utils/detect_error_test.py b/python/{{cookiecutter.project_class_name}}/utils/detect_error_test.py index f052d6d..753ec04 100644 --- a/python/{{cookiecutter.project_class_name}}/utils/detect_error_test.py +++ b/python/{{cookiecutter.project_class_name}}/utils/detect_error_test.py @@ -25,6 +25,11 @@ def test_base_error_response(): assert response[1] == 500 assert '{"type":"UnknownError","message":"Test"}' in response[0] {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + assert isinstance(response, dict) + assert response["statusCode"] == 500 + assert '{"type":"UnknownError","message":"Test"}' in response["body"] +{%- endif %} def test_validation_error_response(): try: @@ -41,6 +46,11 @@ def test_validation_error_response(): assert response[1] == 422 assert '"type":"ValidationError"' in response[0] {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + assert isinstance(response, dict) + assert response["statusCode"] == 422 + assert '"type":"ValidationError"' in response["body"] +{%- endif %} def test_pydantic_validation_error_response(): try: @@ -57,6 +67,11 @@ def test_pydantic_validation_error_response(): assert response[1] == 422 assert '"type":"ValidationError"' in response[0] {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + assert isinstance(response, dict) + assert response["statusCode"] == 422 + assert '"type":"ValidationError"' in response["body"] +{%- endif %} def test_generic_exception_response(): try: @@ -73,3 +88,8 @@ def test_generic_exception_response(): assert response[1] == 500 assert '"type":"UnknownError"' in response[0] {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + assert isinstance(response, dict) + assert response["statusCode"] == 500 + assert '"type":"UnknownError"' in response["body"] +{%- endif %} diff --git a/python/{{cookiecutter.project_class_name}}/utils/response_generator.py b/python/{{cookiecutter.project_class_name}}/utils/response_generator.py index 9b4d9f6..b50cebf 100644 --- a/python/{{cookiecutter.project_class_name}}/utils/response_generator.py +++ b/python/{{cookiecutter.project_class_name}}/utils/response_generator.py @@ -8,7 +8,7 @@ def response_generator(items: {{ cookiecutter.project_class_name }}Response | li {%- if cookiecutter.cloud_service == 'GCP Cloud Function' %} headers = {'Content-Type': 'application/json'} {%- endif %} - + if isinstance(items, list): if not items: body = json.dumps([]) @@ -16,10 +16,17 @@ def response_generator(items: {{ cookiecutter.project_class_name }}Response | li body = json.dumps([item.model_dump() for item in items]) else: body = json.dumps(items.model_dump()) - + {%- if cookiecutter.cloud_service == 'Azure Function App' %} return HttpResponse(body=body, status_code=status_code) {%- endif %} {%- if cookiecutter.cloud_service == 'GCP Cloud Function' %} return (body, status_code, headers) {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + return { + "statusCode": status_code, + "headers": {"Content-Type": "application/json"}, + "body": body + } +{%- endif %} diff --git a/python/{{cookiecutter.project_class_name}}/utils/response_generator_test.py b/python/{{cookiecutter.project_class_name}}/utils/response_generator_test.py index f73c547..83db68f 100644 --- a/python/{{cookiecutter.project_class_name}}/utils/response_generator_test.py +++ b/python/{{cookiecutter.project_class_name}}/utils/response_generator_test.py @@ -15,6 +15,10 @@ def test_list_item_response_single_item(): assert response[0] == '[{"name": "mockName", "type": "mockType", "id": "935e5045-4a1c-46c9-8e26-9d9d5c2597f3"}]' assert response[1] == 200 {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + assert response["body"] == '[{"name": "mockName", "type": "mockType", "id": "935e5045-4a1c-46c9-8e26-9d9d5c2597f3"}]' + assert response["statusCode"] == 200 +{%- endif %} def test_empty_list(): item_response_list = [] @@ -28,6 +32,10 @@ def test_empty_list(): assert response[0] == '[]' assert response[1] == 200 {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + assert response["body"] == '[]' + assert response["statusCode"] == 200 +{%- endif %} def test_list_item_response_multiple_items(): item_response = {{ cookiecutter.project_class_name }}Response(id="935e5045-4a1c-46c9-8e26-9d9d5c2597f3",name="mockName",type="mockType") @@ -42,6 +50,10 @@ def test_list_item_response_multiple_items(): assert response[0] == '[{"name": "mockName", "type": "mockType", "id": "935e5045-4a1c-46c9-8e26-9d9d5c2597f3"}, {"name": "mockName", "type": "mockType", "id": "935e5045-4a1c-46c9-8e26-9d9d5c2597f3"}]' assert response[1] == 200 {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + assert response["body"] == '[{"name": "mockName", "type": "mockType", "id": "935e5045-4a1c-46c9-8e26-9d9d5c2597f3"}, {"name": "mockName", "type": "mockType", "id": "935e5045-4a1c-46c9-8e26-9d9d5c2597f3"}]' + assert response["statusCode"] == 200 +{%- endif %} def test_item_response(): item_response = {{ cookiecutter.project_class_name }}Response(id="935e5045-4a1c-46c9-8e26-9d9d5c2597f3",name="mockName",type="mockType") @@ -55,3 +67,7 @@ def test_item_response(): assert response[0] == '{"name": "mockName", "type": "mockType", "id": "935e5045-4a1c-46c9-8e26-9d9d5c2597f3"}' assert response[1] == 200 {%- endif %} +{%- if cookiecutter.cloud_service == 'AWS Lambda' %} + assert response["body"] == '{"name": "mockName", "type": "mockType", "id": "935e5045-4a1c-46c9-8e26-9d9d5c2597f3"}' + assert response["statusCode"] == 200 +{%- endif %} From 5e5575421e9f36f8ceb7bbf9f2e95944b2626208 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 17:01:48 +0000 Subject: [PATCH 2/7] Update Python template dependencies to latest versions - azure-cosmos: ^4.7.0 -> ^4.15.0 - google-cloud-firestore: ^2.16.0 -> ^2.26.0 - functions-framework: ^3.5.0 -> ^3.10.0 - boto3: ^1.35.0 -> ^1.42.0 - pydantic: ^2.8.2 -> ^2.12.0 Note: azure-functions kept at 1.24.0 (v2.0.0 requires Python >=3.13, incompatible with template's >=3.10 support). Other packages (pytest, pytest-cov, pytest-mock, pytest-describe) already resolve to latest via their caret ranges. https://claude.ai/code/session_01JmXGz7hA4WARPVez6ycB28 --- .../{{cookiecutter.project_class_name}}/pyproject.toml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/{{cookiecutter.project_class_name}}/pyproject.toml b/python/{{cookiecutter.project_class_name}}/pyproject.toml index 0b45402..df198ab 100644 --- a/python/{{cookiecutter.project_class_name}}/pyproject.toml +++ b/python/{{cookiecutter.project_class_name}}/pyproject.toml @@ -9,14 +9,14 @@ package-mode = false [tool.poetry.dependencies] python = ">=3.10,<3.15" {% if cookiecutter.cloud_service == 'Azure Function App' -%} -azure-cosmos = "^4.7.0" +azure-cosmos = "^4.15.0" {%- endif %} {% if cookiecutter.cloud_service == 'GCP Cloud Function' -%} -google-cloud-firestore = "^2.16.0" -functions-framework = "^3.5.0" +google-cloud-firestore = "^2.26.0" +functions-framework = "^3.10.0" {%- endif %} {% if cookiecutter.cloud_service == 'AWS Lambda' -%} -boto3 = "^1.35.0" +boto3 = "^1.42.0" {%- endif %} pytest-mock = "^3.14.0" pytest-describe = "^3.0.0" @@ -27,7 +27,7 @@ azure-functions = "1.24.0" {%- endif %} pytest = "^9.0.0" pytest-cov = "^7.0.0" -pydantic = "^2.8.2" +pydantic = "^2.12.0" ruff = "^0.11.0" [tool.ruff] From 99ec7a6c92e642d26d2d6261c08b8919b4cbb7e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 18:01:37 +0000 Subject: [PATCH 3/7] Address PR review feedback for AWS Lambda implementation - Fix import order in blueprints API: move shared imports above cloud-specific initialization blocks to prevent NameError - Fix DynamoDB scan: handle missing isDeleted attribute and implement pagination via LastEvaluatedKey for large tables - Handle JSONDecodeError in Lambda controller create/update methods: return ValidationError (422) instead of unhandled 500 - Fix lambda_app.py 404 fallback to return valid JSON body - Fix delete response to return JSON body matching Content-Type header https://claude.ai/code/session_01JmXGz7hA4WARPVez6ycB28 --- .../{{cookiecutter.project_slug}}_api.py | 10 +++++----- .../{{cookiecutter.project_slug}}_controller.py | 11 +++++++++-- .../lambda_app.py | 2 +- .../{{cookiecutter.project_slug}}_repository.py | 15 +++++++++++---- 4 files changed, 26 insertions(+), 12 deletions(-) diff --git a/python/{{cookiecutter.project_class_name}}/blueprints/{{cookiecutter.project_slug}}_api.py b/python/{{cookiecutter.project_class_name}}/blueprints/{{cookiecutter.project_slug}}_api.py index 11ab52b..48ac388 100644 --- a/python/{{cookiecutter.project_class_name}}/blueprints/{{cookiecutter.project_slug}}_api.py +++ b/python/{{cookiecutter.project_class_name}}/blueprints/{{cookiecutter.project_slug}}_api.py @@ -1,5 +1,9 @@ import os import logging +from controllers import {{ cookiecutter.project_class_name }}Controller +from services import {{ cookiecutter.project_class_name }}Service +from repositories import {{ cookiecutter.project_class_name }}Repository +from utils import detect_error, response_generator {% if cookiecutter.cloud_service == 'Azure Function App' -%} import azure.functions as func from azure.cosmos import CosmosClient @@ -40,10 +44,6 @@ table = dynamodb.Table(table_name) repository = {{ cookiecutter.project_class_name }}Repository(table) {%- endif %} -from controllers import {{ cookiecutter.project_class_name }}Controller -from services import {{ cookiecutter.project_class_name }}Service -from repositories import {{ cookiecutter.project_class_name }}Repository -from utils import detect_error, response_generator service = {{ cookiecutter.project_class_name }}Service(repository) controller = {{ cookiecutter.project_class_name }}Controller(service) @@ -191,7 +191,7 @@ def delete(event): return { "statusCode": 200, "headers": {"Content-Type": "application/json"}, - "body": "{{ cookiecutter.project_class_name }} deleted." + "body": '{"message": "{{ cookiecutter.project_class_name }} deleted."}' } {%- endif %} diff --git a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py index 02637be..52bf295 100644 --- a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py +++ b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py @@ -7,6 +7,7 @@ {%- endif %} {% if cookiecutter.cloud_service == 'AWS Lambda' -%} import json +from errors import ValidationError {%- endif %} from services import {{ cookiecutter.project_class_name }}Service from models import {{ cookiecutter.project_class_name }}Response, {{ cookiecutter.project_class_name }}, {{ cookiecutter.project_class_name }}IdValidation @@ -57,7 +58,10 @@ def create(self, request: Request) -> {{ cookiecutter.project_class_name }}Respo {%- if cookiecutter.cloud_service == 'AWS Lambda' %} def create(self, event: dict) -> {{ cookiecutter.project_class_name }}Response: - item_json: dict = json.loads(event.get("body", "{}")) + try: + item_json: dict = json.loads(event.get("body", "{}")) + except json.JSONDecodeError: + raise ValidationError("Invalid JSON in request body.") item = {{ cookiecutter.project_class_name }}(**item_json) return self.service.create(item) {%- endif %} @@ -85,7 +89,10 @@ def update(self, request: Request) -> {{ cookiecutter.project_class_name }}Respo def update(self, event: dict) -> {{ cookiecutter.project_class_name }}Response: item_id: str = event.get("pathParameters", {}).get("item_id") - item_data: dict = json.loads(event.get("body", "{}")) + try: + item_data: dict = json.loads(event.get("body", "{}")) + except json.JSONDecodeError: + raise ValidationError("Invalid JSON in request body.") item = {{ cookiecutter.project_class_name }}(**item_data) item.id = item_id return self.service.update(item) diff --git a/python/{{cookiecutter.project_class_name}}/lambda_app.py b/python/{{cookiecutter.project_class_name}}/lambda_app.py index 031f6a0..d652677 100644 --- a/python/{{cookiecutter.project_class_name}}/lambda_app.py +++ b/python/{{cookiecutter.project_class_name}}/lambda_app.py @@ -29,5 +29,5 @@ def lambda_handler(event, context): return { "statusCode": 404, "headers": {"Content-Type": "application/json"}, - "body": "Not Found" + "body": '{"message": "Not Found"}' } diff --git a/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository.py b/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository.py index 288654e..138d6ca 100644 --- a/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository.py +++ b/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository.py @@ -96,10 +96,17 @@ def get_list(self) -> List[{{ cookiecutter.project_class_name }}Response | None] return items {%- endif %} {%- if cookiecutter.cloud_service == 'AWS Lambda' %} - response = self.table.scan( - FilterExpression=Attr("isDeleted").eq(False) - ) - items = response.get("Items", []) + items = [] + filter_exp = Attr("isDeleted").eq(False) | Attr("isDeleted").not_exists() + response = self.table.scan(FilterExpression=filter_exp) + items.extend(response.get("Items", [])) + + while "LastEvaluatedKey" in response: + response = self.table.scan( + FilterExpression=filter_exp, + ExclusiveStartKey=response["LastEvaluatedKey"] + ) + items.extend(response.get("Items", [])) return [{{ cookiecutter.project_class_name }}Response.model_validate(item) for item in items] {%- endif %} From 0f8f44a1cd872d31bd5faf42f84a37f54a3a9dc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 20:28:02 +0000 Subject: [PATCH 4/7] Improve code quality from self-review - Fix import order: move shared imports above cloud-specific init blocks to prevent NameError at module load time - Extract _parse_body helper in Lambda controller to deduplicate JSON parsing logic between create and update methods - Fix controller test: use MagicMock instead of AsyncMock for sync Lambda controller methods - Optimize DynamoDB delete: collapse get_item + update_item into single update_item with ConditionExpression, halving round-trips - Use generate_error_response for lambda_app.py 404 fallback instead of hand-rolled response dict https://claude.ai/code/session_01JmXGz7hA4WARPVez6ycB28 --- ...{cookiecutter.project_slug}}_controller.py | 12 +++++----- ...iecutter.project_slug}}_controller_test.py | 4 ++-- .../lambda_app.py | 11 +++++---- ...{cookiecutter.project_slug}}_repository.py | 23 ++++++++++--------- ...iecutter.project_slug}}_repository_test.py | 11 ++------- 5 files changed, 28 insertions(+), 33 deletions(-) diff --git a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py index 52bf295..0fb838e 100644 --- a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py +++ b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py @@ -57,11 +57,14 @@ def create(self, request: Request) -> {{ cookiecutter.project_class_name }}Respo {%- endif %} {%- if cookiecutter.cloud_service == 'AWS Lambda' %} - def create(self, event: dict) -> {{ cookiecutter.project_class_name }}Response: + def _parse_body(self, event: dict) -> dict: try: - item_json: dict = json.loads(event.get("body", "{}")) + return json.loads(event.get("body", "{}")) except json.JSONDecodeError: raise ValidationError("Invalid JSON in request body.") + + def create(self, event: dict) -> {{ cookiecutter.project_class_name }}Response: + item_json: dict = self._parse_body(event) item = {{ cookiecutter.project_class_name }}(**item_json) return self.service.create(item) {%- endif %} @@ -89,10 +92,7 @@ def update(self, request: Request) -> {{ cookiecutter.project_class_name }}Respo def update(self, event: dict) -> {{ cookiecutter.project_class_name }}Response: item_id: str = event.get("pathParameters", {}).get("item_id") - try: - item_data: dict = json.loads(event.get("body", "{}")) - except json.JSONDecodeError: - raise ValidationError("Invalid JSON in request body.") + item_data: dict = self._parse_body(event) item = {{ cookiecutter.project_class_name }}(**item_data) item.id = item_id return self.service.update(item) diff --git a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller_test.py b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller_test.py index 322dd72..aff42e1 100644 --- a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller_test.py +++ b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller_test.py @@ -91,7 +91,7 @@ def test_value_error_invalid_uuid(controller, mock_service): {%- endif %} {% if cookiecutter.cloud_service == 'AWS Lambda' -%} import pytest -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import MagicMock from models import {{ cookiecutter.project_class_name }}Response, {{ cookiecutter.project_class_name }}IdValidation from controllers import {{ cookiecutter.project_class_name }}Controller from services import {{ cookiecutter.project_class_name }}Service @@ -100,7 +100,7 @@ def test_value_error_invalid_uuid(controller, mock_service): def describe_item_controller(): @pytest.fixture def mock_service(): - service = AsyncMock({{ cookiecutter.project_class_name }}Service) + service = MagicMock({{ cookiecutter.project_class_name }}Service) return service @pytest.fixture diff --git a/python/{{cookiecutter.project_class_name}}/lambda_app.py b/python/{{cookiecutter.project_class_name}}/lambda_app.py index d652677..3e71e04 100644 --- a/python/{{cookiecutter.project_class_name}}/lambda_app.py +++ b/python/{{cookiecutter.project_class_name}}/lambda_app.py @@ -9,6 +9,7 @@ update, delete ) +from utils.detect_error import generate_error_response def lambda_handler(event, context): @@ -26,8 +27,8 @@ def lambda_handler(event, context): elif http_method == "DELETE": return delete(event) else: - return { - "statusCode": 404, - "headers": {"Content-Type": "application/json"}, - "body": '{"message": "Not Found"}' - } + return generate_error_response( + message="Not Found", + type="NotFoundError", + status_code=404 + ) diff --git a/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository.py b/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository.py index 138d6ca..2fb58f9 100644 --- a/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository.py +++ b/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository.py @@ -7,6 +7,7 @@ {%- endif %} {% if cookiecutter.cloud_service == 'AWS Lambda' -%} from boto3.dynamodb.conditions import Attr +from botocore.exceptions import ClientError {%- endif %} from typing import List, Optional from models import {{ cookiecutter.project_class_name }}, {{ cookiecutter.project_class_name }}Response @@ -181,15 +182,15 @@ def delete(self, item_id: str): doc_ref.update({'isDeleted': True}) {%- endif %} {%- if cookiecutter.cloud_service == 'AWS Lambda' %} - response = self.table.get_item(Key={"id": item_id}) - item = response.get("Item") - - if not item or item.get("isDeleted", False): - raise NotFoundError() - - self.table.update_item( - Key={"id": item_id}, - UpdateExpression="SET isDeleted = :val", - ExpressionAttributeValues={":val": True} - ) + try: + self.table.update_item( + Key={"id": item_id}, + UpdateExpression="SET isDeleted = :val", + ConditionExpression="attribute_exists(id) AND (attribute_not_exists(isDeleted) OR isDeleted = :false)", + ExpressionAttributeValues={":val": True, ":false": False} + ) + except ClientError as error: + if error.response["Error"]["Code"] == "ConditionalCheckFailedException": + raise NotFoundError() + raise {%- endif %} diff --git a/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository_test.py b/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository_test.py index bff06dd..cbfc2ee 100644 --- a/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository_test.py +++ b/python/{{cookiecutter.project_class_name}}/repositories/{{cookiecutter.project_slug}}_repository_test.py @@ -386,20 +386,13 @@ def test_successfully_call(mock_dynamodb_table): def describe_delete(): def test_successfully_call(mock_dynamodb_table): - mock_dynamodb_table.get_item.return_value = { - "Item": { - "id": "ac1df01c-7ece-4a20-ab60-179829dad8f5", - "isDeleted": False - } - } - repository = {{ cookiecutter.project_class_name }}Repository(mock_dynamodb_table) repository.delete(item_id='ac1df01c-7ece-4a20-ab60-179829dad8f5') - mock_dynamodb_table.get_item.assert_called_once_with(Key={"id": "ac1df01c-7ece-4a20-ab60-179829dad8f5"}) mock_dynamodb_table.update_item.assert_called_once_with( Key={"id": "ac1df01c-7ece-4a20-ab60-179829dad8f5"}, UpdateExpression="SET isDeleted = :val", - ExpressionAttributeValues={":val": True} + ConditionExpression="attribute_exists(id) AND (attribute_not_exists(isDeleted) OR isDeleted = :false)", + ExpressionAttributeValues={":val": True, ":false": False} ) {%- endif %} From bf90ca6c49fce71aef938b239821d662d70b00c2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Apr 2026 23:08:58 +0000 Subject: [PATCH 5/7] Fix PR review feedback: pathParameters null safety, _parse_body hardening, README docs - Use `(event.get("pathParameters") or {})` to handle API Gateway sending null - Handle None body and base64-encoded bodies in _parse_body - Add DynamoDB local dev dependency note to README https://claude.ai/code/session_01JmXGz7hA4WARPVez6ycB28 --- .../{{cookiecutter.project_class_name}}/README.md | 5 +++++ .../{{cookiecutter.project_slug}}_controller.py | 14 +++++++++----- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/python/{{cookiecutter.project_class_name}}/README.md b/python/{{cookiecutter.project_class_name}}/README.md index 7e0142f..75738a8 100644 --- a/python/{{cookiecutter.project_class_name}}/README.md +++ b/python/{{cookiecutter.project_class_name}}/README.md @@ -209,6 +209,11 @@ Dependency management is handled using [Poetry](https://python-poetry.org/), ens This command starts the local API Gateway using SAM CLI, where you can interact with your API endpoints. + > **Note:** API endpoints that interact with DynamoDB require a running DynamoDB instance. + > For local development, you can use [DynamoDB Local](https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/DynamoDBLocal.html) + > or connect to a deployed DynamoDB table by configuring your AWS credentials and setting the + > `DYNAMODB_TABLE_NAME` environment variable in `template.yaml`. + 5. Deploy to AWS Build and deploy to AWS using SAM: diff --git a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py index 0fb838e..de30385 100644 --- a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py +++ b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py @@ -34,7 +34,7 @@ def get_by_id(self, request: Request) -> {{ cookiecutter.project_class_name }}Re {%- if cookiecutter.cloud_service == 'AWS Lambda' %} def get_by_id(self, event: dict) -> {{ cookiecutter.project_class_name }}Response: - item_id: str = event.get("pathParameters", {}).get("item_id") + item_id: str = (event.get("pathParameters") or {}).get("item_id") {{ cookiecutter.project_class_name }}IdValidation(id=item_id) return self.service.get_by_id(item_id) {%- endif %} @@ -59,8 +59,12 @@ def create(self, request: Request) -> {{ cookiecutter.project_class_name }}Respo def _parse_body(self, event: dict) -> dict: try: - return json.loads(event.get("body", "{}")) - except json.JSONDecodeError: + body = event.get("body") or "{}" + if event.get("isBase64Encoded"): + import base64 + body = base64.b64decode(body).decode("utf-8") + return json.loads(body) + except (json.JSONDecodeError, Exception) as e: raise ValidationError("Invalid JSON in request body.") def create(self, event: dict) -> {{ cookiecutter.project_class_name }}Response: @@ -91,7 +95,7 @@ def update(self, request: Request) -> {{ cookiecutter.project_class_name }}Respo {%- if cookiecutter.cloud_service == 'AWS Lambda' %} def update(self, event: dict) -> {{ cookiecutter.project_class_name }}Response: - item_id: str = event.get("pathParameters", {}).get("item_id") + item_id: str = (event.get("pathParameters") or {}).get("item_id") item_data: dict = self._parse_body(event) item = {{ cookiecutter.project_class_name }}(**item_data) item.id = item_id @@ -114,6 +118,6 @@ def soft_delete(self, request: Request) -> None: {%- if cookiecutter.cloud_service == 'AWS Lambda' %} def soft_delete(self, event: dict) -> None: - item_id: str = event.get("pathParameters", {}).get("item_id") + item_id: str = (event.get("pathParameters") or {}).get("item_id") self.service.soft_delete(item_id) {%- endif %} From f49a438653bcda227ca08a79a1136f3fdde0b4ec Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 07:09:54 +0000 Subject: [PATCH 6/7] Fix unused variable lint error in _parse_body exception handler https://claude.ai/code/session_01JmXGz7hA4WARPVez6ycB28 --- .../controllers/{{cookiecutter.project_slug}}_controller.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py index de30385..f4675a2 100644 --- a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py +++ b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller.py @@ -64,7 +64,7 @@ def _parse_body(self, event: dict) -> dict: import base64 body = base64.b64decode(body).decode("utf-8") return json.loads(body) - except (json.JSONDecodeError, Exception) as e: + except (json.JSONDecodeError, Exception): raise ValidationError("Invalid JSON in request body.") def create(self, event: dict) -> {{ cookiecutter.project_class_name }}Response: From 42c944e44e03b05fcb55d137e19a9bd135441ef2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Apr 2026 07:16:11 +0000 Subject: [PATCH 7/7] Address Copilot review: GCP cleanup, sam build, region config, test fix - Add host.json to GCP post-gen cleanup (was Azure-only leftover) - Add sam build before sam local start-api in Makefile - Use explicit AWS region for DynamoDB init to avoid NoRegionError - Fix test_value_error_invalid_uuid across all providers to use side_effect=ValueError and pytest.raises for proper validation https://claude.ai/code/session_01JmXGz7hA4WARPVez6ycB28 --- python/hooks/post_gen_project.py | 1 + .../Makefile | 3 +- .../{{cookiecutter.project_slug}}_api.py | 6 ++-- ...iecutter.project_slug}}_controller_test.py | 33 +++++++++---------- 4 files changed, 22 insertions(+), 21 deletions(-) diff --git a/python/hooks/post_gen_project.py b/python/hooks/post_gen_project.py index f48a44d..d243692 100644 --- a/python/hooks/post_gen_project.py +++ b/python/hooks/post_gen_project.py @@ -17,6 +17,7 @@ files_to_remove = [ "function_app.py", "local.settings.json", + "host.json", "lambda_app.py", "template.yaml", ] diff --git a/python/{{cookiecutter.project_class_name}}/Makefile b/python/{{cookiecutter.project_class_name}}/Makefile index d617860..c417fa3 100644 --- a/python/{{cookiecutter.project_class_name}}/Makefile +++ b/python/{{cookiecutter.project_class_name}}/Makefile @@ -16,7 +16,8 @@ run: ## Run function app. @poetry run functions-framework --target=get_list --source=main.py --port=8080 {%- endif %} {% if cookiecutter.cloud_service == 'AWS Lambda' -%} - @echo "🚀 Running Lambda API locally via SAM" + @echo "🚀 Building and running Lambda API locally via SAM" + @sam build @sam local start-api {%- endif %} diff --git a/python/{{cookiecutter.project_class_name}}/blueprints/{{cookiecutter.project_slug}}_api.py b/python/{{cookiecutter.project_class_name}}/blueprints/{{cookiecutter.project_slug}}_api.py index 48ac388..1218bbf 100644 --- a/python/{{cookiecutter.project_class_name}}/blueprints/{{cookiecutter.project_slug}}_api.py +++ b/python/{{cookiecutter.project_class_name}}/blueprints/{{cookiecutter.project_slug}}_api.py @@ -38,8 +38,10 @@ {% if cookiecutter.cloud_service == 'AWS Lambda' -%} import boto3 -# Initialize DynamoDB table resource -dynamodb = boto3.resource("dynamodb") +# Initialize DynamoDB table resource with explicit region to avoid +# NoRegionError in local dev or misconfigured environments. +aws_region = os.getenv("AWS_REGION") or os.getenv("AWS_DEFAULT_REGION") or "us-east-1" +dynamodb = boto3.resource("dynamodb", region_name=aws_region) table_name = os.getenv("DYNAMODB_TABLE_NAME", "{{ cookiecutter.project_slug }}") table = dynamodb.Table(table_name) repository = {{ cookiecutter.project_class_name }}Repository(table) diff --git a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller_test.py b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller_test.py index aff42e1..40b093f 100644 --- a/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller_test.py +++ b/python/{{cookiecutter.project_class_name}}/controllers/{{cookiecutter.project_slug}}_controller_test.py @@ -36,13 +36,12 @@ def test_value_error_invalid_uuid(controller, mock_service): mock_request = MagicMock(spec=HttpRequest) mock_request.route_params = {'item_id': 'mockInvalidId'} - with patch.object({{ cookiecutter.project_class_name }}IdValidation, '__init__', return_value=None) as Mock{{ cookiecutter.project_class_name }}IdValidation: - try: + with patch.object({{ cookiecutter.project_class_name }}IdValidation, '__init__', side_effect=ValueError("Invalid ID")) as Mock{{ cookiecutter.project_class_name }}IdValidation: + with pytest.raises(ValueError): controller.get_by_id(mock_request) - except Exception as error: - assert isinstance(error, ValueError) - Mock{{ cookiecutter.project_class_name }}IdValidation.assert_called_once_with(id='mockInvalidId') - mock_service.get_by_id.assert_not_called() + + Mock{{ cookiecutter.project_class_name }}IdValidation.assert_called_once_with(id='mockInvalidId') + mock_service.get_by_id.assert_not_called() {%- endif %} {% if cookiecutter.cloud_service == 'GCP Cloud Function' -%} import pytest @@ -81,13 +80,12 @@ def test_value_error_invalid_uuid(controller, mock_service): mock_request = MagicMock() mock_request.path = '/kitties/mockInvalidId' - with patch.object({{ cookiecutter.project_class_name }}IdValidation, '__init__', return_value=None) as Mock{{ cookiecutter.project_class_name }}IdValidation: - try: + with patch.object({{ cookiecutter.project_class_name }}IdValidation, '__init__', side_effect=ValueError("Invalid ID")) as Mock{{ cookiecutter.project_class_name }}IdValidation: + with pytest.raises(ValueError): controller.get_by_id(mock_request) - except Exception as error: - assert isinstance(error, ValueError) - Mock{{ cookiecutter.project_class_name }}IdValidation.assert_called_once_with(id='mockInvalidId') - mock_service.get_by_id.assert_not_called() + + Mock{{ cookiecutter.project_class_name }}IdValidation.assert_called_once_with(id='mockInvalidId') + mock_service.get_by_id.assert_not_called() {%- endif %} {% if cookiecutter.cloud_service == 'AWS Lambda' -%} import pytest @@ -128,11 +126,10 @@ def test_value_error_invalid_uuid(controller, mock_service): "pathParameters": {"item_id": "mockInvalidId"} } - with patch.object({{ cookiecutter.project_class_name }}IdValidation, '__init__', return_value=None) as Mock{{ cookiecutter.project_class_name }}IdValidation: - try: + with patch.object({{ cookiecutter.project_class_name }}IdValidation, '__init__', side_effect=ValueError("Invalid ID")) as Mock{{ cookiecutter.project_class_name }}IdValidation: + with pytest.raises(ValueError): controller.get_by_id(mock_event) - except Exception as error: - assert isinstance(error, ValueError) - Mock{{ cookiecutter.project_class_name }}IdValidation.assert_called_once_with(id='mockInvalidId') - mock_service.get_by_id.assert_not_called() + + Mock{{ cookiecutter.project_class_name }}IdValidation.assert_called_once_with(id='mockInvalidId') + mock_service.get_by_id.assert_not_called() {%- endif %}