feat: add search and external integration contracts

This commit is contained in:
2026-07-29 15:50:08 +02:00
parent 68328f3d8e
commit d9b5708df0
14 changed files with 607 additions and 3 deletions

View File

@@ -0,0 +1,137 @@
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import datetime
from typing import Literal
from urllib.parse import urlsplit
IntegrationMaturity = Literal[
"discover",
"link",
"search",
"read",
"publish",
"synchronize",
"migrate",
"replace",
]
INTEGRATION_MATURITY_ORDER: tuple[IntegrationMaturity, ...] = (
"discover",
"link",
"search",
"read",
"publish",
"synchronize",
"migrate",
"replace",
)
class ExternalReferenceValidationError(ValueError):
pass
@dataclass(frozen=True, slots=True)
class ExternalObjectReference:
"""Stable identity and provenance for an object owned by another system."""
system: str
object_type: str
object_id: str
maturity: IntegrationMaturity = "link"
connector_id: str | None = None
canonical_url: str | None = None
version: str | None = None
etag: str | None = None
observed_at: datetime | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
for field_name in ("system", "object_type", "object_id"):
value = str(getattr(self, field_name) or "").strip()
if not value:
raise ExternalReferenceValidationError(
f"External reference {field_name} is required."
)
if len(value) > 255:
raise ExternalReferenceValidationError(
f"External reference {field_name} is limited to 255 characters."
)
object.__setattr__(self, field_name, value)
if self.maturity not in INTEGRATION_MATURITY_ORDER:
raise ExternalReferenceValidationError(
f"Unsupported integration maturity: {self.maturity!r}."
)
if self.connector_id is not None:
connector_id = self.connector_id.strip()
if not connector_id:
raise ExternalReferenceValidationError(
"External reference connector_id cannot be blank."
)
object.__setattr__(self, "connector_id", connector_id)
if self.canonical_url is not None:
object.__setattr__(
self,
"canonical_url",
_validated_reference_url(self.canonical_url),
)
@property
def identity_key(self) -> str:
return f"{self.system}:{self.object_type}:{self.object_id}"
def supports(self, maturity: IntegrationMaturity) -> bool:
return integration_maturity_rank(self.maturity) >= integration_maturity_rank(
maturity
)
def to_dict(self) -> dict[str, object]:
return {
"system": self.system,
"object_type": self.object_type,
"object_id": self.object_id,
"maturity": self.maturity,
"connector_id": self.connector_id,
"canonical_url": self.canonical_url,
"version": self.version,
"etag": self.etag,
"observed_at": (
self.observed_at.isoformat() if self.observed_at is not None else None
),
"metadata": dict(self.metadata),
}
def integration_maturity_rank(maturity: IntegrationMaturity) -> int:
try:
return INTEGRATION_MATURITY_ORDER.index(maturity)
except ValueError as exc:
raise ExternalReferenceValidationError(
f"Unsupported integration maturity: {maturity!r}."
) from exc
def _validated_reference_url(value: str) -> str:
normalized = value.strip()
parsed = urlsplit(normalized)
if parsed.scheme not in {"http", "https"} or not parsed.hostname:
raise ExternalReferenceValidationError(
"External reference URLs must use HTTP or HTTPS."
)
if parsed.username is not None or parsed.password is not None:
raise ExternalReferenceValidationError(
"External reference URLs must not contain credentials."
)
return normalized
__all__ = [
"ExternalObjectReference",
"ExternalReferenceValidationError",
"INTEGRATION_MATURITY_ORDER",
"IntegrationMaturity",
"integration_maturity_rank",
]

View File

@@ -8,6 +8,7 @@ from govoplan_core.core.views import ViewSurface
if TYPE_CHECKING:
from fastapi import APIRouter
from govoplan_core.core.search import SearchProviderRegistration
SUPPORTED_MANIFEST_CONTRACT_VERSION = "1"
@@ -356,6 +357,7 @@ class ModuleManifest:
delete_veto_providers: Mapping[str, Sequence[DeleteVetoProvider]] = field(default_factory=dict)
uninstall_guard_providers: tuple[UninstallGuardProvider, ...] = ()
capability_factories: Mapping[str, CapabilityFactory] = field(default_factory=dict)
search_providers: tuple["SearchProviderRegistration", ...] = ()
compatibility: ModuleCompatibility = field(default_factory=ModuleCompatibility)
on_activate: LifecycleHook | None = None
on_deactivate: LifecycleHook | None = None

View File

