Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature Request: Optionally allow SwaggerUI Top Bar #483

Closed
ahebard opened this issue Aug 24, 2021 · 4 comments
Closed

Feature Request: Optionally allow SwaggerUI Top Bar #483

ahebard opened this issue Aug 24, 2021 · 4 comments

Comments

@ahebard
Copy link

ahebard commented Aug 24, 2021

WHY:
It would be useful to have the optional ability to use the SwaggerUI Top Bar, which includes a dropdown for which schema to view. This dropdown is particularly useful for api's with different versions, the user can clearly see that there are different versions without having to navigate to any other page.
HOW:
This could be accomplished by adding a SWAGGER_UI_USE_TOP_BAR setting that gets passed from the SwaggerUI views into the html and js templates. In this example implementation, the html SwaggerUI template optionally loads swagger-ui-standalone-preset.js based on the value of use_topbar. The SwaggerUI js template optionally adds SwaggerUIStandalonePreset to the presets based on the value of use_topbar and optionally uses StandaloneLayout instead of BaseLayout based on use_topbar.

Below are copies of the full content of the 4 relevant files with the (small) changes described.

# drf-spectacular/drf_spectacular/templates/drf_spectacular/swagger_ui.html
<!DOCTYPE html>
<html>
  <head>
    <title>Swagger</title>
    <meta charset="utf-8"/>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    {% if favicon_href %}
    <link rel="icon" type="image/png" href="{{favicon_href}}"/>
    {% endif %}
    <link rel="stylesheet" type="text/css" href="{{dist}}/swagger-ui.css" />
  </head>
  <body>
    <div id="swagger-ui"></div>
    <script src="{{dist}}/swagger-ui-bundle.js"></script>
    {% if use_topbar %}
    <script src="{{dist}}/swagger-ui-standalone-preset.js"></script>
    {%  endif %}
    {% if script_url %}
    <script src="{{script_url|safe}}"></script>
    {% else %}
    <script>
    {% include template_name_js %}
    </script>
    {% endif %}
  </body>
</html>
# drf-spectacular/drf_spectacular/templates/drf_spectacular/swagger_ui.js
const swagger_settings  = {{settings|safe}}
const use_topbar = {{use_topbar|yesno:"true,false"}}


const ui = SwaggerUIBundle({
  url: "{{schema_url|safe}}",
  dom_id: "#swagger-ui",
  presets: (use_topbar ?
    [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset] :
    [SwaggerUIBundle.presets.apis]
  ),
  plugin: [
    SwaggerUIBundle.plugins.DownloadUrl
  ],
  layout: use_topbar ? "StandaloneLayout" : "BaseLayout",
  requestInterceptor: (request) => {
    request.headers["X-CSRFToken"] = "{{csrf_token}}"
    return request;
  },
  ...swagger_settings
})

{% if oauth2_config %}
ui.initOAuth({{oauth2_config|safe}})
{% endif %}
# drf-spectacular/drf_spectacular/settings.py
from typing import Any, Dict

from django.conf import settings
from rest_framework.settings import APISettings

