Suot API is a backend engineering project for building a user-owned fashion inventory and recommendation system.
The project is designed as a practical backend learning system. It currently covers REST API design, layered architecture, PostgreSQL persistence, Docker, Alembic migrations, automated testing, authentication, row-level authorization, closet analytics, item filtering, sorting, pagination, and pagination metadata.
Detailed documentation is split into:
DEVLOG.md— day-by-day development history and completed milestones.NOTES.md— reusable backend concepts and current understanding notes.
- Python
- FastAPI
- Pydantic
- pydantic-settings
- SQLAlchemy
- PostgreSQL
- SQLite for local and isolated test scenarios
- Alembic
- Docker and Docker Compose
- uv
- pytest
- Ruff
- Git and GitHub pull requests
- email-validator
- python-multipart
- PyJWT
- pwdlib with Argon2 password hashing
- FastAPI application with automatic Swagger/OpenAPI documentation.
- Health check endpoint.
- Layered router, schema, service, model, and database structure.
- Environment-based configuration.
- PostgreSQL persistence through SQLAlchemy.
- Dockerized API and PostgreSQL services.
- Versioned database schema changes with Alembic.
- User registration.
- Email and username uniqueness validation.
- Argon2 password hashing.
- Safe user responses that exclude passwords and password hashes.
- OAuth2 password-form login.
- Signed JWT access tokens with expiration.
- Bearer token authentication.
- Protected
GET /auth/meendpoint.
- Authenticated item CRUD.
- Each item belongs to one user through
items.user_id. - Ownership is assigned from the authenticated user, not client input.
- Item list, detail, update, and delete operations are scoped to
current_user.id. - Cross-user authorization tests verify private inventory isolation.
- Name.
- Brand.
- Category.
- Color.
- Size.
- Price stored precisely with
Numeric(10, 2)andDecimal. - Purchase date.
- Condition.
- Notes.
- Protected closet analytics endpoint.
- Total item count.
- Total closet value.
- Item counts by category and brand.
- Most expensive item.
- Empty-closet analytics behavior.
- Basic item filtering by category, brand, and condition.
- Price range filtering with
min_priceandmax_price. - Sorting by name, price, and purchase date.
- Sort order support with ascending and descending options.
- Offset-based pagination with
limitandoffset. - Pagination metadata with
total,limit,offset, andhas_more. - Analytics remain scoped to the current user and are not limited by pagination.
- Isolated test database.
- FastAPI dependency overrides.
- Registration, login, and
/auth/metests. - Authenticated item CRUD tests.
- Cross-user authorization tests.
- Richer item field tests.
- Closet analytics tests.
- Basic and combined filtering tests.
- Price range filtering tests.
- Sorting tests.
- Pagination tests.
- Pagination metadata tests.
- Regression test ensuring closet analytics are not limited by item pagination.
GET /healthPOST /auth/register
POST /auth/login
GET /auth/meGET /items
POST /items
GET /items/stats
GET /items/{item_id}
PUT /items/{item_id}
DELETE /items/{item_id}All item endpoints require:
Authorization: Bearer <access_token>GET /items?category=Shirt
GET /items?brand=UNIQLO
GET /items?condition=new
GET /items?category=Shirt&brand=UNIQLOGET /items?min_price=50
GET /items?max_price=150
GET /items?min_price=50&max_price=150GET /items?sort_by=name&sort_order=asc
GET /items?sort_by=price&sort_order=desc
GET /items?sort_by=purchase_date&sort_order=descSupported sort_by values:
name
price
purchase_dateSupported sort_order values:
asc
descInvalid sorting values return 422 Unprocessable Entity.
GET /items?limit=20&offset=0
GET /items?limit=20&offset=20Pagination rules:
limit → how many items to return
offset → how many items to skiplimit must be between 1 and 100.
offset must be 0 or greater.
Invalid pagination values return 422 Unprocessable Entity.
GET /items?category=Shirt&brand=UNIQLO&min_price=25&max_price=100&sort_by=price&sort_order=asc&limit=20&offset=0The service applies query behavior in this order:
ownership
↓
filters
↓
sorting
↓
paginationPOST /auth/register
Content-Type: application/json{
"email": "jim@example.com",
"username": "jim",
"password": "password123"
}Example response:
{
"id": 1,
"email": "jim@example.com",
"username": "jim",
"is_active": true,
"created_at": "2026-08-01T05:12:13.071212Z",
"updated_at": "2026-08-01T05:12:13.071222Z"
}The API never returns:
password
hashed_passwordDuplicate emails or usernames return:
409 ConflictPOST /auth/login
Content-Type: application/x-www-form-urlencodedThe OAuth2 form uses:
username → the user's email address
password → the user's passwordExample response:
{
"access_token": "eyJ...",
"token_type": "bearer"
}Invalid credentials return 401 Unauthorized without revealing whether the email exists.
GET /auth/me
Authorization: Bearer <access_token>This route verifies the token, reads the user ID from its sub claim, and returns the authenticated user.
POST /items
Authorization: Bearer <access_token>
Content-Type: application/json{
"name": "New Balance 9060",
"brand": "New Balance",
"category": "Shoes",
"color": "Grey",
"size": "10",
"price": "138.00",
"purchase_date": "2026-08-10",
"condition": "new",
"notes": "Everyday sneakers"
}The client does not send user_id.
The backend assigns ownership from the verified access token:
JWT access token
↓
get_current_user()
↓
current_user.id
↓
item.user_idExample response:
{
"id": 1,
"user_id": 1,
"name": "New Balance 9060",
"brand": "New Balance",
"category": "Shoes",
"color": "Grey",
"size": "10",
"price": "138.00",
"purchase_date": "2026-08-10",
"condition": "new",
"notes": "Everyday sneakers"
}GET /items
Authorization: Bearer <access_token>Example response:
{
"items": [
{
"id": 1,
"user_id": 1,
"name": "New Balance 9060",
"brand": "New Balance",
"category": "Shoes",
"color": "Grey",
"size": "10",
"price": "138.00",
"purchase_date": "2026-08-10",
"condition": "new",
"notes": "Everyday sneakers"
}
],
"total": 1,
"limit": 20,
"offset": 0,
"has_more": false
}The GET /items endpoint returns a paginated response object, not a raw list.
items → the current page of item records
total → total number of matching items before pagination
limit → requested page size
offset → number of skipped items
has_more → whether another page existsGET /items?category=Shoes
GET /items?brand=New%20Balance
GET /items?condition=new
GET /items?category=Shoes&condition=new
GET /items?min_price=50&max_price=150
GET /items?sort_by=price&sort_order=asc
GET /items?limit=10&offset=20The service always begins with the ownership condition and then adds optional query behavior:
user_id == current_user.id
AND optional category
AND optional brand
AND optional condition
AND optional minimum price
AND optional maximum price
THEN optional sorting
THEN paginationGET /items/stats
Authorization: Bearer <access_token>Example response:
{
"total_items": 3,
"total_closet_value": "307.99",
"category_counts": {
"Shirt": 2,
"Shoes": 1
},
"brand_counts": {
"Saturn LA": 1,
"UNIQLO": 1,
"New Balance": 1
},
"most_expensive_item": {
"id": 3,
"name": "New Balance 9060",
"brand": "New Balance",
"price": "138.00"
}
}An empty closet returns zero values, empty count objects, and null for the most expensive item.
Closet analytics are not paginated. The analytics query uses the full current-user item set instead of reusing the paginated item list.
Client
↓
FastAPI router
↓
Pydantic validation
↓
Authentication dependency
↓
Service layer
↓
SQLAlchemy Session
↓
PostgreSQLRouter
→ HTTP paths and methods
→ request parameters and dependencies
→ status codes and exceptions
Schema
→ request and response validation
Service
→ database queries and application logic
→ ownership filtering
→ filtering, sorting, pagination, and counting
→ analytics calculations
Model
→ database table shape and relationships
Session
→ active database communication and transactionsServices receive user_id as a plain integer. They do not call FastAPI dependencies directly.
Authentication
→ Who are you?
→ login, JWT, and get_current_user()
Authorization
→ What are you allowed to access?
→ owner-scoped item queriesThe core item authorization rule is:
item.user_id == current_user.idAn authenticated user receives 404 Not Found when an item is missing or inaccessible.
No valid token
→ 401 Not authenticated
Valid token but no accessible item
→ 404 Item not foundReturning 404 for another user's item avoids revealing whether that private resource exists.
users
├── id
├── email
├── username
├── hashed_password
├── is_active
├── created_at
└── updated_atitems
├── id
├── user_id
├── name
├── brand
├── category
├── color
├── size
├── price
├── purchase_date
├── condition
└── notesRelationship:
users.id → items.user_id
One user can own many items.
Each item belongs to one user.Suot uses one shared items table. Private inventories are created by scoping rows to the authenticated user's stable ID.
Alembic manages explicit, versioned database changes.
Change SQLAlchemy model
↓
Generate migration
↓
Review migration
↓
Apply migration
↓
PostgreSQL schema updatesCurrent migrations cover:
- Initial
itemstable. - Optional
brandfield. userstable.- User ownership on items.
- Richer item fields:
price,purchase_date,condition, andnotes.
Create a migration:
uv run alembic revision --autogenerate -m "migration message"Apply migrations:
uv run alembic upgrade headapp/
├── main.py
├── config.py
├── database.py
├── security.py
├── routers/
│ ├── auth.py
│ ├── health.py
│ └── items.py
├── schemas/
│ ├── item.py
│ └── user.py
├── services/
│ ├── item_service.py
│ └── user_service.py
└── models/
├── item.py
└── user.py
alembic/
└── versions/
tests/
├── test_auth.py
└── test_items.py
Dockerfile
.dockerignore
docker-compose.yml
.env.example
alembic.ini
pyproject.toml
README.md
DEVLOG.md
NOTES.md
uv.lockCreate a local .env file from .env.example.
Example:
DATABASE_URL=postgresql+psycopg://suot:suot@localhost:5432/suot
SECRET_KEY=change-me
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30Important:
.env → contains real local values; do not commit
.env.example → safe configuration template; commit thisDatabase hostname distinction:
localhost → API or Alembic running on the laptop
db → API running inside Docker ComposeUse a strong secret supplied through secure environment configuration in production.
Install dependencies:
uv syncStart PostgreSQL through Docker:
docker compose up -d dbApply migrations:
uv run alembic upgrade headStart the FastAPI development server:
uv run uvicorn app.main:app --reloadOpen Swagger:
http://127.0.0.1:8000/docsRun tests:
uv run pytestRun formatting and lint checks:
uv run ruff check .
uv run ruff format --check .Automatically fix lint and formatting issues:
uv run ruff check . --fix
uv run ruff format .Build and start the API and database:
docker compose up --buildCheck services:
docker compose psOpen Swagger:
http://localhost:8000/docsStop services while keeping database data:
docker compose downDelete the local PostgreSQL volume only when intentionally resetting the database:
docker compose down -v1. Register with POST /auth/register.
2. Click Authorize.
3. Enter the user's email in the username field.
4. Enter the user's password.
5. Leave client_id and client_secret blank.
6. Click Authorize.
7. Run GET /auth/me.
8. Use the protected item routes.In the OAuth2 login form:
username → the user's email
password → the user's password
client_id → leave blank
client_secret → leave blankRun the complete suite with:
uv run pytestThe suite currently covers:
successful registration
duplicate email and username rejection
valid and invalid login
protected /auth/me behavior
authenticated item CRUD
missing item behavior
cross-user inventory isolation
richer item field persistence and updates
empty and populated closet analytics
analytics user isolation
category, brand, and condition filtering
combined item filters
minimum and maximum price filtering
price range filtering
sorting by price, name, and purchase date
invalid sorting validation
pagination with limit and offset
invalid pagination validation
pagination metadata
analytics beyond the default page sizeTests use a separate database and reset state between test cases.
sync main
↓
create a focused branch
↓
implement one feature
↓
format and test
↓
commit and push
↓
open and review pull request
↓
merge
↓
sync main againImportant rules:
Keep main stable.
Use branches for meaningful changes.
Review migrations before applying them.
Run tests before committing.
Do not commit .env, local databases, or Python cache files.- Phase 1: In-memory item CRUD — DONE
- Phase 2: SQLite persistence with SQLAlchemy — DONE
- Phase 3: Cleanup and item CRUD tests — DONE
- Phase 4A: Environment-based configuration — DONE
- Phase 4B: PostgreSQL with Docker — DONE
- Phase 5: Dockerized FastAPI API — DONE
- Phase 6A: Alembic setup and initial migration — DONE
- Phase 6B: Schema evolution with
brand— DONE
- Phase 7A: User model and users table — DONE
- Phase 7B: User registration — DONE
- Phase 7C: Login and JWT access tokens — DONE
- Phase 7D: Protected
/auth/meroute — DONE - Phase 7E: Authentication tests — DONE
- Phase 8A: User-owned inventory — DONE
- Phase 8B: Cross-user authorization tests — DONE
- Phase 9: Richer item fields — DONE
- Phase 10A: Closet analytics endpoint — DONE
- Phase 11A: Basic item filtering — DONE
- Phase 11B: Price range filters — DONE
- Phase 11C: Item sorting — DONE
- Phase 11D: Item pagination — DONE
- Phase 11E: Pagination metadata — DONE
- Next.js application setup — NEXT
- TypeScript and Tailwind setup
- Login and registration pages
- Authenticated item list page
- Add item form
- Filter, sort, and pagination controls
- Basic dashboard cards from
/items/stats
- GitHub Actions CI checks
- Production-ready configuration
- Initial backend deployment
- Initial frontend deployment
- Deployment documentation and release checklist
- Rule-based wardrobe recommendations
- Outfit generation logic
- Wardrobe gap analysis
- Spending insights and duplicate-purchase warnings
- Recommendation feedback loop
- PostgreSQL integration tests
- Refresh tokens and account security
- Image uploads and object storage
- Search and indexing
- Rate limiting
- Structured logging and observability
- Redis caching
- Background jobs
- Performance testing
The current backend checkpoint includes:
FastAPI application structure
PostgreSQL persistence
Docker Compose runtime
Alembic migrations
automated tests
user registration and secure password storage
JWT login and current-user authentication
user-owned inventory
cross-user authorization isolation
richer item records
closet analytics
basic and combined item filters
price range filters
sorting
pagination
pagination metadata
analytics protected from pagination bugsNext planned work:
Phase 12: Frontend MVP setupThe next milestone is to make Suot visual:
Log in
↓
view your own closet items
↓
add new items
↓
filter, sort, and paginate inventory
↓
view basic closet analytics