@@ -25,6 +25,7 @@ from govoplan_core.core.modules import (
user_workflow_scope_condition_issues,
)
from govoplan_core.core.versioning import format_version_range, version_range_is_valid, version_satisfies_range
from govoplan_core.core.search import RegisteredSearchProvider, SearchProvider
from govoplan_core.core.views import (
ViewSurface,
module_view_surface_id,
@@ -60,6 +61,8 @@ class PlatformRegistry:
self._capability_factories: dict[str, CapabilityFactory] = {}
self._capabilities: dict[str, object] = {}
self._capability_context: ModuleContext | None = None
self._search_provider_registrations: list[RegisteredSearchProvider] = []
self._search_providers: dict[str, SearchProvider] = {}
def register(self, manifest: ModuleManifest) -> ModuleManifest:
if manifest.id in self._manifests:
@@ -72,6 +75,13 @@ class PlatformRegistry:
self.register_delete_veto(manifest.id, resource_type, provider)
for name, factory in manifest.capability_factories.items():
self.register_capability_factory(manifest.id, name, factory)
for registration in manifest.search_providers:
self._search_provider_registrations.append(
RegisteredSearchProvider(
module_id=manifest.id,
registration=registration,
)
)
return manifest
def replace(self, manifests: Iterable[ModuleManifest]) -> RegistrySnapshot:
@@ -89,7 +99,11 @@ class PlatformRegistry:
for resource_type, providers in replacement._delete_veto_providers.items()
})
self._capability_factories = dict(replacement._capability_factories)
self._search_provider_registrations = list(
replacement._search_provider_registrations
)
self._capabilities.clear()
self._search_providers.clear()
return snapshot
def get(self, module_id: str) -> ModuleManifest | None:
@@ -133,6 +147,7 @@ class PlatformRegistry:
def configure_capability_context(self, context: ModuleContext) -> None:
self._capability_context = context
self._capabilities.clear()
self._search_providers.clear()
def register_capability_factory(self, module_id: str, name: str, factory: CapabilityFactory) -> None:
if name in self._capability_factories:
@@ -160,6 +175,37 @@ class PlatformRegistry:
raise RegistryError(f"Required capability is not available: {name}")
return capability
def search_provider_registrations(
self,
) -> tuple[RegisteredSearchProvider, ...]:
return tuple(
sorted(
self._search_provider_registrations,
key=lambda item: (
item.registration.order,
item.module_id,
item.registration.id,
),
)
)
def search_providers(
self,
) -> tuple[tuple[RegisteredSearchProvider, SearchProvider], ...]:
if self._capability_context is None:
if self._search_provider_registrations:
raise RegistryError("Search provider context is not configured.")
return ()
providers: list[tuple[RegisteredSearchProvider, SearchProvider]] = []
for registered in self.search_provider_registrations():
key = f"{registered.module_id}:{registered.registration.id}"
provider = self._search_providers.get(key)
if provider is None:
provider = registered.registration.create(self._capability_context)
self._search_providers[key] = provider
providers.append((registered, provider))
return tuple(providers)
def register_tenant_summary_provider(self, module_id: str, provider: TenantSummaryProvider) -> None:
self._tenant_summary_providers[module_id] = provider
@@ -462,6 +508,19 @@ def _validate_manifest_contract_lists(manifest: ModuleManifest) -> None:
_validate_capability_list(manifest.id, "optional_capabilities", manifest.optional_capabilities)
_validate_interface_providers(manifest.id, manifest.provides_interfaces)
_validate_interface_requirements(manifest.id, manifest.requires_interfaces)
provider_ids: set[str] = set()
for registration in manifest.search_providers:
if not _INTERFACE_NAME_RE.match(registration.id):
raise RegistryError(
f"Module {manifest.id!r} search provider id must be namespaced: "
f"{registration.id!r}"
)
if registration.id in provider_ids:
raise RegistryError(
f"Module {manifest.id!r} declares duplicate search provider "
f"{registration.id!r}"
)
provider_ids.add(registration.id)
def _validate_manifest_overlaps(manifest: ModuleManifest) -> None:

View File

