Add governed RSS and Atom connectors
This commit is contained in:
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user