SPECTACULAR_DEFAULTS: Dict[str, Any] = {
    # A regex specifying the common denominator for all operation paths. If
    # SCHEMA_PATH_PREFIX is set to None, drf-spectacular will attempt to estimate
    # a common prefix. use '' to disable.
    # Mainly used for tag extraction, where paths like '/api/v1/albums' with
    # a SCHEMA_PATH_PREFIX regex '/api/v[0-9]' would yield the tag 'albums'.
    'SCHEMA_PATH_PREFIX': None,
    # Remove matching SCHEMA_PATH_PREFIX from operation path. Usually used in
    # conjunction with appended prefixes in SERVERS.
    'SCHEMA_PATH_PREFIX_TRIM': False,

    'DEFAULT_GENERATOR_CLASS': 'drf_spectacular.generators.SchemaGenerator',

    # Schema generation parameters to influence how components are constructed.
    # Some schema features might not translate well to your target.
    # Demultiplexing/modifying components might help alleviate those issues.
    #
    # Create separate components for PATCH endpoints (without required list)
    'COMPONENT_SPLIT_PATCH': True,
    # Split components into request and response parts where appropriate
    'COMPONENT_SPLIT_REQUEST': False,
    # Aid client generator targets that have trouble with read-only properties.
    'COMPONENT_NO_READ_ONLY_REQUIRED': False,

    # Configuration for serving a schema subset with SpectacularAPIView
    'SERVE_URLCONF': None,
    # complete public schema or a subset based on the requesting user
    'SERVE_PUBLIC': True,
    # include schema enpoint into schema
    'SERVE_INCLUDE_SCHEMA': True,
    # list of authentication/permission classes for spectacular's views.
    'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'],
    # None will default to DRF's AUTHENTICATION_CLASSES
    'SERVE_AUTHENTICATION': None,

    # Dictionary of general configuration to pass to the SwaggerUI({ ... })
    # https://swagger.io/docs/open-source-tools/swagger-ui/usage/configuration/
    'SWAGGER_UI_SETTINGS': {
        'deepLinking': True,
    },
    # Initialize SwaggerUI with additional OAuth2 configuration.
    # https://swagger.io/docs/open-source-tools/swagger-ui/usage/oauth2/
    'SWAGGER_UI_OAUTH2_CONFIG': {},

    # Initialize SwaggerUI using the TopBar plugin
    'SWAGGER_UI_USE_TOP_BAR': False,
    
    # CDNs for for swagger and redoc. You can change the version or even host your
    # own depending on your requirements.
    'SWAGGER_UI_DIST': '//unpkg.com/swagger-ui-dist@3.51.0',
    'SWAGGER_UI_FAVICON_HREF': '//unpkg.com/swagger-ui-dist@3.51.0/favicon-32x32.png',

    'REDOC_DIST': '//cdn.jsdelivr.net/npm/redoc@next',

    # Append OpenAPI objects to path and components in addition to the generated objects
    'APPEND_PATHS': {},
    'APPEND_COMPONENTS': {},

    # DISCOURAGED - please don't use this anymore as it has tricky implications that
    # are hard to get right. For authentication, OpenApiAuthenticationExtension are
    # strongly preferred because they are more robust and easy to write.
    # However if used, the list of methods is appended to every endpoint in the schema!
    'SECURITY': [],

    # Postprocessing functions that run at the end of schema generation.
    # must satisfy interface result = hook(generator, request, public, result)
    'POSTPROCESSING_HOOKS': [
        'drf_spectacular.hooks.postprocess_schema_enums'
    ],

    # Preprocessing functions that run before schema generation.
    # must satisfy interface result = hook(endpoints=result) where result
    # is a list of Tuples (path, path_regex, method, callback).
    # Example: 'drf_spectacular.hooks.preprocess_exclude_path_format'
    'PREPROCESSING_HOOKS': [],

    # Determines how operations should be sorted. If you intend to do sorting with a
    # PREPROCESSING_HOOKS, be sure to disable this setting. If configured, the sorting
    # is applied after the PREPROCESSING_HOOKS. Accepts either
    # True (drf-spectacular's alpha-sorter), False, or a callable for sort's key arg.
    'SORT_OPERATIONS': True,

    # enum name overrides. dict with keys "YourEnum" and their choice values "field.choices"
    'ENUM_NAME_OVERRIDES': {},
    # Adds "blank" and "null" enum choices where appropriate. disable on client generation issues
    'ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE': True,

    # function that returns a list of all classes that should be excluded from doc string extraction
    'GET_LIB_DOC_EXCLUDES': 'drf_spectacular.plumbing.get_lib_doc_excludes',

    # Function that returns a mocked request for view processing. For CLI usage
    # original_request will be None.
    # interface: request = build_mock_request(method, path, view, original_request, **kwargs)
    'GET_MOCK_REQUEST': 'drf_spectacular.plumbing.build_mock_request',

    # Camelize names like operationId and path parameter names
    'CAMELIZE_NAMES': False,

    # Determines if and how free-form 'additionalProperties' should be emitted in the schema. Some
    # code generator targets are sensitive to this. None disables generic 'additionalProperties'.
    # allowed values are 'dict', 'bool', None
    'GENERIC_ADDITIONAL_PROPERTIES': 'dict',

    # Determines whether operation parameters should be sorted alphanumerically or just in
    # the order they arrived. Accepts either True, False, or a callable for sort's key arg.
    'SORT_OPERATION_PARAMETERS': True,

    # @extend_schema allows to specify status codes besides 200. This functionality is usually used
    # to describe error responses, which rarely make use of list mechanics. Therefore, we suppress
    # listing (pagination and filtering) on non-2XX status codes by default. Toggle this to enable
    # list responses with ListSerializers/many=True irrespective of the status code.
    'ENABLE_LIST_MECHANICS_ON_NON_2XX': False,

    # Controls which authentication methods are exposed in the schema. If not empty, will hide
    # authentication classes that are not contained in the whitelist. Use full import paths
    # like ['rest_framework.authentication.TokenAuthentication', ...]
    'AUTHENTICATION_WHITELIST': [],

    # Option for turning off error and warn messages
    'DISABLE_ERRORS_AND_WARNINGS': False,

    # General schema metadata. Refer to spec for valid inputs
    # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#openapi-object
    'TITLE': '',
    'DESCRIPTION': '',
    'TOS': None,
    # Optional: MAY contain "name", "url", "email"
    'CONTACT': {},
    # Optional: MUST contain "name", MAY contain URL
    'LICENSE': {},
    # Statically set schema version. May also be an empty string. When used together with
    # view versioning, will become '0.0.0 (v2)' for 'v2' versioned requests.
    # Set VERSION to None if only the request version should be rendered.
    'VERSION': '0.0.0',
    # Optional list of servers.
    # Each entry MUST contain "url", MAY contain "description", "variables"
    'SERVERS': [],
    # Tags defined in the global scope
    'TAGS': [],
    # Optional: MUST contain 'url', may contain "description"
    'EXTERNAL_DOCS': {},

    # Arbitrary specification extensions attached to the schema's info object.
    # https://swagger.io/specification/#specification-extensions
    'EXTENSIONS_INFO': {},

    # Oauth2 related settings. used for example by django-oauth2-toolkit.
    # https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.3.md#oauth-flows-object
    'OAUTH2_FLOWS': [],
    'OAUTH2_AUTHORIZATION_URL': None,
    'OAUTH2_TOKEN_URL': None,
    'OAUTH2_REFRESH_URL': None,
    'OAUTH2_SCOPES': None,
}

