Coverage for src/lexigram/graphql/config.py: 95%

142 statements  

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

1"""Configuration models for Lexigram GraphQL. 

2 

3This module provides Pydantic models for configuring GraphQL 

4servers, subscriptions, and security. 

5 

6Example: 

7 from lexigram.graphql.config import GraphQLConfig 

8 

9 # From YAML 

10 config = GraphQLConfig.from_yaml("application.yaml") 

11 

12 # From environment 

13 config = GraphQLConfig() # reads LEX_GRAPHQL__* env vars 

14""" 

15 

16from __future__ import annotations 

17 

18import os 

19from typing import ClassVar, cast 

20 

21from lexigram.config import BaseConfig 

22from lexigram.graphql import constants as const 

23from lexigram.graphql.security.rate_limit import RateLimitConfig 

24from lexigram.graphql.types import CacheScope, SubscriptionProtocol 

25from lexigram.validation import ConfigDict, Field, model_validator 

26 

27try: 

28 from lexigram.contracts.core import Duration 

29except ( 

30 ImportError 

31): # pragma: no cover - fallback for environments where contracts aren't available 

32 Duration = None # type: ignore[assignment,misc] 

33 

34 

35class CacheConfig(BaseConfig): 

36 """Cache configuration for GraphQL responses. 

37 

38 Attributes: 

39 enabled: Whether caching is enabled. 

40 default_max_age: Default cache duration (seconds or Duration string). 

41 default_scope: Default cache scope (PUBLIC/PRIVATE). 

42 vary_headers: Headers to vary cache on. 

43 """ 

44 

45 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

46 

47 enabled: bool = True 

48 default_max_age: Duration | int = Field(default=const.DEFAULT_CACHE_MAX_AGE, ge=0) 

49 default_scope: CacheScope = CacheScope.PUBLIC 

50 vary_headers: list[str] = Field( 

51 default_factory=lambda: ["Accept", "Accept-Encoding"], 

52 ) 

53 

54 

55class DepthLimitConfig(BaseConfig): 

56 """Query depth limiting configuration. 

57 

58 Attributes: 

59 enabled: Whether depth limiting is enabled. 

60 max_depth: Maximum allowed query depth. 

61 ignore_introspection: Ignore introspection queries. 

62 """ 

63 

64 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

65 

66 enabled: bool = True 

67 max_depth: int = Field(default=const.DEFAULT_MAX_DEPTH, ge=1) 

68 ignore_introspection: bool = True 

69 

70 

71class ComplexityConfig(BaseConfig): 

72 """Query complexity limiting configuration. 

73 

74 Attributes: 

75 enabled: Whether complexity limiting is enabled. 

76 max_complexity: Maximum allowed complexity score. 

77 default_field_cost: Default cost for scalar fields. 

78 default_list_cost: Default cost multiplier for list fields. 

79 """ 

80 

81 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

82 

83 enabled: bool = True 

84 max_complexity: int = Field(default=const.DEFAULT_MAX_COMPLEXITY, ge=1) 

85 default_field_cost: float = Field(default=1.0, ge=0) 

86 default_list_cost: float = Field(default=10.0, ge=0) 

87 

88 

89class AliasLimitConfig(BaseConfig): 

90 """Query alias limiting configuration. 

91 

92 Attributes: 

93 enabled: Whether alias limiting is enabled. 

94 max_aliases: Maximum allowed aliases per query. 

95 """ 

96 

97 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

98 

99 enabled: bool = True 

100 max_aliases: int = Field(default=const.DEFAULT_MAX_ALIASES, ge=1) 

101 

102 

103class PersistedQueryConfig(BaseConfig): 

104 """Automatic Persisted Queries configuration. 

105 

106 Attributes: 

107 enabled: Whether APQ is enabled. 

108 store_type: Type of store ('memory' or 'redis'). 

109 ttl_seconds: Time-to-live for stored queries (seconds or Duration string). 

110 """ 

111 

112 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

113 

114 enabled: bool = True 

115 store_type: str = "memory" 

116 ttl_seconds: Duration | int = Field(default=86400, ge=60) # 24 hours 

117 

118 

119class BatchConfig(BaseConfig): 

120 """Query batching configuration. 

121 

122 Attributes: 

123 enabled: Whether query batching is enabled. 

124 max_batch_size: Maximum number of operations per batch. 

125 """ 

126 

127 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

128 

129 enabled: bool = False 

130 max_batch_size: int = Field(default=10, ge=1, le=50) 

131 

132 

133class IntrospectionConfig(BaseConfig): 

