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 | - polling-based ingestion |
9 | - polling-based ingestion |
| 10 | - complete `todo.sr.ht` ingestion path |
10 | - complete `todo.sr.ht` ingestion path |
| 11 | - practical `git.sr.ht` commit ingestion for tracked repositories |
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 | - Alembic-managed schema migrations |
13 | - Alembic-managed schema migrations |
| 14 | |
14 | |
| 15 | ## What It Does |
15 | ## What It Does |
| @@ -75,7 +75,7 @@ Tracked git repositories are persisted in the `tracked_repositories` table and s |
| 75 | |
75 | |
| 76 | Environment variables: |
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 | - `ENABLE_SCHEDULER`: defaults to `false`; enables in-process polling when set to `true` |
79 | - `ENABLE_SCHEDULER`: defaults to `false`; enables in-process polling when set to `true` |
| 80 | - `SRHT_TOKEN`: bearer token for SourceHut GraphQL |
80 | - `SRHT_TOKEN`: bearer token for SourceHut GraphQL |
| 81 | - `TODO_SRHT_ENDPOINT`: defaults to `https://todo.sr.ht/query` |
81 | - `TODO_SRHT_ENDPOINT`: defaults to `https://todo.sr.ht/query` |
| @@ -189,15 +189,13 @@ Response: |
| 189 | ### Contribution Calendar by Year |
189 | ### Contribution Calendar by Year |
| 190 | |
190 | |
| 191 | ```bash |
191 | ```bash |
| 192 | curl "http://127.0.0.1:8000/api/contributions/~ccleberg?year=2026" \ |
192 | curl "http://127.0.0.1:8000/api/contributions/~ccleberg?year=2026" |
| 193 | -H "X-API-Key: replace-me" |
| |
| 194 | ``` |
193 | ``` |
| 195 | |
194 | |
| 196 | ### Contribution Calendar by Date Range |
195 | ### Contribution Calendar by Date Range |
| 197 | |
196 | |
| 198 | ```bash |
197 | ```bash |
| 199 | curl "http://127.0.0.1:8000/api/contributions/~ccleberg?from=2026-01-01&to=2026-03-30" \ |
198 | 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" |
| |
| 201 | ``` |
199 | ``` |
| 202 | |
200 | |
| 203 | Example response: |
201 | Example response: |
| @@ -218,8 +216,7 @@ Example response: |
| 218 | ### Contribution Stats |
216 | ### Contribution Stats |
| 219 | |
217 | |
| 220 | ```bash |
218 | ```bash |
| 221 | curl "http://127.0.0.1:8000/api/contributions/~ccleberg/stats?year=2026" \ |
219 | curl "http://127.0.0.1:8000/api/contributions/~ccleberg/stats?year=2026" |
| 222 | -H "X-API-Key: replace-me" |
| |
| 223 | ``` |
220 | ``` |
| 224 | |
221 | |
| 225 | Example response: |
222 | Example response: |
| @@ -292,7 +289,8 @@ pytest |
| 292 | Covered areas: |
289 | Covered areas: |
| 293 | |
290 | |
| 294 | - health endpoint |
291 | - health endpoint |
| 295 | - API key enforcement |
292 | - public read-only contribution endpoints |
| |
293 | - API key enforcement for mutating/admin routes |
| 296 | - calendar aggregation |
294 | - calendar aggregation |
| 297 | - zero-filled ranges |
295 | - zero-filled ranges |
| 298 | - stats calculations |
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 | from srht_contrib.utils.dates import parse_date, year_bounds |
13 | from srht_contrib.utils.dates import parse_date, year_bounds |
| 14 | from srht_contrib.utils.identity import ActorIdentityResolver |
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 | def _resolve_range(year: int | None, from_date: str | None, to_date: str | None) -> tuple[date, date]: |
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 | return ContributionAggregator().build_stats(db, canonical_actor, start, end) |
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 | def trigger_manual_poll( |
67 | def trigger_manual_poll( |
| 68 | actor: str, |
68 | actor: str, |
| 69 | poller: PollerService = Depends(get_poller), |
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 | from srht_contrib.models import ContributionEvent |
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 | app = create_app(settings, engine=db_engine, session_factory=session_factory) |
10 | app = create_app(settings, engine=db_engine, session_factory=session_factory) |
| 11 | with TestClient(app) as open_client: |
11 | with TestClient(app) as open_client: |
| 12 | response = open_client.get("/health") |
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 | assert response.status_code == 200 |
15 | assert response.status_code == 200 |
| |
16 | assert public_contributions.status_code == 200 |
| |
17 | assert public_stats.status_code == 200 |
| 14 | |
18 | |
| 15 | with TestClient(app) as unauthorized: |
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 | assert unauthorized_response.status_code == 401 |
21 | assert unauthorized_response.status_code == 401 |
| 18 | |
22 | |
| 19 | with TestClient(app) as invalid: |
23 | with TestClient(app) as invalid: |