Add support for SerializerMethodField (#179)

Closes #137, #179.
openapi3
John Carter 2018-08-08 06:23:36 +12:00 committed by Cristi Vîjdea
parent 1dd7cfe043
commit 748b5d3c2f
12 changed files with 507 additions and 4 deletions

View File

@ -4,9 +4,22 @@ Changelog
**********
**1.9.2**
**1.10.0**
**********
*Release date: TBD, 2018*
- **IMPROVED:** added support for ``SerializerMethodField``, via the ``swagger_serializer_method`` decorator for the
method field, and support for Python 3.5 style type hinting of the method field return type
(:issue:`137`, :pr:`175`, :pr:`179`)
*NOTE:* in order for this to work, you will have to add the new ``drf_yasg.inspectors.SerializerMethodFieldInspector``
to your ``DEFAULT_FIELD_INSPECTORS`` array if you changed it from the default value
*********
**1.9.2**
*********
*Release date: Aug 03, 2018*
- **IMPROVED:** updated ``swagger-ui`` to version 3.17.6

View File

@ -155,6 +155,49 @@ Where you can use the :func:`@swagger_auto_schema <.swagger_auto_schema>` decora
replacing/decorating methods on the base class itself.
*********************************
Support for SerializerMethodField
*********************************
Schema generation of ``serializers.SerializerMethodField`` supported in two ways:
1) The decorator ``swagger_serializer_method(serializer)`` for the use case where the serializer method
is using a serializer. e.g.:
.. code-block:: python
from drf_yasg.utils import swagger_serializer_method
class OtherStuffSerializer(serializers.Serializer):
foo = serializers.CharField()
class ParentSerializer(serializers.Serializer):
other_stuff = serializers.SerializerMethodField()
@swagger_serializer_method(serializer=OtherStuffSerializer)
def get_other_stuff(self, obj):
return OtherStuffSerializer().data
Note that the serializer parameter can be either be a serializer class or instance
2) For simple cases where the method is returning one of the supported types,
`Python 3 type hinting`_ of the serializer method return value can be used. e.g.:
.. code-block:: python
class SomeSerializer(serializers.Serializer):
some_number = serializers.SerializerMethodField()
def get_some_number(self, obj) -> float:
return 1.0
********************************
Serializer ``Meta`` nested class
********************************
@ -333,3 +376,6 @@ A second example, of a :class:`~.inspectors.FieldInspector` that removes the ``t
Another caveat that stems from this is that any serializer named "``NestedSerializer``" will be forced inline
unless it has a ``ref_name`` set explicitly.
.. _Python 3 type hinting: https://docs.python.org/3/library/typing.html

View File

@ -76,6 +76,7 @@ to this list.
:class:`'drf_yasg.inspectors.DictFieldInspector' <.inspectors.DictFieldInspector>`, |br| \
:class:`'drf_yasg.inspectors.HiddenFieldInspector' <.inspectors.HiddenFieldInspector>`, |br| \
:class:`'drf_yasg.inspectors.RecursiveFieldInspector' <.inspectors.RecursiveFieldInspector>`, |br| \
:class:`'drf_yasg.inspectors.SerializerMethodFieldInspector' <.inspectors.SerializerMethodFieldInspector>`, |br| \
:class:`'drf_yasg.inspectors.SimpleFieldInspector' <.inspectors.SimpleFieldInspector>`, |br| \
:class:`'drf_yasg.inspectors.StringDefaultFieldInspector' <.inspectors.StringDefaultFieldInspector>`, |br| \
``]``

View File

@ -14,6 +14,7 @@ SWAGGER_DEFAULTS = {
'drf_yasg.inspectors.DictFieldInspector',
'drf_yasg.inspectors.HiddenFieldInspector',
'drf_yasg.inspectors.RelatedFieldInspector',
'drf_yasg.inspectors.SerializerMethodFieldInspector',
'drf_yasg.inspectors.SimpleFieldInspector',
'drf_yasg.inspectors.StringDefaultFieldInspector',
],

View File

