From 0149e54ee42a398c4500e25f7da1b579501b01eb Mon Sep 17 00:00:00 2001 From: Emanuel Arguinarena Date: Wed, 23 Sep 2026 14:39:43 -0300 Subject: [PATCH 1/3] test: define grouped order handlers and shared layer --- rest-api-dynamodb/tests/test_handlers.py | 76 ++++-- rest-api-dynamodb/tests/test_stack.py | 327 +++++++++++++++++------ 2 files changed, 301 insertions(+), 102 deletions(-) diff --git a/rest-api-dynamodb/tests/test_handlers.py b/rest-api-dynamodb/tests/test_handlers.py index 83578be..edd0d4a 100644 --- a/rest-api-dynamodb/tests/test_handlers.py +++ b/rest-api-dynamodb/tests/test_handlers.py @@ -6,9 +6,12 @@ import pytest -LAMBDA_DIR = Path(__file__).parents[1] / "lambdas" -sys.path.insert(0, str(LAMBDA_DIR)) -pytest.importorskip("lambda_api_decorators", reason="published lambda-api-decorators is not installed") +ROOT = Path(__file__).parents[1] +LAYER_PYTHON_DIR = ROOT / "layers" / "orders" / "python" +pytest.importorskip( + "lambda_api_decorators", + reason="published lambda-api-decorators is not installed", +) class FakeTable: @@ -41,17 +44,27 @@ def Table(self, name): @pytest.fixture def handlers(monkeypatch): + """Expose the layer exactly as AWS does through /opt/python.""" table = FakeTable([{"id": "1", "customer": "Ada", "total": 10}]) monkeypatch.setenv("TABLE_NAME", "Orders-test") monkeypatch.setenv("STAGE", "test") - monkeypatch.setitem(sys.modules, "boto3", SimpleNamespace(resource=lambda service: FakeResource(table))) - for module_name in ("orders", "list_orders", "get_order", "create_order", "update_order", "delete_order"): + monkeypatch.syspath_prepend(str(ROOT)) + monkeypatch.syspath_prepend(str(LAYER_PYTHON_DIR)) + monkeypatch.setitem( + sys.modules, + "boto3", + SimpleNamespace(resource=lambda service: FakeResource(table)), + ) + for module_name in ("orders_shared", "lambdas.orders"): sys.modules.pop(module_name, None) - return table + handlers_module = importlib.import_module("lambdas.orders") + handlers_module.table = lambda: table + return table, handlers_module -def invoke(module_name, event): - result = importlib.import_module(module_name).lambda_handler(event, None) + +def invoke(handlers_module, function_name, event): + result = getattr(handlers_module, function_name)(event, None) assert set(result) == {"statusCode", "headers", "body"} assert result["headers"] == {"Content-Type": "application/json"} assert isinstance(result["statusCode"], int) @@ -63,39 +76,54 @@ def payload(result): return json.loads(result["body"]) -def test_get_orders_returns_200_and_json_list(handlers): - result = invoke("list_orders", {}) +def test_list_orders_returns_200_and_json_list(handlers): + table, module = handlers + result = invoke(module, "list_orders", {}) assert result["statusCode"] == 200 assert payload(result) == [{"id": "1", "customer": "Ada", "total": 10}] + assert table.items["1"]["customer"] == "Ada" @pytest.mark.parametrize("order_id, status", [("1", 200), ("missing", 404)]) -def test_get_order_returns_200_when_present_and_404_when_absent(handlers, order_id, status): - result = invoke("get_order", {"pathParameters": {"id": order_id}}) +def test_get_order_returns_200_when_present_and_404_when_absent( + handlers, order_id, status +): + _table, module = handlers + result = invoke(module, "get_order", {"pathParameters": {"id": order_id}}) assert result["statusCode"] == status -def test_post_requires_id_stores_object_and_returns_201(handlers): +def test_create_order_requires_id_stores_object_and_returns_201(handlers): + table, module = handlers order = {"id": "2", "customer": "Lin"} - result = invoke("create_order", {"body": json.dumps(order)}) + result = invoke(module, "create_order", {"body": json.dumps(order)}) assert result["statusCode"] == 201 assert payload(result) == order - assert handlers.items["2"] == order + assert table.items["2"] == order -def test_put_uses_path_id_updates_and_returns_200(handlers): - result = invoke("update_order", {"pathParameters": {"id": "1"}, "body": json.dumps({"customer": "Grace"})}) +def test_update_order_uses_path_id_updates_and_returns_200(handlers): + table, module = handlers + result = invoke( + module, + "update_order", + {"pathParameters": {"id": "1"}, "body": json.dumps({"customer": "Grace"})}, + ) assert result["statusCode"] == 200 assert payload(result) == {"id": "1", "customer": "Grace"} - assert handlers.items["1"] == {"id": "1", "customer": "Grace"} + assert table.items["1"] == {"id": "1", "customer": "Grace"} -def test_delete_removes_order_and_returns_204(handlers): - result = invoke("delete_order", {"pathParameters": {"id": "1"}}) +def test_delete_order_removes_order_and_returns_204(handlers): + table, module = handlers + result = invoke(module, "delete_order", {"pathParameters": {"id": "1"}}) assert result["statusCode"] == 204 - assert "1" not in handlers.items + assert "1" not in table.items -@pytest.mark.parametrize("event", [{"body": "not-json"}, {"body": json.dumps({"customer": "Missing id"})}]) -def test_post_invalid_json_or_missing_id_returns_400(handlers, event): - assert invoke("create_order", event)["statusCode"] == 400 +@pytest.mark.parametrize( + "event", [{"body": "not-json"}, {"body": json.dumps({"customer": "Missing id"})}] +) +def test_create_order_invalid_json_or_missing_id_returns_400(handlers, event): + _table, module = handlers + assert invoke(module, "create_order", event)["statusCode"] == 400 diff --git a/rest-api-dynamodb/tests/test_stack.py b/rest-api-dynamodb/tests/test_stack.py index 6e7e324..5d7368b 100644 --- a/rest-api-dynamodb/tests/test_stack.py +++ b/rest-api-dynamodb/tests/test_stack.py @@ -3,7 +3,6 @@ import os import shutil import subprocess -import sys from pathlib import Path import pytest @@ -12,17 +11,94 @@ pytest.importorskip("aws_cdk", reason="aws-cdk-lib is not installed") pytest.importorskip("constructs", reason="constructs is not installed") -pytest.importorskip("lambda_api_decorators_cdk", reason="published lambda-api-decorators-cdk is not installed") +pytest.importorskip( + "lambda_api_decorators_cdk", + reason="published lambda-api-decorators-cdk is not installed", +) import aws_cdk as cdk from aws_cdk.assertions import Template + ROOT = Path(__file__).parents[1] -sys.path.insert(0, str(ROOT)) +LAMBDA_DIR = ROOT / "lambdas" +LAYER_DIR = ROOT / "layers" / "orders" +LAYER_PYTHON_DIR = LAYER_DIR / "python" DOCKER_AVAILABLE = bool(shutil.which("docker")) and subprocess.run( - ["docker", "info"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False + ["docker", "info"], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=False, ).returncode == 0 +HTTP_DECORATORS = {"GET", "POST", "PUT", "DELETE", "ANY"} +EXPECTED_ROUTES = { + "list_orders": ("GET", "/orders"), + "get_order": ("GET", "/orders/{id}"), + "create_order": ("POST", "/orders"), + "update_order": ("PUT", "/orders/{id}"), + "delete_order": ("DELETE", "/orders/{id}"), +} +EXPECTED_HELPERS = { + "JSON_HEADERS", + "REQUIRED_FIELDS", + "table", + "response", + "request_json", + "order_id", + "missing_fields", +} + + +def parse(path): + return ast.parse(path.read_text(), filename=str(path)) + + +def call_name(node): + if isinstance(node, ast.Name): + return node.id + return None + + +def decorator_calls(function): + return [ + decorator + for decorator in function.decorator_list + if isinstance(decorator, ast.Call) + ] + + +def route_decorators(function): + return [ + decorator + for decorator in decorator_calls(function) + if call_name(decorator.func) in HTTP_DECORATORS + ] + + +def layer_decorators(function): + return [ + decorator + for decorator in decorator_calls(function) + if call_name(decorator.func) == "layer" + ] + + +def handler_functions(tree): + return { + node.name: node + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + + +def decorator_signature(node): + return ( + call_name(node.func), + tuple(argument.value for argument in node.args), + tuple((keyword.arg, keyword.value.value) for keyword in node.keywords), + ) + def synthesized(monkeypatch): if not DOCKER_AVAILABLE: @@ -33,35 +109,181 @@ def synthesized(monkeypatch): return Template.from_stack(RestApiDynamodbStack(cdk.App(), "TestRestApiDynamodbStack")) -def test_stack_has_real_table_rest_api_five_lambdas_and_destroy_policy(monkeypatch): +def test_lambdas_are_grouped_in_one_orders_module(): + assert sorted(path.name for path in LAMBDA_DIR.glob("*.py") if path.name != "__init__.py") == [ + "orders.py" + ] + assert (LAMBDA_DIR / "orders.py").is_file() + for old_name in ( + "create_order.py", + "delete_order.py", + "get_order.py", + "list_orders.py", + "update_order.py", + ): + assert not (LAMBDA_DIR / old_name).exists() + + +def test_layer_uses_python_import_root_and_contains_only_shared_helpers(): + source = LAYER_PYTHON_DIR / "orders_shared.py" + assert source.is_file() + assert (LAYER_DIR / "requirements.txt").is_file() + + tree = parse(source) + names = { + node.name + for node in tree.body + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + } + names.update( + target.id + for node in tree.body + if isinstance(node, ast.Assign) + for target in node.targets + if isinstance(target, ast.Name) + ) + assert names == EXPECTED_HELPERS + + assert not any( + isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) + and node.name in EXPECTED_ROUTES + for node in tree.body + ) + assert not any( + call_name(node.func) in HTTP_DECORATORS + for node in ast.walk(tree) + if isinstance(node, ast.Call) + ) + + +def test_orders_module_has_five_handlers_one_route_and_one_registered_layer(): + source = LAMBDA_DIR / "orders.py" + tree = parse(source) + functions = handler_functions(tree) + assert set(functions) == set(EXPECTED_ROUTES) + + for name, (method, path) in EXPECTED_ROUTES.items(): + routes = route_decorators(functions[name]) + assert len(routes) == 1 + assert decorator_signature(routes[0]) == (method, (path,), ()) + layers = layer_decorators(functions[name]) + assert len(layers) == 1 + assert decorator_signature(layers[0]) == ("layer", ("orders",), ()) + + +def test_orders_module_imports_helpers_from_layer(): + tree = parse(LAMBDA_DIR / "orders.py") + imports = [node for node in tree.body if isinstance(node, ast.ImportFrom)] + assert any( + node.module == "orders_shared" + and {alias.name for alias in node.names} + >= {"order_id", "request_json", "response", "table"} + for node in imports + ) + + +def test_stack_declares_layer_path_and_the_grouped_module(): + source = (ROOT / "rest_api_dynamodb" / "rest_api_dynamodb_stack.py").read_text() + assert "lambda_path=\"lambdas\"" in source + assert "layers_path=\"layers\"" in source + + +def test_post_decorator_contract_is_exact_except_for_layer_selection(): + tree = parse(LAMBDA_DIR / "orders.py") + handler = handler_functions(tree)["create_order"] + actual = [ + decorator_signature(decorator) + for decorator in decorator_calls(handler) + if call_name(decorator.func) != "layer" + ] + assert actual == [ + ("POST", ("/orders",), ()), + ("grant_dynamodb", ("orders", "write"), ()), + ("memory_size", (1024,), ()), + ("timeout", (15,), ()), + ("environment", ("STAGE", "TABLE_NAME"), ()), + ("runtime", ("python3.12",), ()), + ("role", ("api-role",), ()), + ("description", ("Configured endpoint",), ()), + ("name", ("configured-handler",), ()), + ] + + +def test_stack_has_real_table_rest_api_five_lambdas_layer_and_destroy_policy(monkeypatch): template = synthesized(monkeypatch) template.resource_count_is("AWS::ApiGateway::RestApi", 1) template.resource_count_is("AWS::DynamoDB::Table", 1) template.resource_count_is("AWS::Lambda::Function", 5) - template.has_resource_properties("AWS::DynamoDB::Table", { - "BillingMode": "PAY_PER_REQUEST", - "AttributeDefinitions": [{"AttributeName": "id", "AttributeType": "S"}], - "KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}], - }) - template.has_resource("AWS::DynamoDB::Table", {"DeletionPolicy": "Delete", "UpdateReplacePolicy": "Delete"}) + template.resource_count_is("AWS::Lambda::LayerVersion", 1) + template.has_resource_properties( + "AWS::DynamoDB::Table", + { + "BillingMode": "PAY_PER_REQUEST", + "AttributeDefinitions": [{"AttributeName": "id", "AttributeType": "S"}], + "KeySchema": [{"AttributeName": "id", "KeyType": "HASH"}], + }, + ) + template.has_resource( + "AWS::DynamoDB::Table", + {"DeletionPolicy": "Delete", "UpdateReplacePolicy": "Delete"}, + ) -def test_template_contains_the_five_required_methods_and_paths(monkeypatch): +def test_every_lambda_references_the_orders_layer(monkeypatch): + template = synthesized(monkeypatch) + layer_id = next(iter(template.find_resources("AWS::Lambda::LayerVersion"))) + functions = template.find_resources("AWS::Lambda::Function") + assert len(functions) == 5 + assert all( + layer_id in json.dumps(resource["Properties"].get("Layers", [])) + for resource in functions.values() + ) + + +def test_template_contains_five_methods_and_expected_paths(monkeypatch): template = synthesized(monkeypatch) methods = template.find_resources("AWS::ApiGateway::Method") assert len(methods) == 5 - assert {method["Properties"]["HttpMethod"] for method in methods.values()} == {"GET", "POST", "PUT", "DELETE"} + assert {method["Properties"]["HttpMethod"] for method in methods.values()} == { + "GET", + "POST", + "PUT", + "DELETE", + } resources = template.find_resources("AWS::ApiGateway::Resource") - assert {resource["Properties"].get("PathPart") for resource in resources.values()} >= {"orders", "{id}"} + assert {resource["Properties"].get("PathPart") for resource in resources.values()} >= { + "orders", + "{id}", + } + + +def test_five_lambda_handlers_are_distinct_functions_in_orders_module(monkeypatch): + template = synthesized(monkeypatch) + handlers = { + resource["Properties"]["Handler"] + for resource in template.find_resources("AWS::Lambda::Function").values() + } + assert handlers == { + "orders.list_orders", + "orders.get_order", + "orders.create_order", + "orders.update_order", + "orders.delete_order", + } def test_default_and_post_lambda_configuration(monkeypatch): template = synthesized(monkeypatch) functions = template.find_resources("AWS::Lambda::Function") assert sum(resource["Properties"].get("Runtime") == "python3.14" for resource in functions.values()) == 4 - configured = [resource for resource in functions.values() if resource["Properties"].get("FunctionName") == "configured-handler"] + configured = [ + resource + for resource in functions.values() + if resource["Properties"].get("FunctionName") == "configured-handler" + ] assert len(configured) == 1 properties = configured[0]["Properties"] + assert properties["Handler"] == "orders.create_order" assert properties["Runtime"] == "python3.12" assert properties["MemorySize"] == 1024 assert properties["Timeout"] == 15 @@ -84,74 +306,23 @@ def test_dynamodb_permissions_reference_the_table(monkeypatch): assert all(table_logical_id in json.dumps(statement["Resource"]) for statement in statements) -def test_each_handler_has_exactly_one_http_decorator(): - http_names = {"GET", "POST", "PUT", "DELETE", "ANY"} - for source in (ROOT / "lambdas").glob("*.py"): - if source.name.startswith("__") or source.name == "orders.py": - continue - tree = ast.parse(source.read_text(), filename=str(source)) - handlers = [node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name == "lambda_handler"] - assert len(handlers) == 1, source - decorators = [decorator for decorator in handlers[0].decorator_list if isinstance(decorator, ast.Call) and isinstance(decorator.func, ast.Name) and decorator.func.id in http_names] - assert len(decorators) == 1, source - - -def test_post_decorator_contract_is_exact(): - tree = ast.parse((ROOT / "lambdas" / "create_order.py").read_text()) - handler = next(node for node in ast.walk(tree) if isinstance(node, ast.FunctionDef) and node.name == "lambda_handler") - def decorator_name(node): - parts = [] - while isinstance(node, ast.Attribute): - parts.append(node.attr) - node = node.value - assert isinstance(node, ast.Name) - parts.append(node.id) - return ".".join(reversed(parts)) - - def constant_value(node): - assert isinstance(node, ast.Constant) - return node.value - - def signature(node): - assert isinstance(node, ast.Call) - return ( - decorator_name(node.func), - tuple(constant_value(argument) for argument in node.args), - tuple((keyword.arg, constant_value(keyword.value)) for keyword in node.keywords), - ) - - actual = [signature(decorator) for decorator in handler.decorator_list] - assert actual == [ - ("POST", ("/orders",), ()), - ("grant_dynamodb", ("orders", "write"), ()), - ("memory_size", (1024,), ()), - ("timeout", (15,), ()), - ("environment", ("STAGE", "TABLE_NAME"), ()), - ("runtime", ("python3.12",), ()), - ("role", ("api-role",), ()), - ("description", ("Configured endpoint",), ()), - ("name", ("configured-handler",), ()), - ] - - def test_stack_uses_named_registries_and_no_shared_default_role(): source = (ROOT / "rest_api_dynamodb" / "rest_api_dynamodb_stack.py").read_text() - for name in ("default_runtime", "common_environment", "environment_registry", "role_registry", "dynamodb_table_registry"): + for name in ( + "default_runtime", + "common_environment", + "environment_registry", + "role_registry", + "dynamodb_table_registry", + ): assert name in source assert "default_role" not in source -def test_requirements_are_pinned_and_do_not_use_git_path_or_editable(): - cdk_requirements = (ROOT / "requirements.txt").read_text().splitlines() - lambda_requirements = (ROOT / "lambdas" / "requirements.txt").read_text().splitlines() - assert "aws-cdk-lib" in "\n".join(cdk_requirements) - assert "constructs" in "\n".join(cdk_requirements) - assert "lambda-api-decorators-cdk==0.4.4" in cdk_requirements - assert "lambda-api-decorators" not in cdk_requirements - assert "lambda-api-decorators==0.3.2" in lambda_requirements - assert not any(any(token in line for token in ("git+", "-e ", "file:", "path:")) for line in cdk_requirements + lambda_requirements) - - def test_no_generated_artifacts_are_tracked(): tracked = subprocess.check_output(["git", "ls-files", str(ROOT)], text=True).splitlines() - assert not [path for path in tracked if any(part in path.split("/") for part in ("cdk.out", "__pycache__", ".pytest_cache"))] + assert not [ + path + for path in tracked + if any(part in path.split("/") for part in ("cdk.out", "__pycache__", ".pytest_cache")) + ] From 634afac35f2bc29e24c0277ddc22ca5a763e9e21 Mon Sep 17 00:00:00 2001 From: Emanuel Arguinarena Date: Wed, 23 Sep 2026 14:45:07 -0300 Subject: [PATCH 2/3] refactor: group order handlers and extract shared layer --- rest-api-dynamodb/README.md | 33 +++++- rest-api-dynamodb/lambdas/create_order.py | 32 ------ rest-api-dynamodb/lambdas/delete_order.py | 15 --- rest-api-dynamodb/lambdas/get_order.py | 13 --- rest-api-dynamodb/lambdas/list_orders.py | 11 -- rest-api-dynamodb/lambdas/orders.py | 103 +++++++++++++----- rest-api-dynamodb/lambdas/update_order.py | 19 ---- .../layers/orders/python/orders_shared.py | 38 +++++++ .../layers/orders/requirements.txt | 1 + .../rest_api_dynamodb_stack.py | 8 +- 10 files changed, 152 insertions(+), 121 deletions(-) delete mode 100644 rest-api-dynamodb/lambdas/create_order.py delete mode 100644 rest-api-dynamodb/lambdas/delete_order.py delete mode 100644 rest-api-dynamodb/lambdas/get_order.py delete mode 100644 rest-api-dynamodb/lambdas/list_orders.py delete mode 100644 rest-api-dynamodb/lambdas/update_order.py create mode 100644 rest-api-dynamodb/layers/orders/python/orders_shared.py create mode 100644 rest-api-dynamodb/layers/orders/requirements.txt diff --git a/rest-api-dynamodb/README.md b/rest-api-dynamodb/README.md index 4d1300c..e91f722 100644 --- a/rest-api-dynamodb/README.md +++ b/rest-api-dynamodb/README.md @@ -1,12 +1,15 @@ # REST API + DynamoDB -Independent AWS CDK Python example that builds a REST API with five independent -Lambda handlers backed by a real DynamoDB table named `Orders`. +Independent AWS CDK Python example that builds a REST API backed by a real +DynamoDB table named `Orders`. The five functions are grouped by resource in +`lambdas/orders.py`, while shared runtime code is provided by the `orders` +AWS Lambda Layer. ## Architecture The stack contains one API Gateway REST API, one on-demand DynamoDB table with -string partition key `id`, and one Lambda per route: +string partition key `id`, and five independent Lambda functions. Each +function declares exactly one route: | Method | Path | Access | | --- | --- | --- | @@ -16,6 +19,30 @@ string partition key `id`, and one Lambda per route: | PUT | `/orders/{id}` | DynamoDB write | | DELETE | `/orders/{id}` | DynamoDB write | +The handlers import `orders_shared` from the `orders` Layer through the +standard Lambda `/opt/python` import root. `@layer("orders")` associates every +function with that Layer; the Layer does not grant DynamoDB permissions. + +## Project layout + +```text +rest-api-dynamodb/ +├── lambdas/ +│ ├── __init__.py +│ ├── orders.py +│ └── requirements.txt +└── layers/ + └── orders/ + ├── requirements.txt + └── python/ + └── orders_shared.py +``` + +`lambdas/orders.py` contains `list_orders`, `get_order`, `create_order`, +`update_order`, and `delete_order`. The CDK application passes both +`lambda_path="lambdas"` and `layers_path="layers"` to `LambdaApi`, so the +published Layer directory is discovered and attached to each function. + The POST function demonstrates named configuration with Python 3.12, 1024 MB, 15 seconds, `STAGE` and `TABLE_NAME`, the mutable `api-role`, and the function name `configured-handler`. Other functions use independent CDK-created roles diff --git a/rest-api-dynamodb/lambdas/create_order.py b/rest-api-dynamodb/lambdas/create_order.py deleted file mode 100644 index b30f759..0000000 --- a/rest-api-dynamodb/lambdas/create_order.py +++ /dev/null @@ -1,32 +0,0 @@ -from lambda_api_decorators import ( - POST, - description, - environment, - grant_dynamodb, - memory_size, - name, - role, - runtime, - timeout, -) - -from orders import request_json, response, table - - -@POST("/orders") -@grant_dynamodb("orders", "write") -@memory_size(1024) -@timeout(15) -@environment("STAGE", "TABLE_NAME") -@runtime("python3.12") -@role("api-role") -@description("Configured endpoint") -@name("configured-handler") -def lambda_handler(event, context): - order, error = request_json(event) - if error: - return error - if "id" not in order: - return response(400, {"error": "Missing required fields", "fields": ["id"]}) - table().put_item(Item=order, ConditionExpression="attribute_not_exists(id)") - return response(201, order) diff --git a/rest-api-dynamodb/lambdas/delete_order.py b/rest-api-dynamodb/lambdas/delete_order.py deleted file mode 100644 index 61878b5..0000000 --- a/rest-api-dynamodb/lambdas/delete_order.py +++ /dev/null @@ -1,15 +0,0 @@ -from lambda_api_decorators import DELETE, environment, grant_dynamodb - -from orders import order_id, response, table - - -@DELETE("/orders/{id}") -@grant_dynamodb("orders", "write") -@environment("TABLE_NAME") -def lambda_handler(event, context): - deleted = table().delete_item( - Key={"id": order_id(event)}, ReturnValues="ALL_OLD" - ).get("Attributes") - if deleted is None: - return response(404, {"error": "Order not found"}) - return response(204, None) diff --git a/rest-api-dynamodb/lambdas/get_order.py b/rest-api-dynamodb/lambdas/get_order.py deleted file mode 100644 index ee11835..0000000 --- a/rest-api-dynamodb/lambdas/get_order.py +++ /dev/null @@ -1,13 +0,0 @@ -from lambda_api_decorators import GET, environment, grant_dynamodb - -from orders import order_id, response, table - - -@GET("/orders/{id}") -@grant_dynamodb("orders", "read") -@environment("TABLE_NAME") -def lambda_handler(event, context): - item = table().get_item(Key={"id": order_id(event)}).get("Item") - if item is None: - return response(404, {"error": "Order not found"}) - return response(200, item) diff --git a/rest-api-dynamodb/lambdas/list_orders.py b/rest-api-dynamodb/lambdas/list_orders.py deleted file mode 100644 index 2358050..0000000 --- a/rest-api-dynamodb/lambdas/list_orders.py +++ /dev/null @@ -1,11 +0,0 @@ -from lambda_api_decorators import GET, environment, grant_dynamodb - -from orders import response, table - - -@GET("/orders") -@grant_dynamodb("orders", "read") -@environment("TABLE_NAME") -def lambda_handler(event, context): - items = table().scan().get("Items", []) - return response(200, items) diff --git a/rest-api-dynamodb/lambdas/orders.py b/rest-api-dynamodb/lambdas/orders.py index aa93b97..32102a3 100644 --- a/rest-api-dynamodb/lambdas/orders.py +++ b/rest-api-dynamodb/lambdas/orders.py @@ -1,38 +1,87 @@ -import json -import os +from lambda_api_decorators import ( + DELETE, + GET, + POST, + PUT, + description, + environment, + grant_dynamodb, + layer, + memory_size, + name, + role, + runtime, + timeout, +) -import boto3 +from orders_shared import order_id, request_json, response, table -JSON_HEADERS = {"Content-Type": "application/json"} -REQUIRED_FIELDS = ("customer", "total", "status") +@GET("/orders") +@layer("orders") +@grant_dynamodb("orders", "read") +@environment("TABLE_NAME") +def list_orders(event, context): + items = table().scan().get("Items", []) + return response(200, items) -def table(): - return boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"]) +@GET("/orders/{id}") +@layer("orders") +@grant_dynamodb("orders", "read") +@environment("TABLE_NAME") +def get_order(event, context): + item = table().get_item(Key={"id": order_id(event)}).get("Item") + if item is None: + return response(404, {"error": "Order not found"}) + return response(200, item) -def response(status_code, payload): - return { - "statusCode": status_code, - "headers": JSON_HEADERS, - "body": "" if status_code == 204 else json.dumps(payload), - } +@POST("/orders") +@layer("orders") +@grant_dynamodb("orders", "write") +@memory_size(1024) +@timeout(15) +@environment("STAGE", "TABLE_NAME") +@runtime("python3.12") +@role("api-role") +@description("Configured endpoint") +@name("configured-handler") +def create_order(event, context): + order, error = request_json(event) + if error: + return error + if "id" not in order: + return response(400, {"error": "Missing required fields", "fields": ["id"]}) + table().put_item(Item=order, ConditionExpression="attribute_not_exists(id)") + return response(201, order) -def request_json(event): - try: - body = json.loads(event.get("body") or "") - except (TypeError, json.JSONDecodeError): - return None, response(400, {"error": "Invalid JSON body"}) - if not isinstance(body, dict): - return None, response(400, {"error": "JSON body must be an object"}) - return body, None +@PUT("/orders/{id}") +@layer("orders") +@grant_dynamodb("orders", "write") +@environment("TABLE_NAME") +def update_order(event, context): + identifier = order_id(event) + order, error = request_json(event) + if error: + return error + existing = table().get_item(Key={"id": identifier}).get("Item") + if existing is None: + return response(404, {"error": "Order not found"}) + updated = {"id": identifier, **order} + table().put_item(Item=updated) + return response(200, updated) -def order_id(event): - return (event.get("pathParameters") or {}).get("id") - - -def missing_fields(order): - return [field for field in REQUIRED_FIELDS if field not in order] +@DELETE("/orders/{id}") +@layer("orders") +@grant_dynamodb("orders", "write") +@environment("TABLE_NAME") +def delete_order(event, context): + deleted = table().delete_item( + Key={"id": order_id(event)}, ReturnValues="ALL_OLD" + ).get("Attributes") + if deleted is None: + return response(404, {"error": "Order not found"}) + return response(204, None) diff --git a/rest-api-dynamodb/lambdas/update_order.py b/rest-api-dynamodb/lambdas/update_order.py deleted file mode 100644 index d38d4f7..0000000 --- a/rest-api-dynamodb/lambdas/update_order.py +++ /dev/null @@ -1,19 +0,0 @@ -from lambda_api_decorators import PUT, environment, grant_dynamodb - -from orders import order_id, request_json, response, table - - -@PUT("/orders/{id}") -@grant_dynamodb("orders", "write") -@environment("TABLE_NAME") -def lambda_handler(event, context): - identifier = order_id(event) - order, error = request_json(event) - if error: - return error - existing = table().get_item(Key={"id": identifier}).get("Item") - if existing is None: - return response(404, {"error": "Order not found"}) - updated = {"id": identifier, **order} - table().put_item(Item=updated) - return response(200, updated) diff --git a/rest-api-dynamodb/layers/orders/python/orders_shared.py b/rest-api-dynamodb/layers/orders/python/orders_shared.py new file mode 100644 index 0000000..aa93b97 --- /dev/null +++ b/rest-api-dynamodb/layers/orders/python/orders_shared.py @@ -0,0 +1,38 @@ +import json +import os + +import boto3 + + +JSON_HEADERS = {"Content-Type": "application/json"} +REQUIRED_FIELDS = ("customer", "total", "status") + + +def table(): + return boto3.resource("dynamodb").Table(os.environ["TABLE_NAME"]) + + +def response(status_code, payload): + return { + "statusCode": status_code, + "headers": JSON_HEADERS, + "body": "" if status_code == 204 else json.dumps(payload), + } + + +def request_json(event): + try: + body = json.loads(event.get("body") or "") + except (TypeError, json.JSONDecodeError): + return None, response(400, {"error": "Invalid JSON body"}) + if not isinstance(body, dict): + return None, response(400, {"error": "JSON body must be an object"}) + return body, None + + +def order_id(event): + return (event.get("pathParameters") or {}).get("id") + + +def missing_fields(order): + return [field for field in REQUIRED_FIELDS if field not in order] diff --git a/rest-api-dynamodb/layers/orders/requirements.txt b/rest-api-dynamodb/layers/orders/requirements.txt new file mode 100644 index 0000000..e50e4d4 --- /dev/null +++ b/rest-api-dynamodb/layers/orders/requirements.txt @@ -0,0 +1 @@ +# The AWS Lambda Python runtime provides boto3. diff --git a/rest-api-dynamodb/rest_api_dynamodb/rest_api_dynamodb_stack.py b/rest-api-dynamodb/rest_api_dynamodb/rest_api_dynamodb_stack.py index 5a1adf4..186bc7b 100644 --- a/rest-api-dynamodb/rest_api_dynamodb/rest_api_dynamodb_stack.py +++ b/rest-api-dynamodb/rest_api_dynamodb/rest_api_dynamodb_stack.py @@ -43,5 +43,11 @@ def __init__(self, scope: Construct, construct_id: str, **kwargs) -> None: dynamodb_table_registry={"orders": table}, ) - api = LambdaApi(self, "Api", lambda_path="lambdas", config=config) + api = LambdaApi( + self, + "Api", + lambda_path="lambdas", + layers_path="layers", + config=config, + ) CfnOutput(self, "ApiUrl", value=api.api.url) From 54322a15472ef12c9f1154fc03397a9053026b59 Mon Sep 17 00:00:00 2001 From: Emanuel Arguinarena Date: Wed, 23 Sep 2026 15:57:39 -0300 Subject: [PATCH 3/3] test: make CDK stack imports explicit --- rest-api-dynamodb/tests/test_stack.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/rest-api-dynamodb/tests/test_stack.py b/rest-api-dynamodb/tests/test_stack.py index 5d7368b..5cf975e 100644 --- a/rest-api-dynamodb/tests/test_stack.py +++ b/rest-api-dynamodb/tests/test_stack.py @@ -3,6 +3,7 @@ import os import shutil import subprocess +import sys from pathlib import Path import pytest @@ -103,6 +104,7 @@ def decorator_signature(node): def synthesized(monkeypatch): if not DOCKER_AVAILABLE: pytest.skip("Docker is unavailable for CDK PythonFunction bundling") + monkeypatch.syspath_prepend(str(ROOT)) monkeypatch.chdir(ROOT) from rest_api_dynamodb.rest_api_dynamodb_stack import RestApiDynamodbStack