Skip to content

Repository files navigation

fmanagement

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.


Table of Contents


Architecture

 ┌────────────────────────────────────────────────────┐
│ 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.

Prerequisites

  • Docker & Docker Compose, or
  • Python 3.14, uv, a running MongoDB and S3-compatible store.

Quick start (Docker Compose)

docker compose up --build

This brings up:

ServicePortPurpose
api8000The fmanagement HTTP API
mongodb27017Atlas Local replica set
mongo-express8081Mongo admin UI (admin / admin)
minio9000S3-compatible object storage
minio console9001MinIO web console (minioadmin x2)

Health check:

curl -i http://localhost:8000/api/v1/files
# → 401 Not authenticated (you haven't logged in yet)

End-to-end smoke test

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 wired

--mode basic (default) — happy-path smoke test

uv run scenarios # equivalent to: uv run scenarios --mode basic

In order:

  1. Register a deterministic test user (scenario_user by default). A 409 Conflict from a prior run is treated as success.
  2. Login and obtain a JWT.
  3. Purge any files left over from a previous run.
  4. Upload a fixed sample set (alpha.txt, beta.json, gamma.csv).
  5. List the user's files and assert the new IDs match.
  6. Download each file and assert the bytes round-trip exactly.
  7. Delete every uploaded file.
  8. Re-list and assert the user owns zero files.
  9. Logout.

--mode full — multi-scenario suite

uv run scenarios --mode full

Runs 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-scenarioWhat it asserts
unauthenticated_accessEvery files endpoint and /auth/logout returns 401 without a bearer token.
auth_failuresDuplicate register → 409; wrong password / unknown user / garbage token → 401.
upload_disallowed_mimeA PE-executable payload is rejected with 415 by magic-byte sniffing (the spoofed Content-Type header is ignored).
upload_missing_fieldMultipart POST without the file part returns 422.
upload_duplicate_nameA second upload reusing (owner, name) returns 409.
list_query_validationpage=0, size=0, size=201 all return 422.
paginationUploads 5 files, walks size-2 pages, asserts total=5, page lengths 2/2/1/0, and that page IDs don't overlap.
unknown_id_404Download/delete of a well-formed but missing id returns 404.
delete_then_404After delete: download → 404, second delete → 404.
cross_user_isolationA 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_pathOne 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 user

Reusable APIClient

scripts/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.


Running locally without Docker

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

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 varDefaultNotes
MONGODB_HOSTmongodb
MONGODB_PORT27017
MONGODB_DATABASEfmanagement
MONGODB_USERNAME(empty)Optional
MONGODB_PASSWORD(empty)Optional
S3_ENDPOINT_URLhttp://minio:9000Omit / set AWS endpoint in prod
S3_REGIONus-east-1
S3_ACCESS_KEY_IDminioadmin
S3_SECRET_ACCESS_KEYminioadmin
S3_BUCKETfmanagementAuto-created on startup
JWT_SECRET_KEYchange-me-in-production-pleaseOverride 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.


API usage

All endpoints are mounted under /api/v1. Interactive docs are published at http://localhost:8000/docs (Swagger UI).

Authentication

POST /api/v1/auth/register

Register a new user.

Request — JSON body:

{
"username": "alice",
"email": "alice@example.com",
"password": "supersecret"
}

Constraints:

  • username: trimmed, ≥ 3 characters
  • email: valid email
  • password: ≥ 8 characters

Response201 Created:

{
"user": {
"id": "65f1...",
"username": "alice",
"email": "alice@example.com"
}
}

Failure409 Conflict if username/email is already taken; 422 Unprocessable Entity for validation errors.

POST /api/v1/auth/login

Authenticate and receive a JWT.

Requestapplication/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"

Response200 OK:

{
"access_token": "eyJhbGciOi...",
"token_type": "bearer"
}

Failure401 Unauthorized for bad credentials; 422 if either form field is missing.

POST /api/v1/auth/logout

Stateless no-op endpoint. Requires a Bearer header but does not invalidate the token (JWTs are stateless — discard them client-side).

Response204 No Content.

Files

All file endpoints require a valid JWT in the Authorization: Bearer <token> header.

POST /api/v1/files

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"

Response201 Created:

{
"file": {
"id": "65f...",
"name": "hello.txt",
"size": 6,
"content_type": "text/plain",
"owner": "65f...",
"upload_date": "2026-04-27T10:00:00Z"
}
}

Failure422 if the file field is missing.

GET /api/v1/files

List the current user's files (paginated).

Query paramDefaultRange
page1≥ 1
size501–200
curl http://localhost:8000/api/v1/files?page=1&size=20 \
-H "Authorization: Bearer $TOKEN"

Response200 OK:

{
"files": [ /* FileObject[] */ ],"page": 1,
"size": 20,
"total": 137
}

Failure422 if page or size is non-integer or out of range.

GET /api/v1/files/{id}

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"

Failure404 Not Found if the file does not exist; 403 Forbidden if it belongs to another user (see Access control).

DELETE /api/v1/files/{id}

Delete a file by ID. Removes both the metadata document and the underlying S3 object.

Response200 OK:

{ "id": "65f...", "deleted": true }

Failure404 Not Found if missing; 403 Forbidden if the file belongs to another user.


Access control

  • Authentication: every /api/v1/files/* route requires a valid Bearer token. Missing, malformed, or expired tokens yield 401 Unauthorized with {"detail": "Not authenticated."} (or "Token expired.").
  • Ownership enforcement: GET /files/{id}, DELETE /files/{id}, and GET /files (which only returns files where owner == current_user) enforce ownership at the service layer — the route passes owner=current_user into every call, and the service rejects access to files owned by another user with 403 Forbidden.
  • Listing isolation: GET /files never 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.


Validation & error responses

All errors return a uniform JSON shape:

{ "detail": "<human-readable message>" }
StatusWhen
400Malformed request body / unparseable JSON
401Missing / invalid / expired JWT
403Authenticated, but the file belongs to another user
404File or user not found
409Username or email already taken on register
422Pydantic / form / query validation failure (e.g. page=0, missing file field)
500Unexpected server error

The mapping is defined in a single source of truth (fmanagement/api/status_mapping.py).


Logging

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.


Pagination

GET /api/v1/files is paginated:

  • page is 1-indexed; size is bounded at [1, 200] with default 50.
  • The response includes page, size, and total so clients can compute total_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.

Quality checks

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.py

Both should pass cleanly on a fresh checkout. Hook them into your pre-commit / CI alongside pytest.

Testing

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:

PathScope
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

Project layout

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

About

Small File management tool built with FastAPI

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages