Coverage for src / lexigram / ai / relay / gateway / web / contributor.py: 85%
27 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-08 23:08 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-08 23:08 +0800
1"""Web contributor mounting the relay gateway's raw Starlette routes.
3The gateway package cannot import lexigram-web controller classes, so
4``get_controllers`` is empty and the inbound relay routes are mounted
5directly on the host application in ``mount_to_app``. The gateway
6implementation is resolved from the request-scoped DI container at
7request time.
8"""
10from __future__ import annotations
12from typing import Any
14from lexigram.ai.relay.gateway.passthrough import PassthroughService
15from lexigram.ai.relay.gateway.web.routes import build_routes
16from lexigram.contracts.ai.relay import RelayGatewayProtocol
18__all__ = ["RelayGatewayWebContributor"]
21class RelayGatewayWebContributor:
22 """Web contributor for the relay gateway.
24 Implements the ``WebContributorProtocol`` surface duck-typed: the
25 contributor id is ``"relay-gateway"``, no controllers or middleware
26 are contributed (controller classes are lexigram-web types), and the
27 four inbound relay routes are mounted on the Starlette application.
28 Repeated mounts are idempotent: a path already present in
29 ``app.routes`` is skipped.
30 """
32 @property
33 def contributor_id(self) -> str:
34 """Return the unique contributor identifier.
36 Returns:
37 The string ``"relay-gateway"``.
38 """
39 return "relay-gateway"
41 def get_controllers(self) -> list[type[Any]]:
42 """Return the controller classes contributed by the gateway.
44 Controller classes are lexigram-web types, which the gateway
45 package cannot import; raw Starlette routes are mounted in
46 ``mount_to_app`` instead.
48 Returns:
49 An empty list.
50 """
51 return []
53 def get_middleware(self) -> list[type[Any]]:
54 """Return the middleware classes contributed by the gateway.
56 Middleware is contributed by the host application, not the
57 gateway.
59 Returns:
60 An empty list.
61 """
62 return []
64 async def mount_to_app(self, app: Any, container: object) -> None:
65 """Mount the four inbound relay routes on *app*.
67 The gateway is resolved per request: the request-scoped
68 container from ``request.state.container`` is preferred, falling
69 back to the mount-time container. Routes already present in
70 ``app.routes`` are skipped so repeated mounts never duplicate
71 paths.
73 Args:
74 app: The ASGI application (typically Starlette) to mount
75 routes on.
76 container: The DI container used as fallback resolution.
77 """
79 async def _resolve(request: Any) -> RelayGatewayProtocol:
80 request_container: Any = (
81 getattr(request.state, "container", None) or container
82 )
83 return await request_container.resolve(RelayGatewayProtocol)
85 async def _resolve_passthrough(request: Any) -> PassthroughService:
86 request_container: Any = (
87 getattr(request.state, "container", None) or container
88 )
89 return await request_container.resolve(PassthroughService)
91 routes = build_routes(_resolve, resolve_passthrough=_resolve_passthrough)
92 for route in routes:
93 path = route.path
94 if any(
95 getattr(existing, "path", None) == path
96 for existing in getattr(app, "routes", [])
97 ):
98 continue
99 app.add_route(path, route.endpoint, methods=["POST"])