dirigent-server 0.9.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,7 @@
1
+ """The dirigent API server: the REST surface, authentication, and the UI."""
2
+
3
+ from dirigent_server.app import create_app
4
+ from dirigent_server.logging import configure_logging, get_logger
5
+ from dirigent_server.security import SESSION_COOKIE
6
+
7
+ __all__ = ["SESSION_COOKIE", "configure_logging", "create_app", "get_logger"]
dirigent_server/app.py ADDED
@@ -0,0 +1,171 @@
1
+ """The FastAPI application factory."""
2
+
3
+ import asyncio
4
+ import contextlib
5
+ from collections.abc import AsyncGenerator
6
+ from contextlib import asynccontextmanager
7
+
8
+ from fastapi import FastAPI
9
+
10
+ from dirigent_core import __version__
11
+ from dirigent_core.auth import BOOTSTRAP_PASSWORD_ENV, bootstrap_admin
12
+ from dirigent_core.config import Settings, get_settings
13
+ from dirigent_core.database import create_engine, create_session_factory, session_scope
14
+ from dirigent_core.directory import apply_directory
15
+ from dirigent_core.engine.services import EngineServices
16
+ from dirigent_core.plugins import PluginHost, load_plugin_host
17
+ from dirigent_core.scheduler import Scheduler
18
+ from dirigent_core.telemetry import configure_telemetry, instrument_fastapi
19
+ from dirigent_server.errors import install_error_handlers
20
+ from dirigent_server.health import build_registry
21
+ from dirigent_server.health import router as health_router
22
+ from dirigent_server.logging import get_logger
23
+ from dirigent_server.routes import TAGS, build_hooks_router, build_router
24
+ from dirigent_server.security import install_cross_site_guard
25
+ from dirigent_server.ui import mount_ui_assets, mount_ui_shell
26
+
27
+ DESCRIPTION = """
28
+ A generic pipeline orchestrator. Pipelines are data, composed from pluggable building
29
+ blocks and executed as a DAG on a durable engine.
30
+
31
+ Every endpoint under `/api/v1` requires authentication: a bearer token for automation, or
32
+ the session cookie `POST /api/v1/auth/login` sets for a browser. The probes under `/health`
33
+ and this document itself are the only exceptions. `POST /hooks/{token}` is outside the
34
+ versioned API and authenticates with its own per-trigger token.
35
+ """
36
+
37
+ _logger = get_logger("server")
38
+
39
+ BOOTSTRAP_ENV = BOOTSTRAP_PASSWORD_ENV
40
+ SCHEDULER_STOP_SECONDS = 10.0
41
+
42
+
43
+ def create_app(
44
+ settings: Settings | None = None,
45
+ *,
46
+ host: PluginHost | None = None,
47
+ scheduler: bool | None = None,
48
+ ) -> FastAPI:
49
+ """Build the API application: health probes, the routers, the UI, and the embedded scheduler.
50
+
51
+ Scheduler leadership is an advisory lock, so running the scheduler here, in a dedicated
52
+ ``dg scheduler``, or in both at once is safe.
53
+
54
+ The UI mounts in two pieces around the routers: its fixed paths before them, its shell
55
+ after them, so an API route is matched before anything the bundle claims.
56
+ """
57
+ resolved = settings or get_settings()
58
+ embed = resolved.scheduler_enabled if scheduler is None else scheduler
59
+ configure_telemetry(resolved)
60
+
61
+ @asynccontextmanager
62
+ async def lifespan(app: FastAPI) -> AsyncGenerator[None]:
63
+ """Open the database engine for the process, and dispose of it on shutdown."""
64
+ engine = create_engine(resolved)
65
+ app.state.engine = engine
66
+ app.state.session_factory = create_session_factory(engine)
67
+ app.state.health_checks = build_registry(engine)
68
+ await _bootstrap(app)
69
+ await _apply_startup_directory(app)
70
+ clock = await _start_scheduler(app, embed=embed)
71
+ _logger.info(
72
+ "server starting",
73
+ database="sqlite" if resolved.is_sqlite else "postgresql",
74
+ blocks=len(app.state.services.host.block_ids),
75
+ scheduler=embed,
76
+ )
77
+ try:
78
+ yield
79
+ finally:
80
+ await _stop_scheduler(app, clock)
81
+ await engine.dispose()
82
+ _logger.info("server stopped")
83
+
84
+ app = FastAPI(
85
+ title="dirigent",
86
+ summary="A generic pipeline orchestrator.",
87
+ description=DESCRIPTION.strip(),
88
+ version=__version__,
89
+ openapi_tags=TAGS,
90
+ lifespan=lifespan,
91
+ )
92
+ app.state.settings = resolved
93
+ app.state.version = __version__
94
+ app.state.services = EngineServices.build(resolved, host or load_plugin_host())
95
+ install_cross_site_guard(app, resolved)
96
+ install_error_handlers(app)
97
+ mount_ui_assets(app, resolved)
98
+ app.include_router(health_router)
99
+ app.include_router(build_hooks_router())
100
+ app.include_router(build_router(), prefix=resolved.api_prefix)
101
+ mount_ui_shell(app, resolved)
102
+ instrument_fastapi(app)
103
+ return app
104
+
105
+
106
+ async def _start_scheduler(app: FastAPI, *, embed: bool) -> asyncio.Task[None] | None:
107
+ """Start the embedded scheduler as a task in the API's own event loop."""
108
+ app.state.scheduler = None
109
+ if not embed:
110
+ return None
111
+ scheduler = Scheduler(app.state.session_factory, app.state.services)
112
+ app.state.scheduler = scheduler
113
+ return asyncio.create_task(scheduler.run())
114
+
115
+
116
+ async def _stop_scheduler(app: FastAPI, task: asyncio.Task[None] | None) -> None:
117
+ """Ask the embedded scheduler to finish its tick and hand back leadership."""
118
+ scheduler = getattr(app.state, "scheduler", None)
119
+ if scheduler is not None:
120
+ scheduler.request_stop()
121
+ if task is not None:
122
+ with contextlib.suppress(asyncio.CancelledError, TimeoutError):
123
+ await asyncio.wait_for(task, timeout=SCHEDULER_STOP_SECONDS)
124
+
125
+
126
+ async def _apply_startup_directory(app: FastAPI) -> None:
127
+ """Apply the configured directory of schemas and documents, before the scheduler starts firing.
128
+
129
+ The summary is one record; each refused file has already said its own warning. A
130
+ boot that found the lock held skips quietly: whoever holds it is doing this work.
131
+ """
132
+ settings: Settings = app.state.settings
133
+ if settings.apply_dir is None:
134
+ return
135
+ summary = await apply_directory(
136
+ app.state.session_factory,
137
+ app.state.services,
138
+ settings.apply_dir,
139
+ prune=settings.apply_prune,
140
+ lock_key=settings.apply_lock_key,
141
+ )
142
+ if summary.skipped:
143
+ return
144
+ _logger.info(
145
+ "startup directory applied",
146
+ directory=str(settings.apply_dir),
147
+ applied=len(summary.applied),
148
+ updated=len(summary.updated),
149
+ unchanged=len(summary.unchanged),
150
+ schemas=len(summary.schemas),
151
+ refused=[refusal.code or refusal.path for refusal in summary.refused],
152
+ pruned=summary.pruned,
153
+ trigger_documents_removed=summary.trigger_documents_removed,
154
+ )
155
+
156
+
157
+ async def _bootstrap(app: FastAPI) -> None:
158
+ """Create the first admin when a container names a password and no account exists yet.
159
+
160
+ Does nothing once any account exists, so leaving the variable set on every deploy
161
+ cannot reset a live instance's admin password.
162
+ """
163
+ import os
164
+
165
+ password = os.environ.get(BOOTSTRAP_ENV)
166
+ if not password:
167
+ return
168
+ async with session_scope(app.state.session_factory) as session:
169
+ created = await bootstrap_admin(session, password)
170
+ if created is not None:
171
+ _logger.info("bootstrap admin created", username=created.username)
@@ -0,0 +1,51 @@
1
+ """FastAPI dependencies: the request session, the engine services, and the settings."""
2
+
3
+ from collections.abc import AsyncGenerator
4
+ from typing import Annotated
5
+
6
+ from fastapi import Depends, Request
7
+ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
8
+
9
+ from dirigent_core.config import Settings
10
+ from dirigent_core.engine.services import EngineServices
11
+
12
+
13
+ def get_settings(request: Request) -> Settings:
14
+ """Return the settings this application was built with."""
15
+ resolved: Settings = request.app.state.settings
16
+ return resolved
17
+
18
+
19
+ def get_services(request: Request) -> EngineServices:
20
+ """Return the engine services: the plugin host, storage, and the secret box."""
21
+ services: EngineServices = request.app.state.services
22
+ return services
23
+
24
+
25
+ def get_sessions(request: Request) -> async_sessionmaker[AsyncSession]:
26
+ """Return the session factory the API and the engine share."""
27
+ factory: async_sessionmaker[AsyncSession] = request.app.state.session_factory
28
+ return factory
29
+
30
+
31
+ async def get_session(request: Request) -> AsyncGenerator[AsyncSession]:
32
+ """Open one transaction for one request, and hand it to the route to commit.
33
+
34
+ The exit of a dependency that yields runs after the response has gone out, so committing
35
+ here tells a client its write happened before it had: a client that reads straight back
36
+ can miss what it just wrote, and a commit that fails does so after a 2xx. ``Transactional``
37
+ commits while the response can still change. This only has to undo one that did not get
38
+ there.
39
+ """
40
+ async with get_sessions(request)() as session:
41
+ request.state.session = session
42
+ try:
43
+ yield session
44
+ except BaseException:
45
+ await session.rollback()
46
+ raise
47
+
48
+
49
+ SessionDep = Annotated[AsyncSession, Depends(get_session)]
50
+ ServicesDep = Annotated[EngineServices, Depends(get_services)]
51
+ SettingsDep = Annotated[Settings, Depends(get_settings)]
@@ -0,0 +1,116 @@
1
+ """One error envelope for every refusal, in the shape RFC 9457 describes."""
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from http import HTTPStatus
5
+ from typing import Any, cast
6
+
7
+ from fastapi import FastAPI, HTTPException, Request, Response
8
+ from fastapi.exceptions import RequestValidationError
9
+ from fastapi.responses import JSONResponse
10
+ from starlette.exceptions import HTTPException as StarletteHTTPException
11
+
12
+ from dirigent_client.schemas import Problem
13
+ from dirigent_core import __version__
14
+ from dirigent_core.logging import redact_path
15
+ from dirigent_server.logging import get_logger
16
+
17
+ VERSION_HEADER = "X-Dirigent-Version"
18
+
19
+ INTERNAL_DETAIL = "the server failed to handle this request; the server log has the detail"
20
+
21
+ _logger = get_logger("errors")
22
+
23
+
24
+ def render(status: int, detail: str, *, problems: list[str] | None = None, instance: str | None = None) -> Problem:
25
+ """Build the one problem shape, from whichever handler is answering."""
26
+ try:
27
+ title = HTTPStatus(status).phrase
28
+ except ValueError: # pragma: no cover - a non-standard status from a plugin
29
+ title = "Error"
30
+ return Problem(status=status, title=title, detail=detail, problems=problems or [], instance=instance)
31
+
32
+
33
+ def _problems_of(detail: Any) -> tuple[str, list[str]]:
34
+ """Split whatever was raised into one sentence and, when there is one, a list."""
35
+ if isinstance(detail, str):
36
+ return detail, []
37
+ if isinstance(detail, list):
38
+ rendered = [_one(item) for item in cast(list[object], detail)]
39
+ return "; ".join(rendered), rendered
40
+ return str(detail), []
41
+
42
+
43
+ def _one(item: object) -> str:
44
+ """Render one entry of a problem list, whether it is a string or a pydantic error."""
45
+ if isinstance(item, str):
46
+ return item
47
+ if isinstance(item, dict):
48
+ mapping = cast(dict[str, Any], item)
49
+ location = ".".join(str(part) for part in mapping.get("loc", []))
50
+ message = str(mapping.get("msg", mapping))
51
+ return f"{location}: {message}" if location else message
52
+ return str(item)
53
+
54
+
55
+ def _where(request: Request) -> str:
56
+ """Name the path that was asked for, never including a credential from it."""
57
+ return redact_path(request.url.path)
58
+
59
+
60
+ def answer(problem: Problem) -> JSONResponse:
61
+ """Serialise a problem into a response."""
62
+ return JSONResponse(
63
+ status_code=problem.status,
64
+ content=problem.model_dump(mode="json"),
65
+ headers={VERSION_HEADER: __version__},
66
+ )
67
+
68
+
69
+ async def _stamp_version(request: Request, call_next: Callable[[Request], Awaitable[Response]]) -> Response:
70
+ """Put the version header on every response."""
71
+ response = await call_next(request)
72
+ response.headers[VERSION_HEADER] = __version__
73
+ return response
74
+
75
+
76
+ async def http_error(request: Request, error: Exception) -> JSONResponse:
77
+ """Render an HTTPException as a problem."""
78
+ if not isinstance(error, StarletteHTTPException): # pragma: no cover - registered for this class
79
+ return await unhandled(request, error)
80
+ detail, problems = _problems_of(error.detail)
81
+ response = answer(render(error.status_code, detail, problems=problems, instance=_where(request)))
82
+ if isinstance(error, HTTPException) and error.headers:
83
+ response.headers.update(error.headers)
84
+ return response
85
+
86
+
87
+ async def validation_error(request: Request, error: Exception) -> JSONResponse:
88
+ """Render a request-validation failure as a problem with its field list intact."""
89
+ if not isinstance(error, RequestValidationError): # pragma: no cover - registered for this class
90
+ return await unhandled(request, error)
91
+ detail, problems = _problems_of(error.errors())
92
+ return answer(render(422, detail, problems=problems, instance=_where(request)))
93
+
94
+
95
+ async def unhandled(request: Request, error: Exception) -> JSONResponse:
96
+ """Answer an unhandled exception in the same shape, and say nothing about it.
97
+
98
+ An unexpected exception's message may carry a DSN, a path, or a fragment of a payload,
99
+ so it goes to the log only and never to the caller.
100
+ """
101
+ _logger.error(
102
+ "unhandled exception",
103
+ path=_where(request),
104
+ method=request.method,
105
+ error=f"{type(error).__name__}: {error}",
106
+ exc_info=error,
107
+ )
108
+ return answer(render(500, INTERNAL_DETAIL, instance=_where(request)))
109
+
110
+
111
+ def install_error_handlers(app: FastAPI) -> None:
112
+ """Install the error envelope and the version header on the application."""
113
+ app.middleware("http")(_stamp_version)
114
+ app.add_exception_handler(StarletteHTTPException, http_error)
115
+ app.add_exception_handler(RequestValidationError, validation_error)
116
+ app.add_exception_handler(Exception, unhandled)
@@ -0,0 +1,97 @@
1
+ """The liveness and readiness probes, and the checks readiness runs."""
2
+
3
+ import asyncio
4
+ from abc import ABC, abstractmethod
5
+ from collections.abc import Mapping
6
+ from typing import ClassVar
7
+
8
+ from fastapi import APIRouter, Request, Response, status
9
+ from sqlalchemy.ext.asyncio import AsyncEngine
10
+
11
+ from dirigent_client.schemas import CheckResult, CheckStatus, Health, Readiness
12
+ from dirigent_core.database import ping
13
+
14
+ router = APIRouter(tags=["health"])
15
+
16
+
17
+ #: Aggregation takes the maximum, so a new status must sort worse than every status it
18
+ #: outranks.
19
+ _SEVERITY: dict[CheckStatus, int] = {
20
+ CheckStatus.HEALTHY: 0,
21
+ CheckStatus.DEGRADED: 1,
22
+ CheckStatus.UNHEALTHY: 2,
23
+ }
24
+
25
+
26
+ class HealthCheck(ABC):
27
+ """One named dependency the readiness probe verifies."""
28
+
29
+ name: ClassVar[str]
30
+
31
+ @abstractmethod
32
+ async def check(self) -> CheckResult:
33
+ """Verify the dependency, returning a result rather than raising."""
34
+ ...
35
+
36
+
37
+ class DatabaseHealthCheck(HealthCheck):
38
+ """Verifies that the database answers a trivial query."""
39
+
40
+ name = "database"
41
+
42
+ def __init__(self, engine: AsyncEngine) -> None:
43
+ """Bind the check to the process's engine."""
44
+ self._engine = engine
45
+
46
+ async def check(self) -> CheckResult:
47
+ """Run SELECT 1 through the async engine."""
48
+ if await ping(self._engine):
49
+ return CheckResult(status=CheckStatus.HEALTHY)
50
+ return CheckResult(status=CheckStatus.UNHEALTHY, detail="database is unreachable")
51
+
52
+
53
+ type HealthCheckRegistry = Mapping[str, HealthCheck]
54
+
55
+
56
+ def build_registry(engine: AsyncEngine) -> dict[str, HealthCheck]:
57
+ """Assemble the checks this process runs."""
58
+ checks: list[HealthCheck] = [DatabaseHealthCheck(engine)]
59
+ return {check.name: check for check in checks}
60
+
61
+
62
+ def aggregate(results: Mapping[str, CheckResult]) -> CheckStatus:
63
+ """Reduce a set of results to the worst status; no checks means healthy."""
64
+ if not results:
65
+ return CheckStatus.HEALTHY
66
+ return max(results.values(), key=lambda result: _SEVERITY[result.status]).status
67
+
68
+
69
+ async def run_checks(registry: HealthCheckRegistry) -> dict[str, CheckResult]:
70
+ """Run every registered check concurrently, turning a raised error into unhealthy."""
71
+
72
+ async def run(check: HealthCheck) -> CheckResult:
73
+ try:
74
+ return await check.check()
75
+ except Exception as error:
76
+ return CheckResult(status=CheckStatus.UNHEALTHY, detail=f"{type(error).__name__}: {error}")
77
+
78
+ names = list(registry)
79
+ results = await asyncio.gather(*(run(registry[name]) for name in names))
80
+ return dict(zip(names, results, strict=True))
81
+
82
+
83
+ @router.get("/health", operation_id="health", response_model=Health, summary="Liveness")
84
+ async def health(request: Request) -> Health:
85
+ """Report that the process is alive, without touching any dependency."""
86
+ return Health(version=request.app.state.version)
87
+
88
+
89
+ @router.get("/health/ready", operation_id="readiness", response_model=Readiness, summary="Readiness")
90
+ async def health_ready(request: Request, response: Response) -> Readiness:
91
+ """Run every registered check and report the worst status, with 503 when unhealthy."""
92
+ registry: HealthCheckRegistry = request.app.state.health_checks
93
+ results = await run_checks(registry)
94
+ overall = aggregate(results)
95
+ if overall is CheckStatus.UNHEALTHY:
96
+ response.status_code = status.HTTP_503_SERVICE_UNAVAILABLE
97
+ return Readiness(status=overall, checks=results)
@@ -0,0 +1,5 @@
1
+ """Server-side re-export of the logging configuration from dirigent-core."""
2
+
3
+ from dirigent_core.logging import PACKAGE_LOGGER, configure_logging, get_logger
4
+
5
+ __all__ = ["PACKAGE_LOGGER", "configure_logging", "get_logger"]
@@ -0,0 +1,56 @@
1
+ """One envelope for every listing, and the keyset walk behind it.
2
+
3
+ Every listing answers ``{"items": [...], "next": <cursor or null>}``. ``limit`` bounds a page
4
+ at 500 and defaults to 50; ``after`` carries the cursor a previous page's ``next`` gave out.
5
+ A cursor is opaque to the caller and is the sort key of the last row returned, so a listing
6
+ selects ``limit + 1`` rows past it in its own order, answers ``limit`` of them, and says
7
+ ``next`` only when the extra row existed. A cursor that does not parse is a 422.
8
+ """
9
+
10
+ from collections.abc import Callable
11
+ from typing import Annotated
12
+ from uuid import UUID
13
+
14
+ from fastapi import HTTPException, Query, status
15
+
16
+ DEFAULT_PAGE = 50
17
+
18
+ MAX_PAGE = 500
19
+
20
+ LimitParam = Annotated[int, Query(ge=1, le=MAX_PAGE, description="How many rows to return.")]
21
+
22
+ AfterParam = Annotated[str | None, Query(description="Continue from a previous page's next cursor.")]
23
+
24
+
25
+ def clip[T](rows: list[T], limit: int, key: Callable[[T], object]) -> tuple[list[T], str | None]:
26
+ """Cut a ``limit + 1`` selection down to the page, and name the cursor that continues it."""
27
+ page = rows[:limit]
28
+ if len(rows) <= limit or not page:
29
+ return page, None
30
+ return page, str(key(page[-1]))
31
+
32
+
33
+ def uuid_cursor(after: str | None) -> UUID | None:
34
+ """Read an id cursor, refusing anything that is not one."""
35
+ if after is None:
36
+ return None
37
+ try:
38
+ return UUID(after)
39
+ except ValueError as error:
40
+ raise HTTPException(
41
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
42
+ detail=f"after={after!r} is not a cursor this listing gave out",
43
+ ) from error
44
+
45
+
46
+ def int_cursor(after: str | None, *, name: str = "after") -> int | None:
47
+ """Read a numeric cursor, refusing anything that is not one."""
48
+ if after is None:
49
+ return None
50
+ try:
51
+ return int(after)
52
+ except ValueError as error:
53
+ raise HTTPException(
54
+ status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
55
+ detail=f"{name}={after!r} is not a cursor this listing gave out",
56
+ ) from error
File without changes
@@ -0,0 +1,79 @@
1
+ """The versioned API, assembled from one router per resource.
2
+
3
+ Every business router is mounted behind :func:`require_principal`, so authentication is a
4
+ property of the mount. Only login is exempt. Webhook intake authenticates as the trigger
5
+ rather than as a person, so it is mounted at the application root instead.
6
+ """
7
+
8
+ from fastapi import APIRouter, Depends
9
+
10
+ from dirigent_server.routes import (
11
+ alerts,
12
+ auth,
13
+ blocks,
14
+ connections,
15
+ hooks,
16
+ pipelines,
17
+ runs,
18
+ schema,
19
+ schemas,
20
+ system,
21
+ trigger_documents,
22
+ triggers,
23
+ users,
24
+ workers,
25
+ )
26
+ from dirigent_server.security import require_principal
27
+ from dirigent_server.transactions import Transactional
28
+
29
+ TAGS: list[dict[str, str]] = [
30
+ {"name": "auth", "description": "Logging in, and the tokens automation uses."},
31
+ {"name": "pipelines", "description": "Applying, exporting, and running definitions."},
32
+ {"name": "runs", "description": "Runs, their attempts, their logs, and their reports."},
33
+ {"name": "triggers", "description": "A pipeline's schedules and inbound webhooks."},
34
+ {"name": "alerts", "description": "Alert rules, and the queue they deliver through."},
35
+ {"name": "hooks", "description": "Webhook intake, authenticated by its own token."},
36
+ {"name": "connections", "description": "Named credential records of contributed kinds."},
37
+ {"name": "blocks", "description": "The catalog every plugin contributes to."},
38
+ {"name": "schema", "description": "The shape a document is written against."},
39
+ {"name": "schemas", "description": "Named JSON Schemas the instance holds."},
40
+ {"name": "workers", "description": "The worker registry."},
41
+ {"name": "users", "description": "Local accounts."},
42
+ {"name": "system", "description": "What this instance is, and whether it is well."},
43
+ {"name": "health", "description": "Liveness and readiness."},
44
+ ]
45
+
46
+
47
+ def build_router() -> APIRouter:
48
+ """Assemble the versioned API: the login route, then everything behind authentication."""
49
+ router = APIRouter(route_class=Transactional)
50
+ router.include_router(auth.public_router)
51
+ guarded = APIRouter(dependencies=[Depends(require_principal)], route_class=Transactional)
52
+ for module in (
53
+ auth,
54
+ pipelines,
55
+ triggers,
56
+ trigger_documents,
57
+ runs,
58
+ alerts,
59
+ connections,
60
+ blocks,
61
+ schema,
62
+ schemas,
63
+ workers,
64
+ users,
65
+ system,
66
+ ):
67
+ guarded.include_router(module.router)
68
+ router.include_router(guarded)
69
+ return router
70
+
71
+
72
+ def build_hooks_router() -> APIRouter:
73
+ """Assemble the unauthenticated webhook intake, mounted at the application root."""
74
+ router = APIRouter(route_class=Transactional)
75
+ router.include_router(hooks.router)
76
+ return router
77
+
78
+
79
+ __all__ = ["TAGS", "build_hooks_router", "build_router"]