IMPORT_STRINGS = [
    'SCHEMA_AUTHENTICATION_CLASSES',
    'DEFAULT_GENERATOR_CLASS',
    'SERVE_AUTHENTICATION',
    'SERVE_PERMISSIONS',
    'POSTPROCESSING_HOOKS',
    'PREPROCESSING_HOOKS',
    'GET_LIB_DOC_EXCLUDES',
    'GET_MOCK_REQUEST',
    'SORT_OPERATIONS',
    'SORT_OPERATION_PARAMETERS',
    'AUTHENTICATION_WHITELIST',
]

spectacular_settings = APISettings(
    user_settings=getattr(settings, 'SPECTACULAR_SETTINGS', {}),
    defaults=SPECTACULAR_DEFAULTS,  # type: ignore
    import_strings=IMPORT_STRINGS,
)
# drf-spectacular/drf_spectacular/views.py
import json
from collections import namedtuple
from typing import Any, Dict

from django.conf import settings
from django.utils import translation
from django.utils.translation import gettext_lazy as _
from rest_framework.renderers import TemplateHTMLRenderer
from rest_framework.response import Response
from rest_framework.reverse import reverse
from rest_framework.settings import api_settings
from rest_framework.views import APIView

from drf_spectacular.plumbing import get_relative_url, set_query_parameters
from drf_spectacular.renderers import (
    OpenApiJsonRenderer, OpenApiJsonRenderer2, OpenApiYamlRenderer, OpenApiYamlRenderer2,
)
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema

if spectacular_settings.SERVE_INCLUDE_SCHEMA:
    SCHEMA_KWARGS: Dict[str, Any] = {'responses': {200: OpenApiTypes.OBJECT}}

    if settings.USE_I18N:
        SCHEMA_KWARGS['parameters'] = [
            OpenApiParameter(
                'lang', str, OpenApiParameter.QUERY, enum=list(dict(settings.LANGUAGES).keys())
            )
        ]
else:
    SCHEMA_KWARGS = {'exclude': True}

if spectacular_settings.SERVE_AUTHENTICATION is not None:
    AUTHENTICATION_CLASSES = spectacular_settings.SERVE_AUTHENTICATION
