Skip to content
Merged
6 changes: 6 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,15 @@ jobs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis
- name: Install mise
uses: jdx/mise-action@v4
- name: Verify code and infra
run: mise run verify
env:
AWS_DEFAULT_REGION: us-east-1
- name: Scan with SonarQube
uses: SonarSource/sonarqube-scan-action@v8
env:
SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }}
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ Every field in a Pydantic model or pydantic-settings class must be documented us
```python
from pydantic import BaseModel, Field


class Item(BaseModel, populate_by_name=True, alias_generator=to_camel):
id: str = Field(description="Unique item identifier.")
name: str = Field(description="Human-readable item name.")
Expand All @@ -120,6 +121,7 @@ from uuid import uuid4
from pydantic import BaseModel, Field
from pydantic.alias_generators import to_camel


class Item(BaseModel, populate_by_name=True, alias_generator=to_camel):
item_id: str = Field(description="Unique item identifier.", default_factory=str(uuid4()))
# Accepts {"itemId": "..."} from JSON; attribute is item.item_id
Expand All @@ -134,8 +136,11 @@ Do not use `model_config = ConfigDict(...)` or `model_config = SettingsConfigDic
```python
# Good
class Item(BaseModel, extra="allow", populate_by_name=True, alias_generator=to_camel): ...


class Settings(BaseSettings, case_sensitive=False): ...


# Bad
class Item(BaseModel):
model_config = ConfigDict(extra="allow")
Expand All @@ -148,6 +153,7 @@ Each scenario defines a `Repository` class in `repository.py` that owns all `bot
```python
from boto3 import resource


class Repository:
def __init__(self, table_name: str) -> None:
self._table = resource("dynamodb").Table(table_name)
Expand Down
15 changes: 14 additions & 1 deletion docs/README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# AWS Lambda Templates - Python
[![Python](https://img.shields.io/badge/python-3.14+-3776AB.svg?logo=python&style=flat-square)](https://python.org)
[![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-D7FF64.svg?logo=ruff&style=flat-square)](https://docs.astral.sh/ruff)
[![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](LICENSE.md)
[![Coverage](https://sonarcloud.io/api/project_badges/measure?project=cur8d_lambda&metric=coverage)](https://sonarcloud.io/summary/new_code?id=cur8d_lambda)
[![Code Quality](https://sonarcloud.io/api/project_badges/measure?project=cur8d_lambda&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=cur8d_lambda)
[![Code style: ruff](https://img.shields.io/badge/code%20style-ruff-D7FF64.svg?logo=ruff&style=flat-square)](https://docs.astral.sh/ruff)

Production-ready plug-and-play **AWS Lambda templates in Python** for different real-life scenarios.

Expand Down Expand Up @@ -34,6 +36,7 @@ Templates come pre-wired with:
- **Local AWS Deployment**: [LocalStack](https://localstack.cloud) integration for local CDK deployment and testing without an AWS account
- **Testing**: Comprehensive [pytest](https://pytest.org) suite with [moto](http://docs.getmoto.org) for AWS mocking and [hypothesis](https://hypothesis.readthedocs.io) for property-based testing
- **Code Quality**: [ruff](https://docs.astral.sh/ruff) for linting and formatting, [pyright](https://microsoft.github.io/pyright) for type checking, and test coverage using [coverage](https://coverage.readthedocs.io)
- **Code Analysis**: [SonarQube](https://www.sonarsource.com/) / [SonarCloud](https://sonarcloud.io/) integration for code quality and security checks
- **Dependency Control**: [uv](https://docs.astral.sh/uv/) for dependency management and [Dependabot](https://docs.github.com/en/code-security/dependabot) for automated dependency updates
- **Documentation**: Automatic documentation using [MkDocs](https://www.mkdocs.org) and [mkdocstrings](https://mkdocstrings.github.io)
- **Development environment**: [Dev Containers](https://code.visualstudio.com/docs/devcontainers/containers) for dockerized development environment
Expand Down Expand Up @@ -189,6 +192,16 @@ To serve the documentation on a local server, run:
mise run docs-local
```

### Code Analysis with SonarQube / SonarCloud

The project includes a `sonar-project.properties` file and a `verify` GitHub Actions workflow step to automatically scan your code.

