From aeda9697382029cf160031748636c6cbe9991ad2 Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Sat, 25 Sep 2021 00:57:58 +0200 Subject: [PATCH] introducing the spectacular sidecar --- README.rst | 29 ++++++++++++++ drf_spectacular/views.py | 29 +++++++++++--- requirements/optionals.txt | 3 +- setup.py | 4 ++ tests/contrib/test_drf_spectacular_sidecar.py | 40 +++++++++++++++++++ 5 files changed, 99 insertions(+), 6 deletions(-) create mode 100644 tests/contrib/test_drf_spectacular_sidecar.py diff --git a/README.rst b/README.rst index 6869e0d4..0b7a2a69 100644 --- a/README.rst +++ b/README.rst @@ -99,6 +99,33 @@ specify any settings, but we recommend to specify at least some metadata. # OTHER SETTINGS } + +Self-contained UI installation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +Certain environments have no direct access to the internet and as such are unable +to retrieve Swagger UI or Redoc from CDNs. `drf-spectacular-sidecar`_ provides +the these static files as a separate optional package. Usage is as follows: + +.. code:: bash + + $ pip install drf-spectacular[sidecar] + +.. code:: python + + INSTALLED_APPS = [ + # ALL YOUR APPS + 'drf_spectacular', + 'drf_spectacular_sidecar, # required for Django collectstatic discovery + ] + SPECTACULAR_SETTINGS = { + 'SWAGGER_UI_DIST': 'SIDECAR', # shorthand to use the sidecar instead + 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', + 'REDOC_DIST': 'SIDECAR', + # OTHER SETTINGS + } + + Release management ^^^^^^^^^^^^^^^^^^ @@ -255,6 +282,8 @@ globally, and then simply run: .. _tox: http://tox.readthedocs.org/en/latest/ +.. _drf-spectacular-sidecar: https://github.com/tfranzel/drf-spectacular-sidecar + .. |build-status-image| image:: https://api.travis-ci.com/tfranzel/drf-spectacular.svg?branch=master :target: https://travis-ci.com/tfranzel/drf-spectacular?branch=master .. |pypi-version| image:: https://img.shields.io/pypi/v/drf-spectacular.svg diff --git a/drf_spectacular/views.py b/drf_spectacular/views.py index 63a2ea5d..e2cac84e 100644 --- a/drf_spectacular/views.py +++ b/drf_spectacular/views.py @@ -88,6 +88,10 @@ class SpectacularJSONAPIView(SpectacularAPIView): renderer_classes = [OpenApiJsonRenderer, OpenApiJsonRenderer2] +def _get_sidecar_url(package): + return f'{settings.STATIC_URL}drf_spectacular_sidecar/{package}' + + class SpectacularSwaggerView(APIView): renderer_classes = [TemplateHTMLRenderer] permission_classes = spectacular_settings.SERVE_PERMISSIONS @@ -104,8 +108,8 @@ def get(self, request, *args, **kwargs): return Response( data={ 'title': self.title, - 'dist': spectacular_settings.SWAGGER_UI_DIST, - 'favicon_href': spectacular_settings.SWAGGER_UI_FAVICON_HREF, + 'dist': self._swagger_ui_dist(), + 'favicon_href': self._swagger_ui_favicon(), 'schema_url': set_query_parameters( url=schema_url, lang=request.GET.get('lang') @@ -120,6 +124,16 @@ def get(self, request, *args, **kwargs): def _dump(self, data): return data if isinstance(data, str) else json.dumps(data) + def _swagger_ui_dist(self): + if spectacular_settings.SWAGGER_UI_DIST == 'SIDECAR': + return _get_sidecar_url('swagger-ui-dist') + return spectacular_settings.SWAGGER_UI_DIST + + def _swagger_ui_favicon(self): + if spectacular_settings.SWAGGER_UI_FAVICON_HREF == 'SIDECAR': + return _get_sidecar_url('swagger-ui-dist') + '/favicon-32x32.png' + return spectacular_settings.SWAGGER_UI_FAVICON_HREF + class SpectacularSwaggerSplitView(SpectacularSwaggerView): """ @@ -149,8 +163,8 @@ def get(self, request, *args, **kwargs): return Response( data={ 'title': self.title, - 'dist': spectacular_settings.SWAGGER_UI_DIST, - 'favicon_href': spectacular_settings.SWAGGER_UI_FAVICON_HREF, + 'dist': self._swagger_ui_dist(), + 'favicon_href': self._swagger_ui_favicon(), 'script_url': set_query_parameters( url=script_url, lang=request.GET.get('lang'), @@ -177,8 +191,13 @@ def get(self, request, *args, **kwargs): return Response( data={ 'title': self.title, - 'dist': spectacular_settings.REDOC_DIST, + 'dist': self._redoc_dist(), 'schema_url': schema_url, }, template_name=self.template_name ) + + def _redoc_dist(self): + if spectacular_settings.REDOC_DIST == 'SIDECAR': + return _get_sidecar_url('redoc') + return spectacular_settings.SWAGGER_UI_DIST diff --git a/requirements/optionals.txt b/requirements/optionals.txt index cc33cf51..4aa989d4 100644 --- a/requirements/optionals.txt +++ b/requirements/optionals.txt @@ -8,4 +8,5 @@ django-oauth-toolkit>=1.2.0 djangorestframework-camel-case>=1.1.2 django-filter>=2.3.0 psycopg2-binary>=2.7.3.2 -drf-nested-routers>=0.93.3 \ No newline at end of file +drf-nested-routers>=0.93.3 +drf_spectacular_sidecar \ No newline at end of file diff --git a/setup.py b/setup.py index 192e6cb8..9a20bad2 100644 --- a/setup.py +++ b/setup.py @@ -83,6 +83,10 @@ def get_packages(package): include_package_data=True, python_requires=">=3.6", install_requires=requirements, + extras_require={ + "offline": ["drf_spectacular_sidecar"], + "sidecar": ["drf_spectacular_sidecar"], + }, classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', diff --git a/tests/contrib/test_drf_spectacular_sidecar.py b/tests/contrib/test_drf_spectacular_sidecar.py new file mode 100644 index 00000000..1287944f --- /dev/null +++ b/tests/contrib/test_drf_spectacular_sidecar.py @@ -0,0 +1,40 @@ +import inspect +import os +from unittest import mock + +import pytest +from django.urls import path +from rest_framework.test import APIClient + +from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView + +urlpatterns = [ + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(), name='swagger'), + path('api/schema/redoc/', SpectacularRedocView.as_view(), name='redoc'), +] + +BUNDLE_URL = "static/drf_spectacular_sidecar/swagger-ui-dist/swagger-ui-bundle.js" + + +@mock.patch('drf_spectacular.settings.spectacular_settings.SWAGGER_UI_DIST', 'SIDECAR') +@mock.patch('drf_spectacular.settings.spectacular_settings.SWAGGER_UI_FAVICON_HREF', 'SIDECAR') +@mock.patch('drf_spectacular.settings.spectacular_settings.REDOC_DIST', 'SIDECAR') +@pytest.mark.urls(__name__) +@pytest.mark.contrib('drf_spectacular_sidecar') +def test_sidecar_shortcut_urls_are_resolved(no_warnings): + response = APIClient().get('/api/schema/swagger-ui/') + assert b'"/' + BUNDLE_URL.encode() + b'"' in response.content + assert b'"/static/drf_spectacular_sidecar/swagger-ui-dist/favicon-32x32.png"' in response.content + response = APIClient().get('/api/schema/redoc/') + assert b'"/static/drf_spectacular_sidecar/redoc/bundles/redoc.standalone.js"' in response.content + + +@pytest.mark.contrib('drf_spectacular_sidecar') +def test_sidecar_package_urls_matching(no_warnings): + # poor man's test to make sure the sidecar package contents match with what + # collectstatic is going to compile. cannot be tested directly. + import drf_spectacular_sidecar # type: ignore[import] + module_root = os.path.dirname(inspect.getfile(drf_spectacular_sidecar)) + bundle_path = os.path.join(module_root, BUNDLE_URL) + assert os.path.isfile(bundle_path)