Coverage for src/lexigram/graphql/di/provider.py: 73%

171 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-25 04:37 +0800

1"""Lexigram GraphQL Provider 

2 

3Provider for GraphQL functionality in Lexigram Framework. 

4""" 

5 

6from __future__ import annotations 

7 

8import contextlib 

9from typing import TYPE_CHECKING, Any, TypeVar, cast 

10 

11from lexigram.contracts.core import ( 

12 HealthCheckCategory, 

13 HealthCheckResult, 

14 HealthStatus, 

15 ProviderPriority, 

16) 

17from lexigram.contracts.exceptions.container import UnresolvableDependencyError 

18from lexigram.contracts.web import WebRateLimiterProtocol 

19from lexigram.di.provider import Provider 

20from lexigram.graphql import constants as const 

21from lexigram.graphql.config import GraphQLConfig 

22from lexigram.logging import get_logger 

23 

24logger = get_logger(__name__) 

25 

26if TYPE_CHECKING: 

27 from lexigram.contracts.core.di import ( 

28 BootContainerProtocol, 

29 ContainerRegistrarProtocol, 

30 ) 

31 from lexigram.graphql.core.caching import ResponseCache 

32 from lexigram.graphql.core.context import ContextFactory 

33 from lexigram.graphql.core.execution import GraphQLExecutorProtocol 

34 from lexigram.graphql.monitoring.metrics import MetricsCollectorProtocol 

35 

36 # Types used only for annotations inside the provider 

37 from lexigram.graphql.schema.builder import SchemaBuilderProtocol 

38 from lexigram.graphql.security.rate_limit import UnifiedRateLimiter 

39 

40 

41_T = TypeVar("_T") 

42 

43 

44def _require(instance: _T | None, name: str) -> _T: 

45 """Return *instance* or raise RuntimeError if ``boot()`` has not been called. 

46 

47 Args: 

48 instance: The service instance (``None`` until ``boot()`` runs). 

49 name: Human-readable service class name for the error message. 

50 

51 Returns: 

52 The non-None instance. 

53 

54 Raises: 

55 RuntimeError: When ``boot()`` has not completed before resolution. 

56 """ 

57 if instance is None: 

58 raise RuntimeError( 

59 f"{name} not initialised. " 

60 "Ensure GraphQLProvider.boot() has been called before resolving this service.", 

61 ) 

62 return instance 

63 

64 

65from lexigram.graphql.di._discovery import _GraphQLDiscoveryMixin 

66 

67 

68class GraphQLProvider(_GraphQLDiscoveryMixin, Provider): 

69 """Provider for GraphQL functionality. 

70 

71 This provider integrates GraphQL capabilities into the Lexigram Framework, 

72 including schema building, execution, federation, and monitoring. 

73 

74 Features: 

75 - GraphQL schema management and execution 

76 - Apollo Federation support (subgraph) 

77 - DataLoaderProtocol for batching and caching 

78 - Query validation and depth limiting 

79 - Metrics collection and tracing 

80 - Subscription support 

81 - Security features (rate limiting, auth) 

82 

83 Configuration: 

84 The provider is configured via GraphQLConfig in the application 

85 configuration. If no config is provided, sensible defaults are used. 

86 

87 Usage: 

88 ```python 

89 app = Application() 

90 app.add_provider(GraphQLProvider()) 

91 ``` 

92 """ 

93 

94 name = "graphql" 

95 priority = ProviderPriority.PRESENTATION 

96 config_key: str | None = "graphql" 

97 config_model: type | None = GraphQLConfig 

98 

99 def __init__( 

100 self, 

101 config: GraphQLConfig | None = None, 

102 query_class: Any | None = None, 

103 mutation_class: Any | None = None, 

104 subscription_class: Any | None = None, 

105 context_factory_class: Any | None = None, 

106 priority: ProviderPriority = ProviderPriority.PRESENTATION, 

107 ) -> None: 

108 """Initialize the GraphQL provider. 

109 

110 Args: 

111 config: GraphQL configuration. If None, uses defaults. 

112 query_class: Optional GraphQL Query class. 

113 mutation_class: Optional GraphQL Mutation class. 

114 subscription_class: Optional GraphQL Subscription class. 

115 context_factory_class: Optional custom ContextFactory subclass. 

116 When ``None``, the framework's default ``ContextFactory`` is used. 

117 priority: Provider priority for initialization order. 

118 """ 

119 super().__init__(priority=priority) 

120 self._requested_config = config 

121 self.config = config 

122 self._identity: Any = None 

123 self.query_class = query_class 

124 self.mutation_class = mutation_class 

125 self.subscription_class = subscription_class 

126 self._context_factory_class = context_factory_class 

127 self._schema_builder: SchemaBuilderProtocol | None = None 

128 self._executor: GraphQLExecutorProtocol | None = None 

129 self._metrics_collector: MetricsCollectorProtocol | None = None 

130 self._context_factory: Any | None = None 

131 self._response_cache: ResponseCache | None = None 

132 self._ws_transport: Any | None = None 

133 self._rate_limiter: UnifiedRateLimiter | None = None 

134 

135 @classmethod 

136 def from_config(cls, config: GraphQLConfig, **context: Any) -> GraphQLProvider: 

137 """Create a GraphQLProvider from config. 

138 

139 Context kwargs may include query_class, mutation_class, subscription_class, 

140 and context_factory_class. 

141 """ 

142 return cls( 

143 config=config, 

144 query_class=context.get("query_class"), 

145 mutation_class=context.get("mutation_class"), 

146 subscription_class=context.get("subscription_class"), 

147 context_factory_class=context.get("context_factory_class"), 

148 ) 

149 

150 async def register(self, container: ContainerRegistrarProtocol) -> None: 

151 """Register GraphQL services with the DI container. 

152 

153 Args: 

154 container: The dependency injection container registrar. 

155 """ 

156 # Import here to avoid circular imports 

157 from lexigram.graphql.controllers import GraphQLController 

158 from lexigram.graphql.core.caching import ResponseCache 

159 from lexigram.graphql.core.context import ContextFactory 

160 from lexigram.graphql.core.execution import GraphQLExecutorProtocol 

161 from lexigram.graphql.dataloader.cache import InMemoryCache 

162 from lexigram.graphql.monitoring.metrics import MetricsCollectorProtocol 

163 from lexigram.graphql.schema.builder import SchemaBuilderProtocol 

164 from lexigram.graphql.security.rate_limit import RateLimiter, UnifiedRateLimiter 

165 

166 # Register the provider itself for injection 

167 container.singleton(GraphQLProvider, lambda: self) 

168 

169 # Register core services — fully initialised in boot() before resolution; 

170 # _require() raises RuntimeError if resolution is attempted before boot(). 

171 container.singleton( 

172 GraphQLExecutorProtocol, 

173 lambda: _require(self._executor, "GraphQLExecutorProtocol"), 

174 ) 

175 container.singleton( 

176 SchemaBuilderProtocol, 

177 lambda: _require(self._schema_builder, "SchemaBuilderProtocol"), 

178 ) 

179 container.singleton( 

180 MetricsCollectorProtocol, 

181 lambda: _require(self._metrics_collector, "MetricsCollectorProtocol"), 

182 ) 

183 container.singleton( 

184 ContextFactory, lambda: _require(self._context_factory, "ContextFactory") 

185 ) 

186 container.singleton( 

187 ResponseCache, lambda: _require(self._response_cache, "ResponseCache") 

188 ) 

189 

190 # Register default implementations 

191 container.singleton(InMemoryCache, InMemoryCache) 

192 

193 # UnifiedRateLimiter registered with no-op fallback; boot() will wire 

194 # the real WebRateLimiterProtocol dependency via self._rate_limiter. 

195 container.singleton( 

196 RateLimiter, 

197 lambda: ( 

198 self._rate_limiter 

199 if self._rate_limiter is not None 

200 else UnifiedRateLimiter() 

201 ), 

202 ) 

203 container.singleton( 

204 UnifiedRateLimiter, 

205 lambda: ( 

206 self._rate_limiter 

207 if self._rate_limiter is not None 

208 else UnifiedRateLimiter() 

209 ), 

210 ) 

211 

212 # Register GraphQLController for the web layer 

213 container.singleton(GraphQLController, lambda: GraphQLController(self)) 

214 

215 # Register WebSocket transport via contracts protocol (built in boot()) 

216 from lexigram.contracts.graphql.protocols import WebSocketTransportProtocol 

217 

218 container.singleton( 

219 WebSocketTransportProtocol, 

220 lambda: _require(self._ws_transport, "WebSocketTransportProtocol"), 

221 ) 

222 

223 async def boot(self, container: BootContainerProtocol) -> None: 

224 """Initialize GraphQL components. 

225 

226 Args: 

227 container: The application container resolver. 

228 """ 

229 # Import here to avoid circular imports 

230 from lexigram.contracts.core.identity import IdGeneratorProtocol 

231 from lexigram.graphql.config import GraphQLConfig 

232 from lexigram.graphql.core.context import ContextFactory 

233 from lexigram.graphql.core.execution import GraphQLExecutorProtocol 

234 from lexigram.graphql.monitoring.metrics import MetricsCollectorProtocol 

235 from lexigram.graphql.schema.builder import SchemaBuilderProtocol 

236 from lexigram.graphql.security.extensions import RateLimitExtension 

237 from lexigram.graphql.security.rate_limit import RateLimiter, UnifiedRateLimiter 

238 

239 # Resolve core infrastructure (identity) for injection into services 

240 if container is not None and hasattr(container, "resolve"): 

241 with contextlib.suppress(UnresolvableDependencyError, AttributeError): 

242 self._identity = await container.resolve(IdGeneratorProtocol) 

243 

244 # Get or create configuration 

245 self.config = self._requested_config or self.config or GraphQLConfig() 

246 

247 # Initialize schema builder 

248 self._schema_builder = SchemaBuilderProtocol(self.config) 

249 

250 # Configure query/mutation/subscription classes if set 

251 if hasattr(self, "query_class") and self.query_class is not None: 

252 self._schema_builder.query(self.query_class) 

253 if hasattr(self, "mutation_class") and self.mutation_class is not None: 

254 self._schema_builder.mutation(self.mutation_class) 

255 if hasattr(self, "subscription_class") and self.subscription_class is not None: 

256 self._schema_builder.subscription(self.subscription_class) 

257 

258 # Build GraphQL rate limiter with constructor injection from web contracts. 

259 web_rate_limiter = None 

260 if container is not None and hasattr(container, "resolve"): 

261 try: 

262 web_rate_limiter = await container.resolve(WebRateLimiterProtocol) 

263 except UnresolvableDependencyError: 

264 web_rate_limiter = None 

265 

266 self._rate_limiter = UnifiedRateLimiter(web_rate_limiter=web_rate_limiter) 

267 

268 # Wire RateLimitExtension into schema (replaces ad-hoc executor hook) 

269 rate_limiter: RateLimiter | None = self._rate_limiter 

270 if rate_limiter is not None: 

271 self._schema_builder.add_extension( 

272 RateLimitExtension( 

273 rate_limiter=rate_limiter, 

274 max_requests=self.config.rate_limit.requests_per_minute 

275 if self.config.rate_limit 

276 else const.DEFAULT_REQUESTS_PER_MINUTE, 

277 ), 

278 ) 

279 

280 schema = self._schema_builder.build() 

281 

