drf-to-mkdoc 0.1.0__py3-none-any.whl → 0.1.3__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.
- drf_to_mkdoc/__init__.py +6 -6
- drf_to_mkdoc/apps.py +14 -14
- drf_to_mkdoc/conf/settings.py +44 -44
- drf_to_mkdoc/management/commands/build_docs.py +76 -76
- drf_to_mkdoc/management/commands/generate_doc_json.py +512 -512
- drf_to_mkdoc/management/commands/generate_docs.py +138 -138
- drf_to_mkdoc/management/commands/generate_model_docs.py +327 -327
- drf_to_mkdoc/management/commands/update_doc_schema.py +53 -53
- drf_to_mkdoc/utils/__init__.py +3 -3
- drf_to_mkdoc/utils/endpoint_generator.py +945 -945
- drf_to_mkdoc/utils/extractors/__init__.py +3 -3
- drf_to_mkdoc/utils/extractors/query_parameter_extractors.py +229 -229
- drf_to_mkdoc/utils/md_generators/query_parameters_generators.py +72 -72
- drf_to_mkdoc/utils/model_generator.py +269 -269
- {drf_to_mkdoc-0.1.0.dist-info → drf_to_mkdoc-0.1.3.dist-info}/METADATA +247 -247
- drf_to_mkdoc-0.1.3.dist-info/RECORD +25 -0
- {drf_to_mkdoc-0.1.0.dist-info → drf_to_mkdoc-0.1.3.dist-info}/licenses/LICENSE +21 -21
- drf_to_mkdoc-0.1.0.dist-info/RECORD +0 -25
- {drf_to_mkdoc-0.1.0.dist-info → drf_to_mkdoc-0.1.3.dist-info}/WHEEL +0 -0
- {drf_to_mkdoc-0.1.0.dist-info → drf_to_mkdoc-0.1.3.dist-info}/top_level.txt +0 -0
|
@@ -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
|
+
|