From c82d81a6846e53e5889020911c55838496910fbc Mon Sep 17 00:00:00 2001 From: John Vandenberg Date: Sat, 11 Jul 2020 19:24:22 +0700 Subject: [PATCH] Emit enum of possible format suffixes Use DRF renderer based list of possible formats, and filtered by the URLConf-based `format_suffix_patterns` formats if used. Related to https://github.com/tfranzel/drf-spectacular/issues/110 --- drf_spectacular/openapi.py | 14 +++++++++++++- drf_spectacular/plumbing.py | 11 +++++++++-- tests/test_regressions.py | 33 ++++++++++++++++++++++++++++----- 3 files changed, 50 insertions(+), 8 deletions(-) diff --git a/drf_spectacular/openapi.py b/drf_spectacular/openapi.py index 42408146..ca3550e1 100644 --- a/drf_spectacular/openapi.py +++ b/drf_spectacular/openapi.py @@ -165,6 +165,7 @@ def dict_helper(parameters): # override/add @extend_schema parameters for key, parameter in override_parameters.items(): parameters[key] = parameter + return sorted(parameters.values(), key=lambda p: p['name']) def get_description(self): @@ -270,7 +271,9 @@ def _resolve_path_parameters(self, variables): description = '' required = True - resolved_parameter = resolve_regex_path_parameter(self.path_regex, variable) + resolved_parameter = resolve_regex_path_parameter( + self.path_regex, variable, self.map_formats(), + ) if resolved_parameter: schema, required = resolved_parameter['schema'], resolved_parameter['required'] @@ -732,6 +735,15 @@ def map_renderers(self): media_types.append(renderer.media_type) return media_types + def map_formats(self): + formats = set() + for renderer in self.view.renderer_classes: + # BrowsableAPIRenderer not relevant to OpenAPI spec + if renderer == renderers.BrowsableAPIRenderer: + continue + formats.add(renderer.format) + return list(formats) + def _get_serializer(self): view = self.view try: diff --git a/drf_spectacular/plumbing.py b/drf_spectacular/plumbing.py index aad7a2e9..b60247d3 100644 --- a/drf_spectacular/plumbing.py +++ b/drf_spectacular/plumbing.py @@ -643,22 +643,29 @@ def iter_prop_containers(schema): return result -def resolve_regex_path_parameter(path_regex, variable): +def resolve_regex_path_parameter(path_regex, variable, available_formats=None): """ convert django style path parameters to OpenAPI parameters. TODO also try to handle regular grouped regex parameters """ for match in _PATH_PARAMETER_COMPONENT_RE.finditer(path_regex): converter, parameter = match.group('converter'), match.group('parameter') + enum_values = None if converter and converter.startswith('drf_format_suffix_'): - converter = 'drf_format_suffix' # remove appended options + view_formats = converter[len('drf_format_suffix_'):].split('_') + enum_values = [f'.{suffix}' for suffix in view_formats + if suffix in available_formats] + converter = 'drf_format_suffix' + elif converter == 'drf_format_suffix': + enum_values = [f'.{suffix}' for suffix in available_formats] if parameter == variable and converter in DJANGO_PATH_CONVERTER_MAPPING: return build_parameter_type( name=parameter, schema=build_basic_type(DJANGO_PATH_CONVERTER_MAPPING[converter]), location=OpenApiParameter.PATH, + enum=enum_values, ) return None diff --git a/tests/test_regressions.py b/tests/test_regressions.py index e40ff775..634665bf 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -1,6 +1,7 @@ import uuid from unittest import mock +import pytest from django.conf.urls import url from django.db import models from django.db.models import fields @@ -316,25 +317,47 @@ class YViewSet(viewsets.ReadOnlyModelViewSet): assert schema['components']['schemas']['Y']['properties']['x']['format'] == 'uuid' -def test_drf_format_suffix_parameter(no_warnings): +@pytest.mark.parametrize('allowed', [None, ['json', 'api']]) +def test_drf_format_suffix_parameter(no_warnings, allowed): from rest_framework.urlpatterns import format_suffix_patterns @extend_schema(responses=OpenApiTypes.FLOAT) @api_view(['GET']) - def pi(request, format=None): + def view_func(request, format=None): pass # pragma: no cover - urlpatterns = [path('pi', pi)] - urlpatterns = format_suffix_patterns(urlpatterns, allowed=['json', 'html']) + urlpatterns = [ + path('pi', view_func), + path('pi/', view_func), + path('pi/subpath', view_func), + path('pick', view_func), + ] + urlpatterns = format_suffix_patterns(urlpatterns, allowed=allowed) generator = SchemaGenerator(patterns=urlpatterns) schema = generator.get_schema(request=None, public=True) validate_schema(schema) - assert len(schema['paths']) == 2 + + # Only seven alternatives are created, as /pi/{format} would be + # /pi/.json which is not supported. + assert list(schema['paths'].keys()) == [ + '/pi', + '/pi/', + '/pi/subpath', + '/pi/subpath{format}', + '/pick', + '/pick{format}', + '/pi{format}', + ] format_parameter = schema['paths']['/pi{format}']['get']['parameters'][0] assert format_parameter['name'] == 'format' assert format_parameter['required'] is True assert format_parameter['in'] == 'path' + assert format_parameter['schema']['type'] == 'string' + # When allowed is not specified, all of the default formats are possible. + # Even if other values are provided, only the valid formats are possible. + allowed = ['json'] + assert format_parameter['schema']['enum'] == [f'.{suffix}' for suffix in allowed] def test_regex_path_parameter_discovery(no_warnings):