"""Extension registry for the open-core architecture. Provides registration hooks that allow enterprise/commercial code to inject routers, model modules, startup tasks, and OpenAPI tags into the core application — without the core needing any direct knowledge of the extensions. In community edition (CE) mode, ``discover_extensions()`` is a no-op because the ``ee`` package is not present. """ from __future__ import annotations import importlib import logging from dataclasses import dataclass, field from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Callable, Coroutine from typing import Any from fastapi import APIRouter, FastAPI logger = logging.getLogger(__name__) @dataclass class OpenAPITag: """Metadata for a FastAPI OpenAPI tag.""" name: str description: str @dataclass class RouterEntry: """A router registered by an extension.""" router: APIRouter prefix: str = "/api/v1" tags: list[OpenAPITag] = field(default_factory=list) @dataclass class ExtensionRegistry: """Central registry for extension-contributed components. Extensions call the module-level helper functions (``register_router``, ``register_model_module``, etc.) which delegate to the singleton instance stored in ``_registry``. """ routers: list[RouterEntry] = field(default_factory=list) model_modules: list[str] = field(default_factory=list) startup_hooks: list[Callable[[FastAPI], Coroutine[Any, Any, None]]] = field( default_factory=list, ) config_enrichers: list[Callable] = field(default_factory=list) consent_record_hooks: list[Callable] = field(default_factory=list) # ------------------------------------------------------------------ # Registration helpers # ------------------------------------------------------------------ def add_router( self, router: APIRouter, *, prefix: str = "/api/v1", tags: list[OpenAPITag] | None = None, ) -> None: self.routers.append(RouterEntry(router=router, prefix=prefix, tags=tags or [])) def add_model_module(self, module_path: str) -> None: self.model_modules.append(module_path) def add_startup_hook( self, hook: Callable[[FastAPI], Coroutine[Any, Any, None]], ) -> None: self.startup_hooks.append(hook) def add_config_enricher(self, enricher: Callable) -> None: self.config_enrichers.append(enricher) def add_consent_record_hook(self, hook: Callable) -> None: self.consent_record_hooks.append(hook) # ------------------------------------------------------------------ # Application wiring # ------------------------------------------------------------------ def apply(self, app: FastAPI) -> None: """Mount all registered routers and tags onto *app*.""" for entry in self.routers: # Inject OpenAPI tags for tag in entry.tags: existing = app.openapi_tags or [] if not any(t["name"] == tag.name for t in existing): existing.append( {"name": tag.name, "description": tag.description}, ) app.openapi_tags = existing app.include_router(entry.router, prefix=entry.prefix) if self.routers: logger.info( "Registered %d extension router(s)", len(self.routers), ) # Import model modules so SQLAlchemy picks them up for mod in self.model_modules: importlib.import_module(mod) if self.model_modules: logger.info( "Registered %d extension model module(s)", len(self.model_modules), ) # Singleton ------------------------------------------------------------------ _registry = ExtensionRegistry() def get_registry() -> ExtensionRegistry: """Return the global extension registry.""" return _registry # Convenience module-level API ----------------------------------------------- def register_router( router: APIRouter, *, prefix: str = "/api/v1", tags: list[OpenAPITag] | None = None, ) -> None: """Register an API router to be mounted at startup.""" _registry.add_router(router, prefix=prefix, tags=tags) def register_model_module(module_path: str) -> None: """Register a dotted module path whose SQLAlchemy models should be imported.""" _registry.add_model_module(module_path) def register_startup_hook( hook: Callable[[FastAPI], Coroutine[Any, Any, None]], ) -> None: """Register an async callable to run during application startup.""" _registry.add_startup_hook(hook) def register_config_enricher(enricher: Callable) -> None: """Register a callable that enriches published config. The callable signature is ``async (site_id: UUID, db: AsyncSession, config: dict) -> None``. It should mutate *config* in-place to add extension-specific data (e.g. A/B test variants). """ _registry.add_config_enricher(enricher) def register_consent_record_hook(hook: Callable) -> None: """Register a callable invoked after a consent record is persisted. The callable signature is ``async (db: AsyncSession, consent_record) -> None``. It is called from ``POST /api/v1/consent`` after the record has been flushed to the database. Typical use: generating a consent receipt (EE), writing audit logs, firing webhooks. """ _registry.add_consent_record_hook(hook) # Discovery ------------------------------------------------------------------ def discover_extensions() -> None: """Import the EE registration module if installed. Enterprise edition is distributed as a separate ``consent-enterprise`` package. When installed in the same environment, importing ``ee.api.src.register`` triggers its side-effect registrations. In community edition the import simply fails and we carry on. """ try: import ee.api.src.register # noqa: F401 logger.info("Enterprise extensions loaded") except ImportError: logger.debug("No enterprise extensions found (CE mode)")