To enable SonarCloud analysis in GitHub Actions:
1. Create a project on [SonarCloud](https://sonarcloud.io) and get a token.
2. In your GitHub repository, go to **Settings > Secrets and variables > Actions**.
3. Add a new repository secret named `SONAR_TOKEN` with your token value.
4. Update `sonar-project.properties` with your `sonar.organization` and `sonar.projectKey`.

## Coding Conventions

- **camelCase for JSON**: All models use `alias_generator=to_camel` so that JSON payloads use camelCase while Python attributes use snake_case.
Expand Down
2 changes: 2 additions & 0 deletions docs/template/graphql.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,12 @@ The handler in `templates/graphql/handler.py` implements the resolvers:
def get_item(id: str) -> dict | None:
return repository.get_item(id)


@app.resolver(type_name="Query", field_name="listItems")
def list_items() -> list[dict]:
return repository.list_items()


@app.resolver(type_name="Mutation", field_name="createItem")
def create_item(name: str) -> dict:
item = Item(name=name)
Expand Down
4 changes: 4 additions & 0 deletions scripts/rename.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ def main(name: str, description: str, author: str, email: str, github: str):
("docs/README.md", r"^# .*", f"# {description}"),
(".github/CODEOWNERS", r"@.*", f"@{github}"),
(".github/FUNDING.yml", r"^github: .*", f"github: {github}"),
("sonar-project.properties", r"^sonar\.projectKey=.*", f"sonar.projectKey={name}"),
("sonar-project.properties", r"^sonar\.organization=.*", f"sonar.organization={github}"),
("docs/README.md", r"\?project=[a-zA-Z0-9_-]+", f"?project={name}"),
("docs/README.md", r"\?id=[a-zA-Z0-9_-]+", f"?id={name}"),
]

for filepath, pattern, replacement in replacements:
Expand Down
6 changes: 6 additions & 0 deletions sonar-project.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
sonar.projectKey=cur8d_lambda
sonar.organization=cur8d
sonar.sources=templates
sonar.tests=tests
sonar.python.coverage.reportPaths=coverage.xml
sonar.python.version=3.14
41 changes: 35 additions & 6 deletions tests/agent/test_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,44 @@ def test_handler_get_item_invalid_id():
def test_handler_create_item(repository):
from templates.agent.handler import create_item

result = create_item("2", "new item", "new description")
result = create_item("1", "test item", "test description")

assert result["id"] == "2"
assert result["name"] == "new item"
assert result["description"] == "new description"
assert result["id"] == "1"
assert result["name"] == "test item"
assert result["description"] == "test description"

item = repository.get_item("2")
item = repository.get_item("1")
assert item is not None
assert item["name"] == "new item"
assert item["id"] == "1"


def test_handler_get_item_validation_error(repository, mocker):
from templates.agent.handler import get_item

repository.put_item({"id": "1", "name": ""})
result = get_item("1")

assert "error" in result
assert "Internal server error" in result["error"]


def test_handler_create_item_validation_error(repository):
from templates.agent.handler import create_item

result = create_item("invalid!", "test item")

assert "error" in result
assert "Invalid item data" in result["error"]


def test_handler_create_item_exception(repository, mocker):
from templates.agent import handler

mocker.patch.object(handler.repository, "put_item", side_effect=Exception("DynamoDB error"))
result = handler.create_item("1", "test item")

assert "error" in result
assert "Failed to create item" in result["error"]


def test_lambda_handler_get_item(mocker, repository, lambda_context, bedrock_event):
Expand Down
27 changes: 27 additions & 0 deletions tests/api/test_response.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import json

from pytest import main

from templates.api.response import SECURITY_HEADERS, JsonResponse


def test_json_response_with_dict() -> None:
body = {"message": "hello"}
response = JsonResponse(body)
assert response.status_code == 200
assert response.body == json.dumps(body)
assert response.headers.get("Content-Type") == "application/json"
for key, value in SECURITY_HEADERS.items():
assert response.headers.get(key) == value


def test_json_response_with_string() -> None:
body = '{"message": "hello"}'
response = JsonResponse(body, status_code=201)
assert response.status_code == 201
assert response.body == body
assert response.headers.get("Content-Type") == "application/json"


if __name__ == "__main__":
main()
6 changes: 4 additions & 2 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

# aws_xray_sdk is not installed in the test environment; stub it out before
# any handler module is imported so that Powertools Tracer initialises cleanly.
# We use unittest.mock.MagicMock here because pytest-mock fixtures are not
# available at module initialization time.
sys.modules.setdefault("aws_xray_sdk", MagicMock())
sys.modules.setdefault("aws_xray_sdk.core", MagicMock())

Expand All @@ -22,8 +24,8 @@ def aws_credentials(monkeypatch):


@fixture
def lambda_context():
ctx = MagicMock(spec=LambdaContext)
def lambda_context(mocker):
ctx = mocker.MagicMock(spec=LambdaContext)
ctx.function_name = "test-function"
ctx.memory_limit_in_mb = 128
ctx.invoked_function_arn = "arn:aws:lambda:us-east-1:123456789012:function:test-function"
Expand Down
11 changes: 6 additions & 5 deletions tests/eventbridge/test_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,26 @@

import sys
from json import dumps
from unittest.mock import MagicMock, patch

from hypothesis import HealthCheck, given
from hypothesis import settings as h_settings
from hypothesis import strategies as st
from pytest import fixture, main, raises
from requests import HTTPError

# Patch SecretsProvider globally so that handler.py can be imported without
# real AWS credentials. Individual tests override the instance via patch.object.
patch("aws_lambda_powertools.utilities.parameters.SecretsProvider", MagicMock()).start()

