Build a CRUD REST API on AWS and iterate it on your laptop
You are a backend engineer on a small team. The product manager wants a notes feature: users write short notes, the app stores them, lists them, edits them, deletes them. Boring shape. Your team's pattern for any new endpoint is the same standard AWS serverless stack: API Gateway HTTP API in front, one Lambda behind, DynamoDB underneath. You have written this exact shape three times before.
What slows you down is not writing the handler. It is the iteration
cycle. Every time you touch a line, you wait for
terraform apply to
push a new Lambda zip, for API Gateway to redeploy the stage, for
IAM to propagate, and then you curl your sandbox account to see
whether the route wired up the way you expected. Roughly two
minutes per change. Multiply by fifty changes in a morning of
active development and most of the morning is gone.
This tutorial builds the same notes service end to end on LocalEmu:
13 Terraform resources, one handler file, three integration tests,
no AWS account. Deploy in 25 seconds, hit a real endpoint with curl
in milliseconds, tear it back down in 10. The same Terraform and
the same Lambda zip apply to real AWS by passing
aws instead of
local to the
deploy script. The handler code does not change.
What you will have working at the end
Three scenarios, all running locally, all returning the responses captured later on this page verbatim.
- A. Full CRUD lifecycle on one note. POST returns 201 with the created note, GET returns it back, PATCH returns the updated row, DELETE returns 204, and the next GET returns 404.
- B. POST without a title is rejected at 400
with
{"error": "title is required"}. Validation runs in the handler before any DynamoDB call. - C. GET a nonexistent note returns 404
with
{"error": "not found"}. The same shape comes back from PATCH and DELETE on a missing note, driven by a DynamoDBConditionExpressionrather than a manual existence check.
Architecture
client ─HTTP─▶ API Gateway v2 (HTTP API) │ 5 routes ▼ Lambda: le-notes-api (Python 3.12, stdlib + boto3) │ ▼ DynamoDB: le-notes-notes (hash_key = note_id) POST /notes create GET /notes list GET /notes/{id} read PATCH /notes/{id} partial update DELETE /notes/{id} delete
1. The handler
The handler is one Python file, no web framework. API Gateway HTTP
API v2 sends a Lambda proxy v2.0 payload whose
routeKey is one of
"POST /notes",
"GET /notes",
"GET /notes/{id}",
"PATCH /notes/{id}",
or "DELETE /notes/{id}".
The router is five
if lines plus a
404 fallback. The response shape is the standard Lambda proxy dict.
# src/handler.py: one file, five routes, no framework.
TABLE_NAME = os.environ["TABLE_NAME"]
_table = boto3.resource("dynamodb").Table(TABLE_NAME)
def _json_default(v):
"""DynamoDB returns numbers as Decimal; coerce them where we serialize."""
if isinstance(v, Decimal):
return int(v) if v == v.to_integral_value() else float(v)
return str(v)
def _resp(status: int, body) -> dict:
return {
"statusCode": status,
"headers": {"content-type": "application/json"},
"body": json.dumps(body, default=_json_default),
}
def handle(event, _ctx):
# API Gateway HTTP API v2 puts the matched route in event["routeKey"].
# Five lines of routing, no framework needed.
route = event.get("routeKey", "")
try:
if route == "POST /notes": return _create_note(event)
if route == "GET /notes": return _list_notes()
if route == "GET /notes/{id}": return _get_note(event)
if route == "PATCH /notes/{id}": return _update_note(event)
if route == "DELETE /notes/{id}": return _delete_note(event)
return _resp(404, {"error": "route not found", "route": route})
except ClientError as e:
return _resp(500, {"error": str(e)})
Two details in this snippet pay off later. First,
boto3 reads
AWS_ENDPOINT_URL
from the environment automatically, and LocalEmu sets that variable
in every Lambda container it spawns. The handler has no idea it is
running locally: no conditional, no endpoint override. The same
Lambda zip runs against LocalEmu and against real AWS.
Second, the _json_default
helper. DynamoDB's resource API returns every number as a
Decimal, and
json.dumps cannot
serialize that without help. Coercing to
int or
float at the edge
keeps created_at
a number, not a string, so the test on the other side can assert
isinstance(r.json()["created_at"], int)
and catch a regression the day someone reaches for
default=str.
The two routes worth showing in full are
_create_note and
_update_note: one
contains the only input validation, the other contains the only
non-trivial DynamoDB call:
# Same file: create and update, where the interesting work lives.
def _create_note(event):
body = json.loads(event.get("body") or "{}")
title = (body.get("title") or "").strip()
if not title:
return _resp(400, {"error": "title is required"})
note_id = uuid.uuid4().hex[:10]
item = {"note_id": note_id, "title": title,
"content": body.get("content", ""),
"created_at": int(time.time())}
_table.put_item(Item=item)
return _resp(201, item)
def _update_note(event):
note_id = event["pathParameters"]["id"]
body = json.loads(event.get("body") or "{}")
updates = {k: v for k, v in body.items() if k in ("title", "content")}
if not updates:
return _resp(400, {"error": "nothing to update"})
# Build the UpdateExpression dynamically from whichever fields were sent.
expr_parts = ["#u = :ts"]
values = {":ts": int(time.time())}
names = {"#u": "updated_at"}
for k, v in updates.items():
expr_parts.append(f"#{k} = :{k}")
values[f":{k}"] = v
names[f"#{k}"] = k
try:
resp = _table.update_item(
Key={"note_id": note_id},
UpdateExpression="SET " + ", ".join(expr_parts),
ExpressionAttributeValues=values,
ExpressionAttributeNames=names,
# Reject updates to notes that do not exist; map to HTTP 404 below.
ConditionExpression="attribute_exists(note_id)",
ReturnValues="ALL_NEW")
except ClientError as e:
if e.response["Error"]["Code"] == "ConditionalCheckFailedException":
return _resp(404, {"error": "not found"})
raise
return _resp(200, resp.get("Attributes", {}))
Three lines worth noticing in
_update_note.
The UpdateExpression
is built dynamically from whichever fields the client sent, so
PATCH supports partial updates without an explicit per-field
branch. The ConditionExpression="attribute_exists(note_id)"
makes DynamoDB itself reject updates to missing notes; the resulting
ConditionalCheckFailedException
is mapped to an HTTP 404. This is the same code path that produces
scenario C: no separate existence check, no race window between
"the row was there" and "we wrote to it".
2. The infrastructure: 13 resources, one provider, two targets
The Terraform root has 13 resources: one DynamoDB table, one Lambda, an IAM role and inline policy, an API Gateway HTTP API, one integration, five routes, the default stage, and a Lambda permission letting API Gateway invoke the function. The resource blocks themselves know nothing about LocalEmu. The only difference between "deploying on LocalEmu" and "deploying to real AWS" lives in the provider block:
# terraform/main.tf: one provider block, two targets.
# When target=local the endpoints map is emitted and points at LocalEmu.
# When target=aws the endpoints map is absent and the provider behaves
# exactly like the normal hashicorp/aws provider against real AWS.
locals {
is_local = var.target == "local"
endpoint = "http://localhost:4566"
}
provider "aws" {
region = "us-east-1"
access_key = local.is_local ? "AKIAIOSFODNN7EXAMPLE" : null
secret_key = local.is_local ? "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" : null
skip_credentials_validation = local.is_local
skip_metadata_api_check = local.is_local
skip_requesting_account_id = local.is_local
dynamic "endpoints" {
for_each = local.is_local ? [1] : []
content {
apigateway = local.endpoint
apigatewayv2 = local.endpoint
dynamodb = local.endpoint
iam = local.endpoint
lambda = local.endpoint
sts = local.endpoint
logs = local.endpoint
}
}
}
When target=local
the dynamic endpoints
block is emitted and points every service at
http://localhost:4566.
When target=aws
the block is absent and the provider behaves like the normal
hashicorp/aws
provider against real AWS: it reads credentials from your
environment or shared config, validates them, and routes to AWS's
real endpoints. One variable, no forked files.
The five routes are declared once with
for_each over a
local map, so adding a sixth route is one line:
# terraform/main.tf: the five routes, one for_each.
locals {
routes = {
post_note = "POST /notes"
list_notes = "GET /notes"
get_note = "GET /notes/{id}"
update_note = "PATCH /notes/{id}"
delete_note = "DELETE /notes/{id}"
}
}
resource "aws_apigatewayv2_route" "r" {
for_each = local.routes
api_id = aws_apigatewayv2_api.api.id
route_key = each.value
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
The Lambda's IAM role has the usual logs permissions plus the four
DynamoDB actions the handler uses
(GetItem,
PutItem,
UpdateItem,
DeleteItem) plus
Scan for the list
endpoint. LocalEmu evaluates these policies the same way real AWS
does: removing a permission from the role and re-deploying will
make the corresponding handler return a 500 with a
AccessDeniedException,
which is the same failure mode you would see on real AWS.
3. Run the three scenarios on your laptop
Clone the project, start LocalEmu in another terminal, then deploy:
$ cd localemu-examples/01-rest-api
$ localemu start # in a separate terminal
$ ./scripts/deploy.sh local
Terraform applies 13 resources. The slowest single step is the
Lambda create (about 11 seconds the first time, because LocalEmu
pulls the public.ecr.aws/lambda/python:3.12
image and starts a real container). The other 12 resources are
effectively instant:
$ ./scripts/deploy.sh local
aws_iam_role.lambda_role: Creation complete after 1s
aws_dynamodb_table.notes: Creation complete after 1s
aws_apigatewayv2_api.api: Creation complete after 1s
aws_iam_role_policy.lambda_policy: Creation complete after 0s
aws_apigatewayv2_stage.default: Creation complete after 0s
aws_lambda_function.api: Creation complete after 11s
aws_lambda_permission.apigw: Creation complete after 0s
aws_apigatewayv2_integration.lambda: Creation complete after 0s
aws_apigatewayv2_route.r["post_note"]: Creation complete after 0s
aws_apigatewayv2_route.r["list_notes"]: Creation complete after 0s
aws_apigatewayv2_route.r["get_note"]: Creation complete after 0s
aws_apigatewayv2_route.r["update_note"]: Creation complete after 0s
aws_apigatewayv2_route.r["delete_note"]: Creation complete after 0s
Apply complete! Resources: 13 added, 0 changed, 0 destroyed.
→ deployed to local. outputs:
api_endpoint = "https://dppqgkel.execute-api.us-east-1.amazonaws.com"
api_id = "dppqgkel"
function_name = "le-notes-api"
table_name = "le-notes-notes"
real 0m25.042s
The deploy step prints the API URL and the api_id (yours will
differ from dppqgkel
above; copy whatever your run printed). Now drive the three
scenarios with curl. Every response below is captured verbatim
from a real LocalEmu run:
$ # Set the API URL emitted by deploy. Your api_id will be different.
$ API=http://localhost:4566/_aws/execute-api-v2/dppqgkel/\$default
$ ### Scenario A: full CRUD lifecycle on one note.
$ curl -s -X POST $API/notes -H 'Content-Type: application/json' \
-d '{"title":"buy oat milk","content":"2L, organic"}' | jq
{
"note_id": "9080a5700b",
"title": "buy oat milk",
"content": "2L, organic",
"created_at": 1779538412
}
$ curl -s $API/notes/9080a5700b | jq
{
"note_id": "9080a5700b",
"title": "buy oat milk",
"content": "2L, organic",
"created_at": 1779538412
}
$ curl -s -X PATCH $API/notes/9080a5700b -H 'Content-Type: application/json' \
-d '{"title":"buy almond milk"}' | jq
{
"note_id": "9080a5700b",
"title": "buy almond milk",
"content": "2L, organic",
"created_at": 1779538412,
"updated_at": 1779538412
}
$ curl -s -o /dev/null -w "%{http_code}\n" -X DELETE $API/notes/9080a5700b
204
$ curl -s -o /dev/null -w "%{http_code}\n" $API/notes/9080a5700b
404
$ ### Scenario B: POST without a title is rejected at 400.
$ curl -s -X POST $API/notes -H 'Content-Type: application/json' \
-d '{"content":"orphan"}' | jq
{
"error": "title is required"
}
$ ### Scenario C: GET a nonexistent note returns 404.
$ curl -s -o /dev/null -w "%{http_code}\n" $API/notes/does-not-exist
404
$ curl -s $API/notes/does-not-exist | jq
{
"error": "not found"
}
The same three scenarios are baked into
tests/test_api.py
as one CRUD-lifecycle test plus two edge cases. The whole suite
runs in under three seconds because there is no setup or teardown
between assertions, just real HTTP calls:
$ ./scripts/test.sh local
============================= test session starts ==============================
platform darwin -- Python 3.13.12, pytest-9.0.3
collected 3 items
tests/test_api.py::test_crud_lifecycle PASSED
tests/test_api.py::test_create_requires_title PASSED
tests/test_api.py::test_get_missing_returns_404 PASSED
============================== 3 passed in 2.72s =============================== Tear it back down:
$ ./scripts/teardown.sh local
Destroy complete! Resources: 13 destroyed.
→ verifying teardown for prefix 'le-notes' on local
clean: nothing left behind
real 0m9.889s
Deploy 25 seconds, tests 3 seconds, teardown 10 seconds. The
teardown script does more than
terraform destroy;
it then queries DynamoDB and Lambda by prefix and exits non-zero
if anything survived. The day that audit catches a leaked
resource on a real account, it has paid for itself.
4. The same code on real AWS
Apply the same Terraform with
aws instead of
local. You need
AWS credentials configured (the provider reads them from your
environment or shared config). Nothing in the application code
changes; nothing in the Terraform changes either, except the value
of one variable:
$ ./scripts/test.sh aws
$ ./scripts/teardown.sh aws
Expect the AWS round-trip to be noticeably slower than the LocalEmu one: IAM propagation alone takes 5 to 30 seconds the first time, API Gateway stage deployment takes a few seconds, and each HTTP call carries a real network round-trip. The point of LocalEmu is the inner loop, not feature parity: iterate on the handler and the Terraform locally, then promote to AWS once.
Get the full project
git clone https://github.com/localemu/localemu-examples
: the REST-API tutorial lives in
01-rest-api/
with the Terraform, the single-file handler, the three pytest
tests, and the deploy / test / teardown scripts that produced
every terminal output on this page.