Commit 1878dff520
1878dff520ee2e424b088a963c8f6417f5106ce9
parent: acbff854f2
Verified · cmc
cmc <hello@cleberg.net> · 2026-04-11 17:34 UTC
feat: harden contribution api for production use
Layout: unified · split
README.md
+7 −9
| @@ -9,7 +9,7 @@ The current V1 is intentionally narrow and production-oriented: |
| 9 | 9 | - polling-based ingestion |
| 10 | 10 | - complete `todo.sr.ht` ingestion path |
| 11 | 11 | - practical `git.sr.ht` commit ingestion for tracked repositories |
| 12 | | - API-key protection for `/api/*` |
| 12 | - public read-only contribution endpoints plus API-key protection for mutating/admin routes |
| 13 | 13 | - Alembic-managed schema migrations |
| 14 | 14 | |
| 15 | 15 | ## What It Does |
| @@ -75,7 +75,7 @@ Tracked git repositories are persisted in the `tracked_repositories` table and s |
| 75 | 75 | |
| 76 | 76 | Environment variables: |
| 77 | 77 | |
| 78 | | - `API_KEY`: required header token for all `/api/*` routes via `X-API-Key` |
| 78 | - `API_KEY`: required header token for mutating/admin routes via `X-API-Key` |
| 79 | 79 | - `ENABLE_SCHEDULER`: defaults to `false`; enables in-process polling when set to `true` |
| 80 | 80 | - `SRHT_TOKEN`: bearer token for SourceHut GraphQL |
| 81 | 81 | - `TODO_SRHT_ENDPOINT`: defaults to `https://todo.sr.ht/query` |
| @@ -189,15 +189,13 @@ Response: |
| 189 | 189 | ### Contribution Calendar by Year |
| 190 | 190 | |
| 191 | 191 | ```bash |
| 192 | | curl "http://127.0.0.1:8000/api/contributions/~ccleberg?year=2026" \ |
| 193 | | -H "X-API-Key: replace-me" |
| 192 | curl "http://127.0.0.1:8000/api/contributions/~ccleberg?year=2026" |
| 194 | 193 | ``` |
| 195 | 194 | |
| 196 | 195 | ### Contribution Calendar by Date Range |
| 197 | 196 | |
| 198 | 197 | ```bash |
| 199 | | curl "http://127.0.0.1:8000/api/contributions/~ccleberg?from=2026-01-01&to=2026-03-30" \ |
| 200 | | -H "X-API-Key: replace-me" |
| 198 | curl "http://127.0.0.1:8000/api/contributions/~ccleberg?from=2026-01-01&to=2026-03-30" |
| 201 | 199 | ``` |
| 202 | 200 | |
| 203 | 201 | Example response: |
| @@ -218,8 +216,7 @@ Example response: |
| 218 | 216 | ### Contribution Stats |
| 219 | 217 | |
| 220 | 218 | ```bash |
| 221 | | curl "http://127.0.0.1:8000/api/contributions/~ccleberg/stats?year=2026" \ |
| 222 | | -H "X-API-Key: replace-me" |
| 219 | curl "http://127.0.0.1:8000/api/contributions/~ccleberg/stats?year=2026" |
| 223 | 220 | ``` |
| 224 | 221 | |
| 225 | 222 | Example response: |
| @@ -292,7 +289,8 @@ pytest |
| 292 | 289 | Covered areas: |
| 293 | 290 | |
| 294 | 291 | - health endpoint |
| 295 | | - API key enforcement |
| 292 | - public read-only contribution endpoints |
| 293 | - API key enforcement for mutating/admin routes |
| 296 | 294 | - calendar aggregation |
| 297 | 295 | - zero-filled ranges |
| 298 | 296 | - stats calculations |
src/srht_contrib/api/routes_contributions.py
+2 −2
| @@ -13,7 +13,7 @@ from srht_contrib.services.srht_client import SourceHutClientError |
| 13 | 13 | from srht_contrib.utils.dates import parse_date, year_bounds |
| 14 | 14 | from srht_contrib.utils.identity import ActorIdentityResolver |
| 15 | 15 | |
| 16 | | router = APIRouter(prefix="/api/contributions", tags=["contributions"], dependencies=[Depends(require_api_key)]) |
| 16 | router = APIRouter(prefix="/api/contributions", tags=["contributions"]) |
| 17 | 17 | |
| 18 | 18 | |
| 19 | 19 | def _resolve_range(year: int | None, from_date: str | None, to_date: str | None) -> tuple[date, date]: |
| @@ -63,7 +63,7 @@ def get_contribution_stats( |
| 63 | 63 | return ContributionAggregator().build_stats(db, canonical_actor, start, end) |
| 64 | 64 | |
| 65 | 65 | |
| 66 | | @router.post("/poll", response_model=PollResponse) |
| 66 | @router.post("/poll", response_model=PollResponse, dependencies=[Depends(require_api_key)]) |
| 67 | 67 | def trigger_manual_poll( |
| 68 | 68 | actor: str, |
| 69 | 69 | poller: PollerService = Depends(get_poller), |
tests/test_contributions_api.py
+6 −2
| @@ -6,14 +6,18 @@ from srht_contrib.main import create_app |
| 6 | 6 | from srht_contrib.models import ContributionEvent |
| 7 | 7 | |
| 8 | 8 | |
| 9 | | def test_api_routes_require_api_key(settings, db_engine, session_factory) -> None: |
| 9 | def test_read_only_contribution_routes_are_public_and_write_routes_require_api_key(settings, db_engine, session_factory) -> None: |
| 10 | 10 | app = create_app(settings, engine=db_engine, session_factory=session_factory) |
| 11 | 11 | with TestClient(app) as open_client: |
| 12 | 12 | response = open_client.get("/health") |
| 13 | public_contributions = open_client.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30") |
| 14 | public_stats = open_client.get("/api/contributions/~ccleberg/stats?from=2026-03-28&to=2026-03-30") |
| 13 | 15 | assert response.status_code == 200 |
| 16 | assert public_contributions.status_code == 200 |
| 17 | assert public_stats.status_code == 200 |
| 14 | 18 | |
| 15 | 19 | with TestClient(app) as unauthorized: |
| 16 | | unauthorized_response = unauthorized.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30") |
| 20 | unauthorized_response = unauthorized.post("/api/contributions/poll?actor=~ccleberg") |
| 17 | 21 | assert unauthorized_response.status_code == 401 |
| 18 | 22 | |
| 19 | 23 | with TestClient(app) as invalid: |