initial
This commit is contained in:
82
.gitignore
vendored
Normal file
82
.gitignore
vendored
Normal file
@@ -0,0 +1,82 @@
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
pip-wheel-metadata/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-2026 KBS GmbH
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
4
MANIFEST.in
Normal file
4
MANIFEST.in
Normal file
@@ -0,0 +1,4 @@
|
||||
include README.md
|
||||
include LICENSE
|
||||
include pyproject.toml
|
||||
recursive-include django_enhanced_apidocs *.py
|
||||
11
__init__.py
Normal file
11
__init__.py
Normal file
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
Django Enhanced API Docs
|
||||
Enhanced API documentation for Django REST Framework using drf-spectacular.
|
||||
"""
|
||||
|
||||
__version__ = '0.1.0'
|
||||
__author__ = 'KBS GmbH'
|
||||
__email__ = 'holger.sielaff@kbs-gmbh.de'
|
||||
__license__ = 'MIT'
|
||||
|
||||
default_app_config = 'django_enhanced_apidocs.apps.DjangoEnhancedApidocsConfig'
|
||||
7
apps.py
Normal file
7
apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class DjangoEnhancedApidocsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'django_enhanced_apidocs'
|
||||
verbose_name = 'Enhanced API Documentation'
|
||||
93
conf.py
Normal file
93
conf.py
Normal file
@@ -0,0 +1,93 @@
|
||||
def get_spectacular_settings(config):
|
||||
"""
|
||||
Generate SPECTACULAR_SETTINGS dict based on environment configuration.
|
||||
|
||||
Args:
|
||||
config: Dictionary with environment configuration from dotenv
|
||||
|
||||
Returns:
|
||||
Dictionary with drf-spectacular settings
|
||||
"""
|
||||
return {
|
||||
'TITLE': 'Products API',
|
||||
'DESCRIPTION': 'API for products, versions and permissions',
|
||||
'VERSION': '1.0.0',
|
||||
'SERVE_INCLUDE_SCHEMA': False,
|
||||
'CONTACT': {
|
||||
'email': 'holger.sielaff@kbs-gmbh.de',
|
||||
},
|
||||
'EXTERNAL_DOCS': {
|
||||
'description': 'KBS GmbH',
|
||||
'url': 'https://www.brainson.de',
|
||||
},
|
||||
'SERVERS': [
|
||||
{'url': config.get('LOCAL_SWAGGER_HOST', 'http://localhost:8000'), 'description': 'The server'},
|
||||
],
|
||||
'SCHEMA_PATH_PREFIX': '/api/v1/',
|
||||
'COMPONENT_SPLIT_REQUEST': True,
|
||||
'COMPONENT_NO_READ_ONLY_REQUIRED': True,
|
||||
'ENUM_NAME_OVERRIDES': {},
|
||||
'PREPROCESSING_HOOKS': [],
|
||||
'POSTPROCESSING_HOOKS': ['django_enhanced_apidocs.schema.postprocess_schema_enhancements'],
|
||||
'SWAGGER_UI_SETTINGS': {
|
||||
'deepLinking': True,
|
||||
'persistAuthorization': True,
|
||||
'displayOperationId': False,
|
||||
'docExpansion': 'none',
|
||||
'filter': True,
|
||||
'showCommonExtensions': True,
|
||||
'showExtensions': True,
|
||||
'displayRequestDuration': True,
|
||||
'syntaxHighlight.theme': 'monokai',
|
||||
'tryItOutEnabled': True,
|
||||
},
|
||||
'REDOC_UI_SETTINGS': {
|
||||
'hideDownloadButton': False,
|
||||
'expandResponses': '200,201',
|
||||
'pathInMiddlePanel': True,
|
||||
'nativeScrollbars': True,
|
||||
'theme': {
|
||||
'spacing': {
|
||||
'sectionVertical': 20,
|
||||
},
|
||||
'typography': {
|
||||
'code': {
|
||||
'wrap': True,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
'SCHEMA_COERCE_METHOD_NAMES': {},
|
||||
'APPEND_COMPONENTS': {
|
||||
'securitySchemes': {
|
||||
'basicAuth': {
|
||||
'type': 'http',
|
||||
'scheme': 'basic'
|
||||
},
|
||||
'cookieAuth': {
|
||||
'type': 'apiKey',
|
||||
'in': 'cookie',
|
||||
'name': 'sessionid'
|
||||
}
|
||||
}
|
||||
},
|
||||
'SECURITY': [{'basicAuth': []}, {'cookieAuth': []}],
|
||||
'TAGS': [
|
||||
{'name': 'Products',
|
||||
'description': 'Product management endpoints including versions, permissions, registrations, and file associations',
|
||||
'viewsets': [
|
||||
'ProductViewSet',
|
||||
'ProductVersionViewSet',
|
||||
'ProductPermissionViewSet',
|
||||
'ProductRegistrationViewSet',
|
||||
'ProductVersionFileViewSet', ]
|
||||
},
|
||||
{'name': 'Users', 'description': 'User management endpoints', 'viewsets': ['UserViewSet', ]},
|
||||
{'name': 'Categories', 'description': 'Category management endpoints', 'viewsets': ['CategoryViewSet', ]},
|
||||
{'name': 'Tags', 'description': 'Tag management endpoints', 'viewsets': ['TagViewSet', ]},
|
||||
{'name': 'Files', 'description': 'File management endpoints', 'viewsets': ['FileViewSet', ]},
|
||||
{'name': 'Support Tickets', 'description': 'Support ticket endpoints', 'viewsets': ['SupportTicketViewSet', 'TicketMessageViewSet']},
|
||||
{'name': 'Customer Registration', 'description': 'Customer registration endpoints (no auth required)', },
|
||||
{'name': 'Groups', 'description': 'Group/Project management endpoints including group/project types', 'viewsets': ['GroupViewSet', 'ProductGroupTypeViewSet']},
|
||||
],
|
||||
}
|
||||
50
pyproject.toml
Normal file
50
pyproject.toml
Normal file
@@ -0,0 +1,50 @@
|
||||
[build-system]
|
||||
requires = ["setuptools>=61.0", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "django-enhanced-apidocs"
|
||||
version = "0.1.0"
|
||||
description = "Enhanced API documentation for Django REST Framework with drf-spectacular"
|
||||
readme = "README.md"
|
||||
authors = [
|
||||
{name = "KBS GmbH", email = "holger.sielaff@kbs-gmbh.de"}
|
||||
]
|
||||
license = {text = "MIT"}
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"Environment :: Web Environment",
|
||||
"Framework :: Django",
|
||||
"Framework :: Django :: 4.0",
|
||||
"Framework :: Django :: 5.0",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
]
|
||||
keywords = ["django", "rest", "api", "documentation", "swagger", "openapi", "spectacular"]
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"Django>=4.0",
|
||||
"djangorestframework>=3.14",
|
||||
"drf-spectacular>=0.26",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/yourusername/django-enhanced-apidocs"
|
||||
Documentation = "https://github.com/yourusername/django-enhanced-apidocs#readme"
|
||||
Repository = "https://github.com/yourusername/django-enhanced-apidocs"
|
||||
Issues = "https://github.com/yourusername/django-enhanced-apidocs/issues"
|
||||
|
||||
[tool.setuptools]
|
||||
packages = ["django_enhanced_apidocs"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
django_enhanced_apidocs = ["*.py"]
|
||||
427
schema.py
Normal file
427
schema.py
Normal file
@@ -0,0 +1,427 @@
|
||||
from drf_spectacular.openapi import AutoSchema
|
||||
import logging
|
||||
import json
|
||||
from drf_spectacular.utils import OpenApiParameter
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class CustomAutoSchema(AutoSchema):
|
||||
"""
|
||||
Custom schema generator for drf-spectacular.
|
||||
Provides detailed parameter documentation and custom tags.
|
||||
"""
|
||||
|
||||
def get_tags(self):
|
||||
"""Map viewset class names to custom tags for better organization"""
|
||||
viewset_class_name = self.view.__class__.__name__
|
||||
spectacular_settings = getattr(settings, 'SPECTACULAR_SETTINGS', {})
|
||||
for tag in spectacular_settings.get('TAGS', []):
|
||||
if viewset_class_name in tag.get('viewsets', []):
|
||||
return [tag['name']]
|
||||
|
||||
return super().get_tags()
|
||||
|
||||
|
||||
def postprocess_schema_enhancements(result, generator, request, public):
|
||||
"""
|
||||
Postprocessing hook to enhance the OpenAPI schema.
|
||||
Adds detailed parameter descriptions, response codes, and pagination examples.
|
||||
"""
|
||||
|
||||
# Build a mapping of paths to search_fields from the generator
|
||||
path_search_fields = {}
|
||||
for endpoint_path, path_regex, method, callback in generator.endpoints:
|
||||
if hasattr(callback, 'cls'):
|
||||
view_class = callback.cls
|
||||
if hasattr(view_class, 'search_fields') and view_class.search_fields:
|
||||
path_search_fields[endpoint_path] = view_class.search_fields
|
||||
|
||||
# Get schemas for reference resolution
|
||||
schemas_components = result.get('components', {}).get('schemas', {})
|
||||
|
||||
for path, path_item in result.get('paths', {}).items():
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() not in ['get', 'post', 'put', 'patch', 'delete']:
|
||||
continue
|
||||
|
||||
# Enhance GET parameters
|
||||
if method.lower() == 'get':
|
||||
if '{' not in path:
|
||||
# List endpoints
|
||||
search_fields = path_search_fields.get(path, [])
|
||||
_enhance_list_parameters(operation, path, search_fields)
|
||||
else:
|
||||
# Detail endpoints - add lang parameter
|
||||
_add_lang_parameter(operation)
|
||||
|
||||
# Add standard error responses
|
||||
_add_standard_responses(operation, method, path)
|
||||
|
||||
# Add code samples for ReDoc
|
||||
_add_code_samples(operation, path, method, schemas_components)
|
||||
|
||||
# Add pagination examples to components
|
||||
_add_pagination_examples(result)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _enhance_list_parameters(operation, path, search_fields):
|
||||
"""Enhance filter parameters for list endpoints"""
|
||||
parameters = operation.get('parameters', [])
|
||||
|
||||
# Add lang parameter for translatable fields
|
||||
lang_param = {
|
||||
'name': 'lang',
|
||||
'in': 'query',
|
||||
'required': False,
|
||||
'schema': {
|
||||
'type': 'string',
|
||||
'enum': ['en', 'de', 'fr'],
|
||||
},
|
||||
'description': 'Language code for translatable fields. If specified, translatable fields return as strings instead of dicts. Omit to get all translations.'
|
||||
}
|
||||
parameters.insert(0, lang_param)
|
||||
|
||||
# Build list of parameters to keep
|
||||
filtered_params = []
|
||||
|
||||
for param in parameters:
|
||||
param_name = param.get('name', '')
|
||||
schema = param.get('schema', {})
|
||||
param_type = schema.get('type', 'string')
|
||||
|
||||
# Enhance search parameter
|
||||
if param_name == 'search':
|
||||
if search_fields:
|
||||
fields_str = ', '.join(search_fields)
|
||||
param['description'] = f'Search (case-insensitive) across fields: {fields_str}'
|
||||
filtered_params.append(param)
|
||||
# Skip search parameter if no search_fields are defined
|
||||
continue
|
||||
|
||||
# Enhance ordering parameter
|
||||
elif param_name == 'ordering':
|
||||
param['description'] = "Order results by field. Prefix with '-' for descending (e.g., '-created_at')"
|
||||
filtered_params.append(param)
|
||||
|
||||
# Enhance page parameter
|
||||
elif param_name == 'page':
|
||||
param['description'] = "Page number (1-indexed)"
|
||||
filtered_params.append(param)
|
||||
|
||||
# Enhance other parameters based on type
|
||||
elif not param.get('description') or param['description'] in ['', 'A page number within the paginated result set.', 'Which field to use when ordering the results.']:
|
||||
if param_type == 'boolean':
|
||||
param['description'] = f"Filter by {param_name} (use true/false)"
|
||||
elif param_type == 'integer':
|
||||
# Check if it's likely an ID field
|
||||
if param_name in ['category', 'product', 'file', 'product_version', 'user', 'parent', 'group_type']:
|
||||
param['description'] = f"Filter by {param_name.replace('_', ' ').title()} ID"
|
||||
elif '__' in param_name:
|
||||
param['description'] = f"Filter by related ID (lookup via {param_name})"
|
||||
else:
|
||||
param['description'] = f"Filter by {param_name} (integer value)"
|
||||
elif param_type == 'array':
|
||||
# M2M fields
|
||||
if param_name in ['tags', 'groups', 'users']:
|
||||
param['description'] = f"Filter by {param_name.replace('_', ' ').title()} ID (repeat parameter for multiple values, e.g. ?{param_name}=1&{param_name}=2)"
|
||||
else:
|
||||
param['description'] = f"Filter by {param_name} (repeat parameter for multiple values)"
|
||||
else:
|
||||
param['description'] = f"Filter by exact match on {param_name}"
|
||||
filtered_params.append(param)
|
||||
else:
|
||||
# Keep other parameters as-is
|
||||
filtered_params.append(param)
|
||||
|
||||
# Replace parameters list with filtered version
|
||||
operation['parameters'] = filtered_params
|
||||
|
||||
|
||||
def _add_lang_parameter(operation):
|
||||
"""Add lang parameter to GET detail endpoints for translatable fields"""
|
||||
if 'parameters' not in operation:
|
||||
operation['parameters'] = []
|
||||
|
||||
# Check if lang parameter already exists
|
||||
has_lang = any(p.get('name') == 'lang' for p in operation['parameters'])
|
||||
if not has_lang:
|
||||
lang_param = {
|
||||
'name': 'lang',
|
||||
'in': 'query',
|
||||
'required': False,
|
||||
'schema': {
|
||||
'type': 'string',
|
||||
'enum': ['en', 'de', 'fr'],
|
||||
},
|
||||
'description': 'Language code for translatable fields. If specified, translatable fields return as strings instead of dicts. Omit to get all translations.'
|
||||
}
|
||||
operation['parameters'].insert(0, lang_param)
|
||||
|
||||
|
||||
def _add_standard_responses(operation, method, path):
|
||||
"""Add standard error responses to all operations"""
|
||||
if 'responses' not in operation:
|
||||
operation['responses'] = {}
|
||||
|
||||
method_lower = method.lower()
|
||||
|
||||
# All methods can have 401, 403, 500
|
||||
operation['responses'].setdefault('401', {
|
||||
'description': 'Unauthorized - Authentication credentials were not provided or are invalid'
|
||||
})
|
||||
operation['responses'].setdefault('403', {
|
||||
'description': 'Forbidden - You do not have permission to perform this action'
|
||||
})
|
||||
operation['responses'].setdefault('500', {
|
||||
'description': 'Internal Server Error - An unexpected error occurred on the server'
|
||||
})
|
||||
|
||||
# Method-specific responses
|
||||
if method_lower == 'get':
|
||||
if '{' in path: # Detail endpoint
|
||||
operation['responses'].setdefault('404', {
|
||||
'description': 'Not Found - The requested resource does not exist'
|
||||
})
|
||||
|
||||
elif method_lower == 'post':
|
||||
operation['responses'].setdefault('400', {
|
||||
'description': 'Bad Request - Invalid input data or validation error'
|
||||
})
|
||||
operation['responses'].setdefault('409', {
|
||||
'description': 'Conflict - The resource already exists or there is a conflict with the current state'
|
||||
})
|
||||
|
||||
elif method_lower in ['put', 'patch']:
|
||||
operation['responses'].setdefault('400', {
|
||||
'description': 'Bad Request - Invalid input data or validation error'
|
||||
})
|
||||
operation['responses'].setdefault('404', {
|
||||
'description': 'Not Found - The requested resource does not exist'
|
||||
})
|
||||
operation['responses'].setdefault('409', {
|
||||
'description': 'Conflict - There is a conflict with the current state of the resource'
|
||||
})
|
||||
|
||||
elif method_lower == 'delete':
|
||||
operation['responses'].setdefault('404', {
|
||||
'description': 'Not Found - The requested resource does not exist'
|
||||
})
|
||||
|
||||
|
||||
def _add_code_samples(operation, path, method, schemas_components=None):
|
||||
"""Add cURL code samples to operation description"""
|
||||
method_upper = method.upper()
|
||||
live_url = getattr(settings, 'LIVE_URL', 'http://localhost:8000')
|
||||
full_url = f"{live_url}{path}"
|
||||
|
||||
# Build cURL command
|
||||
curl_parts = [f"curl -X {method_upper}"]
|
||||
|
||||
# Add URL
|
||||
curl_parts.append(f'"{full_url}"')
|
||||
|
||||
# Add authentication
|
||||
curl_parts.append('-u "username:password"')
|
||||
|
||||
# Add headers
|
||||
curl_parts.append('-H "Accept: application/json"')
|
||||
|
||||
# Add body for POST/PUT/PATCH
|
||||
if method_upper in ['POST', 'PUT', 'PATCH']:
|
||||
curl_parts.append('-H "Content-Type: application/json"')
|
||||
|
||||
# Try to get request body example
|
||||
request_body = operation.get('requestBody', {})
|
||||
content = request_body.get('content', {})
|
||||
json_content = content.get('application/json', {})
|
||||
|
||||
body_example = None
|
||||
|
||||
# Try to get example from schema
|
||||
if 'example' in json_content:
|
||||
body_example = json_content['example']
|
||||
elif 'examples' in json_content:
|
||||
# Get first example
|
||||
examples = json_content['examples']
|
||||
if examples:
|
||||
first_example_key = list(examples.keys())[0]
|
||||
body_example = examples[first_example_key].get('value')
|
||||
|
||||
# If no example found, try schema or generate from schema
|
||||
if not body_example:
|
||||
schema = json_content.get('schema', {})
|
||||
if 'example' in schema:
|
||||
body_example = schema['example']
|
||||
else:
|
||||
# Generate example from schema structure
|
||||
body_example = _generate_example_from_schema(schema, schemas_components)
|
||||
|
||||
if body_example:
|
||||
body_json = json.dumps(body_example, indent=2)
|
||||
# Escape single quotes in JSON for shell
|
||||
body_json_escaped = body_json.replace("'", "'\"'\"'")
|
||||
curl_parts.append(f"-d '{body_json_escaped}'")
|
||||
|
||||
curl_command = ' \\\n '.join(curl_parts)
|
||||
|
||||
# Log for debugging
|
||||
if method_upper in ['POST', 'PUT', 'PATCH']:
|
||||
logging.debug(f"Generated cURL for {path} ({method_upper}): has body data: {'-d' in curl_command}")
|
||||
|
||||
# Add as formatted text in a custom vendor extension that won't be displayed
|
||||
# But add it to description with proper formatting
|
||||
current_description = operation.get('description', '')
|
||||
|
||||
# Use Markdown code fence with explicit language
|
||||
curl_markdown = f"""
|
||||
|
||||
## Example Request
|
||||
|
||||
```bash
|
||||
{curl_command}
|
||||
```
|
||||
"""
|
||||
|
||||
operation['description'] = current_description + curl_markdown
|
||||
|
||||
|
||||
def _generate_example_from_schema(schema, schemas_components=None, depth=0):
|
||||
"""Generate example JSON from schema"""
|
||||
# Prevent infinite recursion
|
||||
if depth > 5:
|
||||
return None
|
||||
|
||||
if '$ref' in schema:
|
||||
# Resolve reference
|
||||
ref_path = schema['$ref']
|
||||
if ref_path.startswith('#/components/schemas/'):
|
||||
schema_name = ref_path.replace('#/components/schemas/', '')
|
||||
if schemas_components and schema_name in schemas_components:
|
||||
resolved_schema = schemas_components[schema_name]
|
||||
return _generate_example_from_schema(resolved_schema, schemas_components, depth + 1)
|
||||
return None
|
||||
|
||||
# Check if schema has an example
|
||||
if 'example' in schema:
|
||||
return schema['example']
|
||||
|
||||
schema_type = schema.get('type')
|
||||
|
||||
if schema_type == 'object':
|
||||
properties = schema.get('properties', {})
|
||||
example = {}
|
||||
for prop_name, prop_schema in properties.items():
|
||||
if prop_schema.get('readOnly'):
|
||||
continue
|
||||
value = _generate_example_value(prop_schema, schemas_components, depth + 1)
|
||||
if value is not None:
|
||||
example[prop_name] = value
|
||||
return example
|
||||
|
||||
return _generate_example_value(schema, schemas_components, depth)
|
||||
|
||||
|
||||
def _generate_example_value(schema, schemas_components=None, depth=0):
|
||||
"""Generate example value for a schema field"""
|
||||
# Prevent infinite recursion
|
||||
if depth > 5:
|
||||
return None
|
||||
|
||||
# Handle $ref
|
||||
if '$ref' in schema:
|
||||
ref_path = schema['$ref']
|
||||
if ref_path.startswith('#/components/schemas/'):
|
||||
schema_name = ref_path.replace('#/components/schemas/', '')
|
||||
if schemas_components and schema_name in schemas_components:
|
||||
resolved_schema = schemas_components[schema_name]
|
||||
return _generate_example_from_schema(resolved_schema, schemas_components, depth + 1)
|
||||
return None
|
||||
|
||||
if 'example' in schema:
|
||||
return schema['example']
|
||||
|
||||
schema_type = schema.get('type')
|
||||
schema_format = schema.get('format')
|
||||
|
||||
if schema_type == 'string':
|
||||
if schema_format == 'email':
|
||||
return 'user@example.com'
|
||||
elif schema_format == 'date':
|
||||
return '2024-01-01'
|
||||
elif schema_format == 'date-time':
|
||||
return '2024-01-01T12:00:00Z'
|
||||
elif schema_format == 'uuid':
|
||||
return '123e4567-e89b-12d3-a456-426614174000'
|
||||
else:
|
||||
return 'string'
|
||||
elif schema_type == 'integer':
|
||||
return 1
|
||||
elif schema_type == 'number':
|
||||
return 1.0
|
||||
elif schema_type == 'boolean':
|
||||
return True
|
||||
elif schema_type == 'array':
|
||||
items_schema = schema.get('items', {})
|
||||
item_example = _generate_example_value(items_schema, schemas_components, depth + 1)
|
||||
return [item_example] if item_example is not None else []
|
||||
elif schema_type == 'object':
|
||||
properties = schema.get('properties', {})
|
||||
example = {}
|
||||
for prop_name, prop_schema in properties.items():
|
||||
if prop_schema.get('readOnly'):
|
||||
continue
|
||||
value = _generate_example_value(prop_schema, schemas_components, depth + 1)
|
||||
if value is not None:
|
||||
example[prop_name] = value
|
||||
return example
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _add_pagination_examples(result):
|
||||
"""Add pagination URL examples to all paginated response schemas"""
|
||||
if 'components' not in result or 'schemas' not in result['components']:
|
||||
return
|
||||
|
||||
# Build mapping from schema name to endpoint path
|
||||
schema_to_path = {}
|
||||
|
||||
for path, path_item in result.get('paths', {}).items():
|
||||
# Only process list endpoints (no {id} in path)
|
||||
if '{' in path:
|
||||
continue
|
||||
|
||||
for method, operation in path_item.items():
|
||||
if method.lower() != 'get':
|
||||
continue
|
||||
|
||||
# Get the response schema reference
|
||||
responses = operation.get('responses', {})
|
||||
success_response = responses.get('200', {})
|
||||
content = success_response.get('content', {})
|
||||
json_content = content.get('application/json', {})
|
||||
schema_ref = json_content.get('schema', {}).get('$ref', '')
|
||||
|
||||
# Extract schema name from reference like "#/components/schemas/PaginatedProductList"
|
||||
if schema_ref.startswith('#/components/schemas/'):
|
||||
schema_name = schema_ref.replace('#/components/schemas/', '')
|
||||
schema_to_path[schema_name] = path
|
||||
|
||||
# Add examples to paginated schemas
|
||||
live_url = getattr(settings, 'LIVE_URL', 'http://localhost:8000')
|
||||
for schema_name, schema in result['components']['schemas'].items():
|
||||
if not schema_name.startswith('Paginated') or 'properties' not in schema:
|
||||
continue
|
||||
|
||||
# Get the actual endpoint path for this schema
|
||||
endpoint_path = schema_to_path.get(schema_name, '/api/v1/.../')
|
||||
|
||||
if 'next' in schema['properties']:
|
||||
schema['properties']['next']['example'] = f"{live_url}{endpoint_path}?page=<next-page:int>"
|
||||
schema['properties']['next']['nullable'] = True
|
||||
if 'previous' in schema['properties']:
|
||||
schema['properties']['previous']['example'] = f"{live_url}{endpoint_path}?page=<prev-page:int>"
|
||||
schema['properties']['previous']['nullable'] = True
|
||||
45
setup.py
Normal file
45
setup.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from setuptools import setup, find_packages
|
||||
import os
|
||||
|
||||
# Read the README file
|
||||
def read(fname):
|
||||
return open(os.path.join(os.path.dirname(__file__), fname)).read()
|
||||
|
||||
setup(
|
||||
name='django-enhanced-apidocs',
|
||||
version='0.1.0',
|
||||
description='Enhanced API documentation for Django REST Framework with drf-spectacular',
|
||||
long_description=read('README.md'),
|
||||
long_description_content_type='text/markdown',
|
||||
author='KBS GmbH',
|
||||
author_email='holger.sielaff@kbs-gmbh.de',
|
||||
url='https://github.com/yourusername/django-enhanced-apidocs',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
license='MIT',
|
||||
classifiers=[
|
||||
'Development Status :: 3 - Alpha',
|
||||
'Environment :: Web Environment',
|
||||
'Framework :: Django',
|
||||
'Framework :: Django :: 4.0',
|
||||
'Framework :: Django :: 5.0',
|
||||
'Intended Audience :: Developers',
|
||||
'License :: OSI Approved :: MIT License',
|
||||
'Operating System :: OS Independent',
|
||||
'Programming Language :: Python',
|
||||
'Programming Language :: Python :: 3',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Programming Language :: Python :: 3.10',
|
||||
'Programming Language :: Python :: 3.11',
|
||||
'Programming Language :: Python :: 3.12',
|
||||
'Topic :: Internet :: WWW/HTTP',
|
||||
'Topic :: Software Development :: Libraries :: Python Modules',
|
||||
],
|
||||
install_requires=[
|
||||
'Django>=4.0',
|
||||
'djangorestframework>=3.14',
|
||||
'drf-spectacular>=0.26',
|
||||
],
|
||||
python_requires='>=3.9',
|
||||
keywords='django rest api documentation swagger openapi spectacular',
|
||||
)
|
||||
8
urls.py
Normal file
8
urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.urls import path
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
|
||||
|
||||
urlpatterns = [
|
||||
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
||||
path('swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='schema-swagger-ui'),
|
||||
path('redoc/', SpectacularRedocView.as_view(url_name='schema'), name='schema-redoc'),
|
||||
]
|
||||
Reference in New Issue
Block a user