initial commit after split
This commit is contained in:
1
src/govoplan_core/server/__init__.py
Normal file
1
src/govoplan_core/server/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""GovOPlaN server runner package."""
|
||||
56
src/govoplan_core/server/app.py
Normal file
56
src/govoplan_core/server/app.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from govoplan_core.core.modules import ModuleContext
|
||||
from govoplan_core.db.session import configure_database
|
||||
from govoplan_core.server.config import GovoplanServerConfig, load_server_config
|
||||
from govoplan_core.server.fastapi import create_govoplan_app
|
||||
from govoplan_core.server.platform import create_platform_router
|
||||
from govoplan_core.server.registry import build_platform_registry
|
||||
|
||||
|
||||
def create_app(config: GovoplanServerConfig | str | None = None):
|
||||
if isinstance(config, str) or config is None:
|
||||
server_config = load_server_config(config)
|
||||
else:
|
||||
server_config = config
|
||||
|
||||
database_url = getattr(server_config.settings, "database_url", None) if server_config.settings is not None else None
|
||||
if database_url:
|
||||
configure_database(str(database_url))
|
||||
|
||||
registry = build_platform_registry(server_config.enabled_modules, manifest_factories=server_config.manifest_factories)
|
||||
api_router = APIRouter(prefix=server_config.api_prefix)
|
||||
|
||||
for router in server_config.base_routers:
|
||||
api_router.include_router(router)
|
||||
|
||||
module_context = ModuleContext(registry=registry, settings=server_config.settings, data=server_config.module_context_data)
|
||||
for manifest in registry.manifests():
|
||||
if manifest.route_factory is not None:
|
||||
api_router.include_router(manifest.route_factory(module_context))
|
||||
|
||||
api_router.include_router(create_platform_router())
|
||||
|
||||
for router in server_config.post_module_routers:
|
||||
api_router.include_router(router)
|
||||
|
||||
for contribution in server_config.extra_routers:
|
||||
if contribution.should_include(server_config.settings, registry):
|
||||
api_router.include_router(contribution.router)
|
||||
|
||||
app = create_govoplan_app(
|
||||
title=server_config.title,
|
||||
version=server_config.version,
|
||||
registry=registry,
|
||||
api_router=api_router,
|
||||
lifespan=server_config.lifespan,
|
||||
cors_origins=server_config.cors_origins,
|
||||
)
|
||||
for configurator in server_config.app_configurators:
|
||||
configurator(app, registry, server_config.settings)
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
73
src/govoplan_core/server/config.py
Normal file
73
src/govoplan_core/server/config.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from importlib import import_module
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.server.fastapi import LifespanFactory
|
||||
|
||||
ManifestFactory = Callable[[], ModuleManifest]
|
||||
RouterEnabled = Callable[[object | None, PlatformRegistry], bool]
|
||||
AppConfigurator = Callable[[FastAPI, PlatformRegistry, object | None], None]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class RouterContribution:
|
||||
router: APIRouter
|
||||
required_module: str | None = None
|
||||
enabled: RouterEnabled | None = None
|
||||
|
||||
def should_include(self, settings: object | None, registry: PlatformRegistry) -> bool:
|
||||
if self.required_module and not registry.has_module(self.required_module):
|
||||
return False
|
||||
if self.enabled is not None and not self.enabled(settings, registry):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GovoplanServerConfig:
|
||||
title: str = "GovOPlaN Server"
|
||||
version: str = "0.1.0"
|
||||
settings: object | None = None
|
||||
enabled_modules: str | Iterable[str] = ("access",)
|
||||
api_prefix: str = "/api/v1"
|
||||
base_routers: Sequence[APIRouter] = ()
|
||||
post_module_routers: Sequence[APIRouter] = ()
|
||||
extra_routers: Sequence[RouterContribution] = ()
|
||||
lifespan: LifespanFactory | None = None
|
||||
cors_origins: Iterable[str] = ()
|
||||
manifest_factories: Sequence[ManifestFactory] = ()
|
||||
module_context_data: Mapping[str, Any] = field(default_factory=dict)
|
||||
app_configurators: Sequence[AppConfigurator] = ()
|
||||
|
||||
|
||||
def import_object(path: str) -> Any:
|
||||
module_name, separator, attribute = path.partition(":")
|
||||
if not separator:
|
||||
raise ValueError(f"Object path must use module:attribute syntax: {path!r}")
|
||||
module = import_module(module_name)
|
||||
value: Any = module
|
||||
for part in attribute.split("."):
|
||||
value = getattr(value, part)
|
||||
return value
|
||||
|
||||
|
||||
def load_server_config(path: str | None = None) -> GovoplanServerConfig:
|
||||
config_path = path or os.getenv("GOVOPLAN_SERVER_CONFIG") or "govoplan_core.server.default_config:get_server_config"
|
||||
try:
|
||||
loaded = import_object(config_path)
|
||||
except ModuleNotFoundError:
|
||||
if path or os.getenv("GOVOPLAN_SERVER_CONFIG"):
|
||||
raise
|
||||
return GovoplanServerConfig()
|
||||
config = loaded() if callable(loaded) else loaded
|
||||
if not isinstance(config, GovoplanServerConfig):
|
||||
raise TypeError(f"{config_path!r} did not return a GovoplanServerConfig")
|
||||
return config
|
||||
89
src/govoplan_core/server/default_config.py
Normal file
89
src/govoplan_core/server/default_config.py
Normal file
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
from importlib import import_module
|
||||
|
||||
from fastapi import Depends, FastAPI
|
||||
|
||||
from govoplan_core.auth.dependencies import ApiPrincipal, require_scope
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
from govoplan_core.db.bootstrap import bootstrap_dev_data, create_all_tables
|
||||
from govoplan_core.db.session import get_database
|
||||
from govoplan_core.server.config import GovoplanServerConfig, RouterContribution
|
||||
from govoplan_core.settings import Settings, settings
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
if settings.app_env.lower() == "dev" and settings.dev_bootstrap_enabled:
|
||||
create_all_tables()
|
||||
with get_database().SessionLocal() as session:
|
||||
bootstrap_dev_data(
|
||||
session,
|
||||
api_key_secret=settings.dev_bootstrap_api_key,
|
||||
user_password=settings.dev_bootstrap_password,
|
||||
)
|
||||
yield
|
||||
|
||||
|
||||
def _cors_origins(value: str) -> list[str]:
|
||||
return [item.strip() for item in value.split(",") if item.strip()]
|
||||
|
||||
|
||||
def _settings_module_enabled(module_id: str) -> bool:
|
||||
return module_id in {item.strip() for item in settings.enabled_modules.split(",") if item.strip()}
|
||||
|
||||
|
||||
def _dev_mail_enabled(config_settings: object | None, registry: PlatformRegistry) -> bool:
|
||||
active_settings = config_settings if isinstance(config_settings, Settings) else settings
|
||||
return active_settings.app_env == "dev" and active_settings.dev_mailbox_api_enabled and registry.has_module("mail")
|
||||
|
||||
|
||||
def _dev_mail_router_contributions() -> tuple[RouterContribution, ...]:
|
||||
if not (settings.app_env == "dev" and settings.dev_mailbox_api_enabled and _settings_module_enabled("mail")):
|
||||
return ()
|
||||
module = import_module("govoplan_mail.backend.dev_router")
|
||||
return (RouterContribution(module.router, required_module="mail", enabled=_dev_mail_enabled),)
|
||||
|
||||
|
||||
def register_health_details(app: FastAPI, registry: PlatformRegistry, config_settings: object | None) -> None:
|
||||
active_settings = config_settings if isinstance(config_settings, Settings) else settings
|
||||
|
||||
@app.get("/health/details")
|
||||
def health_details(
|
||||
principal: ApiPrincipal = Depends(require_scope("system:settings:read")),
|
||||
):
|
||||
del principal
|
||||
return {
|
||||
"status": "ok",
|
||||
"env": active_settings.app_env,
|
||||
"api": {"version": "v1", "auth": "api-key-or-session"},
|
||||
"modules": [manifest.id for manifest in registry.manifests()],
|
||||
"storage": {
|
||||
"backend": active_settings.file_storage_backend,
|
||||
"local_root": active_settings.file_storage_local_root if active_settings.file_storage_backend == "local" else None,
|
||||
"endpoint": active_settings.file_storage_s3_endpoint_url or active_settings.s3_endpoint_url,
|
||||
"bucket": active_settings.file_storage_s3_bucket or active_settings.s3_bucket,
|
||||
"region": active_settings.file_storage_s3_region or active_settings.s3_region,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def get_server_config() -> GovoplanServerConfig:
|
||||
from govoplan_core.api.v1.admin import router as admin_router
|
||||
from govoplan_core.api.v1.audit import router as audit_router
|
||||
from govoplan_core.api.v1.auth import router as auth_router
|
||||
from govoplan_core.api.v1.system import router as system_router
|
||||
|
||||
return GovoplanServerConfig(
|
||||
title="GovOPlaN Server",
|
||||
version="0.3.0",
|
||||
settings=settings,
|
||||
enabled_modules=settings.enabled_modules,
|
||||
api_prefix="/api/v1",
|
||||
base_routers=(auth_router, admin_router, audit_router, system_router),
|
||||
extra_routers=_dev_mail_router_contributions(),
|
||||
lifespan=lifespan,
|
||||
cors_origins=_cors_origins(settings.cors_origins),
|
||||
app_configurators=(register_health_details,),
|
||||
)
|
||||
48
src/govoplan_core/server/fastapi.py
Normal file
48
src/govoplan_core/server/fastapi.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator, Callable, Iterable
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
LifespanFactory = Callable[[FastAPI], AbstractAsyncContextManager[None] | AsyncIterator[None]]
|
||||
|
||||
|
||||
def create_govoplan_app(
|
||||
*,
|
||||
title: str,
|
||||
version: str,
|
||||
registry: PlatformRegistry,
|
||||
api_router: APIRouter | None = None,
|
||||
lifespan: LifespanFactory | None = None,
|
||||
cors_origins: Iterable[str] = (),
|
||||
health_payload: dict[str, Any] | None = None,
|
||||
) -> FastAPI:
|
||||
app = FastAPI(title=title, version=version, lifespan=lifespan)
|
||||
app.state.govoplan_registry = registry
|
||||
|
||||
origins = [item.strip() for item in cors_origins if item.strip()]
|
||||
if origins:
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"] if "*" in origins else origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
if api_router is not None:
|
||||
app.include_router(api_router)
|
||||
|
||||
@app.get("/health")
|
||||
def health():
|
||||
payload = {"status": "ok"}
|
||||
if health_payload:
|
||||
payload.update(health_payload)
|
||||
return payload
|
||||
|
||||
return app
|
||||
100
src/govoplan_core/server/platform.py
Normal file
100
src/govoplan_core/server/platform.py
Normal file
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from govoplan_core.core.modules import FrontendModule, FrontendRoute, NavItem
|
||||
from govoplan_core.core.registry import PlatformRegistry
|
||||
|
||||
|
||||
def _registry(request: Request) -> PlatformRegistry:
|
||||
registry = getattr(request.app.state, "govoplan_registry", None)
|
||||
if not isinstance(registry, PlatformRegistry):
|
||||
raise HTTPException(status_code=500, detail="GovOPlaN module registry is not configured")
|
||||
return registry
|
||||
|
||||
|
||||
def _nav_item_payload(item: NavItem) -> dict[str, object]:
|
||||
return {
|
||||
"path": item.path,
|
||||
"label": item.label,
|
||||
"icon": item.icon,
|
||||
"section": item.section,
|
||||
"required_all": list(item.required_all),
|
||||
"required_any": list(item.required_any),
|
||||
"order": item.order,
|
||||
}
|
||||
|
||||
|
||||
def _frontend_route_payload(route: FrontendRoute) -> dict[str, object]:
|
||||
return {
|
||||
"path": route.path,
|
||||
"component": route.component,
|
||||
"required_all": list(route.required_all),
|
||||
"required_any": list(route.required_any),
|
||||
"order": route.order,
|
||||
}
|
||||
|
||||
|
||||
def _frontend_payload(frontend: FrontendModule | None) -> dict[str, object] | None:
|
||||
if frontend is None:
|
||||
return None
|
||||
return {
|
||||
"module_id": frontend.module_id,
|
||||
"package_name": frontend.package_name,
|
||||
"asset_manifest": frontend.asset_manifest,
|
||||
"routes": [_frontend_route_payload(route) for route in frontend.routes],
|
||||
"nav": [_nav_item_payload(item) for item in frontend.nav_items],
|
||||
"settings_routes": [_frontend_route_payload(route) for route in frontend.settings_routes],
|
||||
}
|
||||
|
||||
|
||||
def create_platform_router() -> APIRouter:
|
||||
router = APIRouter(prefix="/platform", tags=["platform"])
|
||||
|
||||
@router.get("/modules")
|
||||
def modules(request: Request):
|
||||
registry = _registry(request)
|
||||
return {
|
||||
"modules": [
|
||||
{
|
||||
"id": manifest.id,
|
||||
"name": manifest.name,
|
||||
"version": manifest.version,
|
||||
"dependencies": list(manifest.dependencies),
|
||||
"optional_dependencies": list(manifest.optional_dependencies),
|
||||
"enabled": True,
|
||||
"nav": [_nav_item_payload(item) for item in manifest.nav_items],
|
||||
"frontend": _frontend_payload(manifest.frontend),
|
||||
}
|
||||
for manifest in registry.manifests()
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/permissions")
|
||||
def permissions(request: Request):
|
||||
registry = _registry(request)
|
||||
return {
|
||||
"permissions": [
|
||||
{
|
||||
"scope": item.scope,
|
||||
"module_id": item.module_id,
|
||||
"resource": item.resource,
|
||||
"action": item.action,
|
||||
"label": item.label,
|
||||
"description": item.description,
|
||||
"category": item.category,
|
||||
"level": item.level,
|
||||
"deprecated": item.deprecated,
|
||||
}
|
||||
for item in registry.permissions()
|
||||
]
|
||||
}
|
||||
|
||||
@router.get("/navigation")
|
||||
def navigation(request: Request):
|
||||
registry = _registry(request)
|
||||
return {
|
||||
"items": [_nav_item_payload(item) for item in registry.nav_items()]
|
||||
}
|
||||
|
||||
return router
|
||||
43
src/govoplan_core/server/registry.py
Normal file
43
src/govoplan_core/server/registry.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable, Iterable, Sequence
|
||||
|
||||
from govoplan_core.access.manifest import get_manifest as get_access_manifest
|
||||
from govoplan_core.core.discovery import discover_module_manifests
|
||||
from govoplan_core.core.modules import ModuleManifest
|
||||
from govoplan_core.core.registry import PlatformRegistry, RegistryError
|
||||
|
||||
ManifestFactory = Callable[[], ModuleManifest]
|
||||
|
||||
|
||||
def parse_enabled_modules(value: str | Iterable[str]) -> list[str]:
|
||||
if isinstance(value, str):
|
||||
items = [item.strip() for item in value.split(",")]
|
||||
else:
|
||||
items = [str(item).strip() for item in value]
|
||||
return [item for item in items if item]
|
||||
|
||||
|
||||
def available_module_manifests(manifest_factories: Sequence[ManifestFactory] = ()) -> dict[str, ModuleManifest]:
|
||||
manifests = {manifest.id: manifest for manifest in discover_module_manifests()}
|
||||
manifests.setdefault("access", get_access_manifest())
|
||||
for factory in manifest_factories:
|
||||
manifest = factory()
|
||||
manifests[manifest.id] = manifest
|
||||
return manifests
|
||||
|
||||
|
||||
def build_platform_registry(enabled_modules: str | Iterable[str], *, manifest_factories: Sequence[ManifestFactory] = ()) -> PlatformRegistry:
|
||||
requested = parse_enabled_modules(enabled_modules)
|
||||
if "access" not in requested:
|
||||
requested.insert(0, "access")
|
||||
|
||||
available = available_module_manifests(manifest_factories)
|
||||
registry = PlatformRegistry()
|
||||
for module_id in requested:
|
||||
manifest = available.get(module_id)
|
||||
if manifest is None:
|
||||
raise RegistryError(f"Enabled module is not available: {module_id}")
|
||||
registry.register(manifest)
|
||||
registry.validate()
|
||||
return registry
|
||||
Reference in New Issue
Block a user