DEV Community

Cover image for Build Your First AWS REST API the Smart Way — Unit & Integration Tests, Live Docs, and Infrastructure as Code
Gloria for AWS Community Builders

Posted on

Build Your First AWS REST API the Smart Way — Unit & Integration Tests, Live Docs, and Infrastructure as Code

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=Gloria make 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:

  1. GET / : Serves a live documentation page
  2. GET /hello : Returns a personalised JSON greeting
  3. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 &amp; Lambda &middot; 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
        })
    }
Enter fullscreen mode Exit fullscreen mode

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", "/")
Enter fullscreen mode Exit fullscreen mode

These two lines extract the HTTP method and path from the incoming request and store them in variables.

Note: event only 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")
Enter fullscreen mode Exit fullscreen mode

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")
Enter fullscreen mode Exit fullscreen mode

First, it extracts the query parameters from the event. For example, if you type http://localhost:3000/hello?name=John in your browser:

  • query_params would 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 pass None for queryStringParameters when there are no query params, and calling .get() on None would 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"
    }
Enter fullscreen mode Exit fullscreen mode
  • Return data:
return {
        "statusCode": 200,
        "headers": {
            **COMMON_HEADERS,
            "Content-Type": "application/json"
        },
        "body": json.dumps(response_body)
    }
Enter fullscreen mode Exit fullscreen mode

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 with json.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
Enter fullscreen mode Exit fullscreen mode

Compare above content-Type to /hello:

"Content-Type": "application/json"   # ← this tells the browser to display raw JSON
Enter fullscreen mode Exit fullscreen mode

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:

  1. receive a request,
  2. extract parameters
  3. build a response, and
  4. 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
Enter fullscreen mode Exit fullscreen mode

Understanding the SAM Template

The template is organized into four sections.

Version declaration and Transform

AWSTemplateFormatVersion: '2010-09-09'
Enter fullscreen mode Exit fullscreen mode

This tells AWS “I’m using CloudFormation syntax from 2010–09–09.”

Transform: AWS::Serverless-2016-10-31
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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'"
Enter fullscreen mode Exit fullscreen mode
  • StageName: Prod: creates /Prod in 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
Enter fullscreen mode Exit fullscreen mode
  • 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 is filename.function_name, so this means "call the lambda_handler function inside app.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
Enter fullscreen mode Exit fullscreen mode
  1. **RootApi** : handles GET / request
  2. **HelloApi**: handles GET /hello request
  3. **DocumentationApi**: handles GET /get-documentation request

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: only GET requests 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Understanding output

  • **RootUrl** : the root endpoint of your API (/). This is where the documentation page is served.
  • **HelloApiUrl** : the /hello endpoint URL.
  • **DocumentationUrl**: the /get-documentation endpoint 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"
Enter fullscreen mode Exit fullscreen mode

becomes something like:

https://abc123.execute-api.us-east-1.amazonaws.com/Prod/hello
Enter fullscreen mode Exit fullscreen mode

Understanding !GetAtt

!GetAtt retrieves an attribute of a resource created in the same template. For example:

  • !GetAtt HelloWorldFunction.Arn:gets the ARN of the HelloWorldFunction Lambda.
  • !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:

  1. **Globals** — sets default Lambda settings (timeout, runtime, memory).
  2. **API Gateway** — creates and configures the API Gateway with throttling, CORS, and metrics.
  3. **Function** — creates the Lambda function and wires up the code.
  4. **Events** — connects API routes (/, /hello, /get-documentation) to the Lambda function.
  5. **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** : the if method != "GET" check rejects all non-GET requests before any real work happens, protecting your Lambda function from unnecessary invocations and cost.
  • Throttling : ThrottlingBurstLimit and ThrottlingRateLimit protect 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
Enter fullscreen mode Exit fullscreen mode

You should see: template.yaml is a valid SAM Template

Figure 1: sam validate command and output


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
Enter fullscreen mode Exit fullscreen mode

Figure 2: The requirements.txt warning is expected — sam build succeeded because this API uses only Python’s built-in libraries.


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 like Error: Running AWS SAM projects locally requires Docker and the local API will not start.

