diff --git a/docs/changelog.rst b/docs/changelog.rst index 30cc57b..59b5dd4 100644 --- a/docs/changelog.rst +++ b/docs/changelog.rst @@ -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 diff --git a/docs/custom_spec.rst b/docs/custom_spec.rst index 646ea5a..418ce9c 100644 --- a/docs/custom_spec.rst +++ b/docs/custom_spec.rst @@ -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 diff --git a/docs/settings.rst b/docs/settings.rst index 2eaa20b..afe7c03 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -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| \ ``]`` diff --git a/src/drf_yasg/app_settings.py b/src/drf_yasg/app_settings.py index 2edde3a..f7dfc9e 100644 --- a/src/drf_yasg/app_settings.py +++ b/src/drf_yasg/app_settings.py @@ -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', ], diff --git a/src/drf_yasg/inspectors/__init__.py b/src/drf_yasg/inspectors/__init__.py index 3267b47..cb979c8 100644 --- a/src/drf_yasg/inspectors/__init__.py +++ b/src/drf_yasg/inspectors/__init__.py @@ -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', diff --git a/src/drf_yasg/inspectors/field.py b/src/drf_yasg/inspectors/field.py index d1ea24f..fc6db02 100644 --- a/src/drf_yasg/inspectors/field.py +++ b/src/drf_yasg/inspectors/field.py @@ -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" diff --git a/src/drf_yasg/utils.py b/src/drf_yasg/utils.py index 72a0d46..e7e20a1 100644 --- a/src/drf_yasg/utils.py +++ b/src/drf_yasg/utils.py @@ -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). diff --git a/testproj/users/method_serializers_with_typing.py b/testproj/users/method_serializers_with_typing.py new file mode 100644 index 0000000..66f441e --- /dev/null +++ b/testproj/users/method_serializers_with_typing.py @@ -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 diff --git a/testproj/users/method_serializers_without_typing.py b/testproj/users/method_serializers_without_typing.py new file mode 100644 index 0000000..81edd86 --- /dev/null +++ b/testproj/users/method_serializers_without_typing.py @@ -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 diff --git a/testproj/users/serializers.py b/testproj/users/serializers.py index c10f076..2289273 100644 --- a/testproj/users/serializers.py +++ b/testproj/users/serializers.py @@ -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): diff --git a/tests/reference.yaml b/tests/reference.yaml index 922303f..4a04bc1 100644 --- a/tests/reference.yaml +++ b/tests/reference.yaml @@ -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 diff --git a/tox.ini b/tox.ini index 3789fd0..d3d8832 100644 --- a/tox.ini +++ b/tox.ini @@ -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