diff --git a/drf_spectacular/contrib/django_filters.py b/drf_spectacular/contrib/django_filters.py index 6d4578be..662d712a 100644 --- a/drf_spectacular/contrib/django_filters.py +++ b/drf_spectacular/contrib/django_filters.py @@ -3,9 +3,10 @@ from drf_spectacular.drainage import add_trace_message, get_override, has_override, warn from drf_spectacular.extensions import OpenApiFilterExtension from drf_spectacular.plumbing import ( - build_array_type, build_basic_type, build_parameter_type, follow_field_source, get_manager, - get_type_hints, get_view_model, is_basic_type, + build_array_type, build_basic_type, build_choice_description_list, build_parameter_type, + follow_field_source, get_manager, get_type_hints, get_view_model, is_basic_type, ) +from drf_spectacular.settings import spectacular_settings from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import OpenApiParameter @@ -152,14 +153,10 @@ def resolve_filter_field(self, auto_schema, model, filterset_class, field_name, # explicit filter choices may disable enum retrieved from model if not schema_from_override and filter_choices is not None: enum = filter_choices - if enum: - schema['enum'] = sorted(enum, key=str) description = schema.pop('description', None) - if filter_field.extra.get('help_text', None): - description = filter_field.extra['help_text'] - elif filter_field.label is not None: - description = filter_field.label + if not schema_from_override: + description = self._get_field_description(filter_field, description) # parameter style variations based on filter base class if isinstance(filter_field, filters.BaseCSVFilter): @@ -194,6 +191,7 @@ def resolve_filter_field(self, auto_schema, model, filterset_class, field_name, location=OpenApiParameter.QUERY, description=description, schema=schema, + enum=enum, explode=explode, style=style ) @@ -249,6 +247,35 @@ def _get_schema_from_model_field(self, auto_schema, filter_field, model): model_field = qs.query.annotations[filter_field.field_name].field return auto_schema._map_model_field(model_field, direction=None) + def _get_field_description(self, filter_field, description): + # Try to improve description beyond auto-generated model description + if filter_field.extra.get('help_text', None): + description = filter_field.extra['help_text'] + elif filter_field.label is not None: + description = filter_field.label + + choices = filter_field.extra.get('choices') + if choices and callable(choices): + # remove auto-generated enum list, since choices come from a callable + if '\n\n*' in (description or ''): + description, _, _ = description.partition('\n\n*') + return description + + choice_description = '' + if spectacular_settings.ENUM_GENERATE_CHOICE_DESCRIPTION and choices and not callable(choices): + choice_description = build_choice_description_list(choices) + + if not choices: + return description + + if description: + # replace or append model choice description + if '\n\n*' in description: + description, _, _ = description.partition('\n\n*') + return description + '\n\n' + choice_description + else: + return choice_description + @classmethod def _is_gis(cls, field): if not getattr(cls, '_has_gis', True): diff --git a/drf_spectacular/hooks.py b/drf_spectacular/hooks.py index f87e8d1c..1ab6e742 100644 --- a/drf_spectacular/hooks.py +++ b/drf_spectacular/hooks.py @@ -119,6 +119,14 @@ def create_enum_component(name, schema): enum_schema = {k: v for k, v in prop_schema.items() if k in ['type', 'enum']} prop_schema = {k: v for k, v in prop_schema.items() if k not in ['type', 'enum']} + # separate actual description from name-value tuples + if spectacular_settings.ENUM_GENERATE_CHOICE_DESCRIPTION: + if prop_schema.get('description', '').startswith('*'): + enum_schema['description'] = prop_schema.pop('description') + elif '\n\n*' in prop_schema.get('description', ''): + _, _, post = prop_schema['description'].partition('\n\n*') + enum_schema['description'] = '*' + post + components = [ create_enum_component(enum_name, schema=enum_schema) ] diff --git a/drf_spectacular/openapi.py b/drf_spectacular/openapi.py index dc54e812..01b994ed 100644 --- a/drf_spectacular/openapi.py +++ b/drf_spectacular/openapi.py @@ -723,7 +723,10 @@ def _map_serializer_field(self, field, direction, bypass_extensions=False): return append_meta(build_array_type(build_choice_field(field)), meta) if isinstance(field, serializers.ChoiceField): - return append_meta(build_choice_field(field), meta) + schema = build_choice_field(field) + if 'description' in meta: + meta['description'] = meta['description'] + '\n\n' + schema.pop('description') + return append_meta(schema, meta) if isinstance(field, serializers.ListField): if isinstance(field.child, _UnvalidatedField): diff --git a/drf_spectacular/plumbing.py b/drf_spectacular/plumbing.py index 1a6cda9d..e3d4594a 100644 --- a/drf_spectacular/plumbing.py +++ b/drf_spectacular/plumbing.py @@ -371,9 +371,9 @@ def build_parameter_type( if enum: # in case of array schema, enum makes little sense on the array itself if schema['schema'].get('type') == 'array': - schema['schema']['items']['enum'] = sorted(enum) + schema['schema']['items']['enum'] = sorted(enum, key=str) else: - schema['schema']['enum'] = sorted(enum) + schema['schema']['enum'] = sorted(enum, key=str) if pattern is not None: # in case of array schema, pattern only makes sense on the items if schema['schema'].get('type') == 'array': @@ -423,9 +423,17 @@ def build_choice_field(field): # Ref: https://tools.ietf.org/html/draft-wright-json-schema-validation-00#section-5.21 if type: schema['type'] = type + + if spectacular_settings.ENUM_GENERATE_CHOICE_DESCRIPTION: + schema['description'] = build_choice_description_list(field.choices.items()) + return schema +def build_choice_description_list(choices) -> str: + return '\n'.join(f'* `{value}` - {label}' for value, label in choices) + + def build_bearer_security_scheme_object(header_name, token_prefix, bearer_format=None): """ Either build a bearer scheme or a fallback due to OpenAPI 3.0.3 limitations """ # normalize Django header quirks diff --git a/drf_spectacular/settings.py b/drf_spectacular/settings.py index c935753c..a8e77f28 100644 --- a/drf_spectacular/settings.py +++ b/drf_spectacular/settings.py @@ -112,6 +112,8 @@ 'ENUM_NAME_OVERRIDES': {}, # Adds "blank" and "null" enum choices where appropriate. disable on client generation issues 'ENUM_ADD_EXPLICIT_BLANK_NULL_CHOICE': True, + # Add/Append a list of (``choice value`` - choice name) to the enum description string. + 'ENUM_GENERATE_CHOICE_DESCRIPTION': 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', diff --git a/tests/contrib/test_django_filters.yml b/tests/contrib/test_django_filters.yml index efeba6ba..962a9eb7 100644 --- a/tests/contrib/test_django_filters.yml +++ b/tests/contrib/test_django_filters.yml @@ -24,7 +24,11 @@ paths: enum: - A - B - description: some category description + description: |- + some category description + + * `A` - aaa + * `B` - b - in: query name: choice_field_enum_override schema: @@ -101,7 +105,11 @@ paths: enum: - A - B - description: some category description + description: |- + some category description + + * `A` - aaa + * `B` - b explode: true style: form - in: query @@ -119,7 +127,11 @@ paths: enum: - A - B - description: some category description + description: |- + some category description + + * `A` - aaa + * `B` - b - in: query name: number_id schema: @@ -140,7 +152,13 @@ paths: - -price - in_stock - price - description: Ordering + description: |- + Ordering + + * `price` - Price + * `-price` - Price (descending) + * `in_stock` - in stock + * `-in_stock` - in stock (descending) explode: false style: form - in: query @@ -191,6 +209,7 @@ paths: type: integer enum: - 1 + description: '* `1` - one' - in: query name: untyped_multiple_choice_field_method_with_explicit_choices schema: @@ -199,6 +218,7 @@ paths: type: integer enum: - 1 + description: '* `1` - one' explode: true style: form tags: @@ -246,6 +266,9 @@ components: - A - B type: string + description: |- + * `A` - aaa + * `B` - b Product: type: object properties: @@ -255,7 +278,11 @@ components: category: allOf: - $ref: '#/components/schemas/CategoryEnum' - description: some category description + description: |- + some category description + + * `A` - aaa + * `B` - b in_stock: type: boolean price: diff --git a/tests/test_basic.yml b/tests/test_basic.yml index 7d7d7db3..788ffac9 100644 --- a/tests/test_basic.yml +++ b/tests/test_basic.yml @@ -212,6 +212,9 @@ components: - POP - ROCK type: string + description: |- + * `POP` - Pop + * `ROCK` - Rock PatchedAlbum: type: object properties: diff --git a/tests/test_extend_schema.yml b/tests/test_extend_schema.yml index 415eaff0..05c50cea 100644 --- a/tests/test_extend_schema.yml +++ b/tests/test_extend_schema.yml @@ -269,6 +269,10 @@ paths: - b - c type: string + description: |- + * `a` - a + * `b` - b + * `c` - c default: - a - in: query @@ -367,6 +371,9 @@ components: - a - b type: string + description: |- + * `a` - a + * `b` - b GammaEpsilon: type: object properties: @@ -394,6 +401,10 @@ components: - b - c type: string + description: |- + * `a` - a + * `b` - b + * `c` - c Query: type: object properties: diff --git a/tests/test_postprocessing.yml b/tests/test_postprocessing.yml index 8d408d08..d32c10e1 100644 --- a/tests/test_postprocessing.yml +++ b/tests/test_postprocessing.yml @@ -70,6 +70,11 @@ components: - ru - cn type: string + description: |- + * `en` - en + * `es` - es + * `ru` - ru + * `cn` - cn NullEnum: enum: - null @@ -79,6 +84,10 @@ components: - 0 - -1 type: integer + description: |- + * `1` - Positive + * `0` - Neutral + * `-1` - Negative securitySchemes: basicAuth: type: http