sam local start-api
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Figure 3: command and output of  raw `sam local start-api` endraw

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 see Mounting HelloWorldFunction at http://127.0.0.1:3000 and your API is ready. On future runs, use --skip-pull-image to 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:

  1. Keep the terminal running. Do not close it. Closing it stops your local API.
  2. Open Chrome browser and enter http://localhost:3000/hello in the URL bar
  3. Switch back to your terminal and you will should see a log line confirming the request was handled, with an HTTP 200 status code
  4. Switch back to your browser to see your JSON response

Figure 4:browser showing response of  raw `http://localhost:3000/hello` endraw

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

Figure 5: browser showing response on  raw `http://localhost:3000/hello?name=Gloria` endraw

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

Figure 6: browser displaying  raw `/get-documentation` endraw  endpoint

Test the root / endpoint — should also serve the documentation page

Enter this in a new tab in your browser: http://localhost:3000/

Figure 7: root  raw `/` endraw  endpoint also display API documentation

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:

  1. Terminal 1 — start the API and leave it running:
sam local start-api
Enter fullscreen mode Exit fullscreen mode
  1. Terminal 2 — open a new Git Bash window and run this curl command to test the /hello endpoint:
curl http://localhost:3000/hello
Enter fullscreen mode Exit fullscreen mode

Terminal 2 shows the actual JSON response from curl.

Figure 8:  raw `curl http://localhost:3000/hello` endraw  and expected output

  1. 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 -
Enter fullscreen mode Exit fullscreen mode

Figure 9: http request logs in git bash

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
Enter fullscreen mode Exit fullscreen mode

Figure 10:  raw `curl http://localhost:3000/hello?name=Gloria` endraw  and expected output

Test GET /get-documentation — should serve the documentation page:

curl http://localhost:3000/get-documentation
Enter fullscreen mode Exit fullscreen mode

Figure 11: truncated output of the command  raw `curl http://localhost:3000/get-documentation` endraw

Test the root / endpoint should display the documentation page source:

curl http://localhost:3000/
Enter fullscreen mode Exit fullscreen mode

Figure 12: truncated output of  raw `curl http://localhost:3000/` endraw

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 GET requests from the address bar. You cannot test POST or PUT methods 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
Enter fullscreen mode Exit fullscreen mode

Figure 13:  raw `POST` endraw  and  raw `PUT` endraw  request logs in Git Bash

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

Figure 14:  raw `POST` endraw  and  raw `PUT` endraw  request logs in git bash

GET request log
Figure 15:  raw `GET` endraw  request logs in git bash

Notice what's missing in POST and PUT request logs(Figure 14):
No START RequestId, no Billed Duration. The POST and PUT requests 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
Enter fullscreen mode Exit fullscreen mode

On Windows (Command Prompt):

type nul > hello_world\__init__.py
type nul > tests\__init__.py
type nul > tests\unit\__init__.py
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Add this content:

[pytest]  
pythonpath = .
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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"] == "*"
Enter fullscreen mode Exit fullscreen mode

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 /hello returns 200
  • GET /get-documentation returns 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=GloriaHello, Gloria!)
  • Default fallback works (no name → Hello, World!)
  • Both None and {} query params are handled — API Gateway can send either
  • Response always includes a timestamp field

Test Documentation Endpoint

Two things worth noting here:

  • The documentation endpoint returns text/html — tested explicitly because the wrong Content-Type would 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
Enter fullscreen mode Exit fullscreen mode

Run this from your project root:

pytest tests/unit/test_app.py -v
Enter fullscreen mode Exit fullscreen mode

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 ========================================================================
Enter fullscreen mode Exit fullscreen mode

Figure 16: pytest output

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:

  1. 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.

  2. The __init__.py files were confusing at first. It wasn't obvious why empty files were needed at all. Once the concept of "Python packages" clicked — that __init__.py is what tells Python "treat this folder as importable" — everything fell into place.

  3. Routing in app.py went 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.

  4. Mapping Lambda to events in template.yaml wasn't intuitive — understanding that RestApiId: !Ref HelloWorldApi is what connects a route to your API Gateway (not a new one SAM creates automatically) took time to fully grasp.

  5. 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

  1. A SAM template is a contract — it describes the infrastructure your code needs, and SAM handles creating it. Understanding template.yaml makes everything else — deployment, local testing, debugging — much less mysterious.

  2. 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.

  3. 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.

  4. 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)