Add governed RSS and Atom connectors

This commit is contained in:
2026-07-31 22:48:07 +02:00
parent c5a43b3dae
commit 52fe33568c
5 changed files with 757 additions and 1 deletions
+140 -1
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, HTTPException, Query, status
from dataclasses import asdict
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event
@@ -13,9 +15,18 @@ from govoplan_core.core.tabular_sources import (
TabularSourceError,
TabularSourceNotFoundError,
)
from govoplan_core.core.feeds import (
FeedCapabilityError,
FeedEntry,
FeedRenderRequest,
)
from govoplan_core.core.sanctions import SanctionsSnapshotReference
from govoplan_core.db.session import get_session
from govoplan_connectors.backend.schemas import (
FeedAcquireRequest,
FeedDocumentResponse,
FeedImportRequest,
FeedRenderPayload,
SanctionsAcquisitionRunListResponse,
SanctionsAcquisitionRunResponse,
SanctionsRefreshResponse,
@@ -30,6 +41,7 @@ from govoplan_connectors.backend.schemas import (
TabularSourcePreviewResponse,
TabularSourceResponse,
)
from govoplan_connectors.backend.feeds import ConnectorFeedProvider, feed_rows
from govoplan_connectors.backend.sanctions_sources import (
SANCTIONS_READ_SCOPE,
SANCTIONS_REFRESH_SCOPE,
@@ -50,6 +62,7 @@ from govoplan_connectors.backend.tabular_sources import (
router = APIRouter(prefix="/connectors", tags=["connectors"])
provider = SqlTabularSourceProvider()
sanctions_provider = SqlSanctionsSnapshotProvider()
feed_transport = ConnectorFeedProvider()
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None:
@@ -88,6 +101,132 @@ def _sanctions_http_error(
)
def _feed_http_error(exc: FeedCapabilityError) -> HTTPException:
return HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
)
@router.post("/feeds/preview", response_model=FeedDocumentResponse)
def api_preview_feed(
payload: FeedAcquireRequest,
principal: ApiPrincipal = Depends(get_api_principal),
) -> FeedDocumentResponse:
_require_any_scope(principal, READ_SCOPE, ADMIN_SCOPE)
try:
document = feed_transport.fetch(
payload.url,
max_entries=payload.max_entries,
)
except FeedCapabilityError as exc:
raise _feed_http_error(exc) from exc
return FeedDocumentResponse.model_validate(asdict(document))
@router.post(
"/feeds/import",
response_model=TabularSourceResponse,
status_code=status.HTTP_201_CREATED,
)
def api_import_feed_snapshot(
payload: FeedImportRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(get_api_principal),
) -> TabularSourceResponse:
_require_any_scope(principal, WRITE_SCOPE, ADMIN_SCOPE)
try:
document = feed_transport.fetch(
payload.url,
max_entries=payload.max_entries,
)
source = provider.create_snapshot(
session,
principal,
snapshot=TabularSnapshotInput(
name=payload.name,
source_name=payload.source_name,
description=payload.description or document.description,
rows=feed_rows(document),
metadata={
"import_format": document.format,
"feed": {
"source_url": document.source_url,
"home_url": document.home_url,
"acquired_at": (
document.acquired_at.isoformat()
if document.acquired_at
else None
),
"fresh_until": (
document.fresh_until.isoformat()
if document.fresh_until
else None
),
"etag": document.etag,
"last_modified": document.last_modified,
"content_type": document.content_type,
"sha256": document.sha256,
},
},
),
)
except (FeedCapabilityError, TabularSourceError) as exc:
if isinstance(exc, FeedCapabilityError):
raise _feed_http_error(exc) from exc
raise _http_error(exc) from exc
audit_event(
session,
tenant_id=principal.tenant_id,
user_id=getattr(principal.user, "id", None),
api_key_id=principal.api_key_id,
action="connectors.feed_snapshot.created",
object_type="connector_tabular_source",
object_id=source.ref,
details={
"source_url": document.source_url,
"format": document.format,
"sha256": document.sha256,
"row_count": source.row_count,
},
)
session.commit()
return _source_response(source)
@router.post("/feeds/render")
def api_render_feed(
payload: FeedRenderPayload,
principal: ApiPrincipal = Depends(get_api_principal),
) -> Response:
_require_any_scope(principal, READ_SCOPE, ADMIN_SCOPE)
try:
rendered = feed_transport.render(
FeedRenderRequest(
format=payload.format,
title=payload.title,
feed_url=payload.feed_url,
home_url=payload.home_url,
description=payload.description,
language=payload.language,
entries=tuple(
FeedEntry(**item.model_dump()) for item in payload.entries
),
allowed_visibilities=frozenset(payload.allowed_visibilities),
)
)
except FeedCapabilityError as exc:
raise _feed_http_error(exc) from exc
return Response(
content=rendered.body,
media_type=rendered.content_type,
headers={
"X-GovOPlaN-Feed-Included": str(rendered.included_entries),
"X-GovOPlaN-Feed-Excluded": str(rendered.excluded_entries),
},
)
@router.get("/tabular-sources", response_model=TabularSourceListResponse)
def api_list_tabular_sources(
query: str = Query(default="", max_length=200),