summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-04-11 12:34:11 -0500
committerChristian Cleberg <[email protected]>2026-04-11 12:34:11 -0500
commit1878dff520ee2e424b088a963c8f6417f5106ce9 (patch)
treedf094ec09d8b26bac56c08d1408a2fab35ac9e7d
parentacbff854f2da96bddcaede1385e7fefeba0fb34b (diff)
downloadhutch-stats-1878dff520ee2e424b088a963c8f6417f5106ce9.tar.gz
hutch-stats-1878dff520ee2e424b088a963c8f6417f5106ce9.tar.bz2
hutch-stats-1878dff520ee2e424b088a963c8f6417f5106ce9.zip
feat: harden contribution api for production use
-rw-r--r--README.md16
-rw-r--r--src/srht_contrib/api/routes_contributions.py4
-rw-r--r--tests/test_contributions_api.py8
3 files changed, 15 insertions, 13 deletions
diff --git a/README.md b/README.md
index d9baf54..b05e71a 100644
--- a/README.md
+++ b/README.md
@@ -9,7 +9,7 @@ The current V1 is intentionally narrow and production-oriented:
- polling-based ingestion
- complete `todo.sr.ht` ingestion path
- practical `git.sr.ht` commit ingestion for tracked repositories
-- API-key protection for `/api/*`
+- public read-only contribution endpoints plus API-key protection for mutating/admin routes
- Alembic-managed schema migrations
## What It Does
@@ -75,7 +75,7 @@ Tracked git repositories are persisted in the `tracked_repositories` table and s
Environment variables:
-- `API_KEY`: required header token for all `/api/*` routes via `X-API-Key`
+- `API_KEY`: required header token for mutating/admin routes via `X-API-Key`
- `ENABLE_SCHEDULER`: defaults to `false`; enables in-process polling when set to `true`
- `SRHT_TOKEN`: bearer token for SourceHut GraphQL
- `TODO_SRHT_ENDPOINT`: defaults to `https://todo.sr.ht/query`
@@ -189,15 +189,13 @@ Response:
### Contribution Calendar by Year
```bash
-curl "http://127.0.0.1:8000/api/contributions/~ccleberg?year=2026" \
- -H "X-API-Key: replace-me"
+curl "http://127.0.0.1:8000/api/contributions/~ccleberg?year=2026"
```
### Contribution Calendar by Date Range
```bash
-curl "http://127.0.0.1:8000/api/contributions/~ccleberg?from=2026-01-01&to=2026-03-30" \
- -H "X-API-Key: replace-me"
+curl "http://127.0.0.1:8000/api/contributions/~ccleberg?from=2026-01-01&to=2026-03-30"
```
Example response:
@@ -218,8 +216,7 @@ Example response:
### Contribution Stats
```bash
-curl "http://127.0.0.1:8000/api/contributions/~ccleberg/stats?year=2026" \
- -H "X-API-Key: replace-me"
+curl "http://127.0.0.1:8000/api/contributions/~ccleberg/stats?year=2026"
```
Example response:
@@ -292,7 +289,8 @@ pytest
Covered areas:
- health endpoint
-- API key enforcement
+- public read-only contribution endpoints
+- API key enforcement for mutating/admin routes
- calendar aggregation
- zero-filled ranges
- stats calculations
diff --git a/src/srht_contrib/api/routes_contributions.py b/src/srht_contrib/api/routes_contributions.py
index 0682aa6..c682553 100644
--- a/src/srht_contrib/api/routes_contributions.py
+++ b/src/srht_contrib/api/routes_contributions.py
@@ -13,7 +13,7 @@ from srht_contrib.services.srht_client import SourceHutClientError
from srht_contrib.utils.dates import parse_date, year_bounds
from srht_contrib.utils.identity import ActorIdentityResolver
-router = APIRouter(prefix="/api/contributions", tags=["contributions"], dependencies=[Depends(require_api_key)])
+router = APIRouter(prefix="/api/contributions", tags=["contributions"])
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(
return ContributionAggregator().build_stats(db, canonical_actor, start, end)
[email protected]("/poll", response_model=PollResponse)
[email protected]("/poll", response_model=PollResponse, dependencies=[Depends(require_api_key)])
def trigger_manual_poll(
actor: str,
poller: PollerService = Depends(get_poller),
diff --git a/tests/test_contributions_api.py b/tests/test_contributions_api.py
index d90aceb..e221602 100644
--- a/tests/test_contributions_api.py
+++ b/tests/test_contributions_api.py
@@ -6,14 +6,18 @@ from srht_contrib.main import create_app
from srht_contrib.models import ContributionEvent
-def test_api_routes_require_api_key(settings, db_engine, session_factory) -> None:
+def test_read_only_contribution_routes_are_public_and_write_routes_require_api_key(settings, db_engine, session_factory) -> None:
app = create_app(settings, engine=db_engine, session_factory=session_factory)
with TestClient(app) as open_client:
response = open_client.get("/health")
+ public_contributions = open_client.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30")
+ public_stats = open_client.get("/api/contributions/~ccleberg/stats?from=2026-03-28&to=2026-03-30")
assert response.status_code == 200
+ assert public_contributions.status_code == 200
+ assert public_stats.status_code == 200
with TestClient(app) as unauthorized:
- unauthorized_response = unauthorized.get("/api/contributions/~ccleberg?from=2026-03-28&to=2026-03-30")
+ unauthorized_response = unauthorized.post("/api/contributions/poll?actor=~ccleberg")
assert unauthorized_response.status_code == 401
with TestClient(app) as invalid: