Skip to content

Development

Rod Christiansen edited this page Oct 8, 2026 · 3 revisions

Development

Python 3.11. No build step, no code generation, no ORM.

Set up

git clone https://github.com/reportmate/reportmate-api.git
cd reportmate-api
python -m venv .venv
. .venv/bin/activate
pip install -r requirements-dev.txt

requirements-dev.txt includes the runtime requirements plus pytest and httpx.

Run it

uvicorn main:app --reload --port 8000

DATABASE_URL defaults to postgresql://reportmate:password@localhost:5432/reportmate, so a local Postgres on the default port needs no configuration beyond DB_SSL=false.

To work without a credential:

export APP_ENV=development
export DISABLE_AUTH=true

APP_ENV=development is what makes this work. With APP_ENV set to development, dev, local, test or testing the startup guard always allows DISABLE_AUTH. With APP_ENV unset it refuses to boot as soon as REPORTMATE_PASSPHRASE or API_INTERNAL_SECRET is configured, and with a production or staging APP_ENV it always refuses.

Tests

pytest

Most tests are pure and run anywhere. The SQL-level tests — the usage upsert, the migrations, the pagination and aggregate queries — skip themselves unless TEST_DATABASE_URL points at a real PostgreSQL server:

export TEST_DATABASE_URL=postgresql://reportmate:reportmate@localhost:5432/reportmate
export DB_SSL=false
pytest -rs

-rs prints the skip reasons, which is how you tell "passed" from "did not run". The reason those tests need a server rather than a mocked cursor is concrete: a statement-level type error once shipped green through a full unit suite and silently dropped fleet-wide utilization data. CI runs a postgres:16 service container for exactly this.

tests/conftest.py sets deterministic non-secret values for REPORTMATE_PASSPHRASE and API_INTERNAL_SECRET before dependencies is imported, because those are read into module-level globals at import time. It also resets the rate limiter between tests, since TestClient requests all share one remote address.

Some tests assert structure rather than behaviour and are worth knowing about before you change things:

Test What it enforces
test_route_auth.py Every route on every router depends on verify_authentication, except an explicit allowlist of /health, /health/live, /health/ready, and /auth/config. A new router module has to be added to the test's router list.
test_dockerfile.py The Dockerfile copies what the app needs. Adding a new top-level module means adding a COPY line.
test_migrations.py The Alembic chain applies against a real database.
test_openapi_security.py The published schema advertises the auth schemes.

Adding an endpoint

  1. Put the handler in the router for its area under routers/. Add dependencies=[Depends(verify_authentication)] — test_route_auth.py fails without it.
  2. Pick the path deliberately. The required scope is derived from the method and whether the path contains /admin, not declared per route.
  3. Non-trivial SQL goes in a file under sql/ and is loaded with load_sql("area/name"). Add it to the preload_sql_queries() list in dependencies.py.
  4. If the response is cacheable, add a namespace to _CACHE_TTL and wrap the handler in cache_get / cache_set.
  5. If it is a list endpoint, take limit and offset and call add_pagination_headers.

Adding a migration

alembic revision -m "what it does"

Revision ids in this repo are sequential four-digit strings (0001…0011), not hashes; follow that. Migrations are raw SQL through op.execute — there is no ORM metadata to autogenerate against, and target_metadata is None on purpose.

If your statement touches a table the deployment schema owns rather than Alembic — devices, events, or any collection-module table — wrap it in the _tolerant() helper the existing migrations use. It runs the DDL inside a PL/pgSQL block that swallows only undefined_table, so a fresh database migrates cleanly before ingestion has created those tables. Any other error still aborts.

Write migrations idempotently (IF NOT EXISTS, ADD COLUMN IF NOT EXISTS) — they run on every startup of every replica.

Alembic reads DATABASE_URL and DB_SSL from the environment via alembic/env.py and rewrites the scheme to postgresql+pg8000://. No credentials live in alembic.ini.

OpenAPI

python scripts/export_openapi.py

Writes docs/api/openapi.json and prints the path count and an endpoint count per tag. The security schemes are injected by a custom openapi() in main.py, because authentication is enforced by a dependency rather than by FastAPI's security utilities and so would not otherwise appear in the schema.

CI

.github/workflows/build.yml has three jobs, run on every push to main, every PR, and every v* tag:

Job What it does
test pytest -rs against a postgres:16 service container, so the SQL-level tests run rather than skip.
openapi Runs scripts/export_openapi.py, fails if the spec has fewer than 50 paths or no security schemes, and uploads docs/api/openapi.json as the openapi artifact for that commit.
build After test passes, builds the linux/amd64 image and pushes it to GHCR. Pull requests build without pushing.

The reportmate-website repo exports the same file from main on its own schedule and publishes it at /openapi.json, so integrators read the deployed contract.

Contributing

Pull requests are welcome. First-time contributors are asked by the CLA Assistant bot to sign a one-page CLA, which is what makes the dual-licence model work; sign it once and it covers all future PRs.

Clone this wiki locally