Coverage for src/lexigram/graphql/core/introspection.py: 93%

91 statements  

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

1"""GraphQL introspection utilities. 

2 

3This module provides utilities for GraphQL schema introspection, 

4including introspection query generation and result handling. 

5""" 

6 

7from __future__ import annotations 

8 

9from collections.abc import Iterator 

10from typing import TYPE_CHECKING, Any, cast 

11 

12if TYPE_CHECKING: 

13 from strawberry import Schema as StrawberrySchema 

14 

15 Schema = StrawberrySchema 

16 

17from strawberry.extensions import SchemaExtension 

18 

19from lexigram.graphql.exceptions import GraphQLError 

20from lexigram.logging import get_logger 

21 

22logger = get_logger(__name__) 

23 

24 

25# Standard GraphQL introspection query 

26INTROSPECTION_QUERY = """ 

27query IntrospectionQuery { 

28 __schema { 

29 queryType { 

30 name 

31 } 

32 mutationType { 

33 name 

34 } 

35 subscriptionType { 

36 name 

37 } 

38 types { 

39 ...FullType 

40 } 

41 directives { 

42 name 

43 description 

44 locations 

45 args { 

46 ...InputValue 

47 } 

48 } 

49 } 

50} 

51 

52fragment FullType on __Type { 

53 kind 

54 name 

55 description 

56 fields(includeDeprecated: true) { 

57 name 

58 description 

59 args { 

60 ...InputValue 

61 } 

62 type { 

63 ...TypeRef 

64 } 

65 isDeprecated 

66 deprecationReason 

67 } 

68 inputFields { 

69 ...InputValue 

70 } 

71 interfaces { 

72 ...TypeRef 

73 } 

74 enumValues(includeDeprecated: true) { 

75 name 

76 description 

77 isDeprecated 

78 deprecationReason 

79 } 

80 possibleTypes { 

81 ...TypeRef 

82 } 

83} 

84 

85fragment InputValue on __InputValue { 

86 name 

87 description 

88 type { 

89 ...TypeRef 

90 } 

91 defaultValue 

92} 

93 

94fragment TypeRef on __Type { 

95 kind 

96 name 

97 ofType { 

98 kind 

99 name 

100 ofType { 

101 kind 

102 name 

103 ofType { 

104 kind 

105 name 

106 ofType { 

107 kind 

108 name 

109 ofType { 

110 kind 

111 name 

112 ofType { 

113 kind 

114 name 

115 ofType { 

116 kind 

117 name 

118 } 

119 } 

120 } 

121 } 

122 } 

123 } 

124 } 

125} 

126""" 

127 

128 

129# Simplified introspection query for basic schema info 

130SIMPLE_INTROSPECTION_QUERY = """ 

131query SimpleIntrospectionQuery { 

132 __schema { 

133 queryType { 

134 name 

135 fields { 

136 name 

137 description 

138 type { 

139 name 

140 kind 

141 } 

142 } 

143 } 

144 mutationType { 

145 name 

146 fields { 

147 name 

148 description 

149 type { 

150 name 

151 kind 

152 } 

153 } 

154 } 

155 subscriptionType { 

156 name 

157 fields { 

158 name 

159 description 

160 type { 

161 name 

162 kind 

163 } 

164 } 

165 } 

166 types { 

167 name 

168 kind 

169 description 

170 } 

171 } 

172} 

173""" 

174 

175 

176def get_introspection_query(simplified: bool = False) -> str: 

177 """Get the GraphQL introspection query. 

178 

179 Args: 

180 simplified: If True, return simplified query. 

181 

182 Returns: 

183 Introspection query string. 

184 """ 

185 if simplified: 

186 return SIMPLE_INTROSPECTION_QUERY 

187 return INTROSPECTION_QUERY 

188 

189 

190class IntrospectionHandler: 

191 """Handle GraphQL schema introspection. 

192 

193 Provides utilities for introspecting GraphQL schemas, 

194 extracting type information, and generating schema documentation. 

195 

196 Example: 

197 ```python 

198 handler = IntrospectionHandler(schema) 

199 

200 # Get all types 

201 types = await handler.get_types() 

202 

203 # Get fields for a type 

204 fields = await handler.get_type_fields("User") 

205 ``` 

206 """ 

207 

208 def __init__( 

209 self, 

210 schema: Schema, 

211 enabled: bool = True, 

212 ) -> None: 

213 """Initialize the handler. 

214 

215 Args: 

216 schema: Strawberry GraphQL schema. 

217 enabled: Whether introspection is enabled. 

218 """ 

219 self._schema = schema 

220 self._enabled = enabled 

221 self._cache: dict[str, Any] = {} 

222 

223 @property 

224 def enabled(self) -> bool: 

225 """Check if introspection is enabled.""" 

226 return self._enabled 

227 

228 def enable(self) -> None: 

229 """Enable introspection.""" 

230 self._enabled = True 

231 

232 def disable(self) -> None: 

233 """Disable introspection.""" 

234 self._enabled = False 

235 

236 async def introspect( 

237 self, 

238 simplified: bool = False, 

239 ) -> dict[str, Any]: 

240 """Run introspection query. 

241 

242 Args: 

243 simplified: Use simplified query. 

244 

245 Returns: 

246 Introspection result data. 

247 """ 

248 if not self._enabled: 

249 raise ValueError("Introspection is disabled") 

250 

251 cache_key = f"introspect_{simplified}" 

252 if cache_key in self._cache: 

253 return cast("dict[str, Any]", self._cache[cache_key]) 

254 

255 query = get_introspection_query(simplified) 

256 result = await self._schema.execute(query) 

257 

258 if result.errors: 

259 raise ValueError(f"Introspection failed: {result.errors}") 

260 

261 self._cache[cache_key] = result.data 

262 return cast("dict[str, Any]", result.data) or {} 

263 

264 async def get_types(self) -> list[dict[str, Any]]: 

265 """Get all types in the schema. 

266 

267 Returns: 

268 List of type definitions. 

269 """ 

270 data = await self.introspect(simplified=True) 

271 schema = data.get("__schema", {}) 

272 return cast("list[dict[str, Any]]", schema.get("types", [])) 

273 

274 async def get_type_fields( 

275 self, 

276 type_name: str, 

277 ) -> list[dict[str, Any]]: 

278 """Get fields for a type. 

279 

280 Args: 

281 type_name: Name of the type. 

282 

283 Returns: 

284 List of field definitions. 

285 """ 

286 data = await self.introspect() 

287 schema = data.get("__schema", {}) 

288 

289 for type_def in schema.get("types", []): 

290 if type_def.get("name") == type_name: 

291 return type_def.get("fields") or [] 

292 

293 return [] 

294 

295 async def get_query_type(self) -> dict[str, Any] | None: 

296 """Get the query type. 

297 

298 Returns: 

299 Query type definition or None. 

300 """ 

301 data = await self.introspect(simplified=True) 

302 schema = data.get("__schema", {}) 

303 return cast("dict[str, Any] | None", schema.get("queryType")) 

304 

305 async def get_mutation_type(self) -> dict[str, Any] | None: 

306 """Get the mutation type. 

307 

308 Returns: 

309 Mutation type definition or None. 

310 """ 

311 data = await self.introspect(simplified=True) 

312 schema = data.get("__schema", {}) 

313 return cast("dict[str, Any] | None", schema.get("mutationType")) 

314 

315 async def get_subscription_type(self) -> dict[str, Any] | None: 

316 """Get the subscription type. 

317 

318 Returns: 

319 Subscription type definition or None. 

320 """ 

321 data = await self.introspect(simplified=True) 

322 schema = data.get("__schema", {}) 

323 return cast("dict[str, Any] | None", schema.get("subscriptionType")) 

324 

325 def clear_cache(self) -> None: 

326 """Clear the introspection cache.""" 

327 self._cache.clear() 

328 

329 async def generate_sdl(self) -> str: 

330 """Generate SDL (Schema Definition Language) from schema. 

331 

332 Returns: 

333 Schema as SDL string. 

334 """ 

335 from graphql import print_schema 

336 

337 # Strawberry exposes the underlying graphql-core schema on ``_schema`` 

338 # (or on ``schema`` with older versions); handle both cases. 

339 graphql_schema = getattr(self._schema, "_schema", None) or getattr( 

340 self._schema, 

341 "schema", 

342 self._schema, 

343 ) 

344 return print_schema(graphql_schema) # type: ignore[arg-type] 

345 

346 

347def _document_has_introspection(document: Any) -> bool: 

348 """Return True if any selection in the document targets an introspection field. 

349 

350 Detection mirrors the depth validator's skip logic 

351 (:class:`~lexigram.graphql.security.depth.DepthLimitValidator`): fields 

352 whose names start with ``__``. 

353 """ 

354 for definition in document.definitions: 

355 if not hasattr(definition, "selection_set") or definition.selection_set is None: 

356 continue 

357 to_visit = [definition.selection_set] 

358 while to_visit: 

359 selection_set = to_visit.pop() 

360 for selection in selection_set.selections: 

361 if hasattr(selection, "name") and selection.name is not None: 

362 name = ( 

363 selection.name.value 

364 if hasattr(selection.name, "value") 

365 else str(selection.name) 

366 ) 

367 if name.startswith("__"): 

368 return True 

369 if hasattr(selection, "selection_set") and selection.selection_set: 

370 to_visit.append(selection.selection_set) 

371 return False 

372 

373 

374class IntrospectionGuardExtension(SchemaExtension): 

375 """Reject every introspection operation. 

376 

377 Registered by :class:`~lexigram.graphql.schema.builder.SchemaBuilderProtocol` 

378 only when introspection is *not* effectively enabled — that is, when 

379 ``IntrospectionConfig.enabled`` is False or the current environment is 

380 not listed in ``IntrospectionConfig.allowed_environments``. Production is 

381 additionally force-disabled at config validation time 

382 (``GraphQLConfig._auto_disable_introspection_in_production``), so the 

383 guard is guaranteed present on production deployments. 

384 """ 

385 

386 def on_validate(self) -> Iterator[None]: 

387 """Reject the operation when it targets introspection fields.""" 

388 execution_context = self.execution_context 

389 document = execution_context.graphql_document 

390 if document is not None and _document_has_introspection(document): 

391 error = GraphQLError("Introspection is disabled") 

392 error.safe = ( 

393 True # amendment 2026-08-17: keep the message under default mask_errors 

394 ) 

395 raise error 

396 yield 

397 

398 

399__all__ = [ 

400 "IntrospectionGuardExtension", 

401 "IntrospectionHandler", 

402 "get_introspection_query", 

403]