# Clear any previously cached (broken) handler modules so they re-import cleanly.
# This runs at conftest import time, before any test collection or execution.
for _mod in list(sys.modules):
if _mod.startswith("templates.eventbridge") or _mod == "templates.repository":
sys.modules.pop(_mod, None)


@fixture(autouse=True)
def mock_secrets_provider(mocker) -> None:
"""Patch SecretsProvider globally so that handler.py can be imported without real AWS credentials."""
mocker.patch("aws_lambda_powertools.utilities.parameters.SecretsProvider")


@fixture(autouse=True)
def env(monkeypatch) -> None:
"""Set required environment variables for the handler module."""
Expand Down
22 changes: 22 additions & 0 deletions tests/eventbridge/test_secrets.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from pytest import main
from pytest_mock import MockerFixture

from templates.eventbridge.secrets import SecretManager


def test_secret_manager_get(mocker: MockerFixture) -> None:
# SecretsProvider is mocked globally in test_properties.py, so we patch it locally
# to ensure clean state and verify it's called correctly.
mock_provider_class = mocker.patch("templates.eventbridge.secrets.SecretsProvider")
mock_provider_instance = mock_provider_class.return_value
mock_provider_instance.get.return_value = "test-secret-value"

manager = SecretManager(max_retries=1, max_age=60)
value = manager.get("test-secret")

assert value == "test-secret-value"
mock_provider_instance.get.assert_called_once_with("test-secret", max_age=60)


if __name__ == "__main__":
main()
32 changes: 32 additions & 0 deletions tests/eventbridge/test_session.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
from pytest import main
from pytest_mock import MockerFixture

from templates.eventbridge.session import ApiSession


def test_api_session_get_uses_timeout(mocker: MockerFixture) -> None:
session_mock = mocker.patch("templates.eventbridge.session.Session")
session_instance = session_mock.return_value

mock_response = mocker.MagicMock()
session_instance.get.return_value = mock_response

api_session = ApiSession(timeout=15)
response = api_session.get("http://example.com/api")

assert response == mock_response
session_instance.get.assert_called_once_with("http://example.com/api", timeout=15)


def test_api_session_get_overrides_timeout(mocker: MockerFixture) -> None:
session_mock = mocker.patch("templates.eventbridge.session.Session")
session_instance = session_mock.return_value

api_session = ApiSession(timeout=10)
api_session.get("http://example.com/api", timeout=5)

session_instance.get.assert_called_once_with("http://example.com/api", timeout=5)


if __name__ == "__main__":
main()
59 changes: 52 additions & 7 deletions tests/graphql/test_handler.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
from pytest import fixture, main
from pytest import fixture, main, raises


@fixture(autouse=True)
Expand Down Expand Up @@ -59,12 +59,10 @@ def test_sensitive_data_exposure(repository, lambda_context):


def test_get_item_invalid_id(lambda_context):
import pytest

from templates.graphql.handler import main

event = {"info": {"parentTypeName": "Query", "fieldName": "getItem"}, "arguments": {"id": "invalid!"}}
with pytest.raises(RuntimeError) as excinfo:
with raises(RuntimeError) as excinfo:
main(event, lambda_context)
assert "Invalid item ID" in str(excinfo.value)

Expand All @@ -76,15 +74,62 @@ def test_error_message_information_leakage(lambda_context, mocker):

mocker.patch.object(handler.repository, "get_item", side_effect=Exception("Database connection failed"))

import pytest

with pytest.raises(RuntimeError) as excinfo:
with raises(RuntimeError) as excinfo:
get_item("123")

assert "Database connection failed" not in str(excinfo.value)
assert "Cause:" not in str(excinfo.value)
assert excinfo.value.__cause__ is None


def test_get_item_not_found(repository, lambda_context):
from templates.graphql.handler import get_item

result = get_item("999")
assert result is None


def test_get_item_validation_error(repository, lambda_context, mocker):
from templates.graphql.handler import get_item

repository.put_item({"id": "123", "name": ""}) # MISSING FIELDS

with raises(RuntimeError) as excinfo:
get_item("123")
assert "Item validation failed" in str(excinfo.value)


def test_list_items_exception(repository, lambda_context, mocker):
from templates.graphql import handler
from templates.graphql.handler import list_items

mocker.patch.object(handler.repository, "list_items", side_effect=Exception("Database error"))

with raises(RuntimeError) as excinfo:
list_items()
assert "Failed to list items" in str(excinfo.value)


def test_create_item_validation_error(repository, lambda_context):
from templates.graphql.handler import create_item

# Create item with bad data if possible, though it only takes name.
# If name is empty, it should throw ValidationError.
with raises(RuntimeError) as excinfo:
create_item("")
assert "Invalid item data" in str(excinfo.value)


def test_create_item_exception(repository, lambda_context, mocker):
from templates.graphql import handler
from templates.graphql.handler import create_item

mocker.patch.object(handler.repository, "put_item", side_effect=Exception("Database error"))

with raises(RuntimeError) as excinfo:
create_item("Test Item")
assert "Failed to create item" in str(excinfo.value)


if __name__ == "__main__":
main()
Loading