Introduction
APIs are the foundation of modern software development, which is why this "Hello World!" API is anything but basic.
Most tutorials rush past the foundational concepts, pushing you to build complex systems before you truly understand how everything connects. This guide is different.
By the end, you won't just have a working API—you will understand exactly what is happening under the hood. More importantly, you will have a production-ready infrastructure that you can share, repeat, and version-control. This is the exact API that started my cloud journey. Building it gave me the foundational knowledge to later build a production serverless API that automates data tracking for 165 schools and saves me 25+ hours a year.
Looking back at that project, my biggest takeaway was realizing why we test production APIs using automated code rather than just console testing. This tutorial is me putting that vital lesson into practice.
By building this Hello World API, you will master the core fundamentals that power real-world production systems:
- ✅ HTTP request handling
- ✅ Query parameter extraction
- ✅ JSON response formatting
- ✅ CORS configuration
- ✅ Serverless deployment
- ✅ Infrastructure as Code
- ✅ Unit testing
- ✅ Integration testing
- ✅ Deployment to AWS
The pattern you will learn here — receive request → process data → return response — is the exact same blueprint used in every enterprise API, from simple microservices to complex distributed systems.
Every complex system starts right here. Master this fundamental pattern, and you can build anything.
The Smarter Way to Build Your First API
The AWS Management Console is a great place to start building and exploring APIs. Clicking through Lambda, API Gateway, and IAM helps you see exactly what AWS is creating under the hood. But here’s the thing: once you understand what each service does, manually clicking through the console becomes a major bottleneck:
Time-consuming: Taking 30+ minutes just to wire up a simple endpoint.
Error-prone: Forgetting critical settings when recreating the setup.
Unshareable: Preventing teammates from reliably reproducing your exact environment.
Untracked: Lacking version control, deployment history, or easy rollback.
That’s exactly why we’re using AWS SAM.
What is AWS SAM (Serverless Application Model)?
AWS SAM lets you define your entire API infrastructure in a single YAML file and deploy it with one command. No clicking through menus. No guesswork. Just clean, declarative Infrastructure as Code.
SAM lets you describe that same infrastructure: Lambda, API Gateway, IAM permissions in a single YAML file. One command builds it. One command deploys it. Anyone on your team can clone your repo and have the exact same API running in minutes.
Using the AWS Console is like picking your pizza toppings by hand—great for learning, but slow and easy to mess up. AWS SAM is your digital recipe card. You write the recipe once, hand it to AWS, and it bakes the exact same, perfect pizza with one click.
If a teammate wants that same pizza, you just hand them the recipe.
What Is a REST API?
A REST API (Representational State Transfer) is a way for applications to talk to each other over the internet using standard HTTP methods: GET, POST, PUT, DELETE.
Think of it like a waiter in a restaurant:
- You (the client) place an order (HTTP request).
- The waiter (the API) takes it to the kitchen (your server/Lambda).
- The kitchen prepares it and the waiter brings back your food (JSON response).
Every time you use a weather app, log into a website, or pay online, a REST API is working behind the scenes.
Why REST Over Other API Styles?
There are several ways to design an API but REST remains the industry standard for a wide range of web and mobile applications. Here is how it compares to other common styles:
- REST (Best for Web & mobile apps): Simple, stateless, works everywhere, maximum configuration control.
- HTTP API (Best for High-speed, low-cost use cases): Fewer features but faster performance, less control.
- GraphQL (Best for Complex, flexible data queries): Overkill for beginners.
- WebSocket (Best for Real-time chat, live feeds): Wrong tool for request/response pattern.
AWS offers both REST API and HTTP API in API Gateway. HTTP API is cheaper and faster, but REST API gives you more control like request validation, usage plans, API keys, and detailed metrics. For learning, REST API teaches you more.
Why REST Is the Industry Standard:
- Human-readable: URLs like
/hello?name=Gloriamake sense instantly - Stateless: every request carries everything the server needs, no sessions
- Works everywhere: browsers, mobile apps, CLI tools, other servers
- Scales effortlessly: especially paired with serverless like AWS Lambda
In this tutorial, we will build and deploy a REST API to AWS using AWS SAM, complete with unit tests, integration test, a live documentation endpoint and finally deploying to production AWS.
Let’s build it.
What We’re Building
A REST API with three endpoints:
-
GET /: Serves a live documentation page -
GET /hello: Returns a personalised JSON greeting -
GET /get-documentation: Also serves the documentation page
Prerequisites
Before starting, ensure you have these tools installed and configured:
- AWS account — New AWS customers can get started at no cost with the AWS Free Tier
- AWS CLI — AWS Command Line Interface (configured with your credentials)
- Docker Desktop — User-friendly platform for container management
- Git Bash — Provides a Unix-like terminal for Windows users
- VS Code — Open source AI code editor (or any code editor)
- SAM CLI — installed and ready
- Git Bash / curl — Command line testing
- Pytest
Quick Verification
Run these commands to confirm everything is set up correctly:
# Check AWS CLI
aws --version
# Verify AWS credentials are configured
aws sts get-caller-identity
# Check SAM is installed
sam --version
# Check Python is installed
python --version
# Check Docker
docker --version
Step 1: Create the Project Structure
Open Git Bash or the Windows command prompt and initialize your project directory by running the following commands:
# Check and note your working directory before creating folders
pwd
# Create project folders
mkdir hello-world-api
# Create template.yaml file
touch template.yaml
# Navigate to hello-world-api and create another folder
cd hello-world-api
mkdir hello_world
# Create app.py file inside hello_world
touch app.py
Use Windows Explorer to navigate to the hello-world-api folder, right-click on the folder, and select Open with Code. Your folder structure should look like this in VS Code:
hello-world-api/
├── hello_world/
│ └── app.py ← Lambda function goes here
└── template.yaml ← SAM infrastructure goes here
Step 2: Create the Lambda Function
Open app.py and paste in the following code:
import json
import datetime
COMMON_HEADERS = {
"Access-Control-Allow-Origin": "*"
}
def lambda_handler(event, context):
method = event.get("httpMethod", "")
path = event.get("path", "/")
# Only GET endpoints supported
if method != "GET":
return error_response(405, "Method not allowed")
# Routing
if path == "/":
return documentation_handler()
if path == "/get-documentation":
return documentation_handler()
if path == "/hello":
return hello_handler(event)
return error_response(404, "Endpoint not found")
def hello_handler(event):
query_params = event.get("queryStringParameters") or {}
name = query_params.get("name", "World")
response_body = {
"message": f"Hello, {name}!",
"timestamp": datetime.datetime.now(datetime.UTC).isoformat(),
"method": event.get("httpMethod", "UNKNOWN"),
"path": event.get("path", "UNKNOWN"),
"version": "1.0"
}
return {
"statusCode": 200,
"headers": {
**COMMON_HEADERS,
"Content-Type": "application/json"
},
"body": json.dumps(response_body)
}
def documentation_handler():
html_content = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Hello API Docs</title>
<link rel="icon" href="data:,">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: 'Segoe UI', sans-serif;
background: #0f1117;
color: #ffffff;
min-height: 100vh;
padding: 48px 24px;
}
.container { max-width: 720px; margin: 0 auto; }
.badge {
display: inline-block;
background: #ff9900;
color: #000;
font-size: 11px;
font-weight: 700;
letter-spacing: 1px;
padding: 4px 12px;
border-radius: 20px;
margin-bottom: 24px;
text-transform: uppercase;
}
h1 { font-size: 28px; font-weight: 700; margin-bottom: 8px; }
.subtitle { color: #8b8fa8; font-size: 15px; margin-bottom: 40px; }
.card {
background: #1a1d27;
border: 1px solid #2a2d3e;
border-radius: 12px;
padding: 24px;
margin-bottom: 16px;
}
.endpoint-row {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.method {
background: #1a4731;
color: #4ade80;
font-size: 12px;
font-weight: 700;
padding: 4px 10px;
border-radius: 6px;
font-family: monospace;
}
.path {
font-family: monospace;
font-size: 15px;
color: #ff9900;
}
.description { color: #8b8fa8; font-size: 14px; margin-bottom: 16px; }
.param-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
color: #555870;
margin-bottom: 8px;
}
.param-row {
display: flex;
gap: 12px;
font-size: 13px;
padding: 8px 0;
border-bottom: 1px solid #2a2d3e;
align-items: baseline;
}
.param-row:last-child { border-bottom: none; }
.param-name { font-family: monospace; color: #ff9900; min-width: 80px; }
.param-type { color: #555870; min-width: 60px; }
.param-desc { color: #8b8fa8; }
.response-box {
background: #0f1117;
border: 1px solid #2a2d3e;
border-radius: 8px;
padding: 16px;
font-family: monospace;
font-size: 13px;
color: #4ade80;
margin-top: 16px;
white-space: pre;
}
</style>
</head>
<body>
<div class="container">
<div class="badge">API Documentation</div>
<h1>Hello API</h1>
<p class="subtitle">A beginner-friendly serverless REST API built with AWS SAM & Lambda · v1.0</p>
<div class="card">
<div class="endpoint-row">
<span class="method">GET</span>
<span class="path">/hello</span>
</div>
<p class="description">
Returns a personalised hello message. Pass a name as a query parameter or default to "World".
</p>
<div class="param-label">Query parameters</div>
<div class="param-row">
<span class="param-name">name</span>
<span class="param-type">string</span>
<span class="param-desc">Optional. The name to greet. Defaults to "World".</span>
</div>
</div>
<div class="card">
<div class="endpoint-row">
<span class="method">GET</span>
<span class="path">/get-documentation</span>
</div>
<p class="description">
Returns this documentation page as an HTML response. Demonstrating that Lambda can serve HTML, not just JSON.
</p>
<div class="param-label">No parameters</div>
<div class="param-row">
<span class="param-name">-</span>
<span class="param-type"></span>
<span class="param-desc">No query parameters required.</span>
</div>
</div>
</div>
</body>
</html>
"""
return {
"statusCode": 200,
"headers": {
**COMMON_HEADERS,
"Content-Type": "text/html"
},
"body": html_content
}
def error_response(status_code, error_message):
return {
"statusCode": status_code,
"headers": {
**COMMON_HEADERS,
"Content-Type": "application/json"
},
"body": json.dumps({
"error": error_message
})
}
2.1 Understanding the Lambda Function Code
app.py contains four functions. Here's what each one does:
2.1.1 def lambda_handler(event, context): the entry point
Every Lambda function must have this exact signature. AWS calls this function and passes two things:
-
event— information about what triggered the Lambda; in our case, the HTTP request -
context— metadata about the function execution (request ID, time remaining, etc.)
method = event.get("httpMethod", "")
path = event.get("path", "/")
These two lines extract the HTTP method and path from the incoming request and store them in variables.
Note:
eventonly exists when a request comes in. It is created fresh for every single invocation.
The routing block then directs each request to the right handler:
# Only GET endpoints supported
if method != "GET":
return error_response(405, "Method not allowed")
# Routing
if path == "/":
return documentation_handler()
if path == "/get-documentation":
return documentation_handler()
if path == "/hello":
return hello_handler(event)
return error_response(404, "Endpoint not found")
Good to know: The
error_response(405, ...)for non-GET methods is not just good practice. It also saves Lambda cost by rejecting invalid requests immediately at the API Gateway before they reach Lambda.
2.1.2 def hello_handler(event): the main API logic
The hello_handler function serves as the central API logic. It processes GET /hello requests by first extracting the name query parameter from each request, builds the JSON response body and return data(hello greeting) to the client. This is where we implement the fundamental pattern of every API:
- Extract parameters from the event (request):
query_params = event.get("queryStringParameters") or {}
name = query_params.get("name", "World")
First, it extracts the query parameters from the event. For example, if you type http://localhost:3000/hello?name=John in your browser:
-
query_paramswould be:{"name": "John"} - The
{}is a default : it tells Lambda that if there are no query parameters, use an empty dictionary instead of crashing.
The
or {}is important because API Gateway can passNonefor queryStringParameters when there are no query params, and calling .get() onNonewould crash.
- Build the response body: The response must follow this exact format for API Gateway:
response_body = {
"message": f"Hello, {name}!",
"timestamp": datetime.datetime.now(datetime.UTC).isoformat(),
"method": event.get("httpMethod", "UNKNOWN"),
"path": event.get("path", "UNKNOWN"),
"version": "1.0"
}
- Return data:
return {
"statusCode": 200,
"headers": {
**COMMON_HEADERS,
"Content-Type": "application/json"
},
"body": json.dumps(response_body)
}
Lambda must return this exact format for API Gateway:
-
statusCode: 200: HTTP success code (200 = OK) -
headers: HTTP headers to send back, including: -
Content-Type: application/json: tells the browser "this is JSON" -
Access-Control-Allow-Origin: *: allows any website to call this API (CORS) -
body: the actual response (must be a STRING, so we convert the dictionary to JSON withjson.dumps())
Note: It is important to use
json.dumps()otherwise Lambda will return a raw dictionary as the body, which Lambda will accept, but API Gateway won't know what to do with it and will fail to properly format the response for the client.
2.1.3 def documentation_handler() — the HTML endpoint
This is the part that surprises most beginners: Lambda can return HTML, not just JSON. The only thing that changes is one header:
"Content-Type": "text/html" # ← this tells the browser to render a page
Compare above content-Type to /hello:
"Content-Type": "application/json" # ← this tells the browser to display raw JSON
Same Lambda, same API Gateway but completely different browser behavior, controlled by a single header.
2.1.4 def error_response(status_code, message) — consistent error handling
Every error response goes through this function, which ensures CORS headers are always included. This matters because if a browser receives a 404 without a CORS header, it blocks the response entirely and the client gets a confusing network error instead of the actual error message.
To this point you have learned the four fundamentals of every API:
- receive a request,
- extract parameters
- build a response, and
- return data
Master this pattern and you master every API you'll ever build. Well done for coming this far!
Step 3: Define Your Infrastructure in template.yaml
Open template.yaml and paste in the following:
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: Hello API - A beginner-friendly serverless REST API built with AWS SAM and Lambda
Globals:
Function:
Timeout: 10
Runtime: python3.11
MemorySize: 128
Resources:
# API Gateway
HelloWorldApi:
Type: AWS::Serverless::Api
Properties:
Name: HelloWorldApi
StageName: Prod
TracingEnabled: true
MethodSettings:
- ResourcePath: '/*'
HttpMethod: '*'
ThrottlingBurstLimit: 100
ThrottlingRateLimit: 50
MetricsEnabled: true
Cors:
AllowMethods: "'GET,OPTIONS'"
AllowOrigin: "'*'"
AllowHeaders: "'Content-Type'"
# Lambda Function
HelloWorldFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: HelloWorldFunction
CodeUri: hello_world/
Handler: app.lambda_handler
Description: Beginner-friendly serverless Hello API
Events:
RootApi:
Type: Api
Properties:
RestApiId: !Ref HelloWorldApi
Path: /
Method: get
HelloApi:
Type: Api
Properties:
RestApiId: !Ref HelloWorldApi
Path: /hello
Method: get
DocumentationApi:
Type: Api
Properties:
RestApiId: !Ref HelloWorldApi
Path: /get-documentation
Method: get
Outputs:
RootUrl:
Description: Root URL - serves documentation page
Value: !Sub "https://${HelloWorldApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
HelloApiUrl:
Description: Hello endpoint
Value: !Sub "https://${HelloWorldApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello"
DocumentationUrl:
Description: Documentation endpoint
Value: !Sub "https://${HelloWorldApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/get-documentation"
HelloWorldFunctionArn:
Description: Lambda function ARN
Value: !GetAtt HelloWorldFunction.Arn
HelloWorldFunctionIamRole:
Description: IAM role ARN created for the Lambda function
Value: !GetAtt HelloWorldFunctionRole.Arn
Understanding the SAM Template
The template is organized into four sections.
Version declaration and Transform
AWSTemplateFormatVersion: '2010-09-09'
This tells AWS “I’m using CloudFormation syntax from 2010–09–09.”
Transform: AWS::Serverless-2016-10-31
This line converts the SAM syntax into full CloudFormation. SAM is like a shortcut language that gets translated into longer CloudFormation code. Without this, AWS wouldn’t understand SAM-specific resources.
Globals
Globals:
Function:
Timeout: 10
Runtime: python3.11
MemorySize: 128
These are default settings applied to every Lambda function in the template. Setting them once here means you don’t have to repeat them for every function.
-
Timeout: 10: kills the function if it runs longer than 10 seconds, preventing runaway costs -
Runtime: python3.11: the Python version used to run the code -
MemorySize: 128: the minimum RAM allocation, keeping costs low for a simple API
Resources
This section contains everything that AWS creates. The template groups resources into two parts:
1. API Gateway
The template defines the API Gateway resource explicitly so we can attach settings like throttling and CORS:
Resources:
# API Gateway
HelloWorldApi:
Type: AWS::Serverless::Api
Properties:
Name: HelloWorldApi
StageName: Prod
TracingEnabled: true
MethodSettings:
- ResourcePath: '/*'
HttpMethod: '*'
ThrottlingBurstLimit: 100
ThrottlingRateLimit: 50
MetricsEnabled: true
Cors:
AllowMethods: "'GET,OPTIONS'"
AllowOrigin: "'*'"
AllowHeaders: "'Content-Type'"
-
StageName: Prod: creates/Prodin your URL:https://abc123.execute-api.us-east-1.amazonaws.com/Prod/hello. You can have multiple stages later:Dev,Test,Prod. -
TracingEnabled: true: enables AWS X-Ray for distributed tracing, useful for debugging performance issues. -
ThrottlingBurstLimit: 100/ThrottlingRateLimit: 50: limits how many requests the API can handle per second, protecting both your Lambda function and your AWS bill. -
MetricsEnabled: true: sends API metrics (request count, errors, latency) to CloudWatch. -
Cors: allows browsers to call this API from any website.AllowOrigin: "*"is fine for learning; in production you would restrict this to specific domains.
Lambda Function
# Lambda Function
HelloWorldFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: HelloWorldFunction
CodeUri: hello_world/
Handler: app.lambda_handler
Description: Beginner-friendly serverless Hello API
-
FunctionName: HelloWorldFunction: the name AWS gives this Lambda function. -
CodeUri: hello_world/: tells SAM where your Lambda code lives. -
Handler: app.lambda_handler: the format isfilename.function_name, so this means "call thelambda_handlerfunction insideapp.py", i.e.def lambda_handler(event, context). -
Description: a short description of what the function does.
Events
The Events block describes which API routes trigger the Lambda function. Three events are defined in this template:
Events:
RootApi:
Type: Api
Properties:
RestApiId: !Ref HelloWorldApi
Path: /
Method: get
HelloApi:
Type: Api
Properties:
RestApiId: !Ref HelloWorldApi
Path: /hello
Method: get
DocumentationApi:
Type: Api
Properties:
RestApiId: !Ref HelloWorldApi
Path: /get-documentation
Method: get
-
**RootApi**: handlesGET /request -
**HelloApi**: handlesGET /hellorequest -
**DocumentationApi**: handlesGET /get-documentationrequest
For each event:
-
RestApiId: !Ref HelloWorldApi: attaches the route to the API Gateway defined above. Without this, SAM would automatically create a second, separate API Gateway. -
Path: defines the API endpoint URL (e.g./hello). -
Method: get: onlyGETrequests are allowed on these routes.
Full Resources block:
Resources:
# API Gateway
HelloWorldApi:
Type: AWS::Serverless::Api
Properties:
Name: HelloWorldApi
StageName: Prod
TracingEnabled: true
MethodSettings:
- ResourcePath: '/*'
HttpMethod: '*'
ThrottlingBurstLimit: 100
ThrottlingRateLimit: 50
MetricsEnabled: true
Cors:
AllowMethods: "'GET,OPTIONS'"
AllowOrigin: "'*'"
AllowHeaders: "'Content-Type'"
# Lambda Function
HelloWorldFunction:
Type: AWS::Serverless::Function
Properties:
FunctionName: HelloWorldFunction
CodeUri: hello_world/
Handler: app.lambda_handler
Description: Beginner-friendly serverless Hello API
Events:
RootApi:
Type: Api
Properties:
RestApiId: !Ref HelloWorldApi
Path: /
Method: get
HelloApi:
Type: Api
Properties:
RestApiId: !Ref HelloWorldApi
Path: /hello
Method: get
DocumentationApi:
Type: Api
Properties:
RestApiId: !Ref HelloWorldApi
Path: /get-documentation
Method: get
Outputs
These are values displayed on the screen after deployment. They give you the live URLs and resource identifiers for your deployed stack.
Outputs:
RootUrl:
Description: Root URL - serves documentation page
Value: !Sub "https://${HelloWorldApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/"
HelloApiUrl:
Description: Hello endpoint
Value: !Sub "https://${HelloWorldApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello"
DocumentationUrl:
Description: Documentation endpoint
Value: !Sub "https://${HelloWorldApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/get-documentation"
HelloWorldFunctionArn:
Description: Lambda function ARN
Value: !GetAtt HelloWorldFunction.Arn
HelloWorldFunctionIamRole:
Description: IAM role ARN created for the Lambda function
Value: !GetAtt HelloWorldFunctionRole.Arn
Understanding output
-
**RootUrl**: the root endpoint of your API (/). This is where the documentation page is served. -
**HelloApiUrl**: the/helloendpoint URL. -
**DocumentationUrl**: the/get-documentationendpoint URL. -
**HelloWorldFunctionArn**: the Amazon Resource Name (ARN) of your Lambda function. Useful for referencing the function in other AWS services or IAM policies. -
**HelloWorldFunctionIamRole**: the ARN of the IAM role automatically created for your Lambda function by SAM.
Understanding !Sub
!Sub is a CloudFormation intrinsic function that means "substitute variables". It replaces placeholders in the string with their actual values at deploy time:
-
${HelloWorldApi}— the API Gateway ID, auto-generated by AWS when the stack is created. -
${AWS::Region}— the AWS region the stack is deployed to (e.g.us-east-1).
So a value like:
!Sub "https://${HelloWorldApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello"
becomes something like:
https://abc123.execute-api.us-east-1.amazonaws.com/Prod/hello
Understanding !GetAtt
!GetAtt retrieves an attribute of a resource created in the same template. For example:
-
!GetAtt HelloWorldFunction.Arn:gets the ARN of theHelloWorldFunctionLambda. -
!GetAtt HelloWorldFunctionRole.Arn: gets the ARN of the IAM role SAM automatically created for your Lambda.
Summary of what the SAM Template Does
The SAM template performs 5 major functions:
-
**Globals**— sets default Lambda settings (timeout, runtime, memory). -
**API Gateway**— creates and configures the API Gateway with throttling, CORS, and metrics. -
**Function**— creates the Lambda function and wires up the code. -
**Events**— connects API routes (/,/hello,/get-documentation) to the Lambda function. -
**Outputs**— displays the live URLs and resource ARNs after deployment.
Step 4: A Note on Security
For a beginner-friendly REST API, there isn’t much security configuration needed. However, a few things in this template already protect you:
- Method validation in
**app.py**: theif method != "GET"check rejects all non-GET requests before any real work happens, protecting your Lambda function from unnecessary invocations and cost. - Throttling :
ThrottlingBurstLimitandThrottlingRateLimitprotect against traffic spikes and accidental (or intentional) overuse. - IAM role : SAM automatically creates a least-privilege IAM role for your Lambda function. You don’t define it manually — SAM handles it, and the role ARN appears in your Outputs after deployment.
Step 5: Validate the Template
Before building, check that your template.yaml is valid:
sam validate
You should see: template.yaml is a valid SAM Template
Step 6: Build the Application
This packages your Lambda function and prepares everything for deployment or local testing. Run this every time you change app.py or template.yaml.
sam build
Step 7: Run the API Locally
Note: Docker Desktop must be running before you execute
sam local start-api. SAM uses Docker to simulate the Lambda environment locally. If Docker is not started, you will see an error likeError: Running AWS SAM projects locally requires Dockerand the local API will not start.
sam local start-api
This starts a local API server on the default port 3000. If port 3000 is already in use (for example, by a React or Node.js dev server), use a different port:
sam local start-api --port 8080
Note: The first time you run
sam local start-api, don't panic when you see a stream of dots filling your terminal. That's completely normal! SAM is pulling the Python 3.11 Lambda runtime image from Docker Hub layer by layer. Depending on your internet speed, this can take a minute or two or more. Once it's done, you will seeMounting HelloWorldFunction at http://127.0.0.1:3000and your API is ready. On future runs, use--skip-pull-imageto skip this step.
Step 8: Test the Endpoints
8.1 Using the Browser
Test **GET /hello** endpoint without name parameter should default to world
Once your terminal shows Running on http://127.0.0.1:3000 follow these steps:
- Keep the terminal running. Do not close it. Closing it stops your local API.
- Open Chrome browser and enter
http://localhost:3000/helloin the URL bar - Switch back to your terminal and you will should see a log line confirming the request was handled, with an HTTP
200status code - Switch back to your browser to see your JSON response
Test **GET /hello** endpoint with name parameter should show greeting with your name.
Open chrome browser and type this:
http://localhost:3000/hello?name=YourName — the message updates with your name
Test GET /get-documentation — should serve the documentation page:
Enter this in a new tab in your browser:
http://localhost:3000/get-documentation — you should see the styled documentation page
Test the root / endpoint — should also serve the documentation page
Enter this in a new tab in your browser: http://localhost:3000/
8.2 Using curl (Git Bash or Command Prompt)
sam local start-api takes over the terminal it runs in. It stays running and logs every request in real time. To test with curl, you need a second terminal alongside it.
Windows users: If you are using PowerShell, curl is actually an alias for Invoke-WebRequest and gives a very different output — verbose headers, status codes, and a security warning. Use Git Bash for all the commands in this section instead. Alternatively, type curl.exe instead of curl in PowerShell to call the real curl binary directly and get the same clean output.
Here’s the flow:
- Terminal 1 — start the API and leave it running:
sam local start-api
- Terminal 2 — open a new Git Bash window and run this curl command to test the
/helloendpoint:
curl http://localhost:3000/hello
Terminal 2 shows the actual JSON response from curl.
- Switch back to Terminal 1. You will see the request logged with the HTTP status code:
Invoking app.lambda_handler (python3.11)
Requested to skip pulling images ...
Mounting C:\Users\globa\hello-world-api\.aws-sam\build\HelloWorldFunction as /var/task:ro,delegated, inside runtime container
START RequestId: c607d6bd-84bd-4eab-8588-e971dc303056 Version: $LATEST
[DEBUG] Lambda invoked - method: GET, path: /hello
END RequestId: e4960c8a-e470-4b02-872c-852722317cbd
REPORT RequestId: e4960c8a-e470-4b02-872c-852722317cbd Init Duration: 0.20 ms Duration: 144.05 ms Billed Duration: 145 ms Memory Size: 128 MB Max Memory Used: 128 MB
2026-06-29 14:56:08 127.0.0.1 - - [29/Jun/2026 14:56:08] "GET /hello HTTP/1.1" 200 -
Note: This two-terminal pattern is standard in API development. One terminal runs the server, the other sends requests.
Test /hello endpoint with a name parameter:
Type this in terminal 2:
curl http://localhost:3000/hello?name=Gloria
Test GET /get-documentation — should serve the documentation page:
curl http://localhost:3000/get-documentation
Test the root / endpoint should display the documentation page source:
curl http://localhost:3000/
8.3 Testing Non-GET Methods
Once your GET requests are working, it’s worth sending one or two non-GET requests to see exactly how API Gateway protects your Lambda function.
Open a second terminal (leave Terminal 1 running with sam local start-api) and run:
Note: Browsers can only send
GETrequests from the address bar. You cannot testPOSTorPUTmethods directly in a browser. Use curl, Git Bash, or curl.exe (PowerShell) in a second terminal as shown below.
# Test POST — should be blocked
curl -X POST http://localhost:3000/hello
# Test PUT - should be blocked
curl -X PUT http://localhost:3000/hello
Now switch back to Terminal 1 and compare the POST and PUT request logs(Figure 14) with GET request logs in Figure 15:
POST and PUT request logs
Notice what's missing in POST and PUT request logs(Figure 14):
NoSTART RequestId, noBilled Duration. ThePOSTandPUTrequests were rejected by API Gateway — they never reached your Lambda function. This is exactly what method validation is for. It protects both your function and your AWS bill.
Why does API Gateway return **{"message": "Missing Authentication Token"}** instead of your Lambda's 405 error?
When you send a POST or PUT request, API Gateway checks your template.yaml first. Since only GET is configured for these routes, API Gateway doesn't recognize the method and returns its own default error, before your Lambda function is ever invoked. Despite the confusing name, this message simply means "I don't know what to do with this request." Your Lambda's 405 error would only appear if Lambda was invoked directly, bypassing API Gateway entirely. This is actually good news: the block happens at the gateway level, so Lambda is never called and you are never billed.
Step 9: Automated Testing — Unit Tests
A unit test is a Python script that calls your Lambda function directly — without API Gateway, without SAM, without a running server and checks that the output is exactly what you expected.
Instead of manually opening a browser and typing localhost:3000/hello?name=Gloria every time you make a change, a unit test does that check automatically in one command.
A unit test checks one small piece of your code in complete isolation. No internet. No AWS. No real API Gateway. Just your Python functions.
Unit tests catch bugs before your code reaches AWS. Instead of sending a real HTTP request, you simulate the inputs (the event dictionary that API Gateway normally builds) and check the outputs. This is fast, free, and repeatable.
Why Automated Testing Catches More Than Console Testing
When you test on the console or browser, you only see what the surface shows. For example, API Gateway does not explicitly display "Endpoint not found" when you hit an unknown path — it just returns a generic error and you might think everything is fine.
Automated tests look beneath the surface. They check the actual values in the response — the status code, the error message, the headers: things the console never shows you. That's why you will notice we have far more tests here than things you could verify manually in a browser. A browser tells you "it worked" or "it didn't." A unit test tells you exactly what worked, what didn't, and why.
This is the real power of automated testing: it verifies the contract of your code, not just the appearance.
Step 10: Set Up the Test Structure
10.1 Create the __init__.py Files
These are empty files that tell Python “treat this folder as a package.” Without them, your imports will fail.
Run these commands from your project root:
On Git Bash:
# Create empty __init__.py files
touch hello_world/__init__.py
touch tests/__init__.py
touch tests/unit/__init__.py
On Windows (Command Prompt):
type nul > hello_world\__init__.py
type nul > tests\__init__.py
type nul > tests\unit\__init__.py
10.2 Create a pytest.ini File
Create a pytest.ini file in the root of your project:
# Create pytest.ini files
touch hello-world-api/pytest.ini
Add this content:
[pytest]
pythonpath = .
This single line adds the project root to Python’s search path. Without it, from hello_world.app import ... fails because Python can't find the hello_world folder.
10.3 Create the tests/unit/test_app.py File
# Create test_app.py files
touch hello-world-api/tests/unit/test_app.py
Your project should now look like this:
hello-world-api/
├── hello_world/
│ ├── __init__.py ← makes it a Python package (importable)
│ └── app.py
├── tests/
│ ├── __init__.py ← makes tests a package
│ └── unit/
│ ├── __init__.py ← makes unit a package
│ └── test_app.py ← your test file
├── pytest.ini ← tells pytest where to find your code
└── template.yaml
Open test_app.py and paste in the following code:
import json
import pytest
from hello_world.app import lambda_handler, hello_handler, documentation_handler, error_response
# In real life, AP1 Gateway builds the "event" dict and passes it to Lambda.
# In unit test, you build the dict yourself. This is called mocking
def mock_event(method="GET", path="/", query_params=None):
"""
Build a minimal API Gateway event dictionary.
Args:
method (str): HTTP method, e.g. "GET" or "POST"
path (str): URL path, e.g. "/hello"
query_params (dict): Optional query string params, e.g. {"name": "Gloria"}
Returns:
dict: A fake API Gateway event our Lambda can process
"""
return {
"httpMethod": method,
"path": path,
"queryStringParameters": query_params
}
# SECTION 1: TEST ROUTING
# Test that Lambda_handler sends request to the right places
def test_get_hello_returns_200():
"""GET /hello should return a greeting: 'Hello-World! (status 200)."""
event = mock_event(method="GET", path="/hello")
response = lambda_handler(event, context=None)
assert response["statusCode"] == 200
def test_get_documentation_returns_200():
"""Get /get_documentation should return the documenttion page (status 200)."""
event = mock_event(method="GET", path="/get-documentation")
response = lambda_handler(event, context=None)
assert response["statusCode"] == 200
def test_get_root_returns_200():
"""Get / should return the documentation page (status 200)."""
event = mock_event(method="GET", path="/")
response = lambda_handler(event, context=None)
assert response["statusCode"] == 200
def test_unknown_path_returns_404_error():
"""
GET /nonexistent path should return 404 error - Endpoint Not Found."""
event = mock_event(method="GET", path="/nonexistent")
response = lambda_handler(event, context=None)
assert response["statusCode"] == 404
def test_unknown_method_returns_405():
"""POST request should return 405 error - Method Not Allowed."""
event = mock_event(method="POST", path="/hello")
response = lambda_handler(event, context=None)
assert response["statusCode"] == 405
def test_put_method_returns_405():
"""
PUT requst should return 405 error - Method not Allowed"""
event = mock_event(method="PUT", path="/hello")
response = lambda_handler(event, context=None)
assert response["statusCode"] == 405
# SECTION 2: TEST HELLO HANDLER
# Test the actual logic of /hello endpoint
def test_hello_handler_without_name_parameter():
"""/hello without name parameter should default to 'Hello-World!'"""
event = mock_event(method="GET", path="/hello")
response = hello_handler(event)
body = json.loads(response["body"])
assert body["message"] == "Hello, World!"
def test_hell0_handler_with_name_parameter():
"""/hello?name=Gloria should return 'Hello, Gloria!"""
event = mock_event(method="GET", path="/hello", query_params={"name": "Gloria"})
response = hello_handler(event)
assert response["statusCode"] == 200
body = json.loads(response["body"])
assert body["message"] == "Hello, Gloria!"
def test_hello_handler_with_empty_queryparams_dict():
event = mock_event(method="GET", path="/hello", query_params={})
response = hello_handler(event)
body = json.loads(response["body"])
assert body["message"] == "Hello, World!"
def test_hello_handler_response_contains_timestamp():
event = mock_event(method="GET", path="/hello")
response = hello_handler(event)
body = json.loads(response["body"])
assert "timestamp" in body
# SECTION 3: TEST DOCUMENTATION HANDLER
# Test /get-documentation and / endpoints
def test_documentation_handler_returns_200():
"""Test /get-documentation returns status 200"""
response = documentation_handler()
assert response["statusCode"]== 200
def test_documentation_content_type_is_html():
"""/get-documentation content-Type is 'test/html"""
response = documentation_handler()
assert response["headers"]["Content-Type"]== "text/html"
# SECTION 4
# TEST ERROR
"""
WHY TEST THE ERROR HELPER?
error_response is used for both 404 and 405. If it breaks,
the entire error handling breaks.
"""
def test_404_error_response():
"""
Test 404 error response has correct status code and error message
"""
response = error_response(400, "Endpoint not found")
body = json.loads(response["body"])
assert response["statusCode"] == 400
assert body["error"] =="Endpoint not found"
def test_405_error_response():
"""
Test 405 error response has correct status code and error message
"""
response = error_response(405, "Method not allowed")
body = json.loads(response["body"])
assert response["statusCode"] == 405
assert body["error"] == "Method not allowed"
def test_error_response_cors_header_present():
"""Error responses needs CORS header."""
response = error_response(404, "Endpoint not found")
assert response["headers"]["Access-Control-Allow-Origin"] == "*"
Code Explanation
mock_event(method="GET", path="/", query_params=None) is a helper function that builds a fake API Gateway event.
In production, API Gateway builds the event dictionary and passes it to Lambda. In tests, you build it yourself. This is called mocking. You only include the fields your app.py actually reads, keeping it minimal and focused.
Test Routing
These tests hit lambda_handler — the front door of your API. If routing breaks, everything breaks. They verify:
-
GET /returns 200 -
GET /helloreturns 200 -
GET /get-documentationreturns 200 - Unknown paths return
404 - Non-GET methods (
POST,PUT) return 405
Test Hello Handler
Notice json.loads(). Lambda returns body as a JSON string, not a dict. You must parse it back before checking individual fields.
These tests verify:
- Named greeting works (
?name=Gloria→Hello, Gloria!) - Default fallback works (no name →
Hello, World!) - Both
Noneand{}query params are handled — API Gateway can send either - Response always includes a
timestampfield
Test Documentation Endpoint
Two things worth noting here:
- The documentation endpoint returns
text/html— tested explicitly because the wrongContent-Typewould cause browsers to display raw text instead of a rendered page - Even error responses need CORS headers. If a browser gets a 404 without a CORS header, the browser blocks it and the client sees a confusing network error instead of the actual 404 message
Test Error Response
Each function tests one specific aspect of error_response():
-
test_404_error_response: test 404 error status code and error message are both correct. -
test_405_error_response: test 405 error status code and error message are both correct. -
test_error_response_cors_header_present: CORS headers must be present even on errors. Without them, browsers block the error response entirely and the client sees a confusing network error instead of the actual 404 or 405 message.
Step 11: Run the Unit Tests
First, install pytest if you haven’t already done so from the prerequisite section:
pip install pytest
Run this from your project root:
pytest tests/unit/test_app.py -v
The -v flag means verbose. It prints each test name so you can see exactly what passed or failed.
You should see something like this:
test/unit/test_app.py::test_hello_handler_response_contains_timestamp PASSED [ 66%]
test/unit/test_app.py::test_documentation_handler_returns_200 PASSED [ 73%]
test/unit/test_app.py::test_documentation_content_type_is_html PASSED [ 80%]
test/unit/test_app.py::test_404_error_response PASSED [ 86%]
test/unit/test_app.py::test_405_error_response PASSED [ 93%]
test/unit/test_app.py::test_error_response_cors_header_present PASSED [100%]
======================================================================= 15 passed in 0.16s ========================================================================
15 tests. 0.16 seconds. No AWS account needed.
Every time you change app.py, run pytest before you deploy. If a test fails, you caught the bug locally, not in production.
The pattern is simple: write code → write tests → deploy with confidence.
After the unit tests come integration tests, which are covered in Part 2 of this article.
Challenges:
Writing unit tests was the hardest part of this article. It required learning from multiple sources, comparing approaches, and deliberately settling on best practices rather than just copying the first example found. The learning curve is real, but it's worth it.
The
__init__.pyfiles were confusing at first. It wasn't obvious why empty files were needed at all. Once the concept of "Python packages" clicked — that__init__.pyis what tells Python "treat this folder as importable" — everything fell into place.Routing in
app.pywent through several iterations. The final clean structure didn't appear on the first try. It took a few rewrites before the cost optimization principle emerged: reject non-GET requests first, before any real work happens, to avoid unnecessary Lambda invocations.Mapping Lambda to events in
template.yamlwasn't intuitive — understanding thatRestApiId: !Ref HelloWorldApiis what connects a route to your API Gateway (not a new one SAM creates automatically) took time to fully grasp.Understanding why CORS headers matter on error responses was a hard-won insight. It feels counterintuitive at first. Why would a 404 need CORS? The answer: without it, the browser blocks the error entirely, and the client sees a confusing network failure instead of the actual error message.
What I Learned
A SAM template is a contract — it describes the infrastructure your code needs, and SAM handles creating it. Understanding
template.yamlmakes everything else — deployment, local testing, debugging — much less mysterious.mock_event()is the heart of unit testing a Lambda function — once you understand that API Gateway just builds a dictionary and passes it to your function, testing becomes simple: build the dictionary yourself and check the output.Automated tests reveal what the console hides: Console testing tells you whether something looks right. Automated tests tell you whether it is right including error messages, status codes, and headers that never appear on screen.
Write tests while you build, not after — tests written after the fact feel like chores. Tests written alongside the code feel like guardrails — you immediately see the value.
Conclusion
You’ve built a serverless REST API from scratch — a SAM template, a Lambda function with proper routing and error handling, a documentation endpoint, and a full unit test suite with 15 tests.
More importantly, you’ve developed a habit: write code → write tests → deploy with confidence. That loop is what separates code that probably works from code you can trust.
In Part 2, we’ll move from unit tests to integration tests — testing the real API Gateway, the real Lambda, and the real AWS infrastructure to make sure everything works end-to-end and final deployment to production AWS.
Resources
Connect with me on:















Top comments (0)