134 """GraphQL introspection configuration. 

135 

136 Attributes: 

137 enabled: Whether introspection is enabled. Introspection is served 

138 only when this is True *and* the current environment is listed 

139 in ``allowed_environments``; otherwise the schema builder 

140 registers the 

141 :class:`~lexigram.graphql.core.introspection.IntrospectionGuardExtension`, 

142 which rejects introspection operations at request time. In 

143 production this flag is force-disabled at config validation 

144 time (fail-closed at boot). 

145 allowed_environments: Set of environment names (matched case- 

146 insensitively against ``GraphQLConfig.env`` or ``LEX_ENV``) 

147 where introspection is permitted. Defaults to 

148 ``{"development", "testing"}``; production is excluded. 

149 """ 

150 

151 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

152 

153 enabled: bool = True 

154 allowed_environments: set[str] = Field( 

155 default_factory=lambda: {"development", "testing"}, 

156 ) 

157 

158 

159class PlaygroundConfig(BaseConfig): 

160 """GraphQL Playground/GraphiQL configuration. 

161 

162 Attributes: 

163 enabled: Whether playground is enabled. 

164 path: Path to serve playground at. 

165 title: Title for the playground page. 

166 """ 

167 

168 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

169 

170 enabled: bool = True 

171 path: str = const.DEFAULT_PLAYGROUND_PATH 

172 title: str = "Lexigram GraphQL Playground" 

173 

174 

175class SubscriptionConfig(BaseConfig): 

176 """WebSocket subscription configuration. 

177 

178 Attributes: 

179 enabled: Whether subscriptions are enabled. 

180 path: WebSocket path for subscriptions. 

181 protocol: WebSocket protocol to use. 

182 keepalive_interval: Keepalive interval (seconds or Duration string). 

183 connection_timeout: Connection timeout (seconds or Duration string). 

184 """ 

185 

186 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

187 

188 enabled: bool = True 

189 path: str = const.DEFAULT_SUBSCRIPTIONS_PATH 

190 protocol: SubscriptionProtocol = SubscriptionProtocol.GRAPHQL_TRANSPORT_WS 

191 keepalive_interval: Duration | int = Field( 

192 default=const.DEFAULT_SUBSCRIPTION_KEEPALIVE, ge=1 

193 ) 

194 connection_timeout: Duration | int = Field(default=60, ge=1) 

195 

196 

197class DataLoaderConfig(BaseConfig): 

198 """DataLoaderProtocol configuration. 

199 

200 Attributes: 

201 enabled: Whether DataLoaderProtocol integration is enabled. 

202 batch_enabled: Whether batching is enabled. 

203 cache_enabled: Whether per-request caching is enabled. 

204 max_batch_size: Maximum batch size for DataLoaderProtocol. 

205 batch_schedule_fn: How to schedule batch execution. 

206 """ 

207 

208 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

209 

210 enabled: bool = True 

211 batch_enabled: bool = True 

212 cache_enabled: bool = True 

213 max_batch_size: int = Field(default=100, ge=1) 

214 batch_delay_ms: float = Field( 

215 default=2.0, 

216 ge=0.0, 

217 description=( 

218 "Delay in milliseconds before executing a DataLoaderProtocol batch. " 

219 "A small non-zero value (2ms) lets more keys accumulate in the batch window, " 

220 "improving efficiency at the cost of a slight increase in latency." 

221 ), 

222 ) 

223 

224 

225class TracingConfig(BaseConfig): 

226 """Distributed tracing configuration. 

227 

228 Attributes: 

229 enabled: Whether tracing is enabled. 

230 service_name: Service name for tracing. 

231 trace_resolvers: Whether to trace individual resolvers. 

232 trace_dataloaders: Whether to trace DataLoaderProtocol batches. 

233 sample_rate: Sampling rate (0.0 to 1.0). 

234 """ 

235 

236 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

237 

238 enabled: bool = False 

239 service_name: str = "lexigram-graphql" 

240 trace_resolvers: bool = True 

241 trace_dataloaders: bool = True 

242 sample_rate: float = Field(default=1.0, ge=0.0, le=1.0) 

243 

244 

245class MetricsConfig(BaseConfig): 

246 """Metrics collection configuration. 

247 

248 Attributes: 

249 enabled: Whether metrics are enabled. 

250 namespace: Metrics namespace/prefix. 

251 include_labels: Labels to include in metrics. 

252 histogram_buckets: Buckets for duration histograms. 

253 """ 

254 

255 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

256 

257 enabled: bool = False 

