Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions examples/fastapi-cicd/.github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
name: Functional Tests

on:
push:
branches: [main]
pull_request:
branches: [main]

jobs:
test:
runs-on: ubuntu-24.04-arm

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"

- name: Install uv
uses: astral-sh/setup-uv@v4

- name: Install dependencies
run: |
uv venv
uv pip install -e ".[dev]"

- name: Set up AWS SAM CLI
uses: aws-actions/setup-sam@v2

- name: Build Docker image
run: docker build -t hello-world-lambda .

- name: Build SAM application
run: sam build

- name: Run functional tests
run: uv run pytest tests/test_functional.py -v
16 changes: 16 additions & 0 deletions examples/fastapi-cicd/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Python
__pycache__/
*.py[cod]
*$py.class
.venv/
*.egg-info/

# SAM build artifacts
.aws-sam/

# pytest
.pytest_cache/

# IDE
.idea/
.vscode/
27 changes: 27 additions & 0 deletions examples/fastapi-cicd/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
FROM public.ecr.aws/docker/library/python:3.12-slim AS base

# Install Lambda Web Adapter
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /var/task

# Copy dependency files
COPY pyproject.toml uv.lock ./

# Copy application code first (needed for uv sync to install project)
COPY app/ ./app/

# Install dependencies using uv with locked versions
RUN uv sync --frozen --no-dev

# Port for the application - Lambda Web Adapter will forward to this
ENV PORT=8000

# For local Docker runs (not Lambda), expose the port
EXPOSE 8000

# Run the FastAPI app with uvicorn using the venv
CMD ["uv", "run", "uvicorn", "--host", "0.0.0.0", "--port", "8000", "app.main:app"]
100 changes: 100 additions & 0 deletions examples/fastapi-cicd/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# FastAPI CI/CD Example

A FastAPI application example with functional tests that run both locally and in CI/CD pipelines. This example demonstrates how to test Lambda Web Adapter applications using both Docker and SAM local.

See the full working repository with CI/CD: [andyreagan/aws-lamdba-web-adapter-functional-testing](https://github.com/andyreagan/aws-lamdba-web-adapter-functional-testing)

## Key Features

- **Docker testing**: Validates the app works as a standalone web server
- **SAM local testing**: Validates the app works inside the Lambda runtime with the Web Adapter
- **CI/CD ready**: GitHub Actions workflow included (see `.github/workflows/test.yml`)
- **Modern Python tooling**: Uses `uv` for fast, reproducible dependency management

## Application Structure

```
fastapi-cicd/
├── app/
│ ├── __init__.py
│ └── main.py # FastAPI application
├── tests/
│ ├── conftest.py # Test fixtures for server management
│ └── test_functional.py
├── .github/
│ └── workflows/
│ └── test.yml # CI/CD pipeline
├── Dockerfile
├── template.yaml # SAM template
├── pyproject.toml
└── uv.lock
```

## Dockerfile

```dockerfile
FROM public.ecr.aws/docker/library/python:3.12-slim AS base

# Install Lambda Web Adapter
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter

# Install uv
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv

WORKDIR /var/task
COPY pyproject.toml uv.lock ./
COPY app/ ./app/
RUN uv sync --frozen --no-dev

ENV PORT=8000
EXPOSE 8000
CMD ["uv", "run", "uvicorn", "--host", "0.0.0.0", "--port", "8000", "app.main:app"]
```

## Pre-requisites

- [AWS CLI](https://aws.amazon.com/cli/)
- [SAM CLI](https://github.com/awslabs/aws-sam-cli)
- [Python 3.12+](https://www.python.org/)
- [Docker](https://www.docker.com/products/docker-desktop)
- [uv](https://github.com/astral-sh/uv)

## Setup

```bash
uv sync
```

## Running Tests Locally

```bash
# Build both Docker image and SAM application
docker build -t hello-world-lambda .
sam build

# Run all functional tests
uv run pytest tests/test_functional.py -v
```

The tests spin up:
1. **Docker container directly** - validates the app works as a web server
2. **SAM local** - validates the app works inside the Lambda runtime with the Web Adapter

## Deploy to Lambda

Build and deploy using SAM:

```bash
sam build
sam deploy --guided
```

## Run Docker Locally

Run the same Docker image locally (portable to ECS/EKS):

```bash
docker run -d -p 8000:8000 hello-world-lambda
curl localhost:8000/
curl localhost:8000/health
```
Empty file.
13 changes: 13 additions & 0 deletions examples/fastapi-cicd/app/main.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
from fastapi import FastAPI

app = FastAPI()


@app.get("/")
async def root():
return {"message": "Hello World"}


@app.get("/health")
async def health():
return {"status": "healthy"}
22 changes: 22 additions & 0 deletions examples/fastapi-cicd/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
[project]
name = "fastapi-cicd"
version = "0.1.0"
description = "FastAPI Hello World with Lambda Web Adapter and CI/CD testing"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.104.0",
"uvicorn>=0.24.0",
]

[project.optional-dependencies]
dev = [
"pytest>=7.4.0",
"httpx>=0.25.0",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["app"]
31 changes: 31 additions & 0 deletions examples/fastapi-cicd/template.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: FastAPI Hello World with Lambda Web Adapter

Globals:
Function:
Timeout: 30
MemorySize: 256
Architectures:
- arm64

Resources:
HelloWorldFunction:
Type: AWS::Serverless::Function
Properties:
PackageType: Image
Events:
ApiEvents:
Type: HttpApi
Metadata:
Dockerfile: Dockerfile
DockerContext: .
DockerTag: hello-world-lambda

Outputs:
HelloWorldApi:
Description: API Gateway endpoint URL
Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/"
HelloWorldFunction:
Description: Lambda Function ARN
Value: !GetAtt HelloWorldFunction.Arn
Empty file.
57 changes: 57 additions & 0 deletions examples/fastapi-cicd/tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import time
from collections.abc import Callable
from contextlib import contextmanager
from http.client import HTTPConnection
from subprocess import Popen
from typing import Any


def gen_background_server_ctxmanager(
cmd: list | None = None,
cwd: str = ".",
port: int = 8000,
healthendpoint: str = "/",
wait_seconds: int = 5,
**kwargs: Any,
) -> Callable:
if cmd is None:
cmd = ["python", "-m", "http.server"]

@contextmanager
def server():
print(f"opening server {cmd=} at {cwd=}")
retries = 10
process = Popen(cmd, cwd=cwd, **kwargs)
# give it 1 second right off the bat
time.sleep(1)
while retries > 0:
conn = HTTPConnection(f"localhost:{port}", timeout=10)
try:
conn.request("HEAD", healthendpoint)
response = conn.getresponse()
if response is not None:
print(f"health check for {cmd=} got a response")
conn.close()
time.sleep(1) # Give server time to stabilize
yield process
break
except (ConnectionRefusedError, ConnectionResetError, OSError) as e:
# ConnectionRefusedError: server not listening yet
# ConnectionResetError: server accepted connection but reset it
# OSError: other network errors (e.g., connection aborted)
print(f"failed health check for {cmd=} ({e!r}), waiting {wait_seconds=}")
conn.close()
time.sleep(wait_seconds)
retries -= 1

if not retries:
raise RuntimeError(f"Failed to start server at {port=}")
else:
print(f"terminating process after {retries}")
# do it twice for good measure
process.terminate()
time.sleep(1)
process.terminate()
time.sleep(1)

return server
86 changes: 86 additions & 0 deletions examples/fastapi-cicd/tests/test_functional.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import httpx
import pytest

from tests.conftest import gen_background_server_ctxmanager

# Docker container port
DOCKER_PORT = 8000
# SAM local API port
SAM_PORT = 3000


class TestDockerContainer:
"""Functional tests for the Docker container running directly."""

@pytest.fixture(scope="class")
def docker_server(self):
"""Start the Docker container and yield when ready."""
docker_run_cmd = [
"docker",
"run",
"--rm",
"-p",
f"{DOCKER_PORT}:8000",
"hello-world-lambda",
]
server_ctx = gen_background_server_ctxmanager(
cmd=docker_run_cmd,
port=DOCKER_PORT,
healthendpoint="/health",
wait_seconds=5,
)
with server_ctx() as process:
yield process

def test_docker_root_endpoint(self, docker_server):
"""Test the root endpoint returns Hello World."""
response = httpx.get(f"http://localhost:{DOCKER_PORT}/")
assert response.status_code == 200
data = response.json()
assert data == {"message": "Hello World"}

def test_docker_health_endpoint(self, docker_server):
"""Test the health endpoint returns healthy status."""
response = httpx.get(f"http://localhost:{DOCKER_PORT}/health")
assert response.status_code == 200
data = response.json()
assert data == {"status": "healthy"}


class TestSAMLocal:
"""Functional tests for SAM local (Lambda simulation)."""

@pytest.fixture(scope="class")
def sam_server(self):
"""Start SAM local API and yield when ready."""
sam_cmd = [
"sam",
"local",
"start-api",
"--port",
str(SAM_PORT),
"--warm-containers",
"EAGER",
]
server_ctx = gen_background_server_ctxmanager(
cmd=sam_cmd,
port=SAM_PORT,
healthendpoint="/health",
wait_seconds=5,
)
with server_ctx() as process:
yield process

def test_sam_root_endpoint(self, sam_server):
"""Test the root endpoint via SAM local returns Hello World."""
response = httpx.get(f"http://localhost:{SAM_PORT}/")
assert response.status_code == 200
data = response.json()
assert data == {"message": "Hello World"}

def test_sam_health_endpoint(self, sam_server):
"""Test the health endpoint via SAM local returns healthy status."""
response = httpx.get(f"http://localhost:{SAM_PORT}/health")
assert response.status_code == 200
data = response.json()
assert data == {"status": "healthy"}
Loading
Loading