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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-25 04:37 +0800
1"""Configuration models for Lexigram GraphQL.
3This module provides Pydantic models for configuring GraphQL
4servers, subscriptions, and security.
6Example:
7 from lexigram.graphql.config import GraphQLConfig
9 # From YAML
10 config = GraphQLConfig.from_yaml("application.yaml")
12 # From environment
13 config = GraphQLConfig() # reads LEX_GRAPHQL__* env vars
14"""
16from __future__ import annotations
18import os
19from typing import ClassVar, cast
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
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]
35class CacheConfig(BaseConfig):
36 """Cache configuration for GraphQL responses.
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 """
45 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
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 )
55class DepthLimitConfig(BaseConfig):
56 """Query depth limiting configuration.
58 Attributes:
59 enabled: Whether depth limiting is enabled.
60 max_depth: Maximum allowed query depth.
61 ignore_introspection: Ignore introspection queries.
62 """
64 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
66 enabled: bool = True
67 max_depth: int = Field(default=const.DEFAULT_MAX_DEPTH, ge=1)
68 ignore_introspection: bool = True
71class ComplexityConfig(BaseConfig):
72 """Query complexity limiting configuration.
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 """
81 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
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)
89class AliasLimitConfig(BaseConfig):
90 """Query alias limiting configuration.
92 Attributes:
93 enabled: Whether alias limiting is enabled.
94 max_aliases: Maximum allowed aliases per query.
95 """
97 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
99 enabled: bool = True
100 max_aliases: int = Field(default=const.DEFAULT_MAX_ALIASES, ge=1)
103class PersistedQueryConfig(BaseConfig):
104 """Automatic Persisted Queries configuration.
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 """
112 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
114 enabled: bool = True
115 store_type: str = "memory"
116 ttl_seconds: Duration | int = Field(default=86400, ge=60) # 24 hours
119class BatchConfig(BaseConfig):
120 """Query batching configuration.
122 Attributes:
123 enabled: Whether query batching is enabled.
124 max_batch_size: Maximum number of operations per batch.
125 """
127 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
129 enabled: bool = False
130 max_batch_size: int = Field(default=10, ge=1, le=50)
133class IntrospectionConfig(BaseConfig):
134 """GraphQL introspection configuration.
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 """
151 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
153 enabled: bool = True
154 allowed_environments: set[str] = Field(
155 default_factory=lambda: {"development", "testing"},
156 )
159class PlaygroundConfig(BaseConfig):
160 """GraphQL Playground/GraphiQL configuration.
162 Attributes:
163 enabled: Whether playground is enabled.
164 path: Path to serve playground at.
165 title: Title for the playground page.
166 """
168 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
170 enabled: bool = True
171 path: str = const.DEFAULT_PLAYGROUND_PATH
172 title: str = "Lexigram GraphQL Playground"
175class SubscriptionConfig(BaseConfig):
176 """WebSocket subscription configuration.
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 """
186 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
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)
197class DataLoaderConfig(BaseConfig):
198 """DataLoaderProtocol configuration.
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 """
208 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
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 )
225class TracingConfig(BaseConfig):
226 """Distributed tracing configuration.
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 """
236 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
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)
245class MetricsConfig(BaseConfig):
246 """Metrics collection configuration.
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 """
255 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
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 )
267class ErrorConfig(BaseConfig):
268 """Error handling configuration.
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 """
277 model_config: ClassVar[ConfigDict] = ConfigDict(extra="ignore")
279 mask_errors: bool = True
280 include_stacktrace: bool = False
281 debug_mode: bool = False
282 log_errors: bool = True
285class GraphQLConfig(BaseConfig):
286 """Hierarchical root configuration for Lexigram GraphQL.
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 """
310 config_section: ClassVar[str] = "graphql"
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 )
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
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 )
336 # OAuth Identity Resolution - resolve external OAuth IDs to internal UUIDs
337 enable_identity_resolution: bool = False
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)
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 )
368 @model_validator(mode="after")
369 def _sync_debug_to_errors(self) -> GraphQLConfig:
370 """Propagate top-level ``debug`` flag into ``errors.debug_mode``.
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
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.
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
394 @model_validator(mode="after")
395 def _auto_disable_introspection_in_production(self) -> GraphQLConfig:
396 """Force introspection off when running in a production environment.
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
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"
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"
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"
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 )
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 )
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]