Coverage for src/lexigram/web/integrations/graphql.py: 21%
42 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""GraphQL integration for WebProvider.
3This module integrates GraphQL functionality with the web layer.
4WebSocket subscriptions are registered directly on the Starlette application.
5HTTP endpoints are handled by GraphQLController via the web contributor pattern.
6"""
8from __future__ import annotations
10import contextlib
11from typing import TYPE_CHECKING, Any
13from lexigram.contracts.graphql.protocols import DEFAULT_SUBSCRIPTIONS_PATH
14from lexigram.logging import get_logger
16if TYPE_CHECKING:
17 from starlette.applications import Starlette
19logger = get_logger(__name__)
22class GraphQLIntegration:
23 """Handles GraphQL WebSocket endpoint configuration.
25 HTTP endpoints are handled by GraphQLController using the standard
26 Controller pattern. This integration only handles WebSocket subscriptions
27 which require direct Starlette route registration.
28 """
30 @staticmethod
31 async def configure(app: Starlette, container: Any | None = None) -> None:
32 """Configure GraphQL WebSocket endpoints.
34 Registers a WebSocket route at ``/graphql/ws`` that lazily initialises
35 the GraphQL transport on the first connection. This avoids boot-order
36 issues when the GraphQL provider boots in parallel with the web provider.
38 Falls back to the legacy ``app.graphql_ws_controller_class`` attribute
39 when the container is not available.
41 Args:
42 app: The Starlette application instance.
43 container: Optional DI container resolver for GraphQLProvider lookup.
44 """
45 # ── Strategy 1: Lazy WS endpoint backed by the container ───────────
46 if container is not None:
47 from starlette.routing import WebSocketRoute
49 from lexigram.contracts.graphql.protocols import WebSocketTransportProtocol
51 _transport_holder: dict[str, Any] = {}
53 async def _ws_endpoint(websocket: Any) -> None:
54 transport = _transport_holder.get("transport")
55 if transport is None:
56 try:
57 transport = await container.resolve(WebSocketTransportProtocol)
58 _transport_holder["transport"] = transport
59 logger.info("GraphQL WS transport initialised")
60 except (LookupError, RuntimeError, OSError) as exc:
61 logger.warning(
62 "GraphQL WS transport init failed",
63 error=str(exc),
64 )
65 await websocket.close(code=1013)
66 return
68 await transport.handle(websocket, app)
70 ws_path = DEFAULT_SUBSCRIPTIONS_PATH
71 ws_route = WebSocketRoute(ws_path, _ws_endpoint)
72 app.routes.insert(0, ws_route)
73 logger.info("GraphQL WebSocket route registered at %s (lazy init)", ws_path)
74 return
76 # ── Strategy 2 (legacy): app.graphql_ws_controller_class ───────────
77 graphql_ws_cls = None
78 with contextlib.suppress(AttributeError, TypeError):
79 graphql_ws_cls = getattr(app, "graphql_ws_controller_class", lambda: None)()
81 if graphql_ws_cls is not None and not hasattr(
82 graphql_ws_cls,
83 "_mock_return_value",
84 ):
85 try:
86 from starlette.routing import WebSocketRoute
88 ws_path = getattr(
89 app, "graphql_ws_path", lambda: DEFAULT_SUBSCRIPTIONS_PATH
90 )()
91 ws_route = WebSocketRoute(str(ws_path), graphql_ws_cls)
92 app.routes.append(ws_route)
93 logger.info("GraphQL WebSocket registered at %s", ws_path)
94 except (ImportError, AttributeError, RuntimeError):
95 logger.exception("Failed to register GraphQL WebSocket route")