Skip to content

Commit

Permalink
custom settings per SpectacularAPIView instance #365
Browse files Browse the repository at this point in the history
  • Loading branch information
tfranzel committed Dec 19, 2021
1 parent aff4548 commit 96d9729
Show file tree
Hide file tree
Showing 4 changed files with 144 additions and 7 deletions.
27 changes: 27 additions & 0 deletions docs/faq.rst
Original file line number Diff line number Diff line change
Expand Up @@ -257,3 +257,30 @@ Adapt to your specific requirements.
@extend_schema(responses=enveloper(XSerializer, True))
def list(self, request, *args, **kwargs):
...
How can I have multiple ``SpectacularAPIView`` with differing settings
----------------------------------------------------------------------

First, define your base settings in ``settings.py`` with ``SPECTACULAR_SETTINGS``. Then,
if you need another schema with different settings, you can provide scoped overrides by
providing a ``custom_settings`` argument. ``custom_settings`` expects a ``dict`` and only
allows keys that represent valid setting names.

Beware that using this mechanic is not thread-safe at the moment.

Also note that overriding ``SERVE_*`` or ``DEFAULT_GENERATOR_CLASS`` in ``custom_settings`` is
not allowed. ``SpectacularAPIView`` has dedicated arguments for overriding these settings.

.. code-block:: python
urlpatterns = [
path('api/schema/', SpectacularAPIView.as_view(),
path('api/schema-custom/', SpectacularAPIView.as_view(
custom_settings={
'TITLE': 'your custom title',
'SCHEMA_PATH_PREFIX': 'your custom regex',
...
}
), name='schema-custom'),
]
40 changes: 38 additions & 2 deletions drf_spectacular/settings.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
from contextlib import contextmanager
from typing import Any, Dict

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

SPECTACULAR_DEFAULTS: Dict[str, Any] = {
# A regex specifying the common denominator for all operation paths. If
Expand Down Expand Up @@ -206,8 +207,43 @@
'PARSER_WHITELIST',
]

spectacular_settings = APISettings(

class SpectacularSettings(APISettings):
_original_settings: Dict[str, Any] = {}

def apply_patches(self, patches):
for attr, val in patches.items():
if attr.startswith('SERVE_') or attr == 'DEFAULT_GENERATOR_CLASS':
raise AttributeError(
f'{attr} not allowed in custom_settings. use dedicated parameter instead.'
)
if attr in self.import_strings:
val = perform_import(val, attr)
# load and store original value, then override __dict__ entry
self._original_settings[attr] = getattr(self, attr)
setattr(self, attr, val)

def clear_patches(self):
for attr, orig_val in self._original_settings.items():
setattr(self, attr, orig_val)
self._original_settings = {}


spectacular_settings = SpectacularSettings(
user_settings=getattr(settings, 'SPECTACULAR_SETTINGS', {}), # type: ignore
defaults=SPECTACULAR_DEFAULTS, # type: ignore
import_strings=IMPORT_STRINGS,
)


@contextmanager
def patched_settings(patches):
""" temporarily patch the global spectacular settings (or do nothing) """
if not patches:
yield
else:
try:
spectacular_settings.apply_patches(patches)
yield
finally:
spectacular_settings.clear_patches()
12 changes: 7 additions & 5 deletions drf_spectacular/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
from drf_spectacular.renderers import (
OpenApiJsonRenderer, OpenApiJsonRenderer2, OpenApiYamlRenderer, OpenApiYamlRenderer2,
)
from drf_spectacular.settings import spectacular_settings
from drf_spectacular.settings import patched_settings, spectacular_settings
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema

Expand Down Expand Up @@ -53,18 +53,20 @@ class SpectacularAPIView(APIView):
serve_public = spectacular_settings.SERVE_PUBLIC
urlconf = spectacular_settings.SERVE_URLCONF
api_version = None
custom_settings = 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')):
with patched_settings(self.custom_settings):
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)
else:
return self._get_schema_response(request)

def _get_schema_response(self, request):
# version specified as parameter to the view always takes precedence. after
Expand Down
72 changes: 72 additions & 0 deletions tests/test_custom_settings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import pytest
import yaml
from django.urls import path
from rest_framework import serializers
from rest_framework.decorators import api_view
from rest_framework.response import Response
from rest_framework.test import APIClient

from drf_spectacular.utils import extend_schema
from drf_spectacular.views import SpectacularAPIView


def custom_hook(endpoints, **kwargs):
return [
(path.rstrip('/'), path_regex.rstrip('/'), method, callback)
for path, path_regex, method, callback in endpoints
]


class XSerializer(serializers.Serializer):
field = serializers.CharField()


@extend_schema(request=XSerializer, responses=XSerializer)
@api_view(http_method_names=['POST'])
def pi(request):
return Response(3.1415) # pragma: no cover


urlpatterns = [
path('api/pi/', pi),
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/schema-custom/', SpectacularAPIView.as_view(
custom_settings={
'TITLE': 'Custom settings with this SpectacularAPIView',
'SCHEMA_PATH_PREFIX': '',
'COMPONENT_SPLIT_REQUEST': True,
'PREPROCESSING_HOOKS': ['tests.test_custom_settings.custom_hook']
}
), name='schema-custom'),
path('api/schema-invalid/', SpectacularAPIView.as_view(
custom_settings={'INVALID': 'INVALID'}
), name='schema-invalid'),
path('api/schema-invalid2/', SpectacularAPIView.as_view(
custom_settings={'SERVE_PUBLIC': 'INVALID'}
), name='schema-invalid2'),
]


@pytest.mark.urls(__name__)
def test_custom_settings(no_warnings):
response = APIClient().get('/api/schema-custom/')
schema = yaml.load(response.content, Loader=yaml.SafeLoader)
assert schema['info']['title']
assert '/api/pi' in schema['paths'] # hook executed
assert ['api'] == schema['paths']['/api/pi']['post']['tags'] # SCHEMA_PATH_PREFIX
assert 'XRequest' in schema['components']['schemas'] # COMPONENT_SPLIT_REQUEST

response = APIClient().get('/api/schema/')
schema = yaml.load(response.content, Loader=yaml.SafeLoader)
assert not schema['info']['title']
assert '/api/pi/' in schema['paths'] # hook not executed
assert ['pi'] == schema['paths']['/api/pi/']['post']['tags'] # SCHEMA_PATH_PREFIX
assert 'XRequest' not in schema['components']['schemas'] # COMPONENT_SPLIT_REQUEST


@pytest.mark.urls(__name__)
def test_invalid_custom_settings():
with pytest.raises(AttributeError):
APIClient().get('/api/schema-invalid/')
with pytest.raises(AttributeError):
APIClient().get('/api/schema-invalid2/')

0 comments on commit 96d9729

Please sign in to comment.