@ -5,7 +5,7 @@ from .base import (
from .field import (
CamelCaseJSONFilter, ChoiceFieldInspector, DictFieldInspector, FileFieldInspector, HiddenFieldInspector,
InlineSerializerInspector, RecursiveFieldInspector, ReferencingSerializerInspector, RelatedFieldInspector,
SimpleFieldInspector, StringDefaultFieldInspector
SerializerMethodFieldInspector, SimpleFieldInspector, StringDefaultFieldInspector
)
from .query import CoreAPICompatInspector, DjangoRestResponsePagination
from .view import SwaggerAutoSchema
@ -25,7 +25,7 @@ __all__ = [
# field inspectors
'InlineSerializerInspector', 'RecursiveFieldInspector', 'ReferencingSerializerInspector', 'RelatedFieldInspector',
'SimpleFieldInspector', 'FileFieldInspector', 'ChoiceFieldInspector', 'DictFieldInspector',
'StringDefaultFieldInspector', 'CamelCaseJSONFilter', 'HiddenFieldInspector',
'StringDefaultFieldInspector', 'CamelCaseJSONFilter', 'HiddenFieldInspector', 'SerializerMethodFieldInspector',
# view inspectors
'SwaggerAutoSchema',

View File

@ -1,5 +1,8 @@
import datetime
import inspect
import logging
import operator
import uuid
from collections import OrderedDict
from decimal import Decimal
@ -13,6 +16,12 @@ from ..errors import SwaggerGenerationError
from ..utils import decimal_as_float, filter_none, get_serializer_ref_name
from .base import FieldInspector, NotHandled, SerializerInspector
try:
# Python>=3.5
import typing
except ImportError:
typing = None
logger = logging.getLogger(__name__)
@ -409,6 +418,118 @@ def get_basic_type_info(field):
return result
def decimal_return_type():
return openapi.TYPE_STRING if rest_framework_settings.COERCE_DECIMAL_TO_STRING else openapi.TYPE_NUMBER
raw_type_info = [
(bool, (openapi.TYPE_BOOLEAN, None)),
(int, (openapi.TYPE_INTEGER, None)),
(float, (openapi.TYPE_NUMBER, None)),
(Decimal, (decimal_return_type, openapi.FORMAT_DECIMAL)),
(uuid.UUID, (openapi.TYPE_STRING, openapi.FORMAT_UUID)),
(datetime.datetime, (openapi.TYPE_STRING, openapi.FORMAT_DATETIME)),
(datetime.date, (openapi.TYPE_STRING, openapi.FORMAT_DATE)),
# TODO - support typing.List etc
]
hinting_type_info = raw_type_info
def get_basic_type_info_from_hint(hint_class):
"""Given a class (eg from a SerializerMethodField's return type hint,
return its basic type information - ``type``, ``format``, ``pattern``,
and any applicable min/max limit values.
:param hint_class: the class
:return: the extracted attributes as a dictionary, or ``None`` if the field type is not known
:rtype: OrderedDict
"""
for check_class, type_format in hinting_type_info:
if issubclass(hint_class, check_class):
swagger_type, format = type_format
if callable(swagger_type):
swagger_type = swagger_type()
# if callable(format):
# format = format(klass)
break
else: # pragma: no cover
return None
pattern = None
result = OrderedDict([
('type', swagger_type),
('format', format),
('pattern', pattern)
])
return result
class SerializerMethodFieldInspector(FieldInspector):
"""Provides conversion for SerializerMethodField, optionally using information from the swagger_method_field
decorator
"""
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
if not isinstance(field, serializers.SerializerMethodField):
return NotHandled
method = getattr(field.parent, field.method_name)
if method is None:
return NotHandled
serializer = getattr(method, "_swagger_serializer", None)
if serializer:
# attribute added by the swagger_serializer_method decorator
serializer = getattr(method, '_swagger_serializer', None)
# in order of preference for description, use:
# 1) field.help_text from SerializerMethodField(help_text)
# 2) serializer.help_text from swagger_serializer_method(serializer)
# 3) method's docstring
description = field.help_text
if description is None:
description = getattr(serializer, 'help_text', None)
if description is None:
description = method.__doc__
label = field.label
if label is None:
label = getattr(serializer, 'label', None)
if inspect.isclass(serializer):
serializer_kwargs = {
"help_text": description,
"label": label,
"read_only": True,
}
serializer = method._swagger_serializer(**serializer_kwargs)
else:
serializer.help_text = description
serializer.label = label
serializer.read_only = True
return self.probe_field_inspectors(serializer, swagger_object_type, use_references, read_only=True)
elif typing:
# look for Python 3.5+ style type hinting of the return value
hint_class = inspect.signature(method).return_annotation
if not issubclass(hint_class, inspect._empty):
type_info = get_basic_type_info_from_hint(hint_class)
if type_info is not None:
SwaggerType, ChildSwaggerType = self._get_partial_types(field, swagger_object_type,
use_references, **kwargs)
return SwaggerType(**type_info)
return NotHandled
class SimpleFieldInspector(FieldInspector):
"""Provides conversions for fields which can be described using just ``type``, ``format``, ``pattern``
and min/max validators.
@ -531,6 +652,7 @@ else:
"""Hack to force ``djangorestframework_camel_case`` to camelize a plain string."""
return next(iter(camelize({s: ''})))
def camelize_schema(schema_or_ref, components):
"""Recursively camelize property names for the given schema using ``djangorestframework_camel_case``."""
schema = openapi.resolve_ref(schema_or_ref, components)
@ -545,6 +667,7 @@ else:
return schema_or_ref
class CamelCaseJSONFilter(FieldInspector):
"""Converts property names to camelCase if ``CamelCaseJSONParser`` or ``CamelCaseJSONRenderer`` are used."""
@ -569,6 +692,7 @@ except ImportError: # pragma: no cover
else:
class RecursiveFieldInspector(FieldInspector):
"""Provides conversion for RecursiveField (https://github.com/heywbj/django-rest-framework-recursive)"""
def field_to_swagger_object(self, field, swagger_object_type, use_references, **kwargs):
if isinstance(field, RecursiveField) and swagger_object_type == openapi.Schema:
assert use_references is True, "Can not create schema for RecursiveField when use_references is False"

View File

@ -170,6 +170,23 @@ def swagger_auto_schema(method=None, methods=None, auto_schema=unset, request_bo
return decorator
def swagger_serializer_method(serializer):
"""
Decorates the method of a serializers.SerializerMethodField
to hint as to how Swagger should be generated for this field.
:param serializer: serializer class or instance
:return:
"""
def decorator(serializer_method):
# stash the serializer for SerializerMethodFieldInspector to find
serializer_method._swagger_serializer = serializer
return serializer_method
return decorator
def is_list_view(path, method, view):
"""Check if the given path/method appears to represent a list view (as opposed to a detail/instance view).

View File

@ -0,0 +1,69 @@
import datetime
import decimal
import uuid
from rest_framework import serializers
class Unknown(object):
pass
class MethodFieldExampleSerializer(serializers.Serializer):
"""
Implementation of SerializerMethodField using type hinting for Python >= 3.5
"""
hinted_bool = serializers.SerializerMethodField(
help_text="the type hint on the method should determine this to be a bool")
def get_hinted_bool(self, obj) -> bool:
return True
hinted_int = serializers.SerializerMethodField(
help_text="the type hint on the method should determine this to be an integer")
def get_hinted_int(self, obj) -> int:
return 1
hinted_float = serializers.SerializerMethodField(
help_text="the type hint on the method should determine this to be a number")
def get_hinted_float(self, obj) -> float:
return 1.0
hinted_decimal = serializers.SerializerMethodField(
help_text="the type hint on the method should determine this to be a decimal")
def get_hinted_decimal(self, obj) -> decimal.Decimal:
return decimal.Decimal(1)
hinted_datetime = serializers.SerializerMethodField(
help_text="the type hint on the method should determine this to be a datetime")
def get_hinted_datetime(self, obj) -> datetime.datetime:
return datetime.datetime.now()
hinted_date = serializers.SerializerMethodField(
help_text="the type hint on the method should determine this to be a date")
def get_hinted_date(self, obj) -> datetime.date:
return datetime.date.today()
hinted_uuid = serializers.SerializerMethodField(
help_text="the type hint on the method should determine this to be a uuid")
def get_hinted_uuid(self, obj) -> uuid.UUID:
return uuid.uuid4()
hinted_unknown = serializers.SerializerMethodField(
help_text="type hint is unknown, so is expected to fallback to string")
def get_hinted_unknown(self, obj) -> Unknown:
return Unknown()
non_hinted_number = serializers.SerializerMethodField(
help_text="No hint on the method, so this is expected to fallback to string")
def get_non_hinted_number(self, obj):
return 1.0

View File

@ -0,0 +1,82 @@
import datetime
import decimal
import uuid
from rest_framework import serializers
from drf_yasg.utils import swagger_serializer_method
class Unknown(object):
pass
class MethodFieldExampleSerializer(serializers.Serializer):
"""
Fallback implementation of SerializerMethodField type hinting for Python < 3.5
`->` syntax isn't supported, instead decorate with a serializer that returns the same type
a bit of a hack, but it provides a cross-check between hinting and decorator functionality.
"""
hinted_bool = serializers.SerializerMethodField(
help_text="the type hint on the method should determine this to be a bool")
@swagger_serializer_method(serializer=serializers.BooleanField)
def get_hinted_bool(self, obj):
return True
hinted_int = serializers.SerializerMethodField(
help_text="the type hint on the method should determine this to be an integer")
@swagger_serializer_method(serializer=serializers.IntegerField)
def get_hinted_int(self, obj):
return 1
hinted_float = serializers.SerializerMethodField(
help_text="the type hint on the method should determine this to be a number")
@swagger_serializer_method(serializer=serializers.FloatField)
def get_hinted_float(self, obj):
return 1.0
hinted_decimal = serializers.SerializerMethodField(
help_text="the type hint on the method should determine this to be a decimal")
# note that in this case an instance is required since DecimalField has required arguments
@swagger_serializer_method(serializer=serializers.DecimalField(max_digits=6, decimal_places=4))
def get_hinted_decimal(self, obj):
return decimal.Decimal(1)
hinted_datetime = serializers.SerializerMethodField(
help_text="the type hint on the method should determine this to be a datetime")
@swagger_serializer_method(serializer=serializers.DateTimeField)
def get_hinted_datetime(self, obj):
return datetime.datetime.now()
hinted_date = serializers.SerializerMethodField(
help_text="the type hint on the method should determine this to be a date")
@swagger_serializer_method(serializer=serializers.DateField)
def get_hinted_date(self, obj):
return datetime.date.today()
hinted_uuid = serializers.SerializerMethodField(
help_text="the type hint on the method should determine this to be a uuid")
@swagger_serializer_method(serializer=serializers.UUIDField)
def get_hinted_uuid(self, obj):
return uuid.uuid4()
hinted_unknown = serializers.SerializerMethodField(
help_text="type hint is unknown, so is expected to fallback to string")
def get_hinted_unknown(self, obj):
return Unknown()
non_hinted_number = serializers.SerializerMethodField(
help_text="No hint on the method, so this is expected to fallback to string")
def get_non_hinted_number(self, obj):
return 1.0

View File

@ -1,8 +1,19 @@
from django.contrib.auth.models import User
from rest_framework import serializers
from drf_yasg.utils import swagger_serializer_method
from snippets.models import Snippet
try:
import typing
from .method_serializers_with_typing import MethodFieldExampleSerializer
except ImportError:
from .method_serializers_without_typing import MethodFieldExampleSerializer
class OtherStuffSerializer(serializers.Serializer):
foo = serializers.CharField()
class UserSerializerrr(serializers.ModelSerializer):
snippets = serializers.PrimaryKeyRelatedField(many=True, queryset=Snippet.objects.all())
@ -10,10 +21,61 @@ class UserSerializerrr(serializers.ModelSerializer):
last_connected_ip = serializers.IPAddressField(help_text="i'm out of ideas", protocol='ipv4', read_only=True)
last_connected_at = serializers.DateField(help_text="really?", read_only=True)
other_stuff = serializers.SerializerMethodField(
help_text="the decorator should determine the serializer class for this")
hint_example = MethodFieldExampleSerializer()
@swagger_serializer_method(serializer=OtherStuffSerializer)
def get_other_stuff(self, obj):
"""
method_field that uses a serializer internally.
By using the decorator, we can tell drf-yasg how to represent this in Swagger
:param obj:
:return:
"""
return OtherStuffSerializer().data
help_text_example_1 = serializers.SerializerMethodField(
help_text="help text on field is set, so this should appear in swagger"
)
@swagger_serializer_method(serializer=serializers.IntegerField(
help_text="decorated instance help_text shouldn't appear in swagger because field has priority"))
def get_help_text_example_1(self):
"""
method docstring shouldn't appear in swagger because field has priority
:return:
"""
return 1
help_text_example_2 = serializers.SerializerMethodField()
@swagger_serializer_method(serializer=serializers.IntegerField(
help_text="instance help_text is set, so should appear in swagger"))
def get_help_text_example_2(self):
"""
method docstring shouldn't appear in swagger because decorator has priority
:return:
"""
return 1
help_text_example_3 = serializers.SerializerMethodField()
@swagger_serializer_method(serializer=serializers.IntegerField())
def get_help_text_example_3(self):
"""
docstring is set so should appear in swagger as fallback
:return:
"""
return 1
class Meta:
model = User
fields = ('id', 'username', 'email', 'articles', 'snippets',
'last_connected_ip', 'last_connected_at', 'article_slugs')
'last_connected_ip', 'last_connected_at', 'article_slugs', 'other_stuff', 'hint_example',
'help_text_example_1', 'help_text_example_2', 'help_text_example_3')
class UserListQuerySerializer(serializers.Serializer):

View File

@ -1582,11 +1582,77 @@ definitions:
todo:
title: child
todo: null
OtherStuff:
title: Other stuff
description: the decorator should determine the serializer class for this
required:
- foo
type: object
properties:
foo:
title: Foo
type: string
minLength: 1
readOnly: true
MethodFieldExample:
title: Hint example
type: object
properties:
hinted_bool:
title: Hinted bool
description: the type hint on the method should determine this to be a bool
type: boolean
readOnly: true
hinted_int:
title: Hinted int
description: the type hint on the method should determine this to be an integer
type: integer
readOnly: true
hinted_float:
title: Hinted float
description: the type hint on the method should determine this to be a number
type: number
readOnly: true
hinted_decimal:
title: Hinted decimal
description: the type hint on the method should determine this to be a decimal
type: string
format: decimal
readOnly: true
hinted_datetime:
title: Hinted datetime
description: the type hint on the method should determine this to be a datetime
type: string
format: date-time
readOnly: true
hinted_date:
title: Hinted date
description: the type hint on the method should determine this to be a date
type: string
format: date
readOnly: true
hinted_uuid:
title: Hinted uuid
description: the type hint on the method should determine this to be a uuid
type: string
format: uuid
readOnly: true
hinted_unknown:
title: Hinted unknown
description: type hint is unknown, so is expected to fallback to string
type: string
readOnly: true
non_hinted_number:
title: Non hinted number
description: No hint on the method, so this is expected to fallback to string
type: string
readOnly: true
UserSerializerrr:
required:
- username
- articles
- snippets
- hint_example
type: object
properties:
id:
@ -1637,3 +1703,23 @@ definitions:
pattern: ^[-a-zA-Z0-9_]+$
readOnly: true
uniqueItems: true
other_stuff:
$ref: '#/definitions/OtherStuff'
hint_example:
$ref: '#/definitions/MethodFieldExample'
help_text_example_1:
title: Help text example 1
description: help text on field is set, so this should appear in swagger
type: integer
readOnly: true
help_text_example_2:
title: Help text example 2
description: instance help_text is set, so should appear in swagger
type: integer
readOnly: true
help_text_example_3:
title: Help text example 3
description: "\n docstring is set so should appear in swagger as fallback\n\
\ :return:\n "
type: integer
readOnly: true

View File

@ -33,12 +33,14 @@ commands =
[testenv:lint]
skip_install = true
deps =
-rrequirements/setup.txt
-rrequirements/lint.txt
commands =
flake8 src/drf_yasg testproj tests setup.py
[testenv:docs]
deps =
-rrequirements/setup.txt
-rrequirements/docs.txt
commands =
python setup.py check --restructuredtext --metadata --strict