@@ -0,0 +1,178 @@
from __future__ import annotations
from collections.abc import Callable, Mapping, Sequence
from dataclasses import dataclass, field
from typing import Literal, Protocol, runtime_checkable
from govoplan_core.core.external_references import ExternalObjectReference
from govoplan_core.core.modules import ModuleContext
SearchContextKind = Literal["global", "module", "resource"]
SearchVisibility = Literal["tenant", "restricted"]
@dataclass(frozen=True, slots=True)
class SearchQuery:
text: str
tenant_id: str
module_ids: tuple[str, ...] = ()
resource_types: tuple[str, ...] = ()
context_kind: SearchContextKind = "global"
context_id: str | None = None
limit: int = 25
offset: int = 0
def __post_init__(self) -> None:
normalized_text = self.text.strip()
if len(normalized_text) > 500:
raise ValueError("Search text is limited to 500 characters.")
if not self.tenant_id.strip():
raise ValueError("Search queries require a tenant.")
if not 1 <= self.limit <= 200:
raise ValueError("Search result limits must be between 1 and 200.")
if self.offset < 0:
raise ValueError("Search offsets cannot be negative.")
object.__setattr__(self, "text", normalized_text)
@dataclass(frozen=True, slots=True)
class SearchResult:
provider_id: str
module_id: str
resource_type: str
resource_id: str
title: str
url: str
summary: str | None = None
score: float = 0.0
highlights: tuple[str, ...] = ()
breadcrumbs: tuple[str, ...] = ()
external_reference: ExternalObjectReference | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
@dataclass(frozen=True, slots=True)
class SearchDocument:
tenant_id: str
module_id: str
resource_type: str
resource_id: str
title: str
url: str
summary: str | None = None
body: str | None = None
keywords: tuple[str, ...] = ()
visibility: SearchVisibility = "restricted"
acl_tokens: tuple[str, ...] = ()
external_reference: ExternalObjectReference | None = None
metadata: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
required = {
"tenant_id": self.tenant_id,
"module_id": self.module_id,
"resource_type": self.resource_type,
"resource_id": self.resource_id,
"title": self.title,
"url": self.url,
}
for field_name, value in required.items():
if not str(value or "").strip():
raise ValueError(f"Search document {field_name} is required.")
if self.visibility == "restricted" and not self.acl_tokens:
raise ValueError(
"Restricted search documents require at least one ACL token."
)
@runtime_checkable
class SearchProvider(Protocol):
def search(
self,
session: object,
principal: object,
*,
query: SearchQuery,
) -> Sequence[SearchResult]:
"""Return only results the principal may currently read."""
@runtime_checkable
class SearchIndexWriter(Protocol):
def upsert_document(
self,
session: object,
principal: object,
*,
document: SearchDocument,
) -> None:
...
def delete_document(
self,
session: object,
principal: object,
*,
tenant_id: str,
module_id: str,
resource_type: str,
resource_id: str,
) -> bool:
...
SearchProviderFactory = Callable[[ModuleContext], SearchProvider]
@dataclass(frozen=True, slots=True)
class SearchProviderRegistration:
id: str
factory: SearchProviderFactory
resource_types: tuple[str, ...] = ()
order: int = 100
def create(self, context: ModuleContext) -> SearchProvider:
provider = self.factory(context)
if not isinstance(provider, SearchProvider):
raise TypeError(
f"Search provider {self.id!r} does not implement SearchProvider."
)
return provider
@dataclass(frozen=True, slots=True)
class RegisteredSearchProvider:
module_id: str
registration: SearchProviderRegistration
CAPABILITY_SEARCH_INDEX_WRITER = "search.index_writer"
def search_index_writer(registry: object | None) -> SearchIndexWriter | None:
if (
registry is None
or not hasattr(registry, "has_capability")
or not hasattr(registry, "capability")
or not registry.has_capability(CAPABILITY_SEARCH_INDEX_WRITER)
):
return None
writer = registry.capability(CAPABILITY_SEARCH_INDEX_WRITER)
return writer if isinstance(writer, SearchIndexWriter) else None
__all__ = [
"CAPABILITY_SEARCH_INDEX_WRITER",
"RegisteredSearchProvider",
"SearchContextKind",
"SearchDocument",
"SearchIndexWriter",
"SearchProvider",
"SearchProviderFactory",
"SearchProviderRegistration",
"SearchQuery",
"SearchResult",
"SearchVisibility",
"search_index_writer",
]

View File

@@ -45,7 +45,15 @@ class Settings(BaseSettings):
tenant_data_mode: str = Field(default="shared", alias="TENANT_DATA_MODE")
tenant_db_url_template: str | None = Field(default=None, alias="TENANT_DB_URL_TEMPLATE")
tenant_schema_template: str | None = Field(default=None, alias="TENANT_SCHEMA_TEMPLATE")
enabled_modules: str = Field(default="tenancy,organizations,identity,idm,access,admin,dashboard,policy,audit,campaigns,files,mail,calendar,poll,scheduling,connectors,datasources,dataflow,workflow,views,postbox,notifications,docs,ops", alias="ENABLED_MODULES")
enabled_modules: str = Field(
default=(
"tenancy,organizations,identity,idm,access,admin,dashboard,policy,"
"audit,campaigns,files,mail,calendar,poll,scheduling,connectors,"
"datasources,dataflow,workflow,views,search,postbox,notifications,"
"docs,ops"
),
alias="ENABLED_MODULES",
)
migration_track: str = Field(default="release", alias="GOVOPLAN_MIGRATION_TRACK")
redis_url: str = Field(default="redis://redis:6379/0", alias="REDIS_URL")
celery_enabled: bool = Field(default=False, alias="CELERY_ENABLED")