Coverage for src/lexigram/graphql/schema/builder.py: 86%
106 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 schema builder.
3This module provides utilities for building GraphQL schemas
4from types, queries, mutations, and subscriptions.
5"""
7from __future__ import annotations
9from typing import Any
11import strawberry
12from strawberry import Schema
14from lexigram.graphql.config import GraphQLConfig
15from lexigram.logging import get_logger
17logger = get_logger(__name__)
20class SchemaBuilderProtocol:
21 """Build GraphQL schemas with configuration.
23 Provides a fluent interface for constructing GraphQL schemas
24 with proper configuration, extensions, and type registration.
26 Example:
27 ```python
28 builder = SchemaBuilderProtocol()
30 schema = (
31 builder
32 .query(Query)
33 .mutation(Mutation)
34 .subscription(Subscription)
35 .add_extension(QueryLogger())
36 .build()
37 )
38 ```
39 """
41 def __init__(
42 self,
43 config: GraphQLConfig | None = None,
44 ) -> None:
45 """Initialize the schema builder.
47 Args:
48 config: GraphQL configuration.
49 """
50 self._config = config or GraphQLConfig()
51 self._query_type: type[Any] | None = None
52 self._mutation_type: type[Any] | None = None
53 self._subscription_type: type[Any] | None = None
54 self._types: list[type[Any]] = []
55 # Keep extensions loosely typed to avoid coupling to strawberry's
56 # concrete Extension type which can vary between versions.
57 self._extensions: list[Any] = []
58 self._directives: list[Any] = []
59 self._scalar_overrides: dict[Any, Any] = {}
60 self._dataloader_factories: dict[str, Any] = {}
62 def query(self, query_type: type[Any]) -> SchemaBuilderProtocol:
63 """Set the query type.
65 Args:
66 query_type: The query type class.
68 Returns:
69 Self for chaining.
70 """
71 self._query_type = query_type
72 return self
74 def mutation(self, mutation_type: type[Any]) -> SchemaBuilderProtocol:
75 """Set the mutation type.
77 Args:
78 mutation_type: The mutation type class.
80 Returns:
81 Self for chaining.
82 """
83 self._mutation_type = mutation_type
84 return self
86 def subscription(self, subscription_type: type[Any]) -> SchemaBuilderProtocol:
87 """Set the subscription type.
89 Args:
90 subscription_type: The subscription type class.
92 Returns:
93 Self for chaining.
94 """
95 self._subscription_type = subscription_type
96 return self
98 def add_type(self, type_class: type[Any]) -> SchemaBuilderProtocol:
99 """Add an additional type to the schema.
101 Args:
102 type_class: The type class to add.
104 Returns:
105 Self for chaining.
106 """
107 self._types.append(type_class)
108 return self
110 def add_types(self, *type_classes: type[Any]) -> SchemaBuilderProtocol:
111 """Add multiple types to the schema.
113 Args:
114 type_classes: Type classes to add.
116 Returns:
117 Self for chaining.
118 """
119 self._types.extend(type_classes)
120 return self
122 def add_extension(self, extension: Any) -> SchemaBuilderProtocol:
123 """Add a schema extension.
125 Args:
126 extension: The extension to add (kept as Any to avoid strict coupling
127 to Strawberry's extension type in various environments).
129 Returns:
130 Self for chaining.
131 """
132 self._extensions.append(extension)
133 return self
135 def add_dataloader(self, name: str, factory: Any) -> SchemaBuilderProtocol:
136 """Register a DataLoaderProtocol factory for per-request loader initialisation.
138 Stored factories are wired into :class:`ContextFactory` by
139 :class:`~lexigram.graphql.providers.GraphQLProvider` during ``boot()``,
140 so loaders are available inside resolvers via
141 ``context.get_dataloader(name)``.
143 Args:
144 name: Unique loader name.
145 factory: Callable ``(context) -> DataLoaderProtocol`` invoked per-request.
147 Returns:
148 Self for chaining.
149 """
150 self._dataloader_factories[name] = factory
151 return self
153 def add_directive(self, directive: Any) -> SchemaBuilderProtocol:
154 """Add a custom directive.
156 Args:
157 directive: The directive to add.
159 Returns:
160 Self for chaining.
161 """
162 self._directives.append(directive)
163 return self
165 def scalar_override(
166 self,
167 original: Any,
168 override: Any,
169 ) -> SchemaBuilderProtocol:
170 """Override a scalar type.
172 Args:
173 original: Original scalar type.
174 override: Override scalar type.
176 Returns:
177 Self for chaining.
178 """
179 self._scalar_overrides[original] = override
180 return self
182 def build(self) -> Schema:
183 """Build the GraphQL schema.
185 Returns:
186 Configured Strawberry schema.
188 Raises:
189 ValueError: If no query type is set and no default is provided.
190 """
192 if self._query_type is None:
194 @strawberry.type
195 class EmptyQuery:
196 @strawberry.field
197 def health(self) -> str:
198 return "ok"
200 self._query_type = EmptyQuery
201 logger.info("No query type provided, initialized with default EmptyQuery")
203 # Build schema kwargs
204 schema_kwargs: dict[str, Any] = {
205 "query": self._query_type,
206 }
208 if self._mutation_type:
209 schema_kwargs["mutation"] = self._mutation_type
211 if self._subscription_type:
212 schema_kwargs["subscription"] = self._subscription_type
214 if self._types:
215 schema_kwargs["types"] = self._types
217 if self._extensions:
218 schema_kwargs["extensions"] = self._extensions
220 if self._directives:
221 schema_kwargs["directives"] = self._directives
223 if self._scalar_overrides:
224 schema_kwargs["scalar_overrides"] = self._scalar_overrides
226 import os
228 from lexigram.graphql.core.introspection import IntrospectionGuardExtension
229 from lexigram.graphql.security.alias import AliasLimitExtension
230 from lexigram.graphql.security.complexity import ComplexityLimitExtension
231 from lexigram.graphql.security.depth import DepthLimitExtension
233 cfg = self._config
234 env_raw = cfg.env or os.getenv("LEX_ENV", "development") or "development"
235 introspection_enabled = (
236 cfg.introspection.enabled
237 and env_raw.lower() in cfg.introspection.allowed_environments
238 )
239 security_extensions: list[Any] = []
240 if not introspection_enabled:
241 security_extensions.insert(0, IntrospectionGuardExtension())
242 if cfg.depth_limit.enabled:
243 security_extensions.append(
244 DepthLimitExtension(
245 max_depth=cfg.depth_limit.max_depth,
246 ignore_introspection=cfg.depth_limit.ignore_introspection,
247 )
248 )
249 if cfg.complexity.enabled:
250 security_extensions.append(
251 ComplexityLimitExtension(
252 max_complexity=cfg.complexity.max_complexity,
253 default_field_cost=cfg.complexity.default_field_cost,
254 default_list_cost=cfg.complexity.default_list_cost,
255 )
256 )
257 if cfg.alias_limit.enabled:
258 security_extensions.append(
259 AliasLimitExtension(max_aliases=cfg.alias_limit.max_aliases)
260 )
262 if security_extensions:
263 schema_kwargs["extensions"] = [
264 *security_extensions,
265 *self._extensions,
266 ]
268 # Create schema
269 schema = Schema(**schema_kwargs)
271 logger.info(
272 "Built GraphQL schema with query=%s, mutation=%s, subscription=%s",
273 self._query_type.__name__,
274 self._mutation_type.__name__ if self._mutation_type else None,
275 self._subscription_type.__name__ if self._subscription_type else None,
276 )
278 return schema
280 @property
281 def config(self) -> GraphQLConfig:
282 """Get the configuration."""
283 return self._config
286def create_schema(
287 query: type[Any],
288 mutation: type[Any] | None = None,
289 subscription: type[Any] | None = None,
290 types: list[type[Any]] | None = None,
291 extensions: list[Any] | None = None,
292 config: GraphQLConfig | None = None,
293) -> Schema:
294 """Create a GraphQL schema (convenience function).
296 Args:
297 query: Query type class.
298 mutation: Optional mutation type class.
299 subscription: Optional subscription type class.
300 types: Additional type classes.
301 extensions: Schema extensions.
302 config: GraphQL configuration.
304 Returns:
305 Configured Strawberry schema.
307 Example:
308 ```python
309 schema = create_schema(
310 query=Query,
311 mutation=Mutation,
312 config=GraphQLConfig(depth_limit=5),
313 )
314 ```
315 """
316 builder = SchemaBuilderProtocol(config=config)
317 builder.query(query)
319 if mutation:
320 builder.mutation(mutation)
322 if subscription:
323 builder.subscription(subscription)
325 if types:
326 builder.add_types(*types)
328 if extensions:
329 for ext in extensions:
330 builder.add_extension(ext)
332 return builder.build()
335__all__ = ["SchemaBuilderProtocol", "create_schema"]