else:
    AUTHENTICATION_CLASSES = api_settings.DEFAULT_AUTHENTICATION_CLASSES


class SpectacularAPIView(APIView):
    __doc__ = _("""
    OpenApi3 schema for this API. Format can be selected via content negotiation.

    - YAML: application/vnd.oai.openapi
    - JSON: application/vnd.oai.openapi+json
    """)
    renderer_classes = [
        OpenApiYamlRenderer, OpenApiYamlRenderer2, OpenApiJsonRenderer, OpenApiJsonRenderer2
    ]
    permission_classes = spectacular_settings.SERVE_PERMISSIONS
    authentication_classes = AUTHENTICATION_CLASSES
    generator_class = spectacular_settings.DEFAULT_GENERATOR_CLASS
    serve_public = spectacular_settings.SERVE_PUBLIC
    urlconf = spectacular_settings.SERVE_URLCONF
    api_version = None

    @extend_schema(**SCHEMA_KWARGS)
    def get(self, request, *args, **kwargs):
        if isinstance(self.urlconf, list) or isinstance(self.urlconf, tuple):
            ModuleWrapper = namedtuple('ModuleWrapper', ['urlpatterns'])
            self.urlconf = ModuleWrapper(tuple(self.urlconf))

        if settings.USE_I18N and request.GET.get('lang'):
            with translation.override(request.GET.get('lang')):
                return self._get_schema_response(request)
        else:
            return self._get_schema_response(request)

    def _get_schema_response(self, request):
        # version specified as parameter to the view always takes precedence. after
        # that we try to source version through the schema view's own versioning_class.
        version = self.api_version or request.version
        generator = self.generator_class(urlconf=self.urlconf, api_version=version)
        return Response(generator.get_schema(request=request, public=self.serve_public))


class SpectacularYAMLAPIView(SpectacularAPIView):
    renderer_classes = [OpenApiYamlRenderer, OpenApiYamlRenderer2]


class SpectacularJSONAPIView(SpectacularAPIView):
    renderer_classes = [OpenApiJsonRenderer, OpenApiJsonRenderer2]


class SpectacularSwaggerView(APIView):
    renderer_classes = [TemplateHTMLRenderer]
    permission_classes = spectacular_settings.SERVE_PERMISSIONS
    authentication_classes = AUTHENTICATION_CLASSES
    url_name = 'schema'
    url = None
    template_name = 'drf_spectacular/swagger_ui.html'
    template_name_js = 'drf_spectacular/swagger_ui.js'

    @extend_schema(exclude=True)
    def get(self, request, *args, **kwargs):
        schema_url = self.url or get_relative_url(reverse(self.url_name, request=request))
        return Response(
            data={
                'dist': spectacular_settings.SWAGGER_UI_DIST,
                'favicon_href': spectacular_settings.SWAGGER_UI_FAVICON_HREF,
                'schema_url': set_query_parameters(
                    url=schema_url,
                    lang=request.GET.get('lang')
                ),
                'settings': json.dumps(spectacular_settings.SWAGGER_UI_SETTINGS),
                'use_topbar': spectacular_settings.SWAGGER_UI_USE_TOP_BAR,
                'oauth2_config': json.dumps(spectacular_settings.SWAGGER_UI_OAUTH2_CONFIG),
                'template_name_js': self.template_name_js
            },
            template_name=self.template_name,
        )


class SpectacularSwaggerSplitView(SpectacularSwaggerView):
    """
    Alternate Swagger UI implementation that separates the html request from the
    javascript request to cater to web servers with stricter CSP policies.
    """
    url_self = None

    @extend_schema(exclude=True)
    def get(self, request, *args, **kwargs):
        if request.GET.get('script') is not None:
            schema_url = self.url or get_relative_url(reverse(self.url_name, request=request))
            return Response(
                data={
                    'schema_url': set_query_parameters(
                        url=schema_url,
                        lang=request.GET.get('lang')
                    ),
                    'settings': json.dumps(spectacular_settings.SWAGGER_UI_SETTINGS),
                    'use_topbar': spectacular_settings.SWAGGER_UI_USE_TOP_BAR,
                    'oauth2_config': json.dumps(spectacular_settings.SWAGGER_UI_OAUTH2_CONFIG),
                },
                template_name=self.template_name_js,
                content_type='application/javascript',
            )
        else:
            script_url = self.url_self or request.get_full_path()
            return Response(
                data={
                    'dist': spectacular_settings.SWAGGER_UI_DIST,
                    'use_topbar': spectacular_settings.SWAGGER_UI_USE_TOP_BAR,
                    'favicon_href': spectacular_settings.SWAGGER_UI_FAVICON_HREF,
                    'script_url': set_query_parameters(
                        url=script_url,
                        lang=request.GET.get('lang'),
                        script=''  # signal to deliver init script
                    )
                },
                template_name=self.template_name,
            )


