diff options
Diffstat (limited to 'src/srht_contrib/api')
| -rw-r--r-- | src/srht_contrib/api/__init__.py | 1 | ||||
| -rw-r--r-- | src/srht_contrib/api/dependencies.py | 45 | ||||
| -rw-r--r-- | src/srht_contrib/api/routes_contributions.py | 78 | ||||
| -rw-r--r-- | src/srht_contrib/api/routes_health.py | 12 | ||||
| -rw-r--r-- | src/srht_contrib/api/routes_repositories.py | 117 |
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) |
