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

Enum key/value description #952

Merged
merged 2 commits into from
Mar 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 35 additions & 8 deletions drf_spectacular/contrib/django_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
)
Expand Down Expand Up @@ -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):
Expand Down
8 changes: 8 additions & 0 deletions drf_spectacular/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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*')
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apology for commenting on merging MR, @tfranzel .

Just a question about these changes...

We have a serializer with field (choices) and it has help_text. Unfortunately this help text is not preserved in the description of the enum due to this line. How we can preserve this description along with choices?

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@sshishov, the help_text should still be there in the serializer component (NOT the enum component as it belongs to the field). This could otherwise lead confusing descriptions actually belonging somewhere else.

If that is not the case, please open new issue with a reproduction and full example. thx

enum_schema['description'] = '*' + post

components = [
create_enum_component(enum_name, schema=enum_schema)
]
Expand Down
5 changes: 4 additions & 1 deletion drf_spectacular/openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Copy link

@HansAarneLiblik HansAarneLiblik Mar 6, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This generates a KeyError if schema does not have description. You need to use schema.pop('description', None)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@tfranzel this is breaking our current build, because we set "ENUM_GENERATE_CHOICE_DESCRIPTION": False

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Damn, can't seem do a release without a .1 apparently.

You need to use schema.pop('description', None)

would lead to a None str concat TypeError, but I get the point.

Copy link
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Will wait for the release until tonight to see whether something else will come up.

return append_meta(schema, meta)

if isinstance(field, serializers.ListField):
if isinstance(field.child, _UnvalidatedField):
Expand Down
12 changes: 10 additions & 2 deletions drf_spectacular/plumbing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions drf_spectacular/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
37 changes: 32 additions & 5 deletions tests/contrib/test_django_filters.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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
Expand Down Expand Up @@ -191,6 +209,7 @@ paths:
type: integer
enum:
- 1
description: '* `1` - one'
- in: query
name: untyped_multiple_choice_field_method_with_explicit_choices
schema:
Expand All @@ -199,6 +218,7 @@ paths:
type: integer
enum:
- 1
description: '* `1` - one'
explode: true
style: form
tags:
Expand Down Expand Up @@ -246,6 +266,9 @@ components:
- A
- B
type: string
description: |-
* `A` - aaa
* `B` - b
Product:
type: object
properties:
Expand All @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions tests/test_basic.yml
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ components:
- POP
- ROCK
type: string
description: |-
* `POP` - Pop
* `ROCK` - Rock
PatchedAlbum:
type: object
properties:
Expand Down
11 changes: 11 additions & 0 deletions tests/test_extend_schema.yml
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,10 @@ paths:
- b
- c
type: string
description: |-
* `a` - a
* `b` - b
* `c` - c
default:
- a
- in: query
Expand Down Expand Up @@ -367,6 +371,9 @@ components:
- a
- b
type: string
description: |-
* `a` - a
* `b` - b
GammaEpsilon:
type: object
properties:
Expand Down Expand Up @@ -394,6 +401,10 @@ components:
- b
- c
type: string
description: |-
* `a` - a
* `b` - b
* `c` - c
Query:
type: object
properties:
Expand Down
9 changes: 9 additions & 0 deletions tests/test_postprocessing.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ components:
- ru
- cn
type: string
description: |-
* `en` - en
* `es` - es
* `ru` - ru
* `cn` - cn
NullEnum:
enum:
- null
Expand All @@ -79,6 +84,10 @@ components:
- 0
- -1
type: integer
description: |-
* `1` - Positive
* `0` - Neutral
* `-1` - Negative
securitySchemes:
basicAuth:
type: http
Expand Down