class SpectacularRedocView(APIView):
    renderer_classes = [TemplateHTMLRenderer]
    permission_classes = spectacular_settings.SERVE_PERMISSIONS
    authentication_classes = AUTHENTICATION_CLASSES
    url_name = 'schema'
    url = None
    template_name = 'drf_spectacular/redoc.html'

    @extend_schema(exclude=True)
    def get(self, request, *args, **kwargs):
        schema_url = self.url or get_relative_url(reverse(self.url_name, request=request))
        schema_url = set_query_parameters(schema_url, lang=request.GET.get('lang'))
        return Response(
            data={
                'dist': spectacular_settings.REDOC_DIST,
                'schema_url': schema_url,
            },
            template_name=self.template_name
        )
@ahebard
Copy link
Author

ahebard commented Aug 25, 2021

With release 0.18.0 that now allows raw JS in the SWAGGER_UI_SETTINGS, I was able to use the following settings to add the topbar:
settings.py:

    'SWAGGER_UI_SETTINGS':  '''{
        deepLinking: true,
        urls: [{url: "/api/schema-v1/", name: "v1"}, {url: "/api/schema-v2/", name: "v2"}],
        presets: [SwaggerUIBundle.presets.apis, SwaggerUIStandalonePreset],
        layout: "StandaloneLayout",
    }''',

I created my own template to load in the necessary script, taking advantage of django's include tag as follows:
templates/drf_spectacular_mod/swagger_ui.html:

<script src="{{dist}}/swagger-ui-standalone-preset.js"></script>
{% include "drf_spectacular/swagger_ui.html" %}

I then created my own version of SpectacularSwaggerView as follows:

from drf_spectacular.views import SpectacularSwaggerView

class SpectacularSwaggerViewTopBar(SpectacularSwaggerView):
    template_name = 'drf_spectacular_mod/swagger_ui.html'  # path to my customized swaggerui html template

This is a pretty simple customization and I was able to mostly rely on the package for it, but having <script src="{{dist}}/swagger-ui-standalone-preset.js"></script> either always loaded in the swagger_ui.html template or optionally loaded based on the value of a setting would be convenient. Also perhaps being able to specify SpectacularSwaggerView.template_name via a setting instead of having to extend the class would be nice. These suggestions are just alternative ways to allow the SwaggerUI Top Bar without necessarily adding a whole new SWAGGER_UI_USE_TOP_BAR setting.

@tfranzel
Copy link
Owner

Hi @ahebard,

thanks for that detailed report. I'm not expert on Swagger and so i never 100% understood what SwaggerUIStandalonePreset and StandaloneLayout did differently. I was simply using the basic example from their documentation for the template.

I have no concerns adding SwaggerUIStandalonePreset and StandaloneLayout by default, given that it does not create problems for existing users.

do you have more insight into what the impact could be?

@tfranzel
Copy link
Owner

added a convenient way to version SpectacularAPIView via schema?version=v2

Also perhaps being able to specify SpectacularSwaggerView.template_name via a setting instead of having to extend the class would be nice.

this should already be possible via the default DRF override idiom.

path('api/v2/schema/', SpectacularAPIView.as_view(template_name='drf_spectacular_mod/swagger_ui.html'), name='schema'),

that shouldn't be necessart anymore, because i also decided to include {{dist}}/swagger-ui-standalone-preset.js as it is commonly used and almost like a core feature. I'd like to keep the settings as is for consistency though. with all that done, customization should be a lot easier and you should only need your SWAGGER_UI_SETTINGS to enable the topbar now.

@tfranzel
Copy link
Owner

tfranzel commented Sep 1, 2021

closing this issue for now. feel free to comment if anything is missing or not working and we will follow-up.

@tfranzel tfranzel closed this as completed Sep 1, 2021
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants