Skip to content

Commit 626487f

Browse files
committed
Add fastapi-cicd example with functional testing
This example demonstrates how to run functional tests for Lambda Web Adapter applications both locally and in CI/CD pipelines (GitHub Actions). Features: - FastAPI application with health endpoint - Docker container testing - SAM local testing for Lambda simulation - GitHub Actions workflow - Modern Python tooling with uv
1 parent 1a71d75 commit 626487f

File tree

12 files changed

+725
-0
lines changed

12 files changed

+725
-0
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
name: Functional Tests
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
jobs:
10+
test:
11+
runs-on: ubuntu-24.04-arm
12+
13+
steps:
14+
- uses: actions/checkout@v4
15+
16+
- name: Set up Python
17+
uses: actions/setup-python@v5
18+
with:
19+
python-version: "3.12"
20+
21+
- name: Install uv
22+
uses: astral-sh/setup-uv@v4
23+
24+
- name: Install dependencies
25+
run: |
26+
uv venv
27+
uv pip install -e ".[dev]"
28+
29+
- name: Set up AWS SAM CLI
30+
uses: aws-actions/setup-sam@v2
31+
32+
- name: Build Docker image
33+
run: docker build -t hello-world-lambda .
34+
35+
- name: Build SAM application
36+
run: sam build
37+
38+
- name: Run functional tests
39+
run: uv run pytest tests/test_functional.py -v

examples/fastapi-cicd/.gitignore

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# Python
2+
__pycache__/
3+
*.py[cod]
4+
*$py.class
5+
.venv/
6+
*.egg-info/
7+
8+
# SAM build artifacts
9+
.aws-sam/
10+
11+
# pytest
12+
.pytest_cache/
13+
14+
# IDE
15+
.idea/
16+
.vscode/

examples/fastapi-cicd/Dockerfile

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
FROM public.ecr.aws/docker/library/python:3.12-slim AS base
2+
3+
# Install Lambda Web Adapter
4+
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
5+
6+
# Install uv
7+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
8+
9+
WORKDIR /var/task
10+
11+
# Copy dependency files
12+
COPY pyproject.toml uv.lock ./
13+
14+
# Copy application code first (needed for uv sync to install project)
15+
COPY app/ ./app/
16+
17+
# Install dependencies using uv with locked versions
18+
RUN uv sync --frozen --no-dev
19+
20+
# Port for the application - Lambda Web Adapter will forward to this
21+
ENV PORT=8000
22+
23+
# For local Docker runs (not Lambda), expose the port
24+
EXPOSE 8000
25+
26+
# Run the FastAPI app with uvicorn using the venv
27+
CMD ["uv", "run", "uvicorn", "--host", "0.0.0.0", "--port", "8000", "app.main:app"]

examples/fastapi-cicd/README.md

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
# FastAPI CI/CD Example
2+
3+
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.
4+
5+
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)
6+
7+
## Key Features
8+
9+
- **Docker testing**: Validates the app works as a standalone web server
10+
- **SAM local testing**: Validates the app works inside the Lambda runtime with the Web Adapter
11+
- **CI/CD ready**: GitHub Actions workflow included (see `.github/workflows/test.yml`)
12+
- **Modern Python tooling**: Uses `uv` for fast, reproducible dependency management
13+
14+
## Application Structure
15+
16+
```
17+
fastapi-cicd/
18+
├── app/
19+
│ ├── __init__.py
20+
│ └── main.py # FastAPI application
21+
├── tests/
22+
│ ├── conftest.py # Test fixtures for server management
23+
│ └── test_functional.py
24+
├── .github/
25+
│ └── workflows/
26+
│ └── test.yml # CI/CD pipeline
27+
├── Dockerfile
28+
├── template.yaml # SAM template
29+
├── pyproject.toml
30+
└── uv.lock
31+
```
32+
33+
## Dockerfile
34+
35+
```dockerfile
36+
FROM public.ecr.aws/docker/library/python:3.12-slim AS base
37+
38+
# Install Lambda Web Adapter
39+
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
40+
41+
# Install uv
42+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
43+
44+
WORKDIR /var/task
45+
COPY pyproject.toml uv.lock ./
46+
COPY app/ ./app/
47+
RUN uv sync --frozen --no-dev
48+
49+
ENV PORT=8000
50+
EXPOSE 8000
51+
CMD ["uv", "run", "uvicorn", "--host", "0.0.0.0", "--port", "8000", "app.main:app"]
52+
```
53+
54+
## Pre-requisites
55+
56+
- [AWS CLI](https://aws.amazon.com/cli/)
57+
- [SAM CLI](https://github.com/awslabs/aws-sam-cli)
58+
- [Python 3.12+](https://www.python.org/)
59+
- [Docker](https://www.docker.com/products/docker-desktop)
60+
- [uv](https://github.com/astral-sh/uv)
61+
62+
## Setup
63+
64+
```bash
65+
uv sync
66+
```
67+
68+
## Running Tests Locally
69+
70+
```bash
71+
# Build both Docker image and SAM application
72+
docker build -t hello-world-lambda .
73+
sam build
74+
75+
# Run all functional tests
76+
uv run pytest tests/test_functional.py -v
77+
```
78+
79+
The tests spin up:
80+
1. **Docker container directly** - validates the app works as a web server
81+
2. **SAM local** - validates the app works inside the Lambda runtime with the Web Adapter
82+
83+
## Deploy to Lambda
84+
85+
Build and deploy using SAM:
86+
87+
```bash
88+
sam build
89+
sam deploy --guided
90+
```
91+
92+
## Run Docker Locally
93+
94+
Run the same Docker image locally (portable to ECS/EKS):
95+
96+
```bash
97+
docker run -d -p 8000:8000 hello-world-lambda
98+
curl localhost:8000/
99+
curl localhost:8000/health
100+
```

examples/fastapi-cicd/app/__init__.py

Whitespace-only changes.

examples/fastapi-cicd/app/main.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
from fastapi import FastAPI
2+
3+
app = FastAPI()
4+
5+
6+
@app.get("/")
7+
async def root():
8+
return {"message": "Hello World"}
9+
10+
11+
@app.get("/health")
12+
async def health():
13+
return {"status": "healthy"}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
[project]
2+
name = "fastapi-cicd"
3+
version = "0.1.0"
4+
description = "FastAPI Hello World with Lambda Web Adapter and CI/CD testing"
5+
requires-python = ">=3.12"
6+
dependencies = [
7+
"fastapi>=0.104.0",
8+
"uvicorn>=0.24.0",
9+
]
10+
11+
[project.optional-dependencies]
12+
dev = [
13+
"pytest>=7.4.0",
14+
"httpx>=0.25.0",
15+
]
16+
17+
[build-system]
18+
requires = ["hatchling"]
19+
build-backend = "hatchling.build"
20+
21+
[tool.hatch.build.targets.wheel]
22+
packages = ["app"]
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
AWSTemplateFormatVersion: '2010-09-09'
2+
Transform: AWS::Serverless-2016-10-31
3+
Description: FastAPI Hello World with Lambda Web Adapter
4+
5+
Globals:
6+
Function:
7+
Timeout: 30
8+
MemorySize: 256
9+
Architectures:
10+
- arm64
11+
12+
Resources:
13+
HelloWorldFunction:
14+
Type: AWS::Serverless::Function
15+
Properties:
16+
PackageType: Image
17+
Events:
18+
ApiEvents:
19+
Type: HttpApi
20+
Metadata:
21+
Dockerfile: Dockerfile
22+
DockerContext: .
23+
DockerTag: hello-world-lambda
24+
25+
Outputs:
26+
HelloWorldApi:
27+
Description: API Gateway endpoint URL
28+
Value: !Sub "https://${ServerlessHttpApi}.execute-api.${AWS::Region}.amazonaws.com/"
29+
HelloWorldFunction:
30+
Description: Lambda Function ARN
31+
Value: !GetAtt HelloWorldFunction.Arn

examples/fastapi-cicd/tests/__init__.py

Whitespace-only changes.
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import time
2+
from collections.abc import Callable
3+
from contextlib import contextmanager
4+
from http.client import HTTPConnection
5+
from subprocess import Popen
6+
from typing import Any
7+
8+
9+
def gen_background_server_ctxmanager(
10+
cmd: list | None = None,
11+
cwd: str = ".",
12+
port: int = 8000,
13+
healthendpoint: str = "/",
14+
wait_seconds: int = 5,
15+
**kwargs: Any,
16+
) -> Callable:
17+
if cmd is None:
18+
cmd = ["python", "-m", "http.server"]
19+
20+
@contextmanager
21+
def server():
22+
print(f"opening server {cmd=} at {cwd=}")
23+
retries = 10
24+
process = Popen(cmd, cwd=cwd, **kwargs)
25+
# give it 1 second right off the bat
26+
time.sleep(1)
27+
while retries > 0:
28+
conn = HTTPConnection(f"localhost:{port}", timeout=10)
29+
try:
30+
conn.request("HEAD", healthendpoint)
31+
response = conn.getresponse()
32+
if response is not None:
33+
print(f"health check for {cmd=} got a response")
34+
conn.close()
35+
time.sleep(1) # Give server time to stabilize
36+
yield process
37+
break
38+
except (ConnectionRefusedError, ConnectionResetError, OSError) as e:
39+
# ConnectionRefusedError: server not listening yet
40+
# ConnectionResetError: server accepted connection but reset it
41+
# OSError: other network errors (e.g., connection aborted)
42+
print(f"failed health check for {cmd=} ({e!r}), waiting {wait_seconds=}")
43+
conn.close()
44+
time.sleep(wait_seconds)
45+
retries -= 1
46+
47+
if not retries:
48+
raise RuntimeError(f"Failed to start server at {port=}")
49+
else:
50+
print(f"terminating process after {retries}")
51+
# do it twice for good measure
52+
process.terminate()
53+
time.sleep(1)
54+
process.terminate()
55+
time.sleep(1)
56+
57+
return server

0 commit comments

Comments
 (0)