apisec-code-bolt 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. apisec_code_bolt/__init__.py +42 -0
  2. apisec_code_bolt/__main__.py +11 -0
  3. apisec_code_bolt/analysis/__init__.py +96 -0
  4. apisec_code_bolt/analysis/analyzer.py +2309 -0
  5. apisec_code_bolt/analysis/binding_tracker.py +341 -0
  6. apisec_code_bolt/analysis/call_graph.py +1197 -0
  7. apisec_code_bolt/analysis/call_graph_types.py +332 -0
  8. apisec_code_bolt/analysis/call_resolver.py +988 -0
  9. apisec_code_bolt/analysis/capability_tagger.py +322 -0
  10. apisec_code_bolt/analysis/config_scanner.py +197 -0
  11. apisec_code_bolt/analysis/data_flow.py +1883 -0
  12. apisec_code_bolt/analysis/dependency_extractor.py +959 -0
  13. apisec_code_bolt/analysis/flow_analysis.py +1406 -0
  14. apisec_code_bolt/analysis/hof_catalog.py +61 -0
  15. apisec_code_bolt/analysis/integration_detector.py +1399 -0
  16. apisec_code_bolt/analysis/literal_scanner.py +300 -0
  17. apisec_code_bolt/analysis/path_normalizer.py +55 -0
  18. apisec_code_bolt/analysis/read_site_detector.py +310 -0
  19. apisec_code_bolt/analysis/request_patterns.py +162 -0
  20. apisec_code_bolt/analysis/sensitivity_classifier.py +224 -0
  21. apisec_code_bolt/analysis/sink_evidence.py +333 -0
  22. apisec_code_bolt/analysis/url_prefix_resolver.py +338 -0
  23. apisec_code_bolt/cli/__init__.py +5 -0
  24. apisec_code_bolt/cli/exit_codes.py +17 -0
  25. apisec_code_bolt/cli/main.py +1069 -0
  26. apisec_code_bolt/cloud/__init__.py +1 -0
  27. apisec_code_bolt/cloud/apisec_client.py +118 -0
  28. apisec_code_bolt/cloud/client.py +255 -0
  29. apisec_code_bolt/core/__init__.py +75 -0
  30. apisec_code_bolt/core/config.py +528 -0
  31. apisec_code_bolt/core/credentials.py +65 -0
  32. apisec_code_bolt/core/discovery.py +433 -0
  33. apisec_code_bolt/core/log_format.py +115 -0
  34. apisec_code_bolt/core/manifest.py +1009 -0
  35. apisec_code_bolt/core/repo.py +280 -0
  36. apisec_code_bolt/core/state.py +59 -0
  37. apisec_code_bolt/core/telemetry.py +451 -0
  38. apisec_code_bolt/core/types.py +587 -0
  39. apisec_code_bolt/fingerprinting/__init__.py +1 -0
  40. apisec_code_bolt/frameworks/__init__.py +29 -0
  41. apisec_code_bolt/frameworks/_jwt_common.py +50 -0
  42. apisec_code_bolt/frameworks/auth_helpers.py +437 -0
  43. apisec_code_bolt/frameworks/base.py +608 -0
  44. apisec_code_bolt/frameworks/dotnet/__init__.py +17 -0
  45. apisec_code_bolt/frameworks/dotnet/_path_helpers.py +43 -0
  46. apisec_code_bolt/frameworks/dotnet/aspnet_plugin.py +2546 -0
  47. apisec_code_bolt/frameworks/dotnet/grpc_plugin.py +559 -0
  48. apisec_code_bolt/frameworks/dotnet/jwt_config_extractor.py +545 -0
  49. apisec_code_bolt/frameworks/dotnet/legacy_aspnet_plugin.py +732 -0
  50. apisec_code_bolt/frameworks/dotnet/refit_plugin.py +374 -0
  51. apisec_code_bolt/frameworks/dotnet/wcf_plugin.py +1239 -0
  52. apisec_code_bolt/frameworks/java/__init__.py +6 -0
  53. apisec_code_bolt/frameworks/java/_annotations.py +167 -0
  54. apisec_code_bolt/frameworks/java/_constraints.py +128 -0
  55. apisec_code_bolt/frameworks/java/graphql_plugin.py +287 -0
  56. apisec_code_bolt/frameworks/java/jaxrs_plugin.py +748 -0
  57. apisec_code_bolt/frameworks/java/jwt_config_extractor.py +361 -0
  58. apisec_code_bolt/frameworks/java/micronaut_plugin.py +1059 -0
  59. apisec_code_bolt/frameworks/java/spring_plugin.py +1293 -0
  60. apisec_code_bolt/frameworks/js/__init__.py +8 -0
  61. apisec_code_bolt/frameworks/js/express_plugin.py +391 -0
  62. apisec_code_bolt/frameworks/js/fastify_plugin.py +381 -0
  63. apisec_code_bolt/frameworks/js/graphql_plugin.py +198 -0
  64. apisec_code_bolt/frameworks/js/nestjs_plugin.py +423 -0
  65. apisec_code_bolt/frameworks/python/__init__.py +19 -0
  66. apisec_code_bolt/frameworks/python/celery_plugin.py +393 -0
  67. apisec_code_bolt/frameworks/python/click_plugin.py +427 -0
  68. apisec_code_bolt/frameworks/python/django_plugin.py +867 -0
  69. apisec_code_bolt/frameworks/python/fastapi/__init__.py +28 -0
  70. apisec_code_bolt/frameworks/python/fastapi/plugin.py +1390 -0
  71. apisec_code_bolt/frameworks/python/flask_plugin.py +205 -0
  72. apisec_code_bolt/frameworks/python/graphql_plugin.py +274 -0
  73. apisec_code_bolt/frameworks/python/prefect_plugin.py +251 -0
  74. apisec_code_bolt/frameworks/python/webhook_plugin.py +255 -0
  75. apisec_code_bolt/parsing/__init__.py +62 -0
  76. apisec_code_bolt/parsing/base.py +554 -0
  77. apisec_code_bolt/parsing/csharp/__init__.py +5 -0
  78. apisec_code_bolt/parsing/csharp/language_services.py +203 -0
  79. apisec_code_bolt/parsing/csharp/literals.py +72 -0
  80. apisec_code_bolt/parsing/csharp/parser.py +1158 -0
  81. apisec_code_bolt/parsing/csharp/type_resolver.py +568 -0
  82. apisec_code_bolt/parsing/js/__init__.py +5 -0
  83. apisec_code_bolt/parsing/js/language_services.py +118 -0
  84. apisec_code_bolt/parsing/js/parser.py +622 -0
  85. apisec_code_bolt/parsing/jvm/__init__.py +7 -0
  86. apisec_code_bolt/parsing/jvm/language_services.py +270 -0
  87. apisec_code_bolt/parsing/jvm/parser.py +774 -0
  88. apisec_code_bolt/parsing/jvm/type_resolver.py +422 -0
  89. apisec_code_bolt/parsing/python/__init__.py +150 -0
  90. apisec_code_bolt/parsing/python/cbv_extractor.py +606 -0
  91. apisec_code_bolt/parsing/python/constant_resolver.py +500 -0
  92. apisec_code_bolt/parsing/python/cross_file_resolver.py +1054 -0
  93. apisec_code_bolt/parsing/python/dynamic_route_detector.py +532 -0
  94. apisec_code_bolt/parsing/python/expression_utils.py +221 -0
  95. apisec_code_bolt/parsing/python/extraction_types.py +271 -0
  96. apisec_code_bolt/parsing/python/language_services.py +487 -0
  97. apisec_code_bolt/parsing/python/parameter_analyzer.py +789 -0
  98. apisec_code_bolt/parsing/python/parser.py +719 -0
  99. apisec_code_bolt/parsing/python/path_resolver.py +576 -0
  100. apisec_code_bolt/parsing/python/router_registry.py +806 -0
  101. apisec_code_bolt/parsing/python/type_resolver.py +730 -0
  102. apisec_code_bolt/parsing/python/visitors.py +1544 -0
  103. apisec_code_bolt/parsing/services.py +544 -0
  104. apisec_code_bolt/query/__init__.py +1 -0
  105. apisec_code_bolt/query/ast_cache.py +182 -0
  106. apisec_code_bolt/query/executor.py +283 -0
  107. apisec_code_bolt/query/handlers.py +832 -0
  108. apisec_code_bolt-0.1.0.dist-info/METADATA +230 -0
  109. apisec_code_bolt-0.1.0.dist-info/RECORD +111 -0
  110. apisec_code_bolt-0.1.0.dist-info/WHEEL +4 -0
  111. apisec_code_bolt-0.1.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,730 @@
1
+ """
2
+ Type and schema resolution for Python code analysis.
3
+
4
+ This module handles:
5
+ - Resolving type annotations to their definitions
6
+ - Resolving Pydantic model schemas including nested models
7
+ - Building type dependency graphs
8
+ - Resolving imports to their source definitions
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import re
14
+ from dataclasses import dataclass, field
15
+ from pathlib import Path
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ if TYPE_CHECKING:
19
+ from .visitors import ExtractedClass
20
+
21
+
22
+ # =============================================================================
23
+ # Resolved Type Information
24
+ # =============================================================================
25
+
26
+
27
+ @dataclass
28
+ class ResolvedType:
29
+ """A resolved type with full information."""
30
+
31
+ name: str # Simple name
32
+ qualified_name: str # Full qualified name
33
+ module: str # Module where defined
34
+
35
+ # Type category
36
+ is_builtin: bool = False
37
+ is_generic: bool = False
38
+ is_optional: bool = False
39
+ is_list: bool = False
40
+ is_dict: bool = False
41
+ is_union: bool = False
42
+ is_pydantic_model: bool = False
43
+ is_dataclass: bool = False
44
+ is_enum: bool = False
45
+
46
+ # Generic type arguments
47
+ type_args: list[ResolvedType] = field(default_factory=list)
48
+
49
+ # For complex types
50
+ origin_type: str | None = None # e.g., "list" for list[int]
51
+
52
+
53
+ @dataclass
54
+ class ResolvedField:
55
+ """A resolved field in a model."""
56
+
57
+ name: str
58
+ type_annotation: str
59
+ resolved_type: ResolvedType | None = None
60
+
61
+ required: bool = True
62
+ default: Any = None
63
+ default_factory: str | None = None
64
+
65
+ # Field constraints
66
+ constraints: dict[str, Any] = field(default_factory=dict)
67
+
68
+ # Nested model reference
69
+ nested_model: str | None = None
70
+
71
+
72
+ @dataclass
73
+ class ResolvedModel:
74
+ """A fully resolved Pydantic model or dataclass."""
75
+
76
+ name: str
77
+ qualified_name: str
78
+ module: str
79
+
80
+ fields: list[ResolvedField] = field(default_factory=list)
81
+
82
+ # Inheritance
83
+ base_classes: list[str] = field(default_factory=list)
84
+
85
+ # Nested models (models used as field types)
86
+ nested_models: list[str] = field(default_factory=list)
87
+
88
+ # Model configuration
89
+ config: dict[str, Any] = field(default_factory=dict)
90
+
91
+ is_pydantic: bool = False
92
+ is_dataclass: bool = False
93
+
94
+
95
+ # =============================================================================
96
+ # Built-in Types
97
+ # =============================================================================
98
+
99
+
100
+ BUILTIN_TYPES = frozenset(
101
+ {
102
+ "int",
103
+ "str",
104
+ "float",
105
+ "bool",
106
+ "bytes",
107
+ "None",
108
+ "list",
109
+ "dict",
110
+ "set",
111
+ "frozenset",
112
+ "tuple",
113
+ "List",
114
+ "Dict",
115
+ "Set",
116
+ "FrozenSet",
117
+ "Tuple",
118
+ "Optional",
119
+ "Union",
120
+ "Any",
121
+ "Type",
122
+ "Callable",
123
+ "Sequence",
124
+ "Mapping",
125
+ "Iterable",
126
+ "Iterator",
127
+ "Literal",
128
+ "Final",
129
+ "ClassVar",
130
+ "TypeVar",
131
+ "Annotated",
132
+ "Generic",
133
+ }
134
+ )
135
+
136
+
137
+ TYPING_MODULE_TYPES = frozenset(
138
+ {
139
+ "List",
140
+ "Dict",
141
+ "Set",
142
+ "FrozenSet",
143
+ "Tuple",
144
+ "Optional",
145
+ "Union",
146
+ "Any",
147
+ "Type",
148
+ "Callable",
149
+ "Sequence",
150
+ "Mapping",
151
+ "Iterable",
152
+ "Iterator",
153
+ "Literal",
154
+ "Final",
155
+ "ClassVar",
156
+ "TypeVar",
157
+ "Annotated",
158
+ "Generic",
159
+ "Protocol",
160
+ }
161
+ )
162
+
163
+
164
+ # =============================================================================
165
+ # Type Annotation Parser
166
+ # =============================================================================
167
+
168
+
169
+ class TypeAnnotationParser:
170
+ """
171
+ Parses type annotation strings into structured types.
172
+
173
+ Handles:
174
+ - Simple types: int, str, MyClass
175
+ - Generic types: list[int], dict[str, int]
176
+ - Union types: int | str, Union[int, str]
177
+ - Optional types: Optional[int], int | None
178
+ - Nested generics: list[dict[str, list[int]]]
179
+ """
180
+
181
+ # Pattern for generic types: TypeName[Args]
182
+ GENERIC_PATTERN = re.compile(r"^(\w+(?:\.\w+)*)\s*\[(.*)\]$", re.DOTALL)
183
+
184
+ # Pattern for union types using |
185
+ UNION_PATTERN = re.compile(r"\s*\|\s*")
186
+
187
+ def parse(self, annotation: str) -> ResolvedType:
188
+ """Parse a type annotation string."""
189
+ annotation = annotation.strip()
190
+
191
+ # Handle None
192
+ if annotation == "None":
193
+ return ResolvedType(
194
+ name="None",
195
+ qualified_name="None",
196
+ module="builtins",
197
+ is_builtin=True,
198
+ )
199
+
200
+ # Handle | union syntax
201
+ if " | " in annotation or annotation.startswith("Union["):
202
+ return self._parse_union(annotation)
203
+
204
+ # Handle Optional
205
+ if annotation.startswith("Optional["):
206
+ return self._parse_optional(annotation)
207
+
208
+ # Handle generic types
209
+ generic_match = self.GENERIC_PATTERN.match(annotation)
210
+ if generic_match:
211
+ return self._parse_generic(generic_match.group(1), generic_match.group(2))
212
+
213
+ # Simple type
214
+ return self._create_simple_type(annotation)
215
+
216
+ def _parse_union(self, annotation: str) -> ResolvedType:
217
+ """Parse a union type."""
218
+ # Extract types from Union[...] or A | B syntax
219
+ if annotation.startswith("Union["):
220
+ inner = annotation[6:-1]
221
+ type_strs = self._split_type_args(inner)
222
+ else:
223
+ type_strs = self.UNION_PATTERN.split(annotation)
224
+
225
+ type_args = [self.parse(t.strip()) for t in type_strs]
226
+
227
+ # Check if this is really Optional (Union with None)
228
+ has_none = any(t.name == "None" for t in type_args)
229
+ non_none_types = [t for t in type_args if t.name != "None"]
230
+
231
+ if has_none and len(non_none_types) == 1:
232
+ # This is Optional[X]
233
+ result = non_none_types[0]
234
+ result.is_optional = True
235
+ return result
236
+
237
+ return ResolvedType(
238
+ name="Union",
239
+ qualified_name="typing.Union",
240
+ module="typing",
241
+ is_union=True,
242
+ is_generic=True,
243
+ type_args=type_args,
244
+ origin_type="Union",
245
+ )
246
+
247
+ def _parse_optional(self, annotation: str) -> ResolvedType:
248
+ """Parse Optional[X] type."""
249
+ inner = annotation[9:-1] # Remove "Optional[" and "]"
250
+ inner_type = self.parse(inner)
251
+ inner_type.is_optional = True
252
+ return inner_type
253
+
254
+ def _parse_generic(self, base_type: str, args_str: str) -> ResolvedType:
255
+ """Parse a generic type like list[int] or dict[str, int]."""
256
+ # Parse type arguments
257
+ arg_strs = self._split_type_args(args_str)
258
+ type_args = [self.parse(arg.strip()) for arg in arg_strs]
259
+
260
+ # Determine type properties
261
+ base_lower = base_type.lower()
262
+ is_list = base_lower in {"list", "sequence", "iterable"}
263
+ is_dict = base_lower in {"dict", "mapping"}
264
+ is_builtin = base_type in BUILTIN_TYPES or base_type in TYPING_MODULE_TYPES
265
+
266
+ module = "builtins" if base_lower in {"list", "dict", "set", "tuple"} else "typing"
267
+ if not is_builtin:
268
+ module = "" # User-defined type
269
+
270
+ return ResolvedType(
271
+ name=base_type,
272
+ qualified_name=f"{module}.{base_type}" if module else base_type,
273
+ module=module,
274
+ is_builtin=is_builtin,
275
+ is_generic=True,
276
+ is_list=is_list,
277
+ is_dict=is_dict,
278
+ type_args=type_args,
279
+ origin_type=base_type,
280
+ )
281
+
282
+ def _create_simple_type(self, type_name: str) -> ResolvedType:
283
+ """Create a simple (non-generic) type."""
284
+ is_builtin = type_name in BUILTIN_TYPES
285
+ module = "builtins" if is_builtin else ""
286
+
287
+ return ResolvedType(
288
+ name=type_name,
289
+ qualified_name=f"{module}.{type_name}" if module else type_name,
290
+ module=module,
291
+ is_builtin=is_builtin,
292
+ )
293
+
294
+ def _split_type_args(self, args_str: str) -> list[str]:
295
+ """
296
+ Split type arguments respecting nested brackets.
297
+
298
+ e.g., "str, list[int], dict[str, int]" -> ["str", "list[int]", "dict[str, int]"]
299
+ """
300
+ result = []
301
+ current = []
302
+ depth = 0
303
+
304
+ for char in args_str:
305
+ if char == "[":
306
+ depth += 1
307
+ current.append(char)
308
+ elif char == "]":
309
+ depth -= 1
310
+ current.append(char)
311
+ elif char == "," and depth == 0:
312
+ result.append("".join(current).strip())
313
+ current = []
314
+ else:
315
+ current.append(char)
316
+
317
+ if current:
318
+ result.append("".join(current).strip())
319
+
320
+ return [r for r in result if r]
321
+
322
+
323
+ # =============================================================================
324
+ # Type Resolver
325
+ # =============================================================================
326
+
327
+
328
+ class TypeResolver:
329
+ """
330
+ Resolves type references to their definitions.
331
+
332
+ Maintains:
333
+ - Import mapping (name -> module)
334
+ - Class definitions (name -> ExtractedClass)
335
+ - Resolved type cache
336
+ """
337
+
338
+ def __init__(self) -> None:
339
+ self._parser = TypeAnnotationParser()
340
+
341
+ # Import tracking
342
+ self._imports: dict[str, str] = {} # name -> module
343
+ self._from_imports: dict[str, tuple[str, str]] = {} # name -> (module, original_name)
344
+
345
+ # Class definitions
346
+ self._classes: dict[str, ExtractedClass] = {} # qualified_name -> class
347
+
348
+ # File to module mapping
349
+ self._file_modules: dict[Path, str] = {}
350
+
351
+ # Resolution cache
352
+ self._cache: dict[str, ResolvedType] = {}
353
+
354
+ def add_import(self, imp) -> None:
355
+ """
356
+ Register an import for resolution.
357
+
358
+ Handles both ExtractedImport (names is list of tuples) and
359
+ ParsedImport (names is list of strings with separate alias field).
360
+ """
361
+ # Handle different import formats
362
+ names_list = imp.names
363
+
364
+ if imp.is_from_import:
365
+ for item in names_list:
366
+ # Handle tuple format (name, alias) from ExtractedImport
367
+ if isinstance(item, tuple):
368
+ name, alias = item
369
+ key = alias or name
370
+ self._from_imports[key] = (imp.module, name)
371
+ else:
372
+ # Handle string format from ParsedImport
373
+ name = item
374
+ key = name
375
+ self._from_imports[key] = (imp.module, name)
376
+ else:
377
+ for item in names_list:
378
+ if isinstance(item, tuple):
379
+ name, alias = item
380
+ key = alias or name
381
+ self._imports[key] = name
382
+ else:
383
+ name = item
384
+ # For regular import, the alias is stored separately
385
+ alias = getattr(imp, "alias", None)
386
+ key = alias or name
387
+ self._imports[key] = name
388
+
389
+ def add_class(self, cls: ExtractedClass, file_path: Path | None = None) -> None:
390
+ """Register a class definition."""
391
+ self._classes[cls.qualified_name] = cls
392
+ self._classes[cls.name] = cls # Also by simple name
393
+
394
+ def set_file_module(self, file_path: Path, module_name: str) -> None:
395
+ """Set module name for a file."""
396
+ self._file_modules[file_path] = module_name
397
+
398
+ def resolve_type(self, annotation: str) -> ResolvedType:
399
+ """Resolve a type annotation to full type information."""
400
+ if annotation in self._cache:
401
+ return self._cache[annotation]
402
+
403
+ resolved = self._parser.parse(annotation)
404
+
405
+ # Resolve user-defined types
406
+ if not resolved.is_builtin and not resolved.is_generic:
407
+ self._resolve_user_type(resolved)
408
+
409
+ # Recursively resolve type arguments
410
+ for i, arg in enumerate(resolved.type_args):
411
+ if not arg.is_builtin:
412
+ resolved.type_args[i] = self.resolve_type(arg.name)
413
+
414
+ self._cache[annotation] = resolved
415
+ return resolved
416
+
417
+ def _resolve_user_type(self, resolved: ResolvedType) -> None:
418
+ """Resolve a user-defined type to its definition."""
419
+ name = resolved.name
420
+
421
+ # Check from imports
422
+ if name in self._from_imports:
423
+ module, original_name = self._from_imports[name]
424
+ resolved.module = module
425
+ resolved.qualified_name = f"{module}.{original_name}"
426
+
427
+ # Check regular imports
428
+ elif name in self._imports:
429
+ resolved.module = self._imports[name]
430
+ resolved.qualified_name = self._imports[name]
431
+
432
+ # Check registered classes
433
+ if name in self._classes:
434
+ cls = self._classes[name]
435
+ resolved.is_pydantic_model = cls.is_pydantic_model
436
+ resolved.is_dataclass = cls.is_dataclass
437
+ resolved.qualified_name = cls.qualified_name
438
+
439
+ def resolve_model(self, cls: ExtractedClass) -> ResolvedModel:
440
+ """Fully resolve a Pydantic model or dataclass."""
441
+ resolved_fields: list[ResolvedField] = []
442
+ nested_models: list[str] = []
443
+
444
+ for f in cls.fields:
445
+ resolved_field = ResolvedField(
446
+ name=f.name,
447
+ type_annotation=f.annotation or "Any",
448
+ )
449
+
450
+ # Resolve the field type
451
+ if f.annotation:
452
+ resolved_type = self.resolve_type(f.annotation)
453
+ resolved_field.resolved_type = resolved_type
454
+
455
+ # Check for nested models
456
+ nested = self._find_nested_models(resolved_type)
457
+ nested_models.extend(nested)
458
+ if nested:
459
+ resolved_field.nested_model = nested[0]
460
+
461
+ # Parse field constraints from default
462
+ if f.default:
463
+ self._parse_field_constraints(resolved_field, f.default, f.field_info)
464
+ else:
465
+ resolved_field.required = True
466
+
467
+ resolved_fields.append(resolved_field)
468
+
469
+ return ResolvedModel(
470
+ name=cls.name,
471
+ qualified_name=cls.qualified_name,
472
+ module=cls.qualified_name.rsplit(".", 1)[0] if "." in cls.qualified_name else "",
473
+ fields=resolved_fields,
474
+ base_classes=cls.bases,
475
+ nested_models=list(set(nested_models)),
476
+ is_pydantic=cls.is_pydantic_model,
477
+ is_dataclass=cls.is_dataclass,
478
+ )
479
+
480
+ def _find_nested_models(self, resolved_type: ResolvedType) -> list[str]:
481
+ """Find nested model references in a type."""
482
+ nested: list[str] = []
483
+
484
+ # Check if this type itself is a model
485
+ if resolved_type.is_pydantic_model or resolved_type.is_dataclass:
486
+ nested.append(resolved_type.qualified_name or resolved_type.name)
487
+
488
+ # Check type arguments recursively
489
+ for arg in resolved_type.type_args:
490
+ nested.extend(self._find_nested_models(arg))
491
+
492
+ return nested
493
+
494
+ def _parse_field_constraints(
495
+ self,
496
+ field: ResolvedField,
497
+ default_value: str,
498
+ field_info: dict[str, Any],
499
+ ) -> None:
500
+ """Parse field constraints from default value and Field() info."""
501
+ # Check if it's a Field() call
502
+ if default_value.startswith("Field("):
503
+ field.required = "..." in default_value or "default" not in field_info
504
+
505
+ # Copy constraints from field_info
506
+ for key, value in field_info.items():
507
+ if key in {"default", "default_factory"}:
508
+ if key == "default":
509
+ field.default = value
510
+ field.required = False
511
+ else:
512
+ field.default_factory = value
513
+ field.required = False
514
+ else:
515
+ field.constraints[key] = value
516
+ elif default_value == "...":
517
+ field.required = True
518
+ elif default_value != "":
519
+ field.required = False
520
+ field.default = default_value
521
+
522
+ def get_all_nested_models(self, model_name: str) -> list[ResolvedModel]:
523
+ """
524
+ Get all nested models recursively for a given model.
525
+
526
+ Returns list of all models that are referenced by the given model,
527
+ including transitively nested models.
528
+ """
529
+ visited: set[str] = set()
530
+ result: list[ResolvedModel] = []
531
+
532
+ self._collect_nested_models(model_name, visited, result)
533
+
534
+ return result
535
+
536
+ def _collect_nested_models(
537
+ self,
538
+ model_name: str,
539
+ visited: set[str],
540
+ result: list[ResolvedModel],
541
+ ) -> None:
542
+ """Recursively collect nested models."""
543
+ if model_name in visited:
544
+ return
545
+ visited.add(model_name)
546
+
547
+ # Find the class
548
+ cls = self._classes.get(model_name)
549
+ if not cls:
550
+ return
551
+
552
+ # Resolve the model
553
+ resolved = self.resolve_model(cls)
554
+ result.append(resolved)
555
+
556
+ # Recursively collect nested models
557
+ for nested_name in resolved.nested_models:
558
+ self._collect_nested_models(nested_name, visited, result)
559
+
560
+
561
+ # =============================================================================
562
+ # Schema Builder
563
+ # =============================================================================
564
+
565
+
566
+ class SchemaBuilder:
567
+ """
568
+ Builds complete schema representations for API request/response bodies.
569
+
570
+ Handles:
571
+ - Pydantic model schemas
572
+ - Nested model resolution
573
+ - Field type resolution
574
+ - Constraint extraction
575
+ """
576
+
577
+ def __init__(self, resolver: TypeResolver) -> None:
578
+ self._resolver = resolver
579
+
580
+ def build_body_schema(
581
+ self,
582
+ model_name: str,
583
+ classes: dict[str, ExtractedClass],
584
+ ) -> dict[str, Any]:
585
+ """
586
+ Build a complete schema for a request/response body model.
587
+
588
+ Returns a JSON-schema-like dict representation.
589
+ """
590
+ # Register all classes
591
+ for cls in classes.values():
592
+ self._resolver.add_class(cls)
593
+
594
+ # Check if model exists
595
+ if model_name not in classes:
596
+ return {"type": "unknown", "name": model_name}
597
+
598
+ cls = classes[model_name]
599
+ return self._build_model_schema(cls, set())
600
+
601
+ def _build_model_schema(
602
+ self,
603
+ cls: ExtractedClass,
604
+ visited: set[str],
605
+ ) -> dict[str, Any]:
606
+ """Build schema for a single model."""
607
+ if cls.qualified_name in visited:
608
+ return {"$ref": cls.qualified_name}
609
+ visited.add(cls.qualified_name)
610
+
611
+ schema: dict[str, Any] = {
612
+ "type": "object",
613
+ "name": cls.name,
614
+ "qualified_name": cls.qualified_name,
615
+ "properties": {},
616
+ "required": [],
617
+ }
618
+
619
+ for f in cls.fields:
620
+ field_schema = self._build_field_schema(f, visited)
621
+ schema["properties"][f.name] = field_schema
622
+
623
+ # Check if required
624
+ if f.default is None and not f.field_info.get("default"):
625
+ schema["required"].append(f.name)
626
+
627
+ return schema
628
+
629
+ def _build_field_schema(
630
+ self,
631
+ f: ExtractedField, # noqa: F821
632
+ visited: set[str],
633
+ ) -> dict[str, Any]:
634
+ """Build schema for a single field."""
635
+ field_schema: dict[str, Any] = {}
636
+
637
+ if f.annotation:
638
+ resolved = self._resolver.resolve_type(f.annotation)
639
+ field_schema = self._type_to_schema(resolved, visited)
640
+
641
+ # Add constraints
642
+ if f.field_info:
643
+ for key, value in f.field_info.items():
644
+ if key not in {"default", "default_factory"}:
645
+ field_schema[key] = value
646
+
647
+ # Add default
648
+ if f.default:
649
+ field_schema["default"] = f.default
650
+
651
+ return field_schema
652
+
653
+ def _type_to_schema(
654
+ self,
655
+ resolved: ResolvedType,
656
+ visited: set[str],
657
+ ) -> dict[str, Any]:
658
+ """Convert a resolved type to JSON-schema representation."""
659
+ # Handle builtins
660
+ if resolved.is_builtin:
661
+ return self._builtin_to_schema(resolved)
662
+
663
+ # Handle Optional
664
+ if resolved.is_optional:
665
+ inner_schema = self._type_to_schema(
666
+ resolved.type_args[0] if resolved.type_args else resolved,
667
+ visited,
668
+ )
669
+ return {"anyOf": [inner_schema, {"type": "null"}]}
670
+
671
+ # Handle Union
672
+ if resolved.is_union:
673
+ return {"anyOf": [self._type_to_schema(arg, visited) for arg in resolved.type_args]}
674
+
675
+ # Handle List
676
+ if resolved.is_list and resolved.type_args:
677
+ return {
678
+ "type": "array",
679
+ "items": self._type_to_schema(resolved.type_args[0], visited),
680
+ }
681
+
682
+ # Handle Dict
683
+ if resolved.is_dict and len(resolved.type_args) >= 2:
684
+ return {
685
+ "type": "object",
686
+ "additionalProperties": self._type_to_schema(resolved.type_args[1], visited),
687
+ }
688
+
689
+ # Handle user-defined models
690
+ if resolved.is_pydantic_model or resolved.is_dataclass:
691
+ # Check if we have the class definition
692
+ cls_name = resolved.qualified_name or resolved.name
693
+ if cls_name in self._resolver._classes:
694
+ cls = self._resolver._classes[cls_name]
695
+ return self._build_model_schema(cls, visited)
696
+ return {"$ref": cls_name}
697
+
698
+ # Unknown type
699
+ return {"type": "unknown", "name": resolved.name}
700
+
701
+ def _builtin_to_schema(self, resolved: ResolvedType) -> dict[str, Any]:
702
+ """Convert builtin type to JSON schema."""
703
+ type_mapping = {
704
+ "str": {"type": "string"},
705
+ "int": {"type": "integer"},
706
+ "float": {"type": "number"},
707
+ "bool": {"type": "boolean"},
708
+ "None": {"type": "null"},
709
+ "bytes": {"type": "string", "format": "binary"},
710
+ "Any": {},
711
+ }
712
+
713
+ name = resolved.name.lower()
714
+ if name in type_mapping:
715
+ return type_mapping[name]
716
+
717
+ # Handle generic builtins
718
+ if resolved.is_list:
719
+ items = self._type_to_schema(resolved.type_args[0], set()) if resolved.type_args else {}
720
+ return {"type": "array", "items": items}
721
+
722
+ if resolved.is_dict:
723
+ additional = (
724
+ self._type_to_schema(resolved.type_args[1], set())
725
+ if len(resolved.type_args) > 1
726
+ else {}
727
+ )
728
+ return {"type": "object", "additionalProperties": additional}
729
+
730
+ return {"type": resolved.name}