258 namespace: str = "lexigram_graphql" 

259 include_labels: list[str] = Field( 

260 default_factory=lambda: ["operation_type", "operation_name"], 

261 ) 

262 histogram_buckets: list[float] = Field( 

263 default_factory=lambda: [0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0], 

264 ) 

265 

266 

267class ErrorConfig(BaseConfig): 

268 """Error handling configuration. 

269 

270 Attributes: 

271 mask_errors: Whether to mask internal errors in production. 

272 include_stacktrace: Whether to include stacktrace in errors. 

273 debug_mode: Whether debug mode is enabled. 

274 log_errors: Whether to log errors. 

275 """ 

276 

277 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore") 

278 

279 mask_errors: bool = True 

280 include_stacktrace: bool = False 

281 debug_mode: bool = False 

282 log_errors: bool = True 

283 

284 

285class GraphQLConfig(BaseConfig): 

286 """Hierarchical root configuration for Lexigram GraphQL. 

287 

288 Attributes: 

289 name: Configuration name (default: "graphql") 

290 enabled: Whether GraphQL module is enabled 

291 path: GraphQL endpoint path 

292 debug: Platform debug flag (aligns with ObservabilityConfig.debug). Propagates to errors.debug_mode. 

293 enable_identity_resolution: Resolve OAuth IDs to internal UUIDs 

294 cache: Response caching settings 

295 depth_limit: Query depth limiting 

296 complexity: Query complexity settings 

297 alias_limit: Query alias limiting 

298 persisted_queries: Persisted queries settings 

299 batch: Batch query settings 

300 introspection: Introspection settings 

301 playground: GraphQL playground settings 

302 subscriptions: WebSocket subscription settings 

303 dataloader: DataLoaderProtocol settings 

304 tracing: Tracing settings 

305 metrics: Metrics settings 

306 error: Error handling settings 

307 rate_limit: Rate limiting settings 

308 """ 

309 

310 config_section: ClassVar[str] = "graphql" 

311 

312 model_config = cast( 

313 "ConfigDict", 

314 { 

315 "env_prefix": const.ENV_PREFIX, 

316 "env_nested_delimiter": const.ENV_NESTED_DELIMITER, 

317 "extra": "ignore", 

318 }, 

319 ) 

320 

321 name: str = "graphql" 

322 enabled: bool = True 

323 path: str = const.DEFAULT_GRAPHQL_PATH 

324 # Platform-wide debug flag — aligns with ObservabilityConfig.debug. 

325 # When True, automatically promotes errors.debug_mode=True so that 

326 # the two flags stay in sync (see _sync_debug_to_errors validator). 

327 # Set via GRAPHQL__DEBUG=true env var. 

328 debug: bool = False 

329 

330 # Environment - used for environment-specific behavior 

331 # Set via GRAPHQL__ENV or LEX_ENV env var 

332 env: str | None = Field( 

333 default=None, description="Environment (development/staging/production)" 

334 ) 

335 

336 # OAuth Identity Resolution - resolve external OAuth IDs to internal UUIDs 

337 enable_identity_resolution: bool = False 

338 

339 # Sub-configurations 

340 cache: CacheConfig = Field(default_factory=CacheConfig) 

341 depth_limit: DepthLimitConfig = Field(default_factory=DepthLimitConfig) 

342 complexity: ComplexityConfig = Field(default_factory=ComplexityConfig) 

343 alias_limit: AliasLimitConfig = Field(default_factory=AliasLimitConfig) 

344 persisted_queries: PersistedQueryConfig = Field( 

345 default_factory=PersistedQueryConfig 

346 ) 

347 batch: BatchConfig = Field(default_factory=BatchConfig) 

348 introspection: IntrospectionConfig = Field(default_factory=IntrospectionConfig) 

349 playground: PlaygroundConfig = Field(default_factory=PlaygroundConfig) 

350 subscriptions: SubscriptionConfig = Field(default_factory=SubscriptionConfig) 

351 dataloader: DataLoaderConfig = Field(default_factory=DataLoaderConfig) 

352 tracing: TracingConfig = Field(default_factory=TracingConfig) 

353 metrics: MetricsConfig = Field(default_factory=MetricsConfig) 

354 errors: ErrorConfig = Field(default_factory=ErrorConfig) 

355 rate_limit: RateLimitConfig = Field(default_factory=RateLimitConfig) 

356 

