A file storage service: upload, list, download, and delete user-owned files. Backed by MongoDB (metadata) and S3-compatible object storage (file content). Authenticated via JWT.
The HTTP layer is implemented with FastAPI (native async) on top of a shared async service layer.
- Architecture
- Prerequisites
- Quick start (Docker Compose)
- End-to-end smoke test
- Running locally without Docker
- Configuration
- API usage
- Access control
- Validation & error responses
- Logging
- Pagination
- Testing
- Project layout
┌────────────────────────────────────────────────────┐
│ HTTP layer (FastAPI, async native) │
│ fmanagement/api/ │
└────────────────────────┬───────────────────────────┘
▼
┌──────────────────────────────┐
│ services/ │
│ AuthenticationService │
│ FileManagementService │
└────────────┬─────────────────┘
│
┌─────────────────┼──────────────────┐
▼ ▼
┌───────────────┐ ┌──────────────┐
│ MongoDB │ │ S3 / MinIO │
│ (users,files) │ │ (objects) │
└───────────────┘ └──────────────┘
- Metadata (users, file records) lives in MongoDB.
- File content lives in an S3-compatible bucket (MinIO locally, AWS S3 in production).
- Auth is stateless JWT (HS256) — logout is a documented no-op.
- Docker & Docker Compose, or
- Python 3.14,
uv, a running MongoDB and S3-compatible store.
docker compose up --buildThis brings up:
| Service | Port | Purpose |
|---|---|---|
api | 8000 | The fmanagement HTTP API |
mongodb | 27017 | Atlas Local replica set |
mongo-express | 8081 | Mongo admin UI (admin / admin) |
minio | 9000 | S3-compatible object storage |
minio console | 9001 | MinIO web console (minioadmin x2) |
Health check:
curl -i http://localhost:8000/api/v1/files
# → 401 Not authenticated (you haven't logged in yet)The scripts/ folder ships two idempotent end-to-end runners that hit the live stack — useful as a post-deploy sanity check or as a CI step. Both modes are exposed by the same scenarios console script and select between them with --mode.
Bring the stack up first:
docker compose up -d
uv sync # ensures the `scenarios` console script is wireduv run scenarios # equivalent to: uv run scenarios --mode basicIn order:
- Register a deterministic test user (
scenario_userby default). A409 Conflictfrom a prior run is treated as success. - Login and obtain a JWT.
- Purge any files left over from a previous run.
- Upload a fixed sample set (
alpha.txt,beta.json,gamma.csv). - List the user's files and assert the new IDs match.
- Download each file and assert the bytes round-trip exactly.
- Delete every uploaded file.
- Re-list and assert the user owns zero files.
- Logout.
uv run scenarios --mode fullRuns every endpoint contract end-to-end against the live stack. Each sub-scenario is independent: a failure in one does not abort the rest, and both users are purged between disruptive scenarios and again at exit.
| Sub-scenario | What it asserts |
|---|---|
unauthenticated_access | Every files endpoint and /auth/logout returns 401 without a bearer token. |
auth_failures | Duplicate register → 409; wrong password / unknown user / garbage token → 401. |
upload_disallowed_mime | A PE-executable payload is rejected with 415 by magic-byte sniffing (the spoofed Content-Type header is ignored). |
upload_missing_field | Multipart POST without the file part returns 422. |
upload_duplicate_name | A second upload reusing (owner, name) returns 409. |
list_query_validation | page=0, size=0, size=201 all return 422. |
pagination | Uploads 5 files, walks size-2 pages, asserts total=5, page lengths 2/2/1/0, and that page IDs don't overlap. |
unknown_id_404 | Download/delete of a well-formed but missing id returns 404. |
delete_then_404 | After delete: download → 404, second delete → 404. |
cross_user_isolation | A second user (scenario_user_2) sees only their own files; their attempts to download/delete the primary's file return 403; primary's bytes are left intact. |
happy_path | One inline upload→list→download→delete cycle to confirm the round-trip still works after the negative tests. |
Output looks like this on a green run:
Suite finished base_url=http://localhost:8000 primary=scenario_user secondary=scenario_user_2 passed=11/11
[OK ] unauthenticated_access PASS
[OK ] auth_failures PASS
...
Idempotency: regardless of mode, every run ends with the scenario user(s) existing and owning zero files, so the script is safe to rerun without manual cleanup.
Exit code: 0 on success, 1 on any HTTP or assertion failure (basic) or if any sub-scenario failed (full), so CI can shell out to uv run scenarios [--mode full] directly.
Available flags:
uv run scenarios --help
# --base-url API base URL (default: http://localhost:8000)# --mode {basic,full} Suite to run (default: basic)# --username Primary scenario username (default: scenario_user)# --email Email used when the primary user has to be registered# --password Plaintext password for the primary user# --secondary-username Sibling user for cross-user isolation (mode=full only)# --secondary-email Email used when the sibling has to be registered# --secondary-password Plaintext password for the sibling userscripts/api_client.py exposes a synchronous, httpx-based APIClient class with one method per endpoint (register, login, logout, upload_file, list_files, list_all_files, download_file, delete_file), a raw_request() escape hatch for unchecked / unauthenticated calls, plus the high-level run_scenario() and run_full_scenarios() orchestrators. Drop it into ad-hoc scripts when you need to drive the API from Python:
fromscripts.api_clientimportAPIClientwithAPIClient(base_url="http://localhost:8000") asclient:
client.ensure_user("alice", "alice@example.com", "s3cret-pa$$")
entry=client.upload_file("hello.txt", b"hello world", "text/plain")
print(client.download_file(entry["_id"]))
client.delete_file(entry["_id"])
client.logout()Each individual scenario_* method is also callable in isolation — e.g. client.scenario_pagination() after client.ensure_user(...) — which is handy when iterating on a specific endpoint contract.
uv sync # install deps from uv.lock
uv run python main.py # starts on conf/app.yaml's host:port (default 0.0.0.0:8000)You'll need MongoDB and an S3-compatible endpoint reachable at the addresses configured in conf/app.yaml.
In production the Docker image runs:
uvicorn main:app --host 0.0.0.0 --port 8000
Configuration is loaded from conf/app.yaml. Every field supports ${VAR:-default} shell-style substitution, so you override at deploy time via env vars.
| Env var | Default | Notes |
|---|---|---|
MONGODB_HOST | mongodb | |
MONGODB_PORT | 27017 | |
MONGODB_DATABASE | fmanagement | |
MONGODB_USERNAME | (empty) | Optional |
MONGODB_PASSWORD | (empty) | Optional |
S3_ENDPOINT_URL | http://minio:9000 | Omit / set AWS endpoint in prod |
S3_REGION | us-east-1 | |
S3_ACCESS_KEY_ID | minioadmin | |
S3_SECRET_ACCESS_KEY | minioadmin | |
S3_BUCKET | fmanagement | Auto-created on startup |
JWT_SECRET_KEY | change-me-in-production-please | Override in production |
JWT settings (algorithm HS256, 60-minute access tokens) live in conf/app.yaml under jwt:.
Logging is configured separately in conf/logger.yaml.
All endpoints are mounted under /api/v1. Interactive docs are published at http://localhost:8000/docs (Swagger UI).
Register a new user.
Request — JSON body:
{
"username": "alice",
"email": "alice@example.com",
"password": "supersecret"
}Constraints:
username: trimmed, ≥ 3 charactersemail: valid emailpassword: ≥ 8 characters
Response — 201 Created:
{
"user": {
"id": "65f1...",
"username": "alice",
"email": "alice@example.com"
}
}Failure — 409 Conflict if username/email is already taken; 422 Unprocessable Entity for validation errors.
Authenticate and receive a JWT.
Request — application/x-www-form-urlencoded (mirrors FastAPI's OAuth2PasswordRequestForm):
username=alice&password=supersecret
curl -X POST http://localhost:8000/api/v1/auth/login \
-d "username=alice&password=supersecret"Response — 200 OK:
{
"access_token": "eyJhbGciOi...",
"token_type": "bearer"
}Failure — 401 Unauthorized for bad credentials; 422 if either form field is missing.
Stateless no-op endpoint. Requires a Bearer header but does not invalidate the token (JWTs are stateless — discard them client-side).
Response — 204 No Content.
All file endpoints require a valid JWT in the Authorization: Bearer <token> header.
Upload a file (multipart/form-data with field name file).
curl -X POST http://localhost:8000/api/v1/files \
-H "Authorization: Bearer $TOKEN" \
-F "file=@./hello.txt"Response — 201 Created:
{
"file": {
"id": "65f...",
"name": "hello.txt",
"size": 6,
"content_type": "text/plain",
"owner": "65f...",
"upload_date": "2026-04-27T10:00:00Z"
}
}Failure — 422 if the file field is missing.
List the current user's files (paginated).
| Query param | Default | Range |
|---|---|---|
page | 1 | ≥ 1 |
size | 50 | 1–200 |
curl http://localhost:8000/api/v1/files?page=1&size=20 \
-H "Authorization: Bearer $TOKEN"Response — 200 OK:
{
"files": [ /* FileObject[] */ ],"page": 1,
"size": 20,
"total": 137
}Failure — 422 if page or size is non-integer or out of range.
Download a file by ID. Streams binary with Content-Disposition: attachment; filename="<name>" and an explicit Content-Length.
curl -OJ http://localhost:8000/api/v1/files/65f... \
-H "Authorization: Bearer $TOKEN"Failure — 404 Not Found if the file does not exist; 403 Forbidden if it belongs to another user (see Access control).
Delete a file by ID. Removes both the metadata document and the underlying S3 object.
Response — 200 OK:
{ "id": "65f...", "deleted": true }Failure — 404 Not Found if missing; 403 Forbidden if the file belongs to another user.
- Authentication: every
/api/v1/files/*route requires a validBearertoken. Missing, malformed, or expired tokens yield401 Unauthorizedwith{"detail": "Not authenticated."}(or"Token expired."). - Ownership enforcement:
GET /files/{id},DELETE /files/{id}, andGET /files(which only returns files whereowner == current_user) enforce ownership at the service layer — the route passesowner=current_userinto every call, and the service rejects access to files owned by another user with403 Forbidden. - Listing isolation:
GET /filesnever returns another user's files, even if you guess the page number.
JWTs are HS256-signed with JWT_SECRET_KEY. Token claims include the user ID and an exp timestamp; expiry defaults to 60 minutes.
All errors return a uniform JSON shape:
{ "detail": "<human-readable message>" }| Status | When |
|---|---|
400 | Malformed request body / unparseable JSON |
401 | Missing / invalid / expired JWT |
403 | Authenticated, but the file belongs to another user |
404 | File or user not found |
409 | Username or email already taken on register |
422 | Pydantic / form / query validation failure (e.g. page=0, missing file field) |
500 | Unexpected server error |
The mapping is defined in a single source of truth (fmanagement/api/status_mapping.py).
Logging is configured by conf/logger.yaml.
Examples:
INFO Application starting up
INFO Initializing MongoDB connection manager
INFO Application startup complete
INFO Login the following user: alice
INFO Upload file for user: alice, filename: hello.txt, content_type: text/plain
INFO List endpoint called: user=alice page=1 size=50
INFO Download endpoint called: user=alice file_id=65f...
INFO Delete endpoint called: user=alice file_id=65f...
ERROR Invalid integer query parameter: page='foo'
ERROR Failed to ensure S3 bucket: ...
4xx errors are logged at WARNING/INFO, 5xx at ERROR. Severity is centralized in status_mapping.py so both layers are consistent.
GET /api/v1/files is paginated:
pageis 1-indexed;sizeis bounded at[1, 200]with default50.- The response includes
page,size, andtotalso clients can computetotal_pages = ceil(total / size). - Query parameters are validated inline (no Pydantic model in the API layer); out-of-range or non-integer values return
422.
Linting and type-checking are configured in pyproject.toml under [tool.ruff] and [tool.ty]:
uv run ruff check .# lint (pycodestyle + pyflakes, py3.14, line-length 100)
uv run ruff format --check .# format check (run without --check to apply)
uv run ty check # static type check across fmanagement/ + main.pyBoth should pass cleanly on a fresh checkout. Hook them into your pre-commit / CI alongside pytest.
The full suite runs against an in-memory Mongo (mongomock-motor) and a moto S3 server for unit tests, plus real Docker containers (Mongo + MinIO + the built API image) for integration tests.
# Run everything
uv run pytest
# Run only the API integration tests
uv run pytest tests/api/
Tests are organized as:
| Path | Scope |
|---|---|
tests/api/ | End-to-end HTTP integration (testcontainers) |
tests/services/ | Service-layer unit tests |
tests/storage/ | Repository and S3 client tests |
tests/models/ | Pydantic model and validation tests |
fmanagement/
├── conf/
│ ├── app.yaml # Runtime config (overridable via env vars)
│ └── logger.yaml # Logging config
├── fmanagement/
│ ├── api/ # FastAPI HTTP layer (async native)
│ │ ├── v1/ # Auth + files routers
│ │ ├── dependencies.py # FastAPI dependency providers
│ │ ├── exception_handlers.py
│ │ └── status_mapping.py # Exception → HTTP status + log severity
│ ├── services/ # Async business logic (auth, file management)
│ ├── storage/ # MongoDB + S3 adapters
│ └── models/ # Pydantic request/response and config models
├── tests/ # Pytest suite (unit + integration)
├── scripts/ # `uv run scenarios` end-to-end runner + APIClient
├── main.py # Entrypoint: builds the FastAPI app + lifespan
├── docker-entrypoint.sh # uvicorn launcher
├── docker-compose.yaml # api + mongodb + minio + admin UIs
└── pyproject.toml