Docs/ Use Cases/ REST API

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.

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 (entry and dispatch)
# 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:

src/handler.py (create and update)
# 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 (provider)
# 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 (routes)
# 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:

$ git clone https://github.com/localemu/localemu-examples
$ 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:

Terminal: deploy
$ ./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:

Terminal: three scenarios
$ # 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:

Terminal: pytest
$ ./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:

Terminal: teardown
$ ./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/deploy.sh aws
$ ./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.

Where to go next