282 # Resolve MetricsRecorderProtocol from kernel for unified observability 

283 recorder = None 

284 if container is not None and hasattr(container, "resolve"): 

285 with contextlib.suppress( 

286 ImportError, 

287 AttributeError, 

288 RuntimeError, 

289 TypeError, 

290 UnresolvableDependencyError, 

291 ): 

292 from lexigram.contracts.observability.metrics import ( 

293 MetricsRecorderProtocol, 

294 ) 

295 

296 recorder = await container.resolve(MetricsRecorderProtocol) 

297 self._metrics_collector = MetricsCollectorProtocol(recorder=recorder) 

298 

299 # Resolve EventBusProtocol for execution lifecycle events 

300 event_bus = None 

301 if container is not None and hasattr(container, "resolve"): 

302 with contextlib.suppress( 

303 ImportError, 

304 AttributeError, 

305 RuntimeError, 

306 TypeError, 

307 UnresolvableDependencyError, 

308 ): 

309 from lexigram.contracts.events import EventBusProtocol 

310 

311 event_bus = await container.resolve(EventBusProtocol) 

312 

313 self._executor = GraphQLExecutorProtocol(schema, event_bus=event_bus) 

314 

315 # Initialize context factory — use custom class if provided, otherwise default ContextFactory. 

316 # Pre-populate any DataLoaderProtocol factories registered via 

317 # SchemaBuilderProtocol.add_dataloader() before boot() was called. 

318 factory_cls = self._context_factory_class or ContextFactory 

319 self._context_factory = factory_cls( 

320 config=self.config, 

321 resolver=container, 

322 dataloader_factories=dict(self._schema_builder._dataloader_factories), 

323 identity=self._identity, 

324 ) 

325 

326 # Wire CacheConfig to ResponseCache — use the platform CacheBackendProtocol from the 

327 # container if one is registered; otherwise fall back to a local in-memory shim 

328 # so the GraphQL provider works standalone without a cache provider mounted. 

329 from lexigram.contracts.infra.cache.protocols import CacheBackendProtocol 

330 from lexigram.graphql.core.caching import ResponseCache, _MemoryCacheShim 

331 

332 cache_cfg = self.config.cache 

333 cache_backend: CacheBackendProtocol = _MemoryCacheShim( 

334 default_ttl=cache_cfg.default_max_age or 300 

335 ) # type: ignore[assignment] 

336 if container is not None and hasattr(container, "resolve"): 

337 try: 

338 resolver: BootContainerProtocol = container 

339 cache_backend = await resolver.resolve(CacheBackendProtocol) 

340 except (UnresolvableDependencyError, RuntimeError, ValueError): 

341 logger.debug( 

342 "No CacheBackendProtocol bound; using local in-memory shim for GraphQL response cache." 

343 ) 

344 

345 self._response_cache = ResponseCache( 

346 backend=cache_backend, 

347 enabled=cache_cfg.enabled, 

348 default_ttl=cache_cfg.default_max_age or 300, 

349 ) 

350 

351 # Build WebSocket subscription transport 

352 from lexigram.graphql.subscriptions.transport import GraphQLWSTransport 

353 

354 auth_handler = None 

355 with contextlib.suppress( 

356 ImportError, 

357 AttributeError, 

358 RuntimeError, 

359 TypeError, 

360 UnresolvableDependencyError, 

361 ): 

362 from lexigram.graphql.subscriptions.auth import SubscriptionAuth 

363 

364 auth_handler = await container.resolve( 

365 SubscriptionAuth, 

366 bypass_visibility=True, 

367 ) 

368 

369 self._ws_transport = GraphQLWSTransport( 

370 execute=schema.execute, 

371 subscribe=schema.subscribe, 

372 context_factory=self._context_factory, 

373 auth_handler=auth_handler, 

374 ) 

375 

376 # Schema diff — compare current schema against baseline file when configured 

