drf-to-mkdoc 0.1.0__py3-none-any.whl → 0.1.2__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.

Potentially problematic release.


This version of drf-to-mkdoc might be problematic. Click here for more details.

@@ -1,945 +1,945 @@
1
- #!/usr/bin/env python3
2
-
3
- import ast
4
- import inspect
5
- import json
6
- from collections import defaultdict
7
- from pathlib import Path
8
- from typing import Any
9
-
10
- from drf_to_mkdoc.conf.settings import drf_to_mkdoc_settings
11
- from drf_to_mkdoc.utils.common import (
12
- create_safe_filename,
13
- extract_app_from_operation_id,
14
- extract_viewset_from_operation_id,
15
- extract_viewset_name_from_operation_id,
16
- format_method_badge,
17
- get_custom_schema,
18
- write_file,
19
- )
20
- from drf_to_mkdoc.utils.extractors.query_parameter_extractors import extract_query_parameters_from_view
21
- from drf_to_mkdoc.utils.md_generators.query_parameters_generators import generate_query_parameters_md
22
-
23
-
24
- def analyze_serializer_method_field_schema(serializer_class, field_name: str) -> dict:
25
- """Analyze a SerializerMethodField to determine its actual return type schema."""
26
- method_name = f"get_{field_name}"
27
-
28
-
29
- # Strategy 2: Check type annotations
30
- schema_from_annotations = _extract_schema_from_type_hints(serializer_class, method_name)
31
- if schema_from_annotations:
32
- return schema_from_annotations
33
-
34
- # Strategy 3: Analyze method source code
35
- schema_from_source = _analyze_method_source_code(serializer_class, method_name)
36
- if schema_from_source:
37
- return schema_from_source
38
-
39
- # Strategy 4: Runtime analysis (sample execution)
40
- schema_from_runtime = _analyze_method_runtime(serializer_class, method_name)
41
- if schema_from_runtime:
42
- return schema_from_runtime
43
-
44
- # Fallback to string
45
- return {"type": "string"}
46
-
47
-
48
- def _extract_schema_from_decorator(serializer_class, method_name: str) -> dict:
49
- """Extract schema from @extend_schema_field decorator if present."""
50
- try:
51
- method = getattr(serializer_class, method_name, None)
52
- if not method:
53
- return None
54
-
55
- # Check if method has the decorator attribute (drf-spectacular)
56
- if hasattr(method, '_spectacular_annotation'):
57
- annotation = method._spectacular_annotation
58
- # Handle OpenApiTypes
59
- if hasattr(annotation, 'type'):
60
- return {"type": annotation.type}
61
- elif isinstance(annotation, dict):
62
- return annotation
63
-
64
- # Check for drf-yasg decorator
65
- if hasattr(method, '_swagger_serializer_method'):
66
- swagger_info = method._swagger_serializer_method
67
- if hasattr(swagger_info, 'many') and hasattr(swagger_info, 'child'):
68
- return {"type": "array", "items": {"type": "object"}}
69
-
70
- except Exception:
71
- pass
72
- return None
73
-
74
-
75
- def _extract_schema_from_type_hints(serializer_class, method_name: str) -> dict:
76
- """Extract schema from method type annotations."""
77
- try:
78
- method = getattr(serializer_class, method_name, None)
79
- if not method:
80
- return None
81
-
82
- signature = inspect.signature(method)
83
- return_annotation = signature.return_annotation
84
-
85
- if return_annotation and return_annotation != inspect.Signature.empty:
86
- # Handle common type hints
87
- if return_annotation == int:
88
- return {"type": "integer"}
89
- elif return_annotation == str:
90
- return {"type": "string"}
91
- elif return_annotation == bool:
92
- return {"type": "boolean"}
93
- elif return_annotation == float:
94
- return {"type": "number"}
95
- elif hasattr(return_annotation, '__origin__'):
96
- # Handle generic types like List[str], Dict[str, Any]
97
- origin = return_annotation.__origin__
98
- if origin is list:
99
- return {"type": "array", "items": {"type": "string"}}
100
- elif origin is dict:
101
- return {"type": "object"}
102
-
103
- except Exception:
104
- pass
105
- return None
106
-
107
-
108
- def _analyze_method_source_code(serializer_class, method_name: str) -> dict:
109
- """Analyze method source code to infer return type."""
110
- try:
111
- method = getattr(serializer_class, method_name, None)
112
- if not method:
113
- return None
114
-
115
- source = inspect.getsource(method)
116
- tree = ast.parse(source)
117
-
118
- # Find return statements and analyze them
119
- return_analyzer = ReturnStatementAnalyzer()
120
- return_analyzer.visit(tree)
121
-
122
- return _infer_schema_from_return_patterns(return_analyzer.return_patterns)
123
-
124
- except Exception:
125
- pass
126
- return None
127
-
128
-
129
- def _analyze_method_runtime(serializer_class, method_name: str) -> dict:
130
- """Analyze method by creating mock instances and examining return values."""
131
- try:
132
- # Create a basic mock object with common attributes
133
- mock_obj = type('MockObj', (), {
134
- 'id': 1, 'pk': 1, 'name': 'test', 'count': lambda: 5,
135
- 'items': type('items', (), {'count': lambda: 3, 'all': lambda: []})()
136
- })()
137
-
138
- serializer_instance = serializer_class()
139
- method = getattr(serializer_instance, method_name, None)
140
-
141
- if not method:
142
- return None
143
-
144
- # Execute method with mock data
145
- result = method(mock_obj)
146
- return _infer_schema_from_value(result)
147
-
148
- except Exception:
149
- pass
150
- return None
151
-
152
-
153
- class ReturnStatementAnalyzer(ast.NodeVisitor):
154
- """AST visitor to analyze return statements in method source code."""
155
-
156
- def __init__(self):
157
- self.return_patterns = []
158
-
159
- def visit_Return(self, node):
160
- """Visit return statements and extract patterns."""
161
- if node.value:
162
- pattern = self._analyze_return_value(node.value)
163
- if pattern:
164
- self.return_patterns.append(pattern)
165
- self.generic_visit(node)
166
-
167
- def _analyze_return_value(self, node) -> dict:
168
- """Analyze different types of return value patterns."""
169
- if isinstance(node, ast.Dict):
170
- return self._analyze_dict_return(node)
171
- elif isinstance(node, ast.List):
172
- return self._analyze_list_return(node)
173
- elif isinstance(node, ast.Constant):
174
- return self._analyze_constant_return(node)
175
- elif isinstance(node, ast.Call):
176
- return self._analyze_method_call_return(node)
177
- elif isinstance(node, ast.Attribute):
178
- return self._analyze_attribute_return(node)
179
- return None
180
-
181
- def _analyze_dict_return(self, node) -> dict:
182
- """Analyze dictionary return patterns."""
183
- properties = {}
184
- for key, value in zip(node.keys, node.values):
185
- if isinstance(key, ast.Constant) and isinstance(key.value, str):
186
- prop_schema = self._infer_value_type(value)
187
- if prop_schema:
188
- properties[key.value] = prop_schema
189
-
190
- return {
191
- "type": "object",
192
- "properties": properties
193
- }
194
-
195
- def _analyze_list_return(self, node) -> dict:
196
- """Analyze list return patterns."""
197
- if node.elts:
198
- # Analyze first element to determine array item type
199
- first_element_schema = self._infer_value_type(node.elts[0])
200
- return {
201
- "type": "array",
202
- "items": first_element_schema or {"type": "string"}
203
- }
204
- return {"type": "array", "items": {"type": "string"}}
205
-
206
- def _analyze_constant_return(self, node) -> dict:
207
- """Analyze constant return values."""
208
- return self._python_type_to_schema(type(node.value))
209
-
210
- def _analyze_method_call_return(self, node) -> dict:
211
- """Analyze method call returns (like obj.count(), obj.items.all())."""
212
- if isinstance(node.func, ast.Attribute):
213
- method_name = node.func.attr
214
-
215
- # Common Django ORM patterns
216
- if method_name in ['count']:
217
- return {"type": "integer"}
218
- elif method_name in ['all', 'filter', 'exclude']:
219
- return {"type": "array", "items": {"type": "object"}}
220
- elif method_name in ['first', 'last', 'get']:
221
- return {"type": "object"}
222
- elif method_name in ['exists']:
223
- return {"type": "boolean"}
224
-
225
- return None
226
-
227
- def _analyze_attribute_return(self, node) -> dict:
228
- """Analyze attribute access returns (like obj.name, obj.id)."""
229
- if isinstance(node, ast.Attribute):
230
- attr_name = node.attr
231
-
232
- # Common field name patterns
233
- if attr_name in ['id', 'pk', 'count']:
234
- return {"type": "integer"}
235
- elif attr_name in ['name', 'title', 'description', 'slug']:
236
- return {"type": "string"}
237
- elif attr_name in ['is_active', 'is_published', 'enabled']:
238
- return {"type": "boolean"}
239
-
240
- return None
241
-
242
- def _infer_value_type(self, node) -> dict:
243
- """Infer schema type from AST node."""
244
- if isinstance(node, ast.Constant):
245
- return self._python_type_to_schema(type(node.value))
246
- elif isinstance(node, ast.Call):
247
- return self._analyze_method_call_return(node)
248
- elif isinstance(node, ast.Attribute):
249
- return self._analyze_attribute_return(node)
250
- return {"type": "string"} # Default fallback
251
-
252
- def _python_type_to_schema(self, python_type) -> dict:
253
- """Convert Python type to OpenAPI schema."""
254
- type_mapping = {
255
- int: {"type": "integer"},
256
- float: {"type": "number"},
257
- str: {"type": "string"},
258
- bool: {"type": "boolean"},
259
- list: {"type": "array", "items": {"type": "string"}},
260
- dict: {"type": "object"},
261
- }
262
- return type_mapping.get(python_type, {"type": "string"})
263
-
264
-
265
- def _infer_schema_from_return_patterns(patterns: list) -> dict:
266
- """Infer final schema from collected return patterns."""
267
- if not patterns:
268
- return None
269
-
270
- # If all patterns are the same type, use that
271
- if len(set(p.get("type") for p in patterns)) == 1:
272
- # Merge object properties if multiple object returns
273
- if patterns[0]["type"] == "object":
274
- merged_properties = {}
275
- for pattern in patterns:
276
- merged_properties.update(pattern.get("properties", {}))
277
- return {
278
- "type": "object",
279
- "properties": merged_properties
280
- }
281
- return patterns[0]
282
-
283
- # Mixed types - could be union, but default to string for OpenAPI compatibility
284
- return {"type": "string"}
285
-
286
-
287
- def _infer_schema_from_value(value: Any) -> dict:
288
- """Infer schema from actual runtime value."""
289
- if isinstance(value, dict):
290
- properties = {}
291
- for key, val in value.items():
292
- properties[str(key)] = _infer_schema_from_value(val)
293
- return {
294
- "type": "object",
295
- "properties": properties
296
- }
297
- elif isinstance(value, list):
298
- if value:
299
- return {
300
- "type": "array",
301
- "items": _infer_schema_from_value(value[0])
302
- }
303
- return {"type": "array", "items": {"type": "string"}}
304
- elif isinstance(value, (int, float, str, bool)):
305
- return {
306
- int: {"type": "integer"},
307
- float: {"type": "number"},
308
- str: {"type": "string"},
309
- bool: {"type": "boolean"}
310
- }[type(value)]
311
- else:
312
- return {"type": "string"}
313
-
314
-
315
- def _get_serializer_class_from_schema_name(schema_name: str):
316
- """Try to get the serializer class from schema name."""
317
- try:
318
- # Import Django and get all installed apps
319
- import django
320
- from django.apps import apps
321
- from rest_framework import serializers
322
-
323
- # Search through all apps for the serializer
324
- for app in apps.get_app_configs():
325
- app_module = app.module
326
- try:
327
- # Try to import serializers module from the app
328
- serializers_module = __import__(f"{app_module.__name__}.serializers", fromlist=[''])
329
-
330
- # Look for serializer class matching the schema name
331
- for attr_name in dir(serializers_module):
332
- attr = getattr(serializers_module, attr_name)
333
- if (isinstance(attr, type) and
334
- issubclass(attr, serializers.Serializer) and
335
- attr.__name__.replace('Serializer', '') in schema_name):
336
- return attr
337
- except ImportError:
338
- continue
339
-
340
- except Exception:
341
- pass
342
- return None
343
-
344
-
345
- def schema_to_example_json(schema: dict, components: dict, for_response: bool = True):
346
- """Recursively generate a JSON example, respecting readOnly/writeOnly based on context."""
347
- # Ensure schema is a dictionary
348
- if not isinstance(schema, dict):
349
- return None
350
-
351
- schema = _resolve_schema_reference(schema, components)
352
- schema = _handle_all_of_schema(schema, components, for_response)
353
-
354
- # Handle explicit values first
355
- explicit_value = _get_explicit_value(schema)
356
- if explicit_value is not None:
357
- return explicit_value
358
-
359
- # ENHANCED: Check if this looks like an unanalyzed SerializerMethodField
360
- schema = _enhance_method_field_schema(schema, components)
361
-
362
- return _generate_example_by_type(schema, components, for_response)
363
-
364
-
365
- def _enhance_method_field_schema(schema: dict, components: dict) -> dict:
366
- """Enhance schema by analyzing SerializerMethodField types."""
367
- if not isinstance(schema, dict) or 'properties' not in schema:
368
- return schema
369
-
370
- # Try to get serializer class from schema title or other hints
371
- schema_title = schema.get('title', '')
372
- serializer_class = _get_serializer_class_from_schema_name(schema_title)
373
-
374
- if not serializer_class:
375
- return schema
376
-
377
- enhanced_properties = {}
378
- for prop_name, prop_schema in schema['properties'].items():
379
- # Check if this looks like an unanalyzed SerializerMethodField
380
- if (isinstance(prop_schema, dict) and
381
- prop_schema.get('type') == 'string' and
382
- not prop_schema.get('enum') and
383
- not prop_schema.get('format') and
384
- not prop_schema.get('example')):
385
-
386
- # Try to analyze the method field
387
- analyzed_schema = analyze_serializer_method_field_schema(serializer_class, prop_name)
388
- enhanced_properties[prop_name] = analyzed_schema
389
- else:
390
- enhanced_properties[prop_name] = prop_schema
391
-
392
- enhanced_schema = schema.copy()
393
- enhanced_schema['properties'] = enhanced_properties
394
- return enhanced_schema
395
-
396
-
397
- def _resolve_schema_reference(schema: dict, components: dict) -> dict:
398
- """Resolve $ref references in schema."""
399
- if "$ref" in schema:
400
- ref = schema["$ref"]
401
- return components.get("schemas", {}).get(ref.split("/")[-1], {})
402
- return schema
403
-
404
-
405
- def _handle_all_of_schema(schema: dict, components: dict, _for_response: bool) -> dict:
406
- """Handle allOf schema composition."""
407
- if "allOf" not in schema:
408
- return schema
409
-
410
- merged = {}
411
- for part in schema["allOf"]:
412
- # Resolve the part schema first
413
- resolved_part = _resolve_schema_reference(part, components)
414
- if isinstance(resolved_part, dict):
415
- merged.update(resolved_part)
416
- else:
417
- # If we can't resolve it, skip this part
418
- continue
419
-
420
- # Merge with the original schema properties (like readOnly)
421
- if merged:
422
- result = merged.copy()
423
- # Add any properties from the original schema that aren't in allOf
424
- for key, value in schema.items():
425
- if key != "allOf":
426
- result[key] = value
427
- return result
428
-
429
- return schema
430
-
431
-
432
- def _get_explicit_value(schema: dict):
433
- """Get explicit value from schema (enum, example, or default)."""
434
- # Ensure schema is a dictionary
435
- if not isinstance(schema, dict):
436
- return None
437
-
438
- if "enum" in schema:
439
- return schema["enum"][0]
440
- if "example" in schema:
441
- return schema["example"]
442
- if "default" in schema:
443
- return schema["default"]
444
- return None
445
-
446
-
447
- def _generate_example_by_type(schema: dict, components: dict, for_response: bool):
448
- """Generate example based on schema type."""
449
- schema_type = schema.get("type", "object")
450
-
451
- if schema_type == "object":
452
- return _generate_object_example(schema, components, for_response)
453
- if schema_type == "array":
454
- return _generate_array_example(schema, components, for_response)
455
- return _generate_primitive_example(schema_type)
456
-
457
-
458
- def _generate_object_example(schema: dict, components: dict, for_response: bool) -> dict:
459
- """Generate example for object type schema."""
460
- props = schema.get("properties", {})
461
- result = {}
462
-
463
- for prop_name, prop_schema in props.items():
464
- if _should_skip_property(prop_schema, for_response):
465
- continue
466
- result[prop_name] = schema_to_example_json(prop_schema, components, for_response)
467
-
468
- return result
469
-
470
-
471
- def _should_skip_property(prop_schema: dict, for_response: bool) -> bool:
472
- """
473
- Args:
474
- prop_schema: Property schema containing readOnly/writeOnly flags
475
- for_response: True for response example, False for request example
476
-
477
- Returns:
478
- True if property should be skipped, False otherwise
479
- """
480
- is_write_only = prop_schema.get("writeOnly", False)
481
- is_read_only = prop_schema.get("readOnly", False)
482
-
483
- if for_response:
484
- return is_write_only
485
- return is_read_only
486
-
487
-
488
- def _generate_array_example(schema: dict, components: dict, for_response: bool) -> list:
489
- """Generate example for array type schema."""
490
- items = schema.get("items", {})
491
- return [schema_to_example_json(items, components, for_response)]
492
-
493
-
494
- def _generate_primitive_example(schema_type: str):
495
- """Generate example for primitive types."""
496
- type_examples = {"integer": 0, "number": 0.0, "boolean": True, "string": "string"}
497
- return type_examples.get(schema_type)
498
-
499
-
500
- def format_schema_as_json_example(
501
- schema_ref: str, components: dict[str, Any], for_response: bool = True
502
- ) -> str:
503
- """
504
- Format a schema as a JSON example, resolving $ref and respecting readOnly/writeOnly flags.
505
- """
506
- if not schema_ref.startswith("#/components/schemas/"):
507
- return f"Invalid $ref: `{schema_ref}`"
508
-
509
- schema_name = schema_ref.split("/")[-1]
510
- schema = components.get("schemas", {}).get(schema_name)
511
-
512
- if not schema:
513
- return f"**Error**: Schema `{schema_name}` not found in components."
514
-
515
- description = schema.get("description", "")
516
- example_json = schema_to_example_json(schema, components, for_response=for_response)
517
-
518
- result = ""
519
- if description:
520
- result += f"{description}\n\n"
521
-
522
- result += "```json\n"
523
- result += json.dumps(example_json, indent=2)
524
- result += "\n```\n"
525
-
526
- return result
527
-
528
-
529
- def create_endpoint_page(
530
- path: str, method: str, endpoint_data: dict[str, Any], components: dict[str, Any]
531
- ) -> str:
532
- """Create a documentation page for a single API endpoint."""
533
- operation_id = endpoint_data.get("operationId", "")
534
- summary = endpoint_data.get("summary", "")
535
- description = endpoint_data.get("description", "")
536
- parameters = endpoint_data.get("parameters", [])
537
- request_body = endpoint_data.get("requestBody", {})
538
- responses = endpoint_data.get("responses", {})
539
-
540
- content = _create_endpoint_header(path, method, operation_id, summary, description)
541
- content += _add_path_parameters(parameters)
542
- content += _add_query_parameters(method, path, operation_id)
543
- content += _add_request_body(request_body, components)
544
- content += _add_responses(responses, components)
545
-
546
- return content
547
-
548
-
549
- def _create_endpoint_header(
550
- path: str, method: str, operation_id: str, summary: str, description: str
551
- ) -> str:
552
- """Create the header section of the endpoint documentation."""
553
- content = f"# {method.upper()} {path}\n\n"
554
- content += f"{format_method_badge(method)} `{path}`\n\n"
555
- content += f"**View class:** {extract_viewset_name_from_operation_id(operation_id)}\n\n"
556
-
557
- if summary:
558
- content += f"## Overview\n\n{summary}\n\n"
559
- if operation_id:
560
- content += f"**Operation ID:** `{operation_id}`\n\n"
561
- if description:
562
- content += f"{description}\n\n"
563
-
564
- return content
565
-
566
-
567
- def _add_path_parameters(parameters: list[dict]) -> str:
568
- """Add path parameters section to the documentation."""
569
- path_params = [p for p in parameters if p.get("in") == "path"]
570
- if not path_params:
571
- return ""
572
-
573
- content = "## Path Parameters\n\n"
574
- content += "| Name | Type | Required | Description |\n"
575
- content += "|------|------|----------|-------------|\n"
576
-
577
- for param in path_params:
578
- name = param.get("name", "")
579
- param_type = param.get("schema", {}).get("type", "string")
580
- required = "Yes" if param.get("required", False) else "No"
581
- desc = param.get("description", "")
582
- content += f"| `{name}` | `{param_type}` | {required} | {desc} |\n"
583
-
584
- content += "\n"
585
- return content
586
-
587
-
588
- def _add_query_parameters(method: str, path: str, operation_id: str) -> str:
589
- """Add query parameters section for list endpoints."""
590
- is_list_endpoint = _is_list_endpoint(method, path, operation_id)
591
- if not is_list_endpoint:
592
- return ""
593
-
594
- query_params = extract_query_parameters_from_view(operation_id)
595
- _add_custom_parameters(operation_id, query_params)
596
-
597
- query_params_content = generate_query_parameters_md(query_params)
598
- if query_params_content and not query_params_content.startswith("**Error:**"):
599
- return "## Query Parameters\n\n" + query_params_content
600
-
601
- return ""
602
-
603
-
604
- def _is_list_endpoint(method: str, path: str, operation_id: str) -> bool:
605
- """Check if the endpoint is a list endpoint that should have query parameters."""
606
- return (
607
- method.upper() == "GET"
608
- and operation_id
609
- and ("list" in operation_id or not ("{id}" in path or "{pk}" in path))
610
- )
611
-
612
-
613
- def _add_custom_parameters(operation_id: str, query_params: dict) -> None:
614
- """Add custom parameters to query parameters dictionary."""
615
- custom_parameters = get_custom_schema().get(operation_id, {}).get("parameters", [])
616
- for parameter in custom_parameters:
617
- queryparam_type = parameter["queryparam_type"]
618
- if queryparam_type not in query_params:
619
- query_params[queryparam_type] = []
620
- query_params[queryparam_type].append(parameter["name"])
621
-
622
-
623
- def _add_request_body(request_body: dict, components: dict[str, Any]) -> str:
624
- """Add request body section to the documentation."""
625
- if not request_body:
626
- return ""
627
-
628
- content = "## Request Body\n\n"
629
- req_schema = request_body.get("content", {}).get("application/json", {}).get("schema")
630
-
631
- if req_schema and "$ref" in req_schema:
632
- content += (
633
- format_schema_as_json_example(req_schema["$ref"], components, for_response=False)
634
- + "\n"
635
- )
636
-
637
- return content
638
-
639
-
640
- def _add_responses(responses: dict, components: dict[str, Any]) -> str:
641
- """Add responses section to the documentation."""
642
- if not responses:
643
- return ""
644
-
645
- content = "## Responses\n\n"
646
- for status_code, response_data in responses.items():
647
- content += _format_single_response(status_code, response_data, components)
648
-
649
- return content
650
-
651
-
652
- def _format_single_response(
653
- status_code: str, response_data: dict, components: dict[str, Any]
654
- ) -> str:
655
- """Format a single response entry."""
656
- content = f"### {status_code}\n\n"
657
-
658
- if desc := response_data.get("description", ""):
659
- content += f"{desc}\n\n"
660
-
661
- resp_schema = response_data.get("content", {}).get("application/json", {}).get("schema", {})
662
-
663
- content += _format_response_schema(resp_schema, components)
664
- return content
665
-
666
-
667
- def _format_response_schema(resp_schema: dict, components: dict[str, Any]) -> str:
668
- """Format the response schema as JSON example."""
669
- if "$ref" in resp_schema:
670
- return (
671
- format_schema_as_json_example(resp_schema["$ref"], components, for_response=True)
672
- + "\n"
673
- )
674
- if resp_schema.get("type") == "array" and "$ref" in resp_schema.get("items", {}):
675
- item_ref = resp_schema["items"]["$ref"]
676
- return format_schema_as_json_example(item_ref, components, for_response=True) + "\n"
677
- content = "```json\n"
678
- content += json.dumps(schema_to_example_json(resp_schema, components), indent=2)
679
- content += "\n```\n"
680
- return content
681
-
682
-
683
- def parse_endpoints_from_schema(paths: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
684
- """Parse endpoints from OpenAPI schema and organize by app"""
685
- endpoints_by_app = defaultdict(list)
686
- django_apps = set(drf_to_mkdoc_settings.DJANGO_APPS)
687
-
688
- for path, methods in paths.items():
689
- app_name = extract_app_from_operation_id(next(iter(methods.values()))["operationId"])
690
- if app_name not in django_apps:
691
- continue
692
-
693
- for method, endpoint_data in methods.items():
694
- if method.lower() not in ["get", "post", "put", "patch", "delete"]:
695
- continue
696
-
697
- operation_id = endpoint_data.get("operationId", "")
698
- filename = create_safe_filename(path, method)
699
-
700
- endpoint_info = {
701
- "path": path,
702
- "method": method.upper(),
703
- "viewset": extract_viewset_name_from_operation_id(operation_id),
704
- "operation_id": operation_id,
705
- "filename": filename,
706
- "data": endpoint_data,
707
- }
708
-
709
- endpoints_by_app[app_name].append(endpoint_info)
710
-
711
- return endpoints_by_app
712
-
713
-
714
- def generate_endpoint_files(
715
- endpoints_by_app: dict[str, list[dict[str, Any]]], components: dict[str, Any]
716
- ) -> int:
717
- """Generate individual endpoint documentation files"""
718
- total_endpoints = 0
719
-
720
- for app_name, endpoints in endpoints_by_app.items():
721
- for endpoint in endpoints:
722
- content = create_endpoint_page(
723
- endpoint["path"], endpoint["method"], endpoint["data"], components
724
- )
725
-
726
- file_path = (
727
- f"endpoints/{app_name}/{endpoint['viewset'].lower()}/{endpoint['filename']}"
728
- )
729
- write_file(file_path, content)
730
- total_endpoints += 1
731
-
732
- return total_endpoints
733
-
734
-
735
- class EndpointsIndexGenerator:
736
- def __init__(self, active_filters: list[str] | None = None):
737
- self.active_filters = set(
738
- active_filters
739
- or [
740
- "method",
741
- "path",
742
- "app",
743
- "models",
744
- "auth",
745
- "roles",
746
- "content_type",
747
- "params",
748
- "schema",
749
- "pagination",
750
- "ordering",
751
- "search",
752
- "tags",
753
- ]
754
- )
755
-
756
- def create_endpoint_card(
757
- self, endpoint: dict[str, Any], app_name: str, viewset_name: str
758
- ) -> str:
759
- method = endpoint["method"]
760
- path = endpoint["path"]
761
- filename = endpoint["filename"]
762
- view_class = extract_viewset_from_operation_id(endpoint["operation_id"])
763
-
764
- link_url = f"{app_name}/{viewset_name.lower()}/{filename}".replace(".md", "/index.html")
765
- data_attrs = f"""
766
- data-method="{method.lower()}"
767
- data-path="{path.lower()}"
768
- data-app="{app_name.lower()}"
769
- data-auth="{str(endpoint.get("auth_required", False)).lower()}"
770
- data-pagination="{str(endpoint.get("pagination_support", False)).lower()}"
771
- data-search="{str(bool(getattr(view_class, "search_fields", []))).lower()}"
772
- data-ordering="{str(endpoint.get("ordering_support", False)).lower()}"
773
- data-models="{" ".join(endpoint.get("related_models", [])).lower()}"
774
- data-roles="{" ".join(endpoint.get("permission_roles", [])).lower()}"
775
- data-content-type="{endpoint.get("content_type", "").lower()}"
776
- data-tags="{" ".join(endpoint.get("tags", [])).lower()}"
777
- data-schema="{" ".join(endpoint.get("schema_fields", [])).lower()}"
778
- data-params="{" ".join(endpoint.get("query_parameters", [])).lower()}"
779
- """.strip()
780
-
781
- return f"""
782
- <a href="{link_url}" class="endpoint-card" {data_attrs}>
783
- <span class="method-badge method-{method.lower()}">{method}</span>
784
- <span class="endpoint-path">{path}</span>
785
- </a>
786
- """
787
-
788
- def create_filter_section(self) -> str:
789
- filter_fields = {
790
- "method": """<div class="filter-group">
791
- <label class="filter-label">HTTP Method</label>
792
- <select id="filter-method" class="filter-select">
793
- <option value="">All</option>
794
- <option value="get">GET</option>
795
- <option value="post">POST</option>
796
- <option value="put">PUT</option>
797
- <option value="patch">PATCH</option>
798
- <option value="delete">DELETE</option>
799
- </select>
800
- </div>""",
801
- "path": """<div class="filter-group">
802
- <label class="filter-label">Endpoint Path</label>
803
- <input type="text" id="filter-path" class="filter-input"
804
- placeholder="Search path...">
805
- </div>""",
806
- "app": """<div class="filter-group">
807
- <label class="filter-label">Django App</label>
808
- <select id="filter-app" class="filter-select">
809
- <option value="">All</option>
810
- <!-- Dynamically filled -->
811
- </select>
812
- </div>""",
813
- "models": """<div class="filter-group">
814
- <label class="filter-label">Related Models</label>
815
- <input type="text" id="filter-models" class="filter-input">
816
- </div>""",
817
- "auth": """<div class="filter-group">
818
- <label class="filter-label">Authentication Required</label>
819
- <select id="filter-auth" class="filter-select">
820
- <option value="">All</option>
821
- <option value="true">Yes</option>
822
- <option value="false">No</option>
823
- </select>
824
- </div>""",
825
- "roles": """<div class="filter-group">
826
- <label class="filter-label">Permission Roles</label>
827
- <input type="text" id="filter-roles" class="filter-input">
828
- </div>""",
829
- "content_type": """<div class="filter-group">
830
- <label class="filter-label">Content Type</label>
831
- <input type="text" id="filter-content-type" class="filter-input">
832
- </div>""",
833
- "params": """<div class="filter-group">
834
- <label class="filter-label">Query Parameters</label>
835
- <input type="text" id="filter-params" class="filter-input">
836
- </div>""",
837
- "schema": """<div class="filter-group">
838
- <label class="filter-label">Schema Fields</label>
839
- <input type="text" id="filter-schema" class="filter-input">
840
- </div>""",
841
- "pagination": """<div class="filter-group">
842
- <label class="filter-label">Pagination Support</label>
843
- <select id="filter-pagination" class="filter-select">
844
- <option value="">All</option>
845
- <option value="true">Yes</option>
846
- <option value="false">No</option>
847
- </select>
848
- </div>""",
849
- "ordering": """<div class="filter-group">
850
- <label class="filter-label">Ordering Support</label>
851
- <select id="filter-ordering" class="filter-select">
852
- <option value="">All</option>
853
- <option value="true">Yes</option>
854
- <option value="false">No</option>
855
- </select>
856
- </div>""",
857
- "search": """<div class="filter-group">
858
- <label class="filter-label">Search Support</label>
859
- <select id="filter-search" class="filter-select">
860
- <option value="">All</option>
861
- <option value="true">Yes</option>
862
- <option value="false">No</option>
863
- </select>
864
- </div>""",
865
- "tags": """<div class="filter-group">
866
- <label class="filter-label">Tags</label>
867
- <input type="text" id="filter-tags" class="filter-input">
868
- </div>""",
869
- }
870
-
871
- fields_html = "\n".join(
872
- [html for key, html in filter_fields.items() if (key in self.active_filters)]
873
- )
874
-
875
- return f"""
876
- <div class="filter-sidebar collapsed" id="filterSidebar">
877
- <h3 class="filter-title">🔍 Filters</h3>
878
- <div class="filter-grid">
879
- {fields_html}
880
- </div>
881
-
882
- <div class="filter-actions">
883
- <button class="filter-apply" onclick="applyFilters()">Apply</button>
884
- <button class="filter-clear" onclick="clearFilters()">Clear</button>
885
- </div>
886
-
887
- <div class="filter-results">Showing 0 endpoints</div>
888
- </div>
889
- """
890
-
891
- def create_endpoints_index(
892
- self, endpoints_by_app: dict[str, list[dict[str, Any]]], docs_dir: Path
893
- ) -> None:
894
- content = """# API Endpoints
895
-
896
- <!-- inject CSS and JS directly -->
897
- <link rel="stylesheet" href="../stylesheets/endpoints/variables.css">
898
- <link rel="stylesheet" href="../stylesheets/endpoints/base.css">
899
- <link rel="stylesheet" href="../stylesheets/endpoints/theme-toggle.css">
900
- <link rel="stylesheet" href="../stylesheets/endpoints/filter-section.css">
901
- <link rel="stylesheet" href="../stylesheets/endpoints/layout.css">
902
- <link rel="stylesheet" href="../stylesheets/endpoints/endpoints-grid.css">
903
- <link rel="stylesheet" href="../stylesheets/endpoints/badges.css">
904
- <link rel="stylesheet" href="../stylesheets/endpoints/endpoint-content.css">
905
- <link rel="stylesheet" href="../stylesheets/endpoints/tags.css">
906
- <link rel="stylesheet" href="../stylesheets/endpoints/sections.css">
907
- <link rel="stylesheet" href="../stylesheets/endpoints/stats.css">
908
- <link rel="stylesheet" href="../stylesheets/endpoints/loading.css">
909
- <link rel="stylesheet" href="../stylesheets/endpoints/animations.css">
910
- <link rel="stylesheet" href="../stylesheets/endpoints/responsive.css">
911
- <link rel="stylesheet" href="../stylesheets/endpoints/accessibility.css">
912
- <link rel="stylesheet" href="../stylesheets/endpoints/fixes.css">
913
- <script src="../javascripts/endpoints-filter.js" defer></script>
914
-
915
- <div class="main-content">
916
- """
917
- content += self.create_filter_section()
918
-
919
- for app_name, endpoints in endpoints_by_app.items():
920
- content += f'<h2>{app_name.title()}</h2>\n<div class="endpoints-grid">\n'
921
- for endpoint in endpoints:
922
- viewset = endpoint["viewset"]
923
- content += self.create_endpoint_card(endpoint, app_name, viewset)
924
- content += "</div>\n"
925
-
926
- content += "</div>\n"
927
- output_path = docs_dir / "endpoints" / "index.md"
928
- output_path.parent.mkdir(parents=True, exist_ok=True)
929
- with Path(output_path).open("w", encoding="utf-8") as f:
930
- f.write(content)
931
-
932
-
933
- def create_endpoints_index(
934
- endpoints_by_app: dict[str, list[dict[str, Any]]], docs_dir: Path
935
- ) -> None:
936
- generator = EndpointsIndexGenerator(
937
- active_filters=[
938
- "method",
939
- "path",
940
- "app",
941
- "search",
942
- ]
943
- )
944
- generator.create_endpoints_index(endpoints_by_app, docs_dir)
945
-
1
+ #!/usr/bin/env python3
2
+
3
+ import ast
4
+ import inspect
5
+ import json
6
+ from collections import defaultdict
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from drf_to_mkdoc.conf.settings import drf_to_mkdoc_settings
11
+ from drf_to_mkdoc.utils.common import (
12
+ create_safe_filename,
13
+ extract_app_from_operation_id,
14
+ extract_viewset_from_operation_id,
15
+ extract_viewset_name_from_operation_id,
16
+ format_method_badge,
17
+ get_custom_schema,
18
+ write_file,
19
+ )
20
+ from drf_to_mkdoc.utils.extractors.query_parameter_extractors import extract_query_parameters_from_view
21
+ from drf_to_mkdoc.utils.md_generators.query_parameters_generators import generate_query_parameters_md
22
+
23
+
24
+ def analyze_serializer_method_field_schema(serializer_class, field_name: str) -> dict:
25
+ """Analyze a SerializerMethodField to determine its actual return type schema."""
26
+ method_name = f"get_{field_name}"
27
+
28
+
29
+ # Strategy 2: Check type annotations
30
+ schema_from_annotations = _extract_schema_from_type_hints(serializer_class, method_name)
31
+ if schema_from_annotations:
32
+ return schema_from_annotations
33
+
34
+ # Strategy 3: Analyze method source code
35
+ schema_from_source = _analyze_method_source_code(serializer_class, method_name)
36
+ if schema_from_source:
37
+ return schema_from_source
38
+
39
+ # Strategy 4: Runtime analysis (sample execution)
40
+ schema_from_runtime = _analyze_method_runtime(serializer_class, method_name)
41
+ if schema_from_runtime:
42
+ return schema_from_runtime
43
+
44
+ # Fallback to string
45
+ return {"type": "string"}
46
+
47
+
48
+ def _extract_schema_from_decorator(serializer_class, method_name: str) -> dict:
49
+ """Extract schema from @extend_schema_field decorator if present."""
50
+ try:
51
+ method = getattr(serializer_class, method_name, None)
52
+ if not method:
53
+ return None
54
+
55
+ # Check if method has the decorator attribute (drf-spectacular)
56
+ if hasattr(method, '_spectacular_annotation'):
57
+ annotation = method._spectacular_annotation
58
+ # Handle OpenApiTypes
59
+ if hasattr(annotation, 'type'):
60
+ return {"type": annotation.type}
61
+ elif isinstance(annotation, dict):
62
+ return annotation
63
+
64
+ # Check for drf-yasg decorator
65
+ if hasattr(method, '_swagger_serializer_method'):
66
+ swagger_info = method._swagger_serializer_method
67
+ if hasattr(swagger_info, 'many') and hasattr(swagger_info, 'child'):
68
+ return {"type": "array", "items": {"type": "object"}}
69
+
70
+ except Exception:
71
+ pass
72
+ return None
73
+
74
+
75
+ def _extract_schema_from_type_hints(serializer_class, method_name: str) -> dict:
76
+ """Extract schema from method type annotations."""
77
+ try:
78
+ method = getattr(serializer_class, method_name, None)
79
+ if not method:
80
+ return None
81
+
82
+ signature = inspect.signature(method)
83
+ return_annotation = signature.return_annotation
84
+
85
+ if return_annotation and return_annotation != inspect.Signature.empty:
86
+ # Handle common type hints
87
+ if return_annotation == int:
88
+ return {"type": "integer"}
89
+ elif return_annotation == str:
90
+ return {"type": "string"}
91
+ elif return_annotation == bool:
92
+ return {"type": "boolean"}
93
+ elif return_annotation == float:
94
+ return {"type": "number"}
95
+ elif hasattr(return_annotation, '__origin__'):
96
+ # Handle generic types like List[str], Dict[str, Any]
97
+ origin = return_annotation.__origin__
98
+ if origin is list:
99
+ return {"type": "array", "items": {"type": "string"}}
100
+ elif origin is dict:
101
+ return {"type": "object"}
102
+
103
+ except Exception:
104
+ pass
105
+ return None
106
+
107
+
108
+ def _analyze_method_source_code(serializer_class, method_name: str) -> dict:
109
+ """Analyze method source code to infer return type."""
110
+ try:
111
+ method = getattr(serializer_class, method_name, None)
112
+ if not method:
113
+ return None
114
+
115
+ source = inspect.getsource(method)
116
+ tree = ast.parse(source)
117
+
118
+ # Find return statements and analyze them
119
+ return_analyzer = ReturnStatementAnalyzer()
120
+ return_analyzer.visit(tree)
121
+
122
+ return _infer_schema_from_return_patterns(return_analyzer.return_patterns)
123
+
124
+ except Exception:
125
+ pass
126
+ return None
127
+
128
+
129
+ def _analyze_method_runtime(serializer_class, method_name: str) -> dict:
130
+ """Analyze method by creating mock instances and examining return values."""
131
+ try:
132
+ # Create a basic mock object with common attributes
133
+ mock_obj = type('MockObj', (), {
134
+ 'id': 1, 'pk': 1, 'name': 'test', 'count': lambda: 5,
135
+ 'items': type('items', (), {'count': lambda: 3, 'all': lambda: []})()
136
+ })()
137
+
138
+ serializer_instance = serializer_class()
139
+ method = getattr(serializer_instance, method_name, None)
140
+
141
+ if not method:
142
+ return None
143
+
144
+ # Execute method with mock data
145
+ result = method(mock_obj)
146
+ return _infer_schema_from_value(result)
147
+
148
+ except Exception:
149
+ pass
150
+ return None
151
+
152
+
153
+ class ReturnStatementAnalyzer(ast.NodeVisitor):
154
+ """AST visitor to analyze return statements in method source code."""
155
+
156
+ def __init__(self):
157
+ self.return_patterns = []
158
+
159
+ def visit_Return(self, node):
160
+ """Visit return statements and extract patterns."""
161
+ if node.value:
162
+ pattern = self._analyze_return_value(node.value)
163
+ if pattern:
164
+ self.return_patterns.append(pattern)
165
+ self.generic_visit(node)
166
+
167
+ def _analyze_return_value(self, node) -> dict:
168
+ """Analyze different types of return value patterns."""
169
+ if isinstance(node, ast.Dict):
170
+ return self._analyze_dict_return(node)
171
+ elif isinstance(node, ast.List):
172
+ return self._analyze_list_return(node)
173
+ elif isinstance(node, ast.Constant):
174
+ return self._analyze_constant_return(node)
175
+ elif isinstance(node, ast.Call):
176
+ return self._analyze_method_call_return(node)
177
+ elif isinstance(node, ast.Attribute):
178
+ return self._analyze_attribute_return(node)
179
+ return None
180
+
181
+ def _analyze_dict_return(self, node) -> dict:
182
+ """Analyze dictionary return patterns."""
183
+ properties = {}
184
+ for key, value in zip(node.keys, node.values):
185
+ if isinstance(key, ast.Constant) and isinstance(key.value, str):
186
+ prop_schema = self._infer_value_type(value)
187
+ if prop_schema:
188
+ properties[key.value] = prop_schema
189
+
190
+ return {
191
+ "type": "object",
192
+ "properties": properties
193
+ }
194
+
195
+ def _analyze_list_return(self, node) -> dict:
196
+ """Analyze list return patterns."""
197
+ if node.elts:
198
+ # Analyze first element to determine array item type
199
+ first_element_schema = self._infer_value_type(node.elts[0])
200
+ return {
201
+ "type": "array",
202
+ "items": first_element_schema or {"type": "string"}
203
+ }
204
+ return {"type": "array", "items": {"type": "string"}}
205
+
206
+ def _analyze_constant_return(self, node) -> dict:
207
+ """Analyze constant return values."""
208
+ return self._python_type_to_schema(type(node.value))
209
+
210
+ def _analyze_method_call_return(self, node) -> dict:
211
+ """Analyze method call returns (like obj.count(), obj.items.all())."""
212
+ if isinstance(node.func, ast.Attribute):
213
+ method_name = node.func.attr
214
+
215
+ # Common Django ORM patterns
216
+ if method_name in ['count']:
217
+ return {"type": "integer"}
218
+ elif method_name in ['all', 'filter', 'exclude']:
219
+ return {"type": "array", "items": {"type": "object"}}
220
+ elif method_name in ['first', 'last', 'get']:
221
+ return {"type": "object"}
222
+ elif method_name in ['exists']:
223
+ return {"type": "boolean"}
224
+
225
+ return None
226
+
227
+ def _analyze_attribute_return(self, node) -> dict:
228
+ """Analyze attribute access returns (like obj.name, obj.id)."""
229
+ if isinstance(node, ast.Attribute):
230
+ attr_name = node.attr
231
+
232
+ # Common field name patterns
233
+ if attr_name in ['id', 'pk', 'count']:
234
+ return {"type": "integer"}
235
+ elif attr_name in ['name', 'title', 'description', 'slug']:
236
+ return {"type": "string"}
237
+ elif attr_name in ['is_active', 'is_published', 'enabled']:
238
+ return {"type": "boolean"}
239
+
240
+ return None
241
+
242
+ def _infer_value_type(self, node) -> dict:
243
+ """Infer schema type from AST node."""
244
+ if isinstance(node, ast.Constant):
245
+ return self._python_type_to_schema(type(node.value))
246
+ elif isinstance(node, ast.Call):
247
+ return self._analyze_method_call_return(node)
248
+ elif isinstance(node, ast.Attribute):
249
+ return self._analyze_attribute_return(node)
250
+ return {"type": "string"} # Default fallback
251
+
252
+ def _python_type_to_schema(self, python_type) -> dict:
253
+ """Convert Python type to OpenAPI schema."""
254
+ type_mapping = {
255
+ int: {"type": "integer"},
256
+ float: {"type": "number"},
257
+ str: {"type": "string"},
258
+ bool: {"type": "boolean"},
259
+ list: {"type": "array", "items": {"type": "string"}},
260
+ dict: {"type": "object"},
261
+ }
262
+ return type_mapping.get(python_type, {"type": "string"})
263
+
264
+
265
+ def _infer_schema_from_return_patterns(patterns: list) -> dict:
266
+ """Infer final schema from collected return patterns."""
267
+ if not patterns:
268
+ return None
269
+
270
+ # If all patterns are the same type, use that
271
+ if len(set(p.get("type") for p in patterns)) == 1:
272
+ # Merge object properties if multiple object returns
273
+ if patterns[0]["type"] == "object":
274
+ merged_properties = {}
275
+ for pattern in patterns:
276
+ merged_properties.update(pattern.get("properties", {}))
277
+ return {
278
+ "type": "object",
279
+ "properties": merged_properties
280
+ }
281
+ return patterns[0]
282
+
283
+ # Mixed types - could be union, but default to string for OpenAPI compatibility
284
+ return {"type": "string"}
285
+
286
+
287
+ def _infer_schema_from_value(value: Any) -> dict:
288
+ """Infer schema from actual runtime value."""
289
+ if isinstance(value, dict):
290
+ properties = {}
291
+ for key, val in value.items():
292
+ properties[str(key)] = _infer_schema_from_value(val)
293
+ return {
294
+ "type": "object",
295
+ "properties": properties
296
+ }
297
+ elif isinstance(value, list):
298
+ if value:
299
+ return {
300
+ "type": "array",
301
+ "items": _infer_schema_from_value(value[0])
302
+ }
303
+ return {"type": "array", "items": {"type": "string"}}
304
+ elif isinstance(value, (int, float, str, bool)):
305
+ return {
306
+ int: {"type": "integer"},
307
+ float: {"type": "number"},
308
+ str: {"type": "string"},
309
+ bool: {"type": "boolean"}
310
+ }[type(value)]
311
+ else:
312
+ return {"type": "string"}
313
+
314
+
315
+ def _get_serializer_class_from_schema_name(schema_name: str):
316
+ """Try to get the serializer class from schema name."""
317
+ try:
318
+ # Import Django and get all installed apps
319
+ import django
320
+ from django.apps import apps
321
+ from rest_framework import serializers
322
+
323
+ # Search through all apps for the serializer
324
+ for app in apps.get_app_configs():
325
+ app_module = app.module
326
+ try:
327
+ # Try to import serializers module from the app
328
+ serializers_module = __import__(f"{app_module.__name__}.serializers", fromlist=[''])
329
+
330
+ # Look for serializer class matching the schema name
331
+ for attr_name in dir(serializers_module):
332
+ attr = getattr(serializers_module, attr_name)
333
+ if (isinstance(attr, type) and
334
+ issubclass(attr, serializers.Serializer) and
335
+ attr.__name__.replace('Serializer', '') in schema_name):
336
+ return attr
337
+ except ImportError:
338
+ continue
339
+
340
+ except Exception:
341
+ pass
342
+ return None
343
+
344
+
345
+ def schema_to_example_json(schema: dict, components: dict, for_response: bool = True):
346
+ """Recursively generate a JSON example, respecting readOnly/writeOnly based on context."""
347
+ # Ensure schema is a dictionary
348
+ if not isinstance(schema, dict):
349
+ return None
350
+
351
+ schema = _resolve_schema_reference(schema, components)
352
+ schema = _handle_all_of_schema(schema, components, for_response)
353
+
354
+ # Handle explicit values first
355
+ explicit_value = _get_explicit_value(schema)
356
+ if explicit_value is not None:
357
+ return explicit_value
358
+
359
+ # ENHANCED: Check if this looks like an unanalyzed SerializerMethodField
360
+ schema = _enhance_method_field_schema(schema, components)
361
+
362
+ return _generate_example_by_type(schema, components, for_response)
363
+
364
+
365
+ def _enhance_method_field_schema(schema: dict, components: dict) -> dict:
366
+ """Enhance schema by analyzing SerializerMethodField types."""
367
+ if not isinstance(schema, dict) or 'properties' not in schema:
368
+ return schema
369
+
370
+ # Try to get serializer class from schema title or other hints
371
+ schema_title = schema.get('title', '')
372
+ serializer_class = _get_serializer_class_from_schema_name(schema_title)
373
+
374
+ if not serializer_class:
375
+ return schema
376
+
377
+ enhanced_properties = {}
378
+ for prop_name, prop_schema in schema['properties'].items():
379
+ # Check if this looks like an unanalyzed SerializerMethodField
380
+ if (isinstance(prop_schema, dict) and
381
+ prop_schema.get('type') == 'string' and
382
+ not prop_schema.get('enum') and
383
+ not prop_schema.get('format') and
384
+ not prop_schema.get('example')):
385
+
386
+ # Try to analyze the method field
387
+ analyzed_schema = analyze_serializer_method_field_schema(serializer_class, prop_name)
388
+ enhanced_properties[prop_name] = analyzed_schema
389
+ else:
390
+ enhanced_properties[prop_name] = prop_schema
391
+
392
+ enhanced_schema = schema.copy()
393
+ enhanced_schema['properties'] = enhanced_properties
394
+ return enhanced_schema
395
+
396
+
397
+ def _resolve_schema_reference(schema: dict, components: dict) -> dict:
398
+ """Resolve $ref references in schema."""
399
+ if "$ref" in schema:
400
+ ref = schema["$ref"]
401
+ return components.get("schemas", {}).get(ref.split("/")[-1], {})
402
+ return schema
403
+
404
+
405
+ def _handle_all_of_schema(schema: dict, components: dict, _for_response: bool) -> dict:
406
+ """Handle allOf schema composition."""
407
+ if "allOf" not in schema:
408
+ return schema
409
+
410
+ merged = {}
411
+ for part in schema["allOf"]:
412
+ # Resolve the part schema first
413
+ resolved_part = _resolve_schema_reference(part, components)
414
+ if isinstance(resolved_part, dict):
415
+ merged.update(resolved_part)
416
+ else:
417
+ # If we can't resolve it, skip this part
418
+ continue
419
+
420
+ # Merge with the original schema properties (like readOnly)
421
+ if merged:
422
+ result = merged.copy()
423
+ # Add any properties from the original schema that aren't in allOf
424
+ for key, value in schema.items():
425
+ if key != "allOf":
426
+ result[key] = value
427
+ return result
428
+
429
+ return schema
430
+
431
+
432
+ def _get_explicit_value(schema: dict):
433
+ """Get explicit value from schema (enum, example, or default)."""
434
+ # Ensure schema is a dictionary
435
+ if not isinstance(schema, dict):
436
+ return None
437
+
438
+ if "enum" in schema:
439
+ return schema["enum"][0]
440
+ if "example" in schema:
441
+ return schema["example"]
442
+ if "default" in schema:
443
+ return schema["default"]
444
+ return None
445
+
446
+
447
+ def _generate_example_by_type(schema: dict, components: dict, for_response: bool):
448
+ """Generate example based on schema type."""
449
+ schema_type = schema.get("type", "object")
450
+
451
+ if schema_type == "object":
452
+ return _generate_object_example(schema, components, for_response)
453
+ if schema_type == "array":
454
+ return _generate_array_example(schema, components, for_response)
455
+ return _generate_primitive_example(schema_type)
456
+
457
+
458
+ def _generate_object_example(schema: dict, components: dict, for_response: bool) -> dict:
459
+ """Generate example for object type schema."""
460
+ props = schema.get("properties", {})
461
+ result = {}
462
+
463
+ for prop_name, prop_schema in props.items():
464
+ if _should_skip_property(prop_schema, for_response):
465
+ continue
466
+ result[prop_name] = schema_to_example_json(prop_schema, components, for_response)
467
+
468
+ return result
469
+
470
+
471
+ def _should_skip_property(prop_schema: dict, for_response: bool) -> bool:
472
+ """
473
+ Args:
474
+ prop_schema: Property schema containing readOnly/writeOnly flags
475
+ for_response: True for response example, False for request example
476
+
477
+ Returns:
478
+ True if property should be skipped, False otherwise
479
+ """
480
+ is_write_only = prop_schema.get("writeOnly", False)
481
+ is_read_only = prop_schema.get("readOnly", False)
482
+
483
+ if for_response:
484
+ return is_write_only
485
+ return is_read_only
486
+
487
+
488
+ def _generate_array_example(schema: dict, components: dict, for_response: bool) -> list:
489
+ """Generate example for array type schema."""
490
+ items = schema.get("items", {})
491
+ return [schema_to_example_json(items, components, for_response)]
492
+
493
+
494
+ def _generate_primitive_example(schema_type: str):
495
+ """Generate example for primitive types."""
496
+ type_examples = {"integer": 0, "number": 0.0, "boolean": True, "string": "string"}
497
+ return type_examples.get(schema_type)
498
+
499
+
500
+ def format_schema_as_json_example(
501
+ schema_ref: str, components: dict[str, Any], for_response: bool = True
502
+ ) -> str:
503
+ """
504
+ Format a schema as a JSON example, resolving $ref and respecting readOnly/writeOnly flags.
505
+ """
506
+ if not schema_ref.startswith("#/components/schemas/"):
507
+ return f"Invalid $ref: `{schema_ref}`"
508
+
509
+ schema_name = schema_ref.split("/")[-1]
510
+ schema = components.get("schemas", {}).get(schema_name)
511
+
512
+ if not schema:
513
+ return f"**Error**: Schema `{schema_name}` not found in components."
514
+
515
+ description = schema.get("description", "")
516
+ example_json = schema_to_example_json(schema, components, for_response=for_response)
517
+
518
+ result = ""
519
+ if description:
520
+ result += f"{description}\n\n"
521
+
522
+ result += "```json\n"
523
+ result += json.dumps(example_json, indent=2)
524
+ result += "\n```\n"
525
+
526
+ return result
527
+
528
+
529
+ def create_endpoint_page(
530
+ path: str, method: str, endpoint_data: dict[str, Any], components: dict[str, Any]
531
+ ) -> str:
532
+ """Create a documentation page for a single API endpoint."""
533
+ operation_id = endpoint_data.get("operationId", "")
534
+ summary = endpoint_data.get("summary", "")
535
+ description = endpoint_data.get("description", "")
536
+ parameters = endpoint_data.get("parameters", [])
537
+ request_body = endpoint_data.get("requestBody", {})
538
+ responses = endpoint_data.get("responses", {})
539
+
540
+ content = _create_endpoint_header(path, method, operation_id, summary, description)
541
+ content += _add_path_parameters(parameters)
542
+ content += _add_query_parameters(method, path, operation_id)
543
+ content += _add_request_body(request_body, components)
544
+ content += _add_responses(responses, components)
545
+
546
+ return content
547
+
548
+
549
+ def _create_endpoint_header(
550
+ path: str, method: str, operation_id: str, summary: str, description: str
551
+ ) -> str:
552
+ """Create the header section of the endpoint documentation."""
553
+ content = f"# {method.upper()} {path}\n\n"
554
+ content += f"{format_method_badge(method)} `{path}`\n\n"
555
+ content += f"**View class:** {extract_viewset_name_from_operation_id(operation_id)}\n\n"
556
+
557
+ if summary:
558
+ content += f"## Overview\n\n{summary}\n\n"
559
+ if operation_id:
560
+ content += f"**Operation ID:** `{operation_id}`\n\n"
561
+ if description:
562
+ content += f"{description}\n\n"
563
+
564
+ return content
565
+
566
+
567
+ def _add_path_parameters(parameters: list[dict]) -> str:
568
+ """Add path parameters section to the documentation."""
569
+ path_params = [p for p in parameters if p.get("in") == "path"]
570
+ if not path_params:
571
+ return ""
572
+
573
+ content = "## Path Parameters\n\n"
574
+ content += "| Name | Type | Required | Description |\n"
575
+ content += "|------|------|----------|-------------|\n"
576
+
577
+ for param in path_params:
578
+ name = param.get("name", "")
579
+ param_type = param.get("schema", {}).get("type", "string")
580
+ required = "Yes" if param.get("required", False) else "No"
581
+ desc = param.get("description", "")
582
+ content += f"| `{name}` | `{param_type}` | {required} | {desc} |\n"
583
+
584
+ content += "\n"
585
+ return content
586
+
587
+
588
+ def _add_query_parameters(method: str, path: str, operation_id: str) -> str:
589
+ """Add query parameters section for list endpoints."""
590
+ is_list_endpoint = _is_list_endpoint(method, path, operation_id)
591
+ if not is_list_endpoint:
592
+ return ""
593
+
594
+ query_params = extract_query_parameters_from_view(operation_id)
595
+ _add_custom_parameters(operation_id, query_params)
596
+
597
+ query_params_content = generate_query_parameters_md(query_params)
598
+ if query_params_content and not query_params_content.startswith("**Error:**"):
599
+ return "## Query Parameters\n\n" + query_params_content
600
+
601
+ return ""
602
+
603
+
604
+ def _is_list_endpoint(method: str, path: str, operation_id: str) -> bool:
605
+ """Check if the endpoint is a list endpoint that should have query parameters."""
606
+ return (
607
+ method.upper() == "GET"
608
+ and operation_id
609
+ and ("list" in operation_id or not ("{id}" in path or "{pk}" in path))
610
+ )
611
+
612
+
613
+ def _add_custom_parameters(operation_id: str, query_params: dict) -> None:
614
+ """Add custom parameters to query parameters dictionary."""
615
+ custom_parameters = get_custom_schema().get(operation_id, {}).get("parameters", [])
616
+ for parameter in custom_parameters:
617
+ queryparam_type = parameter["queryparam_type"]
618
+ if queryparam_type not in query_params:
619
+ query_params[queryparam_type] = []
620
+ query_params[queryparam_type].append(parameter["name"])
621
+
622
+
623
+ def _add_request_body(request_body: dict, components: dict[str, Any]) -> str:
624
+ """Add request body section to the documentation."""
625
+ if not request_body:
626
+ return ""
627
+
628
+ content = "## Request Body\n\n"
629
+ req_schema = request_body.get("content", {}).get("application/json", {}).get("schema")
630
+
631
+ if req_schema and "$ref" in req_schema:
632
+ content += (
633
+ format_schema_as_json_example(req_schema["$ref"], components, for_response=False)
634
+ + "\n"
635
+ )
636
+
637
+ return content
638
+
639
+
640
+ def _add_responses(responses: dict, components: dict[str, Any]) -> str:
641
+ """Add responses section to the documentation."""
642
+ if not responses:
643
+ return ""
644
+
645
+ content = "## Responses\n\n"
646
+ for status_code, response_data in responses.items():
647
+ content += _format_single_response(status_code, response_data, components)
648
+
649
+ return content
650
+
651
+
652
+ def _format_single_response(
653
+ status_code: str, response_data: dict, components: dict[str, Any]
654
+ ) -> str:
655
+ """Format a single response entry."""
656
+ content = f"### {status_code}\n\n"
657
+
658
+ if desc := response_data.get("description", ""):
659
+ content += f"{desc}\n\n"
660
+
661
+ resp_schema = response_data.get("content", {}).get("application/json", {}).get("schema", {})
662
+
663
+ content += _format_response_schema(resp_schema, components)
664
+ return content
665
+
666
+
667
+ def _format_response_schema(resp_schema: dict, components: dict[str, Any]) -> str:
668
+ """Format the response schema as JSON example."""
669
+ if "$ref" in resp_schema:
670
+ return (
671
+ format_schema_as_json_example(resp_schema["$ref"], components, for_response=True)
672
+ + "\n"
673
+ )
674
+ if resp_schema.get("type") == "array" and "$ref" in resp_schema.get("items", {}):
675
+ item_ref = resp_schema["items"]["$ref"]
676
+ return format_schema_as_json_example(item_ref, components, for_response=True) + "\n"
677
+ content = "```json\n"
678
+ content += json.dumps(schema_to_example_json(resp_schema, components), indent=2)
679
+ content += "\n```\n"
680
+ return content
681
+
682
+
683
+ def parse_endpoints_from_schema(paths: dict[str, Any]) -> dict[str, list[dict[str, Any]]]:
684
+ """Parse endpoints from OpenAPI schema and organize by app"""
685
+ endpoints_by_app = defaultdict(list)
686
+ django_apps = set(drf_to_mkdoc_settings.DJANGO_APPS)
687
+
688
+ for path, methods in paths.items():
689
+ app_name = extract_app_from_operation_id(next(iter(methods.values()))["operationId"])
690
+ if app_name not in django_apps:
691
+ continue
692
+
693
+ for method, endpoint_data in methods.items():
694
+ if method.lower() not in ["get", "post", "put", "patch", "delete"]:
695
+ continue
696
+
697
+ operation_id = endpoint_data.get("operationId", "")
698
+ filename = create_safe_filename(path, method)
699
+
700
+ endpoint_info = {
701
+ "path": path,
702
+ "method": method.upper(),
703
+ "viewset": extract_viewset_name_from_operation_id(operation_id),
704
+ "operation_id": operation_id,
705
+ "filename": filename,
706
+ "data": endpoint_data,
707
+ }
708
+
709
+ endpoints_by_app[app_name].append(endpoint_info)
710
+
711
+ return endpoints_by_app
712
+
713
+
714
+ def generate_endpoint_files(
715
+ endpoints_by_app: dict[str, list[dict[str, Any]]], components: dict[str, Any]
716
+ ) -> int:
717
+ """Generate individual endpoint documentation files"""
718
+ total_endpoints = 0
719
+
720
+ for app_name, endpoints in endpoints_by_app.items():
721
+ for endpoint in endpoints:
722
+ content = create_endpoint_page(
723
+ endpoint["path"], endpoint["method"], endpoint["data"], components
724
+ )
725
+
726
+ file_path = (
727
+ f"endpoints/{app_name}/{endpoint['viewset'].lower()}/{endpoint['filename']}"
728
+ )
729
+ write_file(file_path, content)
730
+ total_endpoints += 1
731
+
732
+ return total_endpoints
733
+
734
+
735
+ class EndpointsIndexGenerator:
736
+ def __init__(self, active_filters: list[str] | None = None):
737
+ self.active_filters = set(
738
+ active_filters
739
+ or [
740
+ "method",
741
+ "path",
742
+ "app",
743
+ "models",
744
+ "auth",
745
+ "roles",
746
+ "content_type",
747
+ "params",
748
+ "schema",
749
+ "pagination",
750
+ "ordering",
751
+ "search",
752
+ "tags",
753
+ ]
754
+ )
755
+
756
+ def create_endpoint_card(
757
+ self, endpoint: dict[str, Any], app_name: str, viewset_name: str
758
+ ) -> str:
759
+ method = endpoint["method"]
760
+ path = endpoint["path"]
761
+ filename = endpoint["filename"]
762
+ view_class = extract_viewset_from_operation_id(endpoint["operation_id"])
763
+
764
+ link_url = f"{app_name}/{viewset_name.lower()}/{filename}".replace(".md", "/index.html")
765
+ data_attrs = f"""
766
+ data-method="{method.lower()}"
767
+ data-path="{path.lower()}"
768
+ data-app="{app_name.lower()}"
769
+ data-auth="{str(endpoint.get("auth_required", False)).lower()}"
770
+ data-pagination="{str(endpoint.get("pagination_support", False)).lower()}"
771
+ data-search="{str(bool(getattr(view_class, "search_fields", []))).lower()}"
772
+ data-ordering="{str(endpoint.get("ordering_support", False)).lower()}"
773
+ data-models="{" ".join(endpoint.get("related_models", [])).lower()}"
774
+ data-roles="{" ".join(endpoint.get("permission_roles", [])).lower()}"
775
+ data-content-type="{endpoint.get("content_type", "").lower()}"
776
+ data-tags="{" ".join(endpoint.get("tags", [])).lower()}"
777
+ data-schema="{" ".join(endpoint.get("schema_fields", [])).lower()}"
778
+ data-params="{" ".join(endpoint.get("query_parameters", [])).lower()}"
779
+ """.strip()
780
+
781
+ return f"""
782
+ <a href="{link_url}" class="endpoint-card" {data_attrs}>
783
+ <span class="method-badge method-{method.lower()}">{method}</span>
784
+ <span class="endpoint-path">{path}</span>
785
+ </a>
786
+ """
787
+
788
+ def create_filter_section(self) -> str:
789
+ filter_fields = {
790
+ "method": """<div class="filter-group">
791
+ <label class="filter-label">HTTP Method</label>
792
+ <select id="filter-method" class="filter-select">
793
+ <option value="">All</option>
794
+ <option value="get">GET</option>
795
+ <option value="post">POST</option>
796
+ <option value="put">PUT</option>
797
+ <option value="patch">PATCH</option>
798
+ <option value="delete">DELETE</option>
799
+ </select>
800
+ </div>""",
801
+ "path": """<div class="filter-group">
802
+ <label class="filter-label">Endpoint Path</label>
803
+ <input type="text" id="filter-path" class="filter-input"
804
+ placeholder="Search path...">
805
+ </div>""",
806
+ "app": """<div class="filter-group">
807
+ <label class="filter-label">Django App</label>
808
+ <select id="filter-app" class="filter-select">
809
+ <option value="">All</option>
810
+ <!-- Dynamically filled -->
811
+ </select>
812
+ </div>""",
813
+ "models": """<div class="filter-group">
814
+ <label class="filter-label">Related Models</label>
815
+ <input type="text" id="filter-models" class="filter-input">
816
+ </div>""",
817
+ "auth": """<div class="filter-group">
818
+ <label class="filter-label">Authentication Required</label>
819
+ <select id="filter-auth" class="filter-select">
820
+ <option value="">All</option>
821
+ <option value="true">Yes</option>
822
+ <option value="false">No</option>
823
+ </select>
824
+ </div>""",
825
+ "roles": """<div class="filter-group">
826
+ <label class="filter-label">Permission Roles</label>
827
+ <input type="text" id="filter-roles" class="filter-input">
828
+ </div>""",
829
+ "content_type": """<div class="filter-group">
830
+ <label class="filter-label">Content Type</label>
831
+ <input type="text" id="filter-content-type" class="filter-input">
832
+ </div>""",
833
+ "params": """<div class="filter-group">
834
+ <label class="filter-label">Query Parameters</label>
835
+ <input type="text" id="filter-params" class="filter-input">
836
+ </div>""",
837
+ "schema": """<div class="filter-group">
838
+ <label class="filter-label">Schema Fields</label>
839
+ <input type="text" id="filter-schema" class="filter-input">
840
+ </div>""",
841
+ "pagination": """<div class="filter-group">
842
+ <label class="filter-label">Pagination Support</label>
843
+ <select id="filter-pagination" class="filter-select">
844
+ <option value="">All</option>
845
+ <option value="true">Yes</option>
846
+ <option value="false">No</option>
847
+ </select>
848
+ </div>""",
849
+ "ordering": """<div class="filter-group">
850
+ <label class="filter-label">Ordering Support</label>
851
+ <select id="filter-ordering" class="filter-select">
852
+ <option value="">All</option>
853
+ <option value="true">Yes</option>
854
+ <option value="false">No</option>
855
+ </select>
856
+ </div>""",
857
+ "search": """<div class="filter-group">
858
+ <label class="filter-label">Search Support</label>
859
+ <select id="filter-search" class="filter-select">
860
+ <option value="">All</option>
861
+ <option value="true">Yes</option>
862
+ <option value="false">No</option>
863
+ </select>
864
+ </div>""",
865
+ "tags": """<div class="filter-group">
866
+ <label class="filter-label">Tags</label>
867
+ <input type="text" id="filter-tags" class="filter-input">
868
+ </div>""",
869
+ }
870
+
871
+ fields_html = "\n".join(
872
+ [html for key, html in filter_fields.items() if (key in self.active_filters)]
873
+ )
874
+
875
+ return f"""
876
+ <div class="filter-sidebar collapsed" id="filterSidebar">
877
+ <h3 class="filter-title">🔍 Filters</h3>
878
+ <div class="filter-grid">
879
+ {fields_html}
880
+ </div>
881
+
882
+ <div class="filter-actions">
883
+ <button class="filter-apply" onclick="applyFilters()">Apply</button>
884
+ <button class="filter-clear" onclick="clearFilters()">Clear</button>
885
+ </div>
886
+
887
+ <div class="filter-results">Showing 0 endpoints</div>
888
+ </div>
889
+ """
890
+
891
+ def create_endpoints_index(
892
+ self, endpoints_by_app: dict[str, list[dict[str, Any]]], docs_dir: Path
893
+ ) -> None:
894
+ content = """# API Endpoints
895
+
896
+ <!-- inject CSS and JS directly -->
897
+ <link rel="stylesheet" href="../stylesheets/endpoints/variables.css">
898
+ <link rel="stylesheet" href="../stylesheets/endpoints/base.css">
899
+ <link rel="stylesheet" href="../stylesheets/endpoints/theme-toggle.css">
900
+ <link rel="stylesheet" href="../stylesheets/endpoints/filter-section.css">
901
+ <link rel="stylesheet" href="../stylesheets/endpoints/layout.css">
902
+ <link rel="stylesheet" href="../stylesheets/endpoints/endpoints-grid.css">
903
+ <link rel="stylesheet" href="../stylesheets/endpoints/badges.css">
904
+ <link rel="stylesheet" href="../stylesheets/endpoints/endpoint-content.css">
905
+ <link rel="stylesheet" href="../stylesheets/endpoints/tags.css">
906
+ <link rel="stylesheet" href="../stylesheets/endpoints/sections.css">
907
+ <link rel="stylesheet" href="../stylesheets/endpoints/stats.css">
908
+ <link rel="stylesheet" href="../stylesheets/endpoints/loading.css">
909
+ <link rel="stylesheet" href="../stylesheets/endpoints/animations.css">
910
+ <link rel="stylesheet" href="../stylesheets/endpoints/responsive.css">
911
+ <link rel="stylesheet" href="../stylesheets/endpoints/accessibility.css">
912
+ <link rel="stylesheet" href="../stylesheets/endpoints/fixes.css">
913
+ <script src="../javascripts/endpoints-filter.js" defer></script>
914
+
915
+ <div class="main-content">
916
+ """
917
+ content += self.create_filter_section()
918
+
919
+ for app_name, endpoints in endpoints_by_app.items():
920
+ content += f'<h2>{app_name.title()}</h2>\n<div class="endpoints-grid">\n'
921
+ for endpoint in endpoints:
922
+ viewset = endpoint["viewset"]
923
+ content += self.create_endpoint_card(endpoint, app_name, viewset)
924
+ content += "</div>\n"
925
+
926
+ content += "</div>\n"
927
+ output_path = docs_dir / "endpoints" / "index.md"
928
+ output_path.parent.mkdir(parents=True, exist_ok=True)
929
+ with Path(output_path).open("w", encoding="utf-8") as f:
930
+ f.write(content)
931
+
932
+
933
+ def create_endpoints_index(
934
+ endpoints_by_app: dict[str, list[dict[str, Any]]], docs_dir: Path
935
+ ) -> None:
936
+ generator = EndpointsIndexGenerator(
937
+ active_filters=[
938
+ "method",
939
+ "path",
940
+ "app",
941
+ "search",
942
+ ]
943
+ )
944
+ generator.create_endpoints_index(endpoints_by_app, docs_dir)
945
+