A Dockerized REST API for a personal contacts / address book, built with FastAPI. It provides authenticated CRUD operations with JWT while keeping the architecture small and focused.
Built by Jatniel Guzmán (jatniel.dev) for the DevChallenges — Week 34 organized by YoanDev.
Build a complete REST API to manage an address book using GET, POST, PUT
and DELETE. The challenge is pure backend: validate the API with Postman and
do not build a frontend.
The API lets a user authenticate and then create, list, read, replace and delete their own contacts. Contacts are scoped to the authenticated user: nobody can see or modify someone else's contacts.
- OAuth2 password flow + JWT authentication (Argon2 password hashing)
- Full CRUD for contacts (GET, POST, PUT, DELETE)
- Per-user data isolation
- Case-insensitive search and
offset/limitpagination - SQLModel + SQLite with Alembic migrations
- Automated tests (≥ 90% coverage)
- Ruff formatting and linting
- Docker + Docker Compose, one service
- GitHub Actions CI
| Concern | Choice |
|---|---|
| Language | Python 3.13 |
| Framework | FastAPI |
| ORM / data layer | SQLModel |
| Database | SQLite (synchronous access) |
| Migrations | Alembic |
| Authentication | OAuth2 Password flow + JWT (PyJWT) |
| Password hashing | pwdlib (Argon2) |
| Configuration | pydantic-settings |
| Testing | pytest + FastAPI TestClient |
| Lint / format | Ruff |
| Container | Docker / Docker Compose |
| CI | GitHub Actions |
.
├── app/
│ ├── main.py # FastAPI app, lifespan (demo seed)
│ ├── api/
│ │ ├── dependencies.py # session + current-user dependencies
│ │ └── v1/
│ │ ├── router.py # /api/v1 router
│ │ ├── auth.py # POST /auth/token
│ │ └── contacts.py # contact schemas + CRUD endpoints
│ ├── core/
│ │ ├── config.py # pydantic-settings
│ │ └── security.py # password hashing + JWT
│ ├── db/
│ │ ├── session.py # engine + session dependency
│ │ └── seed.py # idempotent demo-user seed
│ └── models/
│ ├── user.py
│ └── contact.py
├── alembic/ # migrations
├── tests/ # pytest suite, including SQLite FK coverage
├── postman/ # Postman collection
├── .github/workflows/tests.yml
├── compose.yaml
├── Dockerfile
├── requirements.txt
├── requirements-dev.txt
└── pyproject.toml
| Method | Path | Auth | Description |
|---|---|---|---|
| POST | /api/v1/auth/token |
public | Obtain a JWT |
| GET | /api/v1/contacts |
Bearer | List own contacts |
| POST | /api/v1/contacts |
Bearer | Create a contact |
| GET | /api/v1/contacts/{id} |
Bearer | Read one contact |
| PUT | /api/v1/contacts/{id} |
Bearer | Replace a contact |
| DELETE | /api/v1/contacts/{id} |
Bearer | Delete a contact |
| GET | /health |
public | Health check |
List contacts supports offset (default 0), limit (default 20, max 100) and
q (case-insensitive search over first name, last name, email and company).
POST /api/v1/auth/token accepts an OAuth2 form body. The demo email goes in the
username field:
curl -X POST http://localhost:8000/api/v1/auth/token \
-d "username=demo@example.com&password=demo-password"The response is a JWT used as a bearer token:
{ "access_token": "...", "token_type": "bearer" }Demo credentials (clearly demo values, configurable via environment variables):
- email:
demo@example.com - password:
demo-password
Only Docker and Docker Compose are required. No local Python install is needed.
- Create the local configuration file:
cp .env.example .env- Generate a secret and paste its output after
JWT_SECRET_KEY=in.env:
openssl rand -hex 32For example, if the command returns abc..., set
JWT_SECRET_KEY=abc.... Do not leave this value empty and do not commit .env.
- Start the API:
docker compose up --buildThe container applies Alembic migrations on startup, then serves the API. Open:
- http://localhost:8000/health
- http://localhost:8000/docs
- http://localhost:8000/redoc
- http://localhost:8000/openapi.json
Stop with:
docker compose downSQLite data is persisted in ./data via a bind mount.
All Python commands run inside the container:
docker compose run --rm api pytest
docker compose run --rm api pytest --cov=app --cov-report=term-missing --cov-fail-under=90
docker compose run --rm api ruff check .
docker compose run --rm api ruff format .
docker compose run --rm api alembic upgrade headCopy .env.example to .env, then replace the empty JWT_SECRET_KEY value
with a unique value of at least 32 characters. Generate one with
openssl rand -hex 32 and never commit .env.
| Variable | Description | Example |
|---|---|---|
APP_NAME |
Application name | Contacts API |
ENVIRONMENT |
Runtime environment | development |
DATABASE_URL |
SQLite URL | sqlite:///./data/contacts.db |
JWT_SECRET_KEY |
Secret used to sign JWTs | (see .env.example) |
JWT_ALGORITHM |
JWT signing algorithm | HS256 |
ACCESS_TOKEN_EXPIRE_MINUTES |
Token lifetime in minutes | 30 |
DEMO_USER_EMAIL |
Seeded demo user email | demo@example.com |
DEMO_USER_PASSWORD |
Seeded demo user password | demo-password |
Alembic is the source of truth for schema changes (no create_all() at startup).
docker compose run --rm api alembic revision --autogenerate -m "describe change"
docker compose run --rm api alembic upgrade head
docker compose run --rm api alembic currentdocker compose run --rm api pytest --cov=app --cov-report=term-missing --cov-fail-under=90Tests use an isolated in-memory SQLite database (StaticPool) and FastAPI
dependency overrides, so the development database is never touched.
The repository includes a ready-to-use collection at
postman/contacts-api.postman_collection.json.
- Start the API with
docker compose up. - In Postman, import
postman/contacts-api.postman_collection.json. - Run
Auth / Loginwith the demo credentials, or the values configured in your.envfile. The request stores the returned JWT in theaccess_tokencollection variable automatically. - Run
Contacts / Listto view your contacts. An empty array is expected for a new demo user. - Run
Contacts / Createto add a contact. The request stores the returned identifier in thecontact_idcollection variable automatically. - Run
Contacts / Get,Contacts / UpdateorContacts / Deleteto manage that contact. These requests reuse both variables automatically, so no token or contact ID needs to be copied manually.
Contacts / List also accepts optional offset, limit and q query
parameters. Use q to search case-insensitively by first name, last name,
email or company.
The collection includes the following requests:
| Folder | Request | Purpose |
|---|---|---|
| Root | Health |
Check that the API is running |
| Auth | Login |
Obtain and store a JWT |
| Contacts | List |
Read all contacts belonging to the authenticated user |
| Contacts | Create |
Create a contact and store its ID |
| Contacts | Get |
Read the stored contact by ID |
| Contacts | Update |
Replace the stored contact data |
| Contacts | Delete |
Permanently delete the stored contact |
docker compose run --rm api ruff format --check .
docker compose run --rm api ruff check .- Why FastAPI? Strong fit for typed APIs: validation, dependency injection and OpenAPI docs out of the box.
- Why SQLModel? Combines SQLAlchemy-style persistence with Pydantic-style models, and integrates naturally with FastAPI.
- Why SQLite? The challenge is a small, self-contained API. SQLite keeps the demo trivial to run without extra infrastructure.
- Why synchronous DB access? Local SQLite gives no benefit from async I/O. Sync code is simpler and avoids fake concurrency complexity.
- Why Alembic if SQLite is simple? To demonstrate controlled schema evolution instead of recreating tables from ORM metadata at runtime.
- Why JWT? It demonstrates a realistic protected API while keeping authentication intentionally minimal.
- Why no service/repository layers? For a small CRUD API, extra layers add indirection without enough value. This is an intentional choice, not an omission; the structure can evolve if business logic grows.
- User registration and forgot-password flow
- PATCH for partial updates and refresh tokens
- Tags / categories on contacts
- Rate limiting
- PostgreSQL for heavier workloads
- Jatniel Guzmán — jatniel.dev
- GitHub: @jatniel
- LinkedIn: in/jatniel
This project is licensed under the MIT License. See LICENSE.
Copyright (c) 2026 Jatniel Guzmán.