aboutsummaryrefslogtreecommitdiff
path: root/src/srht_contrib/api
diff options
context:
space:
mode:
Diffstat (limited to 'src/srht_contrib/api')
-rw-r--r--src/srht_contrib/api/__init__.py1
-rw-r--r--src/srht_contrib/api/dependencies.py45
-rw-r--r--src/srht_contrib/api/routes_contributions.py78
-rw-r--r--src/srht_contrib/api/routes_health.py12
-rw-r--r--src/srht_contrib/api/routes_repositories.py117
5 files changed, 253 insertions, 0 deletions
diff --git a/src/srht_contrib/api/__init__.py b/src/srht_contrib/api/__init__.py
new file mode 100644
index 0000000..1ce04c3
--- /dev/null
+++ b/src/srht_contrib/api/__init__.py
@@ -0,0 +1 @@
+"""API routes."""
diff --git a/src/srht_contrib/api/dependencies.py b/src/srht_contrib/api/dependencies.py
new file mode 100644
index 0000000..8449380
--- /dev/null
+++ b/src/srht_contrib/api/dependencies.py
@@ -0,0 +1,45 @@
+from __future__ import annotations
+
+from fastapi import Depends, Header, HTTPException, Request, status
+from sqlalchemy.orm import Session
+
+from srht_contrib.config import Settings
+from srht_contrib.db import get_db_session
+from srht_contrib.jobs.poller import PollerService
+from srht_contrib.utils.identity import ActorIdentityResolver
+
+
+def get_db(request: Request):
+ yield from get_db_session(request)
+
+
+def get_settings(request: Request) -> Settings:
+ settings = getattr(request.app.state, "settings", None)
+ if settings is None:
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Settings not configured.")
+ return settings
+
+
+def get_actor_identity_resolver(request: Request) -> ActorIdentityResolver:
+ resolver = getattr(request.app.state, "actor_identity_resolver", None)
+ if resolver is None:
+ raise HTTPException(
+ status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
+ detail="Actor identity resolver not configured.",
+ )
+ return resolver
+
+
+def get_poller(request: Request) -> PollerService:
+ poller = getattr(request.app.state, "poller", None)
+ if poller is None:
+ raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Poller not configured.")
+ return poller
+
+
+def require_api_key(
+ x_api_key: str | None = Header(default=None, alias="X-API-Key"),
+ settings: Settings = Depends(get_settings),
+) -> None:
+ if not settings.api_key or x_api_key != settings.api_key:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid API key.")
diff --git a/src/srht_contrib/api/routes_contributions.py b/src/srht_contrib/api/routes_contributions.py
new file mode 100644
index 0000000..0682aa6
--- /dev/null
+++ b/src/srht_contrib/api/routes_contributions.py
@@ -0,0 +1,78 @@
+from __future__ import annotations
+
+from datetime import date
+
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy.orm import Session
+
+from srht_contrib.api.dependencies import get_actor_identity_resolver, get_db, get_poller, require_api_key
+from srht_contrib.jobs.poller import PollerService
+from srht_contrib.schemas import ContributionCalendarResponse, ContributionStatsResponse, PollResponse
+from srht_contrib.services.aggregator import ContributionAggregator
+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)])
+
+
+def _resolve_range(year: int | None, from_date: str | None, to_date: str | None) -> tuple[date, date]:
+ if year is not None and (from_date or to_date):
+ raise HTTPException(status_code=400, detail="Use either `year` or `from`/`to`, not both.")
+
+ try:
+ if year is not None:
+ return year_bounds(year)
+ if from_date and to_date:
+ start = parse_date(from_date)
+ end = parse_date(to_date)
+ if start > end:
+ raise HTTPException(status_code=400, detail="`from` must be on or before `to`.")
+ return start, end
+ except ValueError as exc:
+ raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD.") from exc
+
+ raise HTTPException(status_code=400, detail="Provide `year` or both `from` and `to`.")
+
+
[email protected]("/{actor}", response_model=ContributionCalendarResponse)
+def get_contributions(
+ actor: str,
+ year: int | None = Query(default=None, ge=1970, le=3000),
+ from_date: str | None = Query(default=None, alias="from"),
+ to_date: str | None = Query(default=None, alias="to"),
+ db: Session = Depends(get_db),
+ actor_identity_resolver: ActorIdentityResolver = Depends(get_actor_identity_resolver),
+) -> ContributionCalendarResponse:
+ start, end = _resolve_range(year, from_date, to_date)
+ canonical_actor = actor_identity_resolver.canonicalize(actor, db=db)
+ return ContributionAggregator().build_calendar(db, canonical_actor, start, end)
+
+
[email protected]("/{actor}/stats", response_model=ContributionStatsResponse)
+def get_contribution_stats(
+ actor: str,
+ year: int | None = Query(default=None, ge=1970, le=3000),
+ from_date: str | None = Query(default=None, alias="from"),
+ to_date: str | None = Query(default=None, alias="to"),
+ db: Session = Depends(get_db),
+ actor_identity_resolver: ActorIdentityResolver = Depends(get_actor_identity_resolver),
+) -> ContributionStatsResponse:
+ start, end = _resolve_range(year, from_date, to_date)
+ canonical_actor = actor_identity_resolver.canonicalize(actor, db=db)
+ return ContributionAggregator().build_stats(db, canonical_actor, start, end)
+
+
[email protected]("/poll", response_model=PollResponse)
+def trigger_manual_poll(
+ actor: str,
+ poller: PollerService = Depends(get_poller),
+ db: Session = Depends(get_db),
+ actor_identity_resolver: ActorIdentityResolver = Depends(get_actor_identity_resolver),
+) -> PollResponse:
+ canonical_actor = actor_identity_resolver.canonicalize(actor, db=db)
+ try:
+ inserted = poller.poll_all(db, canonical_actor)
+ except SourceHutClientError as exc:
+ raise HTTPException(status_code=502, detail=f"SourceHut polling failed: {exc}") from exc
+ return PollResponse(actor=canonical_actor, inserted_events=inserted, services=["todo", "git"])
diff --git a/src/srht_contrib/api/routes_health.py b/src/srht_contrib/api/routes_health.py
new file mode 100644
index 0000000..32db759
--- /dev/null
+++ b/src/srht_contrib/api/routes_health.py
@@ -0,0 +1,12 @@
+from __future__ import annotations
+
+from fastapi import APIRouter
+
+from srht_contrib.schemas import HealthResponse
+
+router = APIRouter(tags=["health"])
+
+
[email protected]("/health", response_model=HealthResponse)
+def health() -> HealthResponse:
+ return HealthResponse(status="ok")
diff --git a/src/srht_contrib/api/routes_repositories.py b/src/srht_contrib/api/routes_repositories.py
new file mode 100644
index 0000000..93bc098
--- /dev/null
+++ b/src/srht_contrib/api/routes_repositories.py
@@ -0,0 +1,117 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
+from sqlalchemy import select
+from sqlalchemy.exc import IntegrityError
+from sqlalchemy.orm import Session
+
+from srht_contrib.api.dependencies import get_actor_identity_resolver, get_db, require_api_key
+from srht_contrib.models import TrackedRepository
+from srht_contrib.schemas import (
+ TrackedRepositoryCreateRequest,
+ TrackedRepositoryResponse,
+ TrackedRepositoryUpdateRequest,
+)
+from srht_contrib.utils.identity import ActorIdentityResolver
+from srht_contrib.utils.repositories import canonicalize_repository_name
+
+router = APIRouter(prefix="/api/repositories", tags=["repositories"], dependencies=[Depends(require_api_key)])
+
+
+def _to_response(repository: TrackedRepository) -> TrackedRepositoryResponse:
+ return TrackedRepositoryResponse(
+ id=repository.id,
+ service=repository.service,
+ actor=repository.actor,
+ repo_name=repository.repo_name,
+ )
+
+
+def _get_repository_or_404(db: Session, repository_id: int) -> TrackedRepository:
+ repository = db.scalar(select(TrackedRepository).where(TrackedRepository.id == repository_id))
+ if repository is None:
+ raise HTTPException(status_code=404, detail="Tracked repository not found.")
+ return repository
+
+
[email protected]("", response_model=list[TrackedRepositoryResponse])
+def list_tracked_repositories(
+ actor: str | None = Query(default=None),
+ db: Session = Depends(get_db),
+ actor_identity_resolver: ActorIdentityResolver = Depends(get_actor_identity_resolver),
+) -> list[TrackedRepositoryResponse]:
+ stmt = select(TrackedRepository).where(TrackedRepository.service == "git").order_by(TrackedRepository.repo_name)
+ if actor:
+ canonical_actor = actor_identity_resolver.canonicalize(actor, db=db)
+ stmt = stmt.where(TrackedRepository.actor == canonical_actor)
+
+ repositories = db.scalars(stmt).all()
+ return [_to_response(repository) for repository in repositories]
+
+
[email protected]("/{repository_id}", response_model=TrackedRepositoryResponse)
+def get_tracked_repository(
+ repository_id: int,
+ db: Session = Depends(get_db),
+) -> TrackedRepositoryResponse:
+ return _to_response(_get_repository_or_404(db, repository_id))
+
+
[email protected]("", response_model=TrackedRepositoryResponse, status_code=status.HTTP_201_CREATED)
+def create_tracked_repository(
+ payload: TrackedRepositoryCreateRequest,
+ db: Session = Depends(get_db),
+ actor_identity_resolver: ActorIdentityResolver = Depends(get_actor_identity_resolver),
+) -> TrackedRepositoryResponse:
+ canonical_actor = actor_identity_resolver.canonicalize(payload.actor, db=db)
+ repository = TrackedRepository(
+ service="git",
+ actor=canonical_actor,
+ repo_name=canonicalize_repository_name(canonical_actor, payload.repo_name),
+ )
+ db.add(repository)
+ try:
+ db.commit()
+ except IntegrityError as exc:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="Tracked repository already exists.") from exc
+
+ db.refresh(repository)
+ return _to_response(repository)
+
+
[email protected]("/{repository_id}", response_model=TrackedRepositoryResponse)
+def update_tracked_repository(
+ repository_id: int,
+ payload: TrackedRepositoryUpdateRequest,
+ db: Session = Depends(get_db),
+ actor_identity_resolver: ActorIdentityResolver = Depends(get_actor_identity_resolver),
+) -> TrackedRepositoryResponse:
+ repository = _get_repository_or_404(db, repository_id)
+
+ if payload.actor is not None:
+ repository.actor = actor_identity_resolver.canonicalize(payload.actor, db=db)
+
+ if payload.repo_name is not None:
+ repository.repo_name = canonicalize_repository_name(repository.actor, payload.repo_name)
+
+ db.add(repository)
+ try:
+ db.commit()
+ except IntegrityError as exc:
+ db.rollback()
+ raise HTTPException(status_code=409, detail="Tracked repository already exists.") from exc
+
+ db.refresh(repository)
+ return _to_response(repository)
+
+
[email protected]("/{repository_id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_tracked_repository(
+ repository_id: int,
+ db: Session = Depends(get_db),
+) -> Response:
+ repository = _get_repository_or_404(db, repository_id)
+ db.delete(repository)
+ db.commit()
+ return Response(status_code=status.HTTP_204_NO_CONTENT)