From 19a6728b5f02bc641c1841b2d8f4a03cad8de46c Mon Sep 17 00:00:00 2001 From: Holger Sielaff Date: Tue, 13 Jan 2026 11:00:34 +0100 Subject: [PATCH] initial --- .gitignore | 82 ++++++++++ LICENSE | 21 +++ MANIFEST.in | 4 + __init__.py | 11 ++ apps.py | 7 + conf.py | 93 +++++++++++ pyproject.toml | 50 ++++++ schema.py | 427 +++++++++++++++++++++++++++++++++++++++++++++++++ setup.py | 45 ++++++ urls.py | 8 + 10 files changed, 748 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 __init__.py create mode 100644 apps.py create mode 100644 conf.py create mode 100644 pyproject.toml create mode 100644 schema.py create mode 100644 setup.py create mode 100644 urls.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..6958b5c --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..53db597 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/MANIFEST.in b/MANIFEST.in new file mode 100644 index 0000000..2f406af --- /dev/null +++ b/MANIFEST.in @@ -0,0 +1,4 @@ +include README.md +include LICENSE +include pyproject.toml +recursive-include django_enhanced_apidocs *.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..11dd096 --- /dev/null +++ b/__init__.py @@ -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' diff --git a/apps.py b/apps.py new file mode 100644 index 0000000..855b8b3 --- /dev/null +++ b/apps.py @@ -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' diff --git a/conf.py b/conf.py new file mode 100644 index 0000000..659ca95 --- /dev/null +++ b/conf.py @@ -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']}, + ], + } diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9fd178c --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/schema.py b/schema.py new file mode 100644 index 0000000..bdf6825 --- /dev/null +++ b/schema.py @@ -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=" + schema['properties']['next']['nullable'] = True + if 'previous' in schema['properties']: + schema['properties']['previous']['example'] = f"{live_url}{endpoint_path}?page=" + schema['properties']['previous']['nullable'] = True diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..a9959b7 --- /dev/null +++ b/setup.py @@ -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', +) diff --git a/urls.py b/urls.py new file mode 100644 index 0000000..8a3dc3c --- /dev/null +++ b/urls.py @@ -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'), +]