commit ada9f843eb75e4ab4cf268a50926c8f0ce5a5138 Author: Albrecht Degering Date: Sat Jul 11 17:17:03 2026 +0200 Initialize GovOPlaN REST connector diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a7bf152 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.venv/ +build/ +dist/ +*.egg-info/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..25f953e --- /dev/null +++ b/README.md @@ -0,0 +1,26 @@ +# govoplan-rest + + +**Repository type:** connector (protocol). + + +`govoplan-rest` provides the GovOPlaN REST connector surface. It is intended to +expose selected module functions through governed HTTP endpoints without forcing +business modules to import each other directly. + +The module starts with a small provider contract and discovery endpoint. Domain +modules will later register REST-exposable functions through the connector +contract; the connector owns HTTP binding, policy checks, serialization, and +operational diagnostics for that surface. + +## Ownership + +This repository owns: + +- backend module manifest `rest` +- REST connector route contributions +- REST function publication DTOs and provider protocol +- future REST endpoint policy, throttling, and public/credentialed access rules + +It does not own business semantics. Modules that expose functions remain +responsible for validation, authorization facts, and side effects. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..808b7da --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,24 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "govoplan-rest" +version = "0.1.7" +description = "GovOPlaN REST connector module." +readme = "README.md" +requires-python = ">=3.12" +authors = [{ name = "GovOPlaN" }] +dependencies = [ + "govoplan-core>=0.1.7", + "pydantic>=2,<3", +] + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +govoplan_rest = ["py.typed"] + +[project.entry-points."govoplan.modules"] +rest = "govoplan_rest.backend.manifest:get_manifest" diff --git a/src/govoplan_rest/__init__.py b/src/govoplan_rest/__init__.py new file mode 100644 index 0000000..b80837c --- /dev/null +++ b/src/govoplan_rest/__init__.py @@ -0,0 +1,5 @@ +"""GovOPlaN REST connector module.""" + +__all__ = ["__version__"] + +__version__ = "0.1.7" diff --git a/src/govoplan_rest/backend/__init__.py b/src/govoplan_rest/backend/__init__.py new file mode 100644 index 0000000..124ea2a --- /dev/null +++ b/src/govoplan_rest/backend/__init__.py @@ -0,0 +1 @@ +"""Backend package for the GovOPlaN REST connector.""" diff --git a/src/govoplan_rest/backend/contracts.py b/src/govoplan_rest/backend/contracts.py new file mode 100644 index 0000000..7f064ac --- /dev/null +++ b/src/govoplan_rest/backend/contracts.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from dataclasses import dataclass, field +from typing import Any, Literal, Protocol + + +RestFunctionVisibility = Literal["internal", "authenticated", "public"] + + +@dataclass(frozen=True, slots=True) +class RestFunctionDescriptor: + id: str + module_id: str + name: str + summary: str + method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] = "POST" + path: str | None = None + visibility: RestFunctionVisibility = "authenticated" + required_scopes: tuple[str, ...] = () + request_schema: Mapping[str, Any] = field(default_factory=dict) + response_schema: Mapping[str, Any] = field(default_factory=dict) + tags: tuple[str, ...] = () + + +class RestFunctionProvider(Protocol): + def rest_functions(self) -> Sequence[RestFunctionDescriptor]: + ... + + def invoke_rest_function(self, function_id: str, payload: Mapping[str, Any], context: Mapping[str, Any]) -> Mapping[str, Any]: + ... + + +CAPABILITY_REST_FUNCTION_PROVIDER = "rest.functionProvider" diff --git a/src/govoplan_rest/backend/manifest.py b/src/govoplan_rest/backend/manifest.py new file mode 100644 index 0000000..47872e5 --- /dev/null +++ b/src/govoplan_rest/backend/manifest.py @@ -0,0 +1,57 @@ +from __future__ import annotations + +from govoplan_core.core.access import CAPABILITY_AUTH_PERMISSION_EVALUATOR, CAPABILITY_AUTH_PRINCIPAL_RESOLVER +from govoplan_core.core.modules import ModuleContext, ModuleInterfaceProvider, ModuleManifest, PermissionDefinition, RoleTemplate + +REST_READ_SCOPE = "rest:endpoint:read" +REST_READ_SCOPES = (REST_READ_SCOPE, "system:settings:read", "admin:settings:read") + + +def _permission(scope: str, label: str, description: str) -> PermissionDefinition: + module_id, resource, action = scope.split(":", 2) + return PermissionDefinition( + scope=scope, + label=label, + description=description, + category="REST connector", + level="system", + module_id=module_id, + resource=resource, + action=action, + ) + + +def _route_factory(context: ModuleContext): + del context + from govoplan_rest.backend.router import router + + return router + + +manifest = ModuleManifest( + id="rest", + name="REST Connector", + version="0.1.7", + required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), + optional_dependencies=("audit", "docs", "policy"), + provides_interfaces=( + ModuleInterfaceProvider(name="rest.functionPublication", version="0.1.0"), + ), + permissions=( + _permission(REST_READ_SCOPE, "View REST connector", "Read REST connector status and published function metadata."), + ), + role_templates=( + RoleTemplate( + slug="rest_connector_reader", + name="REST connector reader", + description="Read REST connector diagnostics and published endpoint metadata.", + permissions=(REST_READ_SCOPE,), + level="system", + ), + ), + route_factory=_route_factory, +) + + +def get_manifest() -> ModuleManifest: + return manifest diff --git a/src/govoplan_rest/backend/router.py b/src/govoplan_rest/backend/router.py new file mode 100644 index 0000000..2fd0046 --- /dev/null +++ b/src/govoplan_rest/backend/router.py @@ -0,0 +1,28 @@ +from __future__ import annotations + +from typing import Any + +from fastapi import APIRouter, Depends, Request, status + +from govoplan_core.auth import ApiPrincipal, require_any_scope +from govoplan_core.core.registry import PlatformRegistry + +from govoplan_rest.backend.manifest import REST_READ_SCOPES + +router = APIRouter(prefix="/rest", tags=["rest"]) + + +@router.get("/status", status_code=status.HTTP_200_OK) +def rest_status( + request: Request, + principal: ApiPrincipal = Depends(require_any_scope(*REST_READ_SCOPES)), +) -> dict[str, Any]: + del principal + registry = getattr(request.app.state, "govoplan_registry", None) + module_count = len(registry.manifests()) if isinstance(registry, PlatformRegistry) else 0 + return { + "module": "rest", + "status": "ok", + "module_count": module_count, + "published_function_count": 0, + } diff --git a/src/govoplan_rest/py.typed b/src/govoplan_rest/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/govoplan_rest/py.typed @@ -0,0 +1 @@ + diff --git a/tests/test_rest_module_contract.py b/tests/test_rest_module_contract.py new file mode 100644 index 0000000..689f8b3 --- /dev/null +++ b/tests/test_rest_module_contract.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +import unittest + +from govoplan_core.core.modules import ModuleContext + +from govoplan_rest.backend.contracts import RestFunctionDescriptor +from govoplan_rest.backend.manifest import get_manifest + + +class RestModuleContractTests(unittest.TestCase): + def test_manifest_declares_rest_module(self) -> None: + manifest = get_manifest() + + self.assertEqual("rest", manifest.id) + self.assertTrue(manifest.route_factory) + self.assertEqual(("auth.principalResolver", "auth.permissionEvaluator"), manifest.required_capabilities) + + def test_route_factory_exports_status_router(self) -> None: + manifest = get_manifest() + + router = manifest.route_factory(ModuleContext(registry=object(), settings=object())) + + self.assertEqual("/rest", router.prefix) + self.assertEqual(1, len(router.routes)) + + def test_function_descriptor_is_stable(self) -> None: + descriptor = RestFunctionDescriptor( + id="demo.ping", + module_id="demo", + name="Ping", + summary="Demo function", + ) + + self.assertEqual("POST", descriptor.method) + self.assertEqual("authenticated", descriptor.visibility) + self.assertEqual((), descriptor.required_scopes) + + +if __name__ == "__main__": + unittest.main()