aws2-8 Exercise 2: Running the APIs Locally, and What the Emulator Does Not Enforce ======================================================================== Run with Windows 11, Python 3.14 (py launcher) and a venv with SAM CLI 1.168.0 (AWS SAM Translator 1.114.0, cfn-lint 1.53.3, boto3 1.43.108); a second venv with cfn-lint 1.57.2 plus aws-sam-translator 1.114.0; Docker Desktop 29.8.2 with AWS's public Lambda Python 3.13 images (pulled by sam local). Scratch folder C:/lwaws/c8, nothing installed globally. VERIFICATION LEVEL: 1 (no AWS account, no credentials, nothing deployed); statements about the real service come from the AWS documentation read on 2026-10-13 and are marked as such in the chapter. Outputs below are saved from the real runs of 2026-10-13. --- start (one API per template; run from rest/ or http/) --- sam build sam local start-api --port 3001 --warm-containers EAGER # stop the server, then remove the containers it left: docker ps -a ; docker rm -f --- probe_rest.sh --- #!/bin/bash B=http://127.0.0.1:3001 p() { # label, args... label=$1; shift out=$(curl -s -i -m 30 "$@" | tr -d '\r') status=$(echo "$out" | head -1 | cut -d' ' -f2-) cors=$(echo "$out" | grep -i '^access-control' | tr '\n' ';') body=$(echo "$out" | sed '1,/^$/d' | head -c 110) printf '%-44s %-24s %s | %s\n' "$label" "$status" "$body" "$cors" } p "GET /hello" $B/hello p "GET /items/42?x=1" "$B/items/42?x=1" p "GET /secure (no token)" $B/secure p "GET /secure (token allow-me)" -H "Authorization: allow-me" $B/secure p "GET /secure (token nope)" -H "Authorization: nope" $B/secure p "GET /keyed (no API key)" $B/keyed p "POST /items valid" -X POST -H "Content-Type: application/json" -d '{"name":"pen","qty":2}' $B/items p "POST /items missing name" -X POST -H "Content-Type: application/json" -d '{"qty":2}' $B/items p "POST /items qty 0" -X POST -H "Content-Type: application/json" -d '{"name":"pen","qty":0}' $B/items p "POST /items extra property" -X POST -H "Content-Type: application/json" -d '{"name":"pen","colour":"red"}' $B/items p "POST /items not JSON" -X POST -H "Content-Type: application/json" -d 'not json' $B/items p "POST /items no body" -X POST $B/items p "OPTIONS /hello origin example.com" -X OPTIONS -H "Origin: https://example.com" -H "Access-Control-Request-Method: GET" $B/hello p "OPTIONS /hello origin evil.example" -X OPTIONS -H "Origin: https://evil.example" -H "Access-Control-Request-Method: GET" $B/hello p "GET /nope" $B/nope p "DELETE /hello" -X DELETE $B/hello p "GET /bad (no statusCode)" $B/bad p "GET /slow (8 s, timeout 5 s)" $B/slow --- ./probe_rest.sh (compact view) (saved output of the real run) --- GET /hello 200 OK event 1.0 (REST), CORS headers GET /items/42?x=1 200 OK event 1.0 (REST), CORS headers GET /secure (no token) 401 UNAUTHORIZED GET /secure (token allow-me) 200 OK event 1.0 (REST), CORS headers GET /secure (token nope) 403 FORBIDDEN GET /keyed (no API key) 200 OK event 1.0 (REST), CORS headers POST /items valid 200 OK event 1.0 (REST), CORS headers POST /items missing name 200 OK event 1.0 (REST), CORS headers POST /items qty 0 200 OK event 1.0 (REST), CORS headers POST /items extra property 200 OK event 1.0 (REST), CORS headers POST /items not JSON 200 OK event 1.0 (REST), CORS headers POST /items no body 200 OK event 1.0 (REST), CORS headers OPTIONS /hello origin example.com 200 OK CORS headers OPTIONS /hello origin evil.example 200 OK CORS headers GET /nope 403 FORBIDDEN DELETE /hello 403 FORBIDDEN GET /bad (no statusCode) 502 BAD GATEWAY GET /slow (8 s, timeout 5 s) 502 BAD GATEWAY --- 30-request burst (REST stage: burst 5, rate 2/s) --- for i in $(seq 1 30); do curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:3001/hello; done | sort | uniq -c 30 200 --- probe_http.sh --- #!/bin/bash B=http://127.0.0.1:3002 p() { # label, args... label=$1; shift out=$(curl -s -i -m 30 "$@" | tr -d '\r') status=$(echo "$out" | head -1 | cut -d' ' -f2-) cors=$(echo "$out" | grep -i '^access-control' | tr '\n' ';') body=$(echo "$out" | sed '1,/^$/d' | head -c 110) printf '%-44s %-24s %s | %s\n' "$label" "$status" "$body" "$cors" } p "GET /hello" $B/hello p "GET /items/42?x=1" "$B/items/42?x=1" p "GET /hello" $B/hello p "GET /items/42?x=1" "$B/items/42?x=1" p "GET /secure (no token)" $B/secure p "GET /secure (Bearer junk)" -H "Authorization: Bearer junk" $B/secure p "GET /files/a/b/c.txt (proxy+)" $B/files/a/b/c.txt p "POST /files/x" -X POST -d '{"a":1}' $B/files/x p "OPTIONS /hello origin example.com" -X OPTIONS -H "Origin: https://example.com" -H "Access-Control-Request-Method: GET" $B/hello p "GET /nope" $B/nope p "DELETE /hello" -X DELETE $B/hello --- ./probe_http.sh (compact view) (saved output of the real run) --- GET /hello 200 OK event 2.0 GET /items/42?x=1 200 OK event 2.0 GET /secure (no token) 200 OK event 2.0 GET /secure (Bearer junk) 200 OK event 2.0 GET /files/a/b/c.txt (proxy+) 200 OK event 2.0 POST /files/x 200 OK event 2.0 OPTIONS /hello origin example.com 200 OK CORS headers GET /nope 403 FORBIDDEN DELETE /hello 403 FORBIDDEN --- the log line that shows the JWT route was served without its authorizer --- Authorizer 'Jwt' for route '/secure' was not found, skipping --- REST and HTTP API in ONE template (template.yaml at the top of c8) (saved output of the real run) --- combined template (a REST API and an HTTP API that both define GET /hello), one 'sam local start-api' server --- GET /hello (REST API defines it with HelloFn, HTTP API defines it with HttpFn) {"version": "2.0", "path": "/hello", "method": "GET", "pathParameters": {}, "query": null, "authorizer": null, "hasBody" --- OPTIONS /hello CORS headers (REST says Content-Type,Authorization; HTTP says Content-Type) Access-Control-Allow-Origin: https://example.com Access-Control-Allow-Methods: GET,OPTIONS,POST Access-Control-Allow-Headers: Content-Type --- http/routes.yaml --- AWSTemplateFormatVersion: "2010-09-09" Transform: AWS::Serverless-2016-10-31 Globals: Function: {Runtime: python3.13, Timeout: 5} Resources: Fn: Type: AWS::Serverless::Function Properties: CodeUri: ../app/echo/ Handler: app.handler Events: R1: {Type: HttpApi, Properties: {ApiId: !Ref Api, Path: /pets/dog/1, Method: GET}} R2: {Type: HttpApi, Properties: {ApiId: !Ref Api, Path: "/pets/dog/{id}", Method: GET}} R3: {Type: HttpApi, Properties: {ApiId: !Ref Api, Path: "/pets/{proxy+}", Method: GET}} R4: {Type: HttpApi, Properties: {ApiId: !Ref Api, Path: "/{proxy+}", Method: ANY}} R5: {Type: HttpApi, Properties: {ApiId: !Ref Api, Path: /infer, Method: GET}} R6: {Type: HttpApi, Properties: {ApiId: !Ref Api, Path: /str, Method: GET}} Api: Type: AWS::Serverless::HttpApi --- app/echo/app.py --- import json def handler(event, context): # Return the route key the gateway matched, in the two shapes the docs describe. which = event.get("routeKey") or (event.get("httpMethod", "") + " " + event.get("resource", "")) if event.get("rawPath", "").startswith("/infer"): return {"message": "no statusCode here"} # v2 should infer 200 + application/json if event.get("rawPath", "").startswith("/str"): return "plain string" # v2 should infer a JSON string body return {"statusCode": 200, "body": which} --- routes run locally (port 3003, http/routes.yaml), compared with the docs' table --- GET /pets/dog/1 -> GET /pets/dog/1 GET /pets/dog/2 -> GET /pets/dog/{id} GET /pets/cat/1 -> GET /pets/{proxy+} POST /test/5 -> POST /{proxy+} (the event's routeKey; the route configured was ANY /{proxy+}) GET /infer (function returned a dict without statusCode) -> 200, Content-Type: application/json GET /str (function returned the string 'plain string') -> 200, body: plain string --- py mk8.py (keys of the two generate-event fixtures) (saved output of the real run) --- REST fixture (sam local generate-event apigateway aws-proxy) top-level keys: body, resource, path, httpMethod, isBase64Encoded, queryStringParameters, multiValueQueryStringParameters, pathParameters, stageVariables, headers, multiValueHeaders, requestContext requestContext keys: accountId, resourceId, stage, requestId, requestTime, requestTimeEpoch, identity, path, resourcePath, httpMethod, apiId, protocol version field: (none: REST proxy events have no version field) | isBase64Encoded: True | pathParameters: {'proxy': '/hello'} HTTP API fixture (generate-event apigateway http-api-proxy) top-level keys: version, routeKey, rawPath, rawQueryString, cookies, headers, queryStringParameters, requestContext, body, pathParameters, isBase64Encoded, stageVariables requestContext keys: accountId, apiId, authentication, authorizer, domainName, domainPrefix, http, requestId, routeKey, stage, time, timeEpoch version field: 2.0 | isBase64Encoded: True | pathParameters: {'parameter1': 'value1'} --- sam local invoke with the generated fixtures --- sam local generate-event apigateway aws-proxy --method GET --path hello > ev_rest.json sam local invoke HelloFn -e ../ev_rest.json # in rest/; the fixture's pathParameters is {'proxy': '/hello'} whatever --path says sam local generate-event apigateway http-api-proxy --method GET --path hello > ev_http.json sam local invoke HttpFn -e ../ev_http.json # in http/ -------------------------- What the emulator enforced: routing, the Lambda authorizer (401 / 403 / 200 with context), CORS headers, 403 'Missing Authentication Token' for unknown routes, 502 for a non-proxy response and for a timeout, v2 response inference. What it did not: request validation (5 invalid bodies all 200), API keys (/keyed answered 200), throttling (30 of 30 returned 200), the JWT authorizer (route served open). Mistake made and fixed: my first run used one template with a REST and an HTTP API that both define GET /hello; the local server served only one (the HTTP one) and applied its CORS settings to both, so my first probe table mixed two APIs. I noticed event version 2.0 in a REST probe, split the templates and re-ran. How to avoid it: keep one API per template for local runs and check which event version a response reports before drawing conclusions. Also: stopping the server left three warm containers running; remove them yourself.