357 schema_baseline_path: str | None = Field( 

358 default=None, 

359 description=( 

360 "Path to a GraphQL SDL (.graphql) file containing the baseline schema. " 

361 "When set, GraphQLProvider.boot() compares the current schema against " 

362 "this file and logs any breaking changes. Non-breaking additions are " 

363 "logged at DEBUG level; breaking removals are logged at WARNING level. " 

364 "Intended for use in CI/CD pipelines to catch accidental breaking changes." 

365 ), 

366 ) 

367 

368 @model_validator(mode="after") 

369 def _sync_debug_to_errors(self) -> GraphQLConfig: 

370 """Propagate top-level ``debug`` flag into ``errors.debug_mode``. 

371 

372 Ensures that setting ``GRAPHQL__DEBUG=true`` (or ``debug=True`` in 

373 code) also enables ``errors.debug_mode`` without requiring callers 

374 to set both fields explicitly. Explicit ``errors.debug_mode=True`` 

375 is preserved even when ``debug=False``. 

376 """ 

377 if self.debug: 

378 self.errors.debug_mode = True 

379 return self 

380 

381 @model_validator(mode="after") 

382 def _auto_disable_playground_in_production(self) -> GraphQLConfig: 

383 """Auto-disable GraphQL Playground when running in a production environment. 

384 

385 Checks the ``LEX_ENV`` environment variable; when set to 

386 ``"production"`` (case-insensitive), the playground is forcibly 

387 disabled regardless of the value supplied in configuration. 

388 """ 

389 env_raw = self.env or os.getenv("LEX_ENV", "development") or "development" 

390 if env_raw.lower() == "production": 

391 self.playground.enabled = False 

392 return self 

393 

394 @model_validator(mode="after") 

395 def _auto_disable_introspection_in_production(self) -> GraphQLConfig: 

396 """Force introspection off when running in a production environment. 

397 

398 Mirrors :meth:`_auto_disable_playground_in_production`: when 

399 ``LEX_ENV`` (or ``env``) is production, introspection is disabled 

400 regardless of the value supplied in configuration — fail-closed at 

401 boot, not at request-documentation level. 

402 """ 

403 env_raw = self.env or os.getenv("LEX_ENV", "development") or "development" 

404 if env_raw.lower() == "production": 

405 self.introspection.enabled = False 

406 return self 

407 

408 @property 

409 def is_production(self) -> bool: 

410 """Returns True if running in production environment.""" 

411 env_raw = self.env or os.getenv("LEX_ENV", "development") or "development" 

412 return env_raw.lower() == "production" 

413 

414 @property 

415 def is_development(self) -> bool: 

416 """Returns True if running in development environment.""" 

417 env_raw = self.env or os.getenv("LEX_ENV", "development") or "development" 

418 return env_raw.lower() == "development" 

419 

420 @property 

421 def is_test(self) -> bool: 

422 """Returns True if running in test environment.""" 

423 env_raw = self.env or os.getenv("LEX_ENV", "development") or "development" 

424 return env_raw.lower() == "test" 

425 

426 @classmethod 

427 def development(cls) -> GraphQLConfig: 

428 """Create development configuration with debugging enabled.""" 

429 return cls( 

430 debug=True, 

431 path=const.DEFAULT_GRAPHQL_PATH, 

432 introspection=IntrospectionConfig(enabled=True), 

433 playground=PlaygroundConfig(enabled=True), 

434 errors=ErrorConfig( 

435 mask_errors=False, 

436 include_stacktrace=True, 

437 debug_mode=True, 

438 ), 

439 ) 

440 

441 @classmethod 

442 def production(cls) -> GraphQLConfig: 

443 """Create production configuration with security hardening.""" 

444 return cls( 

445 debug=False, 

446 path=const.DEFAULT_GRAPHQL_PATH, 

447 introspection=IntrospectionConfig(enabled=False), 

448 playground=PlaygroundConfig(enabled=False), 

449 errors=ErrorConfig( 

450 mask_errors=True, 

451 include_stacktrace=False, 

452 debug_mode=False, 

453 ), 

454 depth_limit=DepthLimitConfig( 

455 enabled=True, max_depth=const.DEFAULT_MAX_DEPTH 

456 ), 

457 ) 

458 

459 

460__all__ = [ 

461 "AliasLimitConfig", 

462 "CacheConfig", 

463 "DataLoaderConfig", 

464 "DepthLimitConfig", 

465 "ErrorConfig", 

466 "GraphQLConfig", 

467 "IntrospectionConfig", 

468 "MetricsConfig", 

469 "PlaygroundConfig", 

470 "SubscriptionConfig", 

471 "TracingConfig", 

472]