377 await self._run_schema_diff(schema) 

378 

379 async def _run_schema_diff(self, current_schema: Any) -> None: 

380 """Compare *current_schema* against the configured baseline SDL file. 

381 

382 Only runs when :attr:`~lexigram.graphql.config.GraphQLConfig.schema_baseline_path` 

383 is set. Logs breaking removals at WARNING level and non-breaking additions 

384 at DEBUG level. 

385 

386 Args: 

387 current_schema: The freshly built Strawberry :class:`Schema` object. 

388 """ 

389 if self.config is None or not self.config.schema_baseline_path: 

390 return 

391 

392 import pathlib 

393 

394 from lexigram.graphql.schema.diff import SchemaDiffer 

395 

396 baseline_path = pathlib.Path(self.config.schema_baseline_path) 

397 if not baseline_path.exists(): 

398 logger.warning( 

399 "graphql.schema_diff.baseline_not_found", 

400 path=str(baseline_path), 

401 ) 

402 return 

403 

404 try: 

405 baseline_sdl = baseline_path.read_text(encoding="utf-8") 

406 # Parse the SDL into a comparable structure using strawberry's schema 

407 # introspection. We convert both to type-name sets via SchemaDiffer. 

408 from graphql import build_ast_schema, parse 

409 

410 baseline_schema = build_ast_schema(parse(baseline_sdl)) 

411 differ = SchemaDiffer() 

412 diff = differ.diff(baseline_schema, current_schema) 

413 except Exception as exc: # noqa: BLE001 

414 logger.warning( 

415 "graphql.schema_diff.error", 

416 path=str(baseline_path), 

417 error=str(exc), 

418 ) 

419 return 

420 

421 if not diff.has_changes(): 

422 logger.debug("graphql.schema_diff.no_changes", path=str(baseline_path)) 

423 return 

424 

425 if diff.breaking: 

426 for removed in sorted(diff.removed): 

427 logger.warning( 

428 "graphql.schema_diff.breaking_removal", 

429 type_name=removed, 

430 baseline=str(baseline_path), 

431 ) 

432 for added in sorted(diff.added): 

433 logger.debug( 

434 "graphql.schema_diff.addition", 

435 type_name=added, 

436 baseline=str(baseline_path), 

437 ) 

438 

439 def executor(self) -> GraphQLExecutorProtocol | None: 

440 """Get the GraphQL executor instance.""" 

441 return self._executor 

442 

443 @property 

444 def context_factory(self) -> ContextFactory | None: 

445 """Get the GraphQL context factory instance.""" 

446 return self._context_factory 

447 

448 async def shutdown(self) -> None: 

449 """Shutdown GraphQL components.""" 

450 if self._metrics_collector is not None: 

451 await self._metrics_collector.close() 

452 if self._response_cache is not None and hasattr(self._response_cache, "close"): 

453 await self._response_cache.close() 

454 self._schema_builder = None 

455 self._executor = None 

456 self._metrics_collector = None 

457 self._context_factory = None 

458 self._response_cache = None 

459 self._ws_transport = None 

460 

461 async def health_check(self, timeout: float = 5.0) -> HealthCheckResult: 

462 """Check the health of GraphQL components. 

463 

464 Returns: 

465 Health check results. 

466 """ 

467 details = { 

468 "schema_loaded": self._schema_builder is not None, 

469 "executor_ready": self._executor is not None, 

470 "metrics_enabled": self._metrics_collector is not None, 

471 "federation_enabled": self.config.federation.enabled 

472 if self.config 

473 else False, 

474 } 

475 

476 return HealthCheckResult( 

477 component=self.name, 

478 status=HealthStatus.HEALTHY 

479 if self._executor is not None 

480 else HealthStatus.UNHEALTHY, 

481 details=details, 

482 category=HealthCheckCategory.READINESS, 

483 ) 

484 

485 

486__all__ = ["GraphQLProvider"]