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
+413
View File
@@ -0,0 +1,413 @@
from __future__ import annotations
import hashlib
import re
import xml.etree.ElementTree as ET
from collections.abc import Mapping
from dataclasses import replace
from datetime import datetime, timedelta, timezone
from email.utils import format_datetime, parsedate_to_datetime
from defusedxml import ElementTree as SafeET
from defusedxml.common import DefusedXmlException
from govoplan_core.core.feeds import (
FeedCapabilityError,
FeedDocument,
FeedEntry,
FeedProvider,
FeedRenderRequest,
FeedRenderResult,
)
from govoplan_core.security.http_fetch import fetch_http
MAX_FEED_BYTES = 5_000_000
ATOM_NS = "http://www.w3.org/2005/Atom"
class ConnectorFeedProvider(FeedProvider):
def fetch(
self,
url: str,
*,
timeout: float = 15,
max_entries: int = 2_000,
) -> FeedDocument:
try:
response = fetch_http(
url,
timeout=timeout,
label="RSS/Atom feed URL",
headers={
"Accept": (
"application/atom+xml, application/rss+xml, "
"application/xml;q=0.9, text/xml;q=0.8"
)
},
max_bytes=MAX_FEED_BYTES,
)
except Exception as exc:
raise FeedCapabilityError(f"Feed acquisition failed: {exc}") from exc
if response.status < 200 or response.status >= 300:
raise FeedCapabilityError(
f"Feed acquisition returned HTTP {response.status}."
)
content_type = _header(response.headers, "content-type")
document = self.parse(
response.body,
source_url=url,
content_type=content_type,
max_entries=max_entries,
)
acquired_at = datetime.now(timezone.utc)
return replace(
document,
acquired_at=acquired_at,
fresh_until=_fresh_until(response.headers, acquired_at),
etag=_header(response.headers, "etag"),
last_modified=_header(response.headers, "last-modified"),
metadata={
**dict(document.metadata),
"http_status": response.status,
"byte_count": len(response.body),
},
)
def parse(
self,
content: bytes,
*,
source_url: str,
content_type: str | None = None,
max_entries: int = 2_000,
) -> FeedDocument:
if not content:
raise FeedCapabilityError("Feed content is empty.")
if len(content) > MAX_FEED_BYTES:
raise FeedCapabilityError(
f"Feeds are limited to {MAX_FEED_BYTES // 1_000_000} MB."
)
try:
root = SafeET.fromstring(content)
except (ET.ParseError, DefusedXmlException) as exc:
raise FeedCapabilityError(f"Feed XML is not safe or valid: {exc}") from exc
local_name = _local_name(root.tag)
if local_name == "rss":
document = _parse_rss(root, source_url=source_url, max_entries=max_entries)
elif local_name == "feed":
document = _parse_atom(root, source_url=source_url, max_entries=max_entries)
else:
raise FeedCapabilityError("The document is neither an RSS nor an Atom feed.")
return replace(
document,
content_type=content_type,
sha256=hashlib.sha256(content).hexdigest(),
)
def render(self, request: FeedRenderRequest) -> FeedRenderResult:
if not request.title.strip() or not request.feed_url.strip():
raise FeedCapabilityError("Feed title and feed URL are required.")
entries = tuple(
entry
for entry in request.entries
if entry.visibility in request.allowed_visibilities
)
root = (
_render_rss(request, entries)
if request.format == "rss"
else _render_atom(request, entries)
)
body = ET.tostring(root, encoding="utf-8", xml_declaration=True)
return FeedRenderResult(
format=request.format,
content_type=(
"application/rss+xml; charset=utf-8"
if request.format == "rss"
else "application/atom+xml; charset=utf-8"
),
body=body,
included_entries=len(entries),
excluded_entries=len(request.entries) - len(entries),
)
def feed_rows(document: FeedDocument) -> tuple[Mapping[str, object], ...]:
"""Map feed entries to the connector tabular shape used by Datasources."""
return tuple(
{
"id": entry.id,
"title": entry.title,
"url": entry.url,
"summary": entry.summary,
"content": entry.content,
"author": entry.author,
"published_at": (
entry.published_at.isoformat() if entry.published_at else None
),
"updated_at": entry.updated_at.isoformat() if entry.updated_at else None,
"categories": list(entry.categories),
"enclosures": [dict(item) for item in entry.enclosures],
}
for entry in document.entries
)
def _parse_rss(root: ET.Element, *, source_url: str, max_entries: int) -> FeedDocument:
channel = _first_child(root, "channel")
if channel is None:
raise FeedCapabilityError("RSS feed is missing its channel element.")
entries: list[FeedEntry] = []
for item in _children(channel, "item"):
if len(entries) >= max_entries:
raise FeedCapabilityError(f"Feeds are limited to {max_entries:,} entries.")
url = _text(item, "link")
identifier = _text(item, "guid") or url or _entry_fallback_id(item)
entries.append(
FeedEntry(
id=identifier,
title=_text(item, "title") or "(Untitled)",
url=url,
summary=_text(item, "description"),
content=_text(item, "encoded"),
author=_text(item, "author") or _text(item, "creator"),
published_at=_parse_date(_text(item, "pubDate")),
categories=tuple(
value for child in _children(item, "category")
if (value := (child.text or "").strip())
),
enclosures=tuple(
{
"url": child.attrib.get("url"),
"media_type": child.attrib.get("type"),
"size_bytes": _integer(child.attrib.get("length")),
}
for child in _children(item, "enclosure")
),
)
)
return FeedDocument(
format="rss",
title=_text(channel, "title") or "Untitled feed",
source_url=source_url,
entries=tuple(entries),
description=_text(channel, "description"),
home_url=_text(channel, "link"),
language=_text(channel, "language"),
updated_at=_parse_date(
_text(channel, "lastBuildDate") or _text(channel, "pubDate")
),
)
def _parse_atom(root: ET.Element, *, source_url: str, max_entries: int) -> FeedDocument:
entries: list[FeedEntry] = []
for item in _children(root, "entry"):
if len(entries) >= max_entries:
raise FeedCapabilityError(f"Feeds are limited to {max_entries:,} entries.")
alternate = _atom_link(item, "alternate")
identifier = _text(item, "id") or alternate or _entry_fallback_id(item)
author = _first_child(item, "author")
entries.append(
FeedEntry(
id=identifier,
title=_text(item, "title") or "(Untitled)",
url=alternate,
summary=_text(item, "summary"),
content=_text(item, "content"),
author=_text(author, "name") if author is not None else None,
published_at=_parse_date(_text(item, "published")),
updated_at=_parse_date(_text(item, "updated")),
categories=tuple(
value for child in _children(item, "category")
if (value := (child.attrib.get("term") or "").strip())
),
enclosures=tuple(
{
"url": child.attrib.get("href"),
"media_type": child.attrib.get("type"),
"size_bytes": _integer(child.attrib.get("length")),
}
for child in _children(item, "link")
if child.attrib.get("rel") == "enclosure"
),
)
)
return FeedDocument(
format="atom",
title=_text(root, "title") or "Untitled feed",
source_url=source_url,
entries=tuple(entries),
description=_text(root, "subtitle"),
home_url=_atom_link(root, "alternate"),
updated_at=_parse_date(_text(root, "updated")),
)
def _render_rss(request: FeedRenderRequest, entries: tuple[FeedEntry, ...]) -> ET.Element:
ET.register_namespace("atom", ATOM_NS)
root = ET.Element("rss", {"version": "2.0"})
channel = ET.SubElement(root, "channel")
_element(channel, "title", request.title)
_element(channel, "link", request.home_url)
_element(channel, "description", request.description or request.title)
_element(channel, f"{{{ATOM_NS}}}link", None, {
"href": request.feed_url,
"rel": "self",
"type": "application/rss+xml",
})
if request.language:
_element(channel, "language", request.language)
for entry in entries:
item = ET.SubElement(channel, "item")
_element(item, "guid", entry.id, {"isPermaLink": "false"})
_element(item, "title", entry.title)
if entry.url:
_element(item, "link", entry.url)
if entry.summary or entry.content:
_element(item, "description", entry.summary or entry.content)
if entry.author:
_element(item, "author", entry.author)
date = entry.published_at or entry.updated_at
if date:
_element(item, "pubDate", format_datetime(_utc(date)))
for category in entry.categories:
_element(item, "category", category)
for enclosure in entry.enclosures:
attributes = {
"url": str(enclosure.get("url") or ""),
"type": str(enclosure.get("media_type") or "application/octet-stream"),
"length": str(enclosure.get("size_bytes") or 0),
}
if attributes["url"]:
_element(item, "enclosure", None, attributes)
return root
def _render_atom(request: FeedRenderRequest, entries: tuple[FeedEntry, ...]) -> ET.Element:
ET.register_namespace("", ATOM_NS)
root = ET.Element(f"{{{ATOM_NS}}}feed")
_element(root, f"{{{ATOM_NS}}}id", request.feed_url)
_element(root, f"{{{ATOM_NS}}}title", request.title)
_element(root, f"{{{ATOM_NS}}}link", None, {"href": request.home_url})
_element(
root,
f"{{{ATOM_NS}}}link",
None,
{"href": request.feed_url, "rel": "self", "type": "application/atom+xml"},
)
latest = max(
(date for entry in entries for date in (entry.updated_at, entry.published_at) if date),
default=datetime.now(timezone.utc),
)
_element(root, f"{{{ATOM_NS}}}updated", _utc(latest).isoformat().replace("+00:00", "Z"))
if request.description:
_element(root, f"{{{ATOM_NS}}}subtitle", request.description)
for value in entries:
entry = ET.SubElement(root, f"{{{ATOM_NS}}}entry")
_element(entry, f"{{{ATOM_NS}}}id", value.id)
_element(entry, f"{{{ATOM_NS}}}title", value.title)
if value.url:
_element(entry, f"{{{ATOM_NS}}}link", None, {"href": value.url})
if value.summary:
_element(entry, f"{{{ATOM_NS}}}summary", value.summary)
if value.content:
_element(entry, f"{{{ATOM_NS}}}content", value.content, {"type": "html"})
updated = value.updated_at or value.published_at or latest
_element(entry, f"{{{ATOM_NS}}}updated", _utc(updated).isoformat().replace("+00:00", "Z"))
if value.published_at:
_element(entry, f"{{{ATOM_NS}}}published", _utc(value.published_at).isoformat().replace("+00:00", "Z"))
if value.author:
author = ET.SubElement(entry, f"{{{ATOM_NS}}}author")
_element(author, f"{{{ATOM_NS}}}name", value.author)
for category in value.categories:
_element(entry, f"{{{ATOM_NS}}}category", None, {"term": category})
return root
def _children(element: ET.Element, name: str) -> tuple[ET.Element, ...]:
return tuple(child for child in element if _local_name(child.tag) == name)
def _first_child(element: ET.Element, name: str) -> ET.Element | None:
return next((child for child in element if _local_name(child.tag) == name), None)
def _text(element: ET.Element | None, name: str) -> str | None:
if element is None:
return None
child = _first_child(element, name)
if child is None:
return None
value = "".join(child.itertext()).strip()
return value or None
def _local_name(tag: str) -> str:
return tag.rsplit("}", 1)[-1].split(":", 1)[-1]
def _atom_link(element: ET.Element, relation: str) -> str | None:
for child in _children(element, "link"):
if (child.attrib.get("rel") or "alternate") == relation:
return child.attrib.get("href")
return None
def _parse_date(value: str | None) -> datetime | None:
if not value:
return None
try:
parsed = parsedate_to_datetime(value)
except (TypeError, ValueError, OverflowError):
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return _utc(parsed)
def _utc(value: datetime) -> datetime:
if value.tzinfo is None:
return value.replace(tzinfo=timezone.utc)
return value.astimezone(timezone.utc)
def _integer(value: str | None) -> int | None:
try:
return int(value) if value is not None else None
except ValueError:
return None
def _entry_fallback_id(element: ET.Element) -> str:
body = ET.tostring(element, encoding="utf-8")
return f"urn:sha256:{hashlib.sha256(body).hexdigest()}"
def _header(headers: Mapping[str, str], name: str) -> str | None:
lowered = name.casefold()
return next((value for key, value in headers.items() if key.casefold() == lowered), None)
def _fresh_until(headers: Mapping[str, str], acquired_at: datetime) -> datetime | None:
cache_control = _header(headers, "cache-control") or ""
match = re.search(r"(?:^|,)\s*max-age\s*=\s*(\d+)", cache_control, re.IGNORECASE)
if match:
return acquired_at + timedelta(seconds=int(match.group(1)))
return _parse_date(_header(headers, "expires"))
def _element(
parent: ET.Element,
tag: str,
text: str | None,
attributes: Mapping[str, str] | None = None,
) -> ET.Element:
child = ET.SubElement(parent, tag, dict(attributes or {}))
child.text = text
return child
__all__ = ["ConnectorFeedProvider", "MAX_FEED_BYTES", "feed_rows"]
@@ -11,6 +11,7 @@ from govoplan_core.core.module_guards import (
persistent_table_uninstall_guard, persistent_table_uninstall_guard,
) )
from govoplan_core.core.datasources import CAPABILITY_DATASOURCE_ORIGINS from govoplan_core.core.datasources import CAPABILITY_DATASOURCE_ORIGINS
from govoplan_core.core.feeds import CAPABILITY_CONNECTORS_FEEDS
from govoplan_core.core.modules import ( from govoplan_core.core.modules import (
DocumentationTopic, DocumentationTopic,
MigrationSpec, MigrationSpec,
@@ -46,6 +47,7 @@ from govoplan_connectors.backend.tabular_sources import (
from govoplan_connectors.backend.datasource_origins import ( from govoplan_connectors.backend.datasource_origins import (
ConnectorDatasourceOriginProvider, ConnectorDatasourceOriginProvider,
) )
from govoplan_connectors.backend.feeds import ConnectorFeedProvider
MODULE_ID = "connectors" MODULE_ID = "connectors"
@@ -53,6 +55,7 @@ MODULE_VERSION = "0.1.14"
TABULAR_SOURCE_INTERFACE_VERSION = "0.1.0" TABULAR_SOURCE_INTERFACE_VERSION = "0.1.0"
DATASOURCE_ORIGIN_INTERFACE_VERSION = "0.1.0" DATASOURCE_ORIGIN_INTERFACE_VERSION = "0.1.0"
SANCTIONS_SNAPSHOT_INTERFACE_VERSION = "1.0.0" SANCTIONS_SNAPSHOT_INTERFACE_VERSION = "1.0.0"
FEED_INTERFACE_VERSION = "0.1.0"
def _permission(scope: str, label: str, description: str) -> PermissionDefinition: def _permission(scope: str, label: str, description: str) -> PermissionDefinition:
@@ -138,6 +141,10 @@ def _sanctions_snapshot_provider(
return SqlSanctionsSnapshotProvider() return SqlSanctionsSnapshotProvider()
def _feed_provider(_context) -> ConnectorFeedProvider:
return ConnectorFeedProvider()
def _tenant_summary(session, tenant_id: str) -> dict[str, int]: def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
return { return {
"connector_tabular_sources": ( "connector_tabular_sources": (
@@ -175,6 +182,9 @@ manifest = ModuleManifest(
"audit", "audit",
"files", "files",
"policy", "policy",
"datasources",
"portal",
"reporting",
"risk_compliance", "risk_compliance",
), ),
required_capabilities=( required_capabilities=(
@@ -198,6 +208,10 @@ manifest = ModuleManifest(
name="connectors.sanctions_snapshots", name="connectors.sanctions_snapshots",
version=SANCTIONS_SNAPSHOT_INTERFACE_VERSION, version=SANCTIONS_SNAPSHOT_INTERFACE_VERSION,
), ),
ModuleInterfaceProvider(
name="connectors.feeds",
version=FEED_INTERFACE_VERSION,
),
), ),
permissions=PERMISSIONS, permissions=PERMISSIONS,
role_templates=ROLE_TEMPLATES, role_templates=ROLE_TEMPLATES,
@@ -209,6 +223,7 @@ manifest = ModuleManifest(
CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS: ( CAPABILITY_CONNECTORS_SANCTIONS_SNAPSHOTS: (
_sanctions_snapshot_provider _sanctions_snapshot_provider
), ),
CAPABILITY_CONNECTORS_FEEDS: _feed_provider,
}, },
tenant_summary_providers=(_tenant_summary,), tenant_summary_providers=(_tenant_summary,),
migration_spec=MigrationSpec( migration_spec=MigrationSpec(
@@ -254,6 +269,25 @@ manifest = ModuleManifest(
related_modules=("dataflow", "files", "reporting", "risk_compliance"), related_modules=("dataflow", "files", "reporting", "risk_compliance"),
order=40, order=40,
), ),
DocumentationTopic(
id="connectors.rss-atom",
title="RSS and Atom feeds",
summary="Import governed feed snapshots and emit visibility-filtered feeds.",
body=(
"Connectors owns bounded, SSRF-protected RSS/Atom transport and XML "
"parsing. Imported entries become immutable tabular snapshots exposed "
"through Datasources, including acquisition, freshness, ETag, content "
"digest, and source provenance. Portal or Reporting owns publication "
"routes and must pass the allowed visibility set when rendering output. "
"A separate RSS module is only warranted if GovOPlaN later needs a "
"dedicated feed-reader product surface."
),
layer="available",
documentation_types=("admin", "user"),
audience=("operator", "module_admin", "power_user"),
related_modules=("datasources", "dataflow", "portal", "reporting"),
order=42,
),
DocumentationTopic( DocumentationTopic(
id="connectors.sanctions-snapshots", id="connectors.sanctions-snapshots",
title="Sanctions source snapshots", title="Sanctions source snapshots",
+140 -1
View File
@@ -1,6 +1,8 @@
from __future__ import annotations 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 sqlalchemy.orm import Session
from govoplan_core.audit.logging import audit_event from govoplan_core.audit.logging import audit_event
@@ -13,9 +15,18 @@ from govoplan_core.core.tabular_sources import (
TabularSourceError, TabularSourceError,
TabularSourceNotFoundError, TabularSourceNotFoundError,
) )
from govoplan_core.core.feeds import (
FeedCapabilityError,
FeedEntry,
FeedRenderRequest,
)
from govoplan_core.core.sanctions import SanctionsSnapshotReference from govoplan_core.core.sanctions import SanctionsSnapshotReference
from govoplan_core.db.session import get_session from govoplan_core.db.session import get_session
from govoplan_connectors.backend.schemas import ( from govoplan_connectors.backend.schemas import (
FeedAcquireRequest,
FeedDocumentResponse,
FeedImportRequest,
FeedRenderPayload,
SanctionsAcquisitionRunListResponse, SanctionsAcquisitionRunListResponse,
SanctionsAcquisitionRunResponse, SanctionsAcquisitionRunResponse,
SanctionsRefreshResponse, SanctionsRefreshResponse,
@@ -30,6 +41,7 @@ from govoplan_connectors.backend.schemas import (
TabularSourcePreviewResponse, TabularSourcePreviewResponse,
TabularSourceResponse, TabularSourceResponse,
) )
from govoplan_connectors.backend.feeds import ConnectorFeedProvider, feed_rows
from govoplan_connectors.backend.sanctions_sources import ( from govoplan_connectors.backend.sanctions_sources import (
SANCTIONS_READ_SCOPE, SANCTIONS_READ_SCOPE,
SANCTIONS_REFRESH_SCOPE, SANCTIONS_REFRESH_SCOPE,
@@ -50,6 +62,7 @@ from govoplan_connectors.backend.tabular_sources import (
router = APIRouter(prefix="/connectors", tags=["connectors"]) router = APIRouter(prefix="/connectors", tags=["connectors"])
provider = SqlTabularSourceProvider() provider = SqlTabularSourceProvider()
sanctions_provider = SqlSanctionsSnapshotProvider() sanctions_provider = SqlSanctionsSnapshotProvider()
feed_transport = ConnectorFeedProvider()
def _require_any_scope(principal: ApiPrincipal, *scopes: str) -> None: 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) @router.get("/tabular-sources", response_model=TabularSourceListResponse)
def api_list_tabular_sources( def api_list_tabular_sources(
query: str = Query(default="", max_length=200), query: str = Query(default="", max_length=200),
@@ -6,6 +6,68 @@ from typing import Any, Literal
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, Field, model_validator
class FeedAcquireRequest(BaseModel):
url: str = Field(min_length=1, max_length=2000)
max_entries: int = Field(default=2_000, ge=1, le=10_000)
class FeedImportRequest(FeedAcquireRequest):
name: str = Field(min_length=1, max_length=300)
source_name: str = Field(
min_length=1,
max_length=120,
pattern=r"^[A-Za-z_][A-Za-z0-9_]*$",
)
description: str | None = Field(default=None, max_length=4000)
class FeedEntryPayload(BaseModel):
id: str = Field(min_length=1, max_length=2000)
title: str = Field(min_length=1, max_length=1000)
url: str | None = Field(default=None, max_length=2000)
summary: str | None = None
content: str | None = None
author: str | None = Field(default=None, max_length=500)
published_at: datetime | None = None
updated_at: datetime | None = None
categories: list[str] = Field(default_factory=list, max_length=100)
enclosures: list[dict[str, Any]] = Field(default_factory=list, max_length=100)
visibility: Literal["public", "tenant", "private"] = "public"
metadata: dict[str, Any] = Field(default_factory=dict)
class FeedDocumentResponse(BaseModel):
format: Literal["rss", "atom"]
title: str
source_url: str
description: str | None = None
home_url: str | None = None
language: str | None = None
updated_at: datetime | None = None
acquired_at: datetime | None = None
fresh_until: datetime | None = None
etag: str | None = None
last_modified: str | None = None
content_type: str | None = None
sha256: str
entries: list[FeedEntryPayload]
metadata: dict[str, Any] = Field(default_factory=dict)
class FeedRenderPayload(BaseModel):
format: Literal["rss", "atom"]
title: str = Field(min_length=1, max_length=1000)
feed_url: str = Field(min_length=1, max_length=2000)
home_url: str = Field(min_length=1, max_length=2000)
description: str | None = None
language: str | None = Field(default=None, max_length=100)
entries: list[FeedEntryPayload] = Field(default_factory=list, max_length=10_000)
allowed_visibilities: list[Literal["public", "tenant", "private"]] = Field(
default_factory=lambda: ["public"],
max_length=3,
)
class SnapshotCreateRequest(BaseModel): class SnapshotCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=300) name: str = Field(min_length=1, max_length=300)
source_name: str = Field( source_name: str = Field(
@@ -140,6 +202,11 @@ class SanctionsRefreshResponse(BaseModel):
__all__ = [ __all__ = [
"FeedAcquireRequest",
"FeedDocumentResponse",
"FeedEntryPayload",
"FeedImportRequest",
"FeedRenderPayload",
"SnapshotCreateRequest", "SnapshotCreateRequest",
"SanctionsAcquisitionRunListResponse", "SanctionsAcquisitionRunListResponse",
"SanctionsAcquisitionRunResponse", "SanctionsAcquisitionRunResponse",
+103
View File
@@ -0,0 +1,103 @@
from __future__ import annotations
import unittest
from unittest.mock import patch
from defusedxml import ElementTree as SafeET
from govoplan_connectors.backend.feeds import ConnectorFeedProvider, feed_rows
from govoplan_core.core.feeds import (
FeedCapabilityError,
FeedEntry,
FeedRenderRequest,
)
from govoplan_core.security.http_fetch import HttpFetchResponse
RSS = b"""<?xml version="1.0"?>
<rss version="2.0"><channel>
<title>Decisions</title><link>https://example.test/</link>
<description>Published decisions</description>
<item><guid>decision-1</guid><title>Decision one</title>
<link>https://example.test/1</link>
<pubDate>Fri, 31 Jul 2026 10:00:00 GMT</pubDate>
<category>planning</category>
</item>
</channel></rss>"""
ATOM = b"""<?xml version="1.0"?>
<feed xmlns="http://www.w3.org/2005/Atom">
<id>https://example.test/feed</id><title>Updates</title>
<updated>2026-07-31T10:00:00Z</updated>
<link href="https://example.test/" />
<entry><id>update-1</id><title>Update one</title>
<updated>2026-07-31T10:00:00Z</updated>
<link href="https://example.test/update-1" />
</entry>
</feed>"""
class ConnectorFeedProviderTests(unittest.TestCase):
def setUp(self) -> None:
self.provider = ConnectorFeedProvider()
def test_rss_and_atom_are_normalized_to_tabular_entries(self) -> None:
rss = self.provider.parse(RSS, source_url="https://example.test/rss")
atom = self.provider.parse(ATOM, source_url="https://example.test/atom")
self.assertEqual("rss", rss.format)
self.assertEqual("decision-1", rss.entries[0].id)
self.assertEqual("atom", atom.format)
self.assertEqual("https://example.test/update-1", atom.entries[0].url)
self.assertEqual("decision-1", feed_rows(rss)[0]["id"])
self.assertEqual(64, len(rss.sha256))
def test_fetch_records_transport_freshness_and_provenance(self) -> None:
with patch(
"govoplan_connectors.backend.feeds.fetch_http",
return_value=HttpFetchResponse(
status=200,
headers={
"Content-Type": "application/rss+xml",
"Cache-Control": "public, max-age=600",
"ETag": '"feed-1"',
},
body=RSS,
),
):
document = self.provider.fetch("https://example.test/rss")
self.assertEqual('"feed-1"', document.etag)
self.assertIsNotNone(document.acquired_at)
self.assertEqual(600, int((document.fresh_until - document.acquired_at).total_seconds()))
self.assertEqual(len(RSS), document.metadata["byte_count"])
def test_render_filters_entries_by_explicit_visibility(self) -> None:
request = FeedRenderRequest(
format="atom",
title="Public updates",
feed_url="https://example.test/feed.atom",
home_url="https://example.test/",
entries=(
FeedEntry(id="public-1", title="Public", visibility="public"),
FeedEntry(id="tenant-1", title="Tenant", visibility="tenant"),
),
allowed_visibilities=frozenset({"public"}),
)
result = self.provider.render(request)
root = SafeET.fromstring(result.body)
self.assertEqual(1, result.included_entries)
self.assertEqual(1, result.excluded_entries)
self.assertIn(b"Public", result.body)
self.assertNotIn(b"Tenant", result.body)
self.assertTrue(root.tag.endswith("feed"))
def test_unsafe_xml_is_rejected(self) -> None:
payload = b'<!DOCTYPE x [<!ENTITY y SYSTEM "file:///etc/passwd">]><rss>&y;</rss>'
with self.assertRaisesRegex(FeedCapabilityError, "not safe or valid"):
self.provider.parse(payload, source_url="https://example.test/rss")
if __name__ == "__main__":
unittest.main()