From 73bd7a136d5bbb02fbbaa16795fa90f363683337 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cristi=20V=C3=AEjdea?= Date: Sat, 16 Dec 2017 15:37:42 +0100 Subject: [PATCH] Add query_serializer argument to swagger_auto_schema (#17) Closes #16. --- CONTRIBUTING.rst | 2 +- docs/conf.py | 9 ++-- docs/custom_spec.rst | 61 ++++++++++++++++++++++++- src/drf_yasg/inspectors.py | 84 ++++++++++++++++++++++++++--------- src/drf_yasg/openapi.py | 5 +++ src/drf_yasg/utils.py | 45 ++++++++++++++++--- testproj/users/serializers.py | 5 +++ testproj/users/views.py | 4 +- tests/reference.yaml | 12 ++++- 9 files changed, 190 insertions(+), 37 deletions(-) diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index 6278924..1b239ad 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -77,7 +77,7 @@ You want to contribute some code? Great! Here are a few steps to get you started #. Push your branch and submit a pull request to the master branch on GitHub - Incomplete/Work In Progress pull requrests are encouraged, because they allow you to get feedback and help more + Incomplete/Work In Progress pull requests are encouraged, because they allow you to get feedback and help more easily. #. Your code must pass all the required travis jobs before it is merged. As of now, this includes running on diff --git a/docs/conf.py b/docs/conf.py index ccabf80..794ab32 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -196,6 +196,7 @@ nitpick_ignore = [ ('py:obj', 'APIView'), ] +sys.path.insert(0, os.path.abspath('../src')) sys.path.insert(0, os.path.abspath('../testproj')) os.putenv('DJANGO_SETTINGS_MODULE', 'testproj.settings') @@ -208,8 +209,8 @@ import drf_yasg.views # noqa: E402 # instantiate a SchemaView in the views module to make it available to autodoc drf_yasg.views.SchemaView = drf_yasg.views.get_schema_view(None) -ghiss_uri = "https://github.com/axnsan12/drf-yasg/issues/%d" -ghpr_uri = "https://github.com/axnsan12/drf-yasg/pull/%d" +gh_issue_uri = "https://github.com/axnsan12/drf-yasg/issues/%d" +gh_pr_uri = "https://github.com/axnsan12/drf-yasg/pull/%d" def role_github_pull_request_or_issue(name, rawtext, text, lineno, inliner, options=None, content=None): @@ -229,9 +230,9 @@ def role_github_pull_request_or_issue(name, rawtext, text, lineno, inliner, opti # Base URL mainly used by inliner.rfc_reference, so this is correct: if name == 'pr': - ref = ghpr_uri + ref = gh_pr_uri elif name == 'issue': - ref = ghiss_uri + ref = gh_issue_uri else: msg = inliner.reporter.error('unknown tag name for GitHub reference - "%s"' % name, line=lineno) prb = inliner.problematic(rawtext, rawtext, msg) diff --git a/docs/custom_spec.rst b/docs/custom_spec.rst index 9f8f8ec..479e75c 100644 --- a/docs/custom_spec.rst +++ b/docs/custom_spec.rst @@ -65,13 +65,72 @@ It is interesting to note the main differences between :class:`.Parameter` and : +----------------------------------------------------------+-----------------------------------------------------------+ | Cannot be used in form :class:`.Operation`\ s [#formop]_ | Can be used in form :class:`.Operation`\ s [#formop]_ | +----------------------------------------------------------+-----------------------------------------------------------+ +| Can only describe request or response bodies | Can describe ``query``, ``form``, ``header`` or ``path`` | +| | parameters | ++----------------------------------------------------------+-----------------------------------------------------------+ .. [#formop] a form Operation is an :class:`.Operation` that consumes ``multipart/form-data`` or - ``application/x-www-form-urlencoded`` + ``application/x-www-form-urlencoded`` content * a form Operation cannot have ``body`` parameters * a non-form operation cannot have ``form`` parameters +**************** +Default behavior +**************** + +This section describes where information is sourced from when using the default generation process. + +* :class:`.Paths` are generated by exploring the patterns registered in your default ``urlconf``, or the ``patterns`` + and ``urlconf`` you specified when constructing :class:`.OpenAPISchemaGenerator`; only views inheriting from Django + Rest Framework's ``APIView`` are looked at, all other views are ignored +* ``path`` :class:`.Parameter`\ s are generated by looking in the URL pattern for any template parameters; attempts are + made to guess their type from the views ``queryset`` and ``lookup_field``, if applicable. You can override path + parameters via ``manual_parameters`` in :ref:`@swagger_auto_schema `. +* ``query`` :class:`.Parameter`\ s - i.e. parameters specified in the URL as ``/path/?query1=value&query2=value`` - + are generated from your view's ``filter_backends`` and ``paginator``, if any are declared. Additional parameters can + be specified via the ``query_serializer`` and ``manual_parameters`` arguments of + :ref:`@swagger_auto_schema ` +* The request body is only generated for the HTTP ``POST``, ``PUT`` and ``PATCH`` methods, and is sourced from the + view's ``serializer_class``. You can also override the request body using the ``request_body`` argument of + :ref:`@swagger_auto_schema `. + + - if the view represents a form request (that is, all its parsers are of the ``multipart/form-data`` or + ``application/x-www-form-urlencoded`` media types), the request body will be output as ``form`` + :class:`.Parameter`\ s + - if it is not a form request, the request body will be output as a single ``body`` :class:`.Parameter` wrapped + around a :class:`.Schema` + +* ``header`` :class:`.Parameter`\ s are supported by the OpenAPI specification but are never generated by this library; + you can still add them using ``manual_parameters``. +* :class:`.Responses` are generated as follows: + + + if ``responses`` is provided to :ref:`@swagger_auto_schema ` and contains at least + one success status code (i.e. any `2xx` status code), no automatic response is generated and the given response + is used as described in the :func:`@swagger_auto_schema documentation <.swagger_auto_schema>` + + otherwise, an attempt is made to generate a default response: + + - the success status code is assumed to be ``204` for ``DELETE`` requests, ``201`` for ``POST`` requests, and + ``200`` for all other request methods + - if the view has a request body, the same ``Serializer`` or :class:`.Schema` as in the request body is used + in generating the :class:`.Response` schema; this is inline with the default ``GenericAPIView`` and + ``GenericViewSet`` behavior + - if the view has no request body, its ``serializer_class`` is used to generate the :class:`.Response` schema + - if the view is a list view (as defined by :func:`.is_list_view`), the response schema is wrapped in an array + - if the view is also paginated, the response schema is then wrapped in the appropriate paging response structure + - the description of the response is left blank + +* :class:`.Response` headers are supported by the OpenAPI specification but not currently supported by this library; + you can still add them manually by providing an `appropriately structured dictionary + `_ + to the ``headers`` property of a :class:`.Response` object +* *descriptions* for :class:`.Operation`\ s, :class:`.Parameter`\ s and :class:`.Schema`\ s are picked up from + docstrings and ``help_text`` attributes in the same manner as the `default DRF SchemaGenerator + `_ + + +.. _custom-spec-swagger-auto-schema: + ************************************** The ``@swagger_auto_schema`` decorator ************************************** diff --git a/src/drf_yasg/inspectors.py b/src/drf_yasg/inspectors.py index 0af1459..6cb03e8 100644 --- a/src/drf_yasg/inspectors.py +++ b/src/drf_yasg/inspectors.py @@ -10,7 +10,7 @@ from rest_framework.viewsets import GenericViewSet from . import openapi from .errors import SwaggerGenerationError -from .utils import serializer_field_to_swagger, no_body, is_list_view +from .utils import serializer_field_to_swagger, no_body, is_list_view, param_list_to_odict def force_serializer_instance(serializer): @@ -30,6 +30,8 @@ def force_serializer_instance(serializer): class SwaggerAutoSchema(object): + body_methods = ('PUT', 'PATCH', 'POST') #: methods allowed to have a request body + def __init__(self, view, path, method, overrides, components): """Inspector class responsible for providing :class:`.Operation` definitions given a @@ -88,10 +90,6 @@ class SwaggerAutoSchema(object): :return: a (potentially empty) list of :class:`.Parameter`\ s either ``in: body`` or ``in: formData`` :rtype: list[openapi.Parameter] """ - # only PUT, PATCH or POST can have a request body - if self.method not in ('PUT', 'PATCH', 'POST'): - return [] - serializer = self.get_request_serializer() schema = None if serializer is None: @@ -109,6 +107,15 @@ class SwaggerAutoSchema(object): schema = self.get_request_body_schema(serializer) return [self.make_body_parameter(schema)] + def get_view_serializer(self): + """Return the serializer as defined by the view's ``get_serializer()`` method. + + :return: the view's ``Serializer`` + """ + if not hasattr(self.view, 'get_serializer'): + return None + return self.view.get_serializer() + def get_request_serializer(self): """Return the request serializer (used for parsing the request payload) for this endpoint. @@ -119,13 +126,16 @@ class SwaggerAutoSchema(object): if body_override is not None: if body_override is no_body: return None + if self.method not in self.body_methods: + raise SwaggerGenerationError("request_body can only be applied to PUT, PATCH or POST views; " + "are you looking for query_serializer or manual_parameters?") if isinstance(body_override, openapi.Schema.OR_REF): return body_override return force_serializer_instance(body_override) - else: - if not hasattr(self.view, 'get_serializer'): - return None - return self.view.get_serializer() + elif self.method in self.body_methods: + return self.get_view_serializer() + + return None def get_request_form_parameters(self, serializer): """Given a Serializer, return a list of ``in: formData`` :class:`.Parameter`\ s. @@ -133,12 +143,7 @@ class SwaggerAutoSchema(object): :param serializer: the view's request serializer as returned by :meth:`.get_request_serializer` :rtype: list[openapi.Parameter] """ - fields = getattr(serializer, 'fields', {}) - return [ - self.field_to_parameter(value, key, openapi.IN_FORM) - for key, value - in fields.items() - ] + return self.serializer_to_parameters(serializer, in_=openapi.IN_FORM) def get_request_body_schema(self, serializer): """Return the :class:`.Schema` for a given request's body data. Only applies to PUT, PATCH and POST requests. @@ -163,7 +168,7 @@ class SwaggerAutoSchema(object): :return: modified parameters :rtype: list[openapi.Parameter] """ - parameters = OrderedDict(((param.name, param.in_), param) for param in parameters) + parameters = param_list_to_odict(parameters) manual_parameters = self.overrides.get('manual_parameters', None) or [] if any(param.in_ == openapi.IN_BODY for param in manual_parameters): # pragma: no cover @@ -173,7 +178,7 @@ class SwaggerAutoSchema(object): raise SwaggerGenerationError("cannot add form parameters when the request has a request schema; " "did you forget to set an appropriate parser class on the view?") - parameters.update(((param.name, param.in_), param) for param in manual_parameters) + parameters.update(param_list_to_odict(manual_parameters)) return list(parameters.values()) def get_responses(self): @@ -218,11 +223,11 @@ class SwaggerAutoSchema(object): default_schema = '' if method == 'post': default_status = status.HTTP_201_CREATED - default_schema = self.get_request_serializer() + default_schema = self.get_request_serializer() or self.get_view_serializer() elif method == 'delete': default_status = status.HTTP_204_NO_CONTENT elif method in ('get', 'put', 'patch'): - default_schema = self.get_request_serializer() + default_schema = self.get_request_serializer() or self.get_view_serializer() default_schema = default_schema or '' if any(is_form_media_type(encoding) for encoding in self.get_consumes()): @@ -290,12 +295,35 @@ class SwaggerAutoSchema(object): return responses + def get_query_serializer(self): + """Return the query serializer (used for parsing query parameters) for this endpoint. + + :return: the query serializer, or ``None`` + """ + query_serializer = self.overrides.get('query_serializer', None) + if query_serializer is not None: + query_serializer = force_serializer_instance(query_serializer) + return query_serializer + def get_query_parameters(self): """Return the query parameters accepted by this view. :rtype: list[openapi.Parameter] """ - return self.get_filter_parameters() + self.get_pagination_parameters() + natural_parameters = self.get_filter_parameters() + self.get_pagination_parameters() + + query_serializer = self.get_query_serializer() + serializer_parameters = [] + if query_serializer is not None: + serializer_parameters = self.serializer_to_parameters(query_serializer, in_=openapi.IN_QUERY) + + if len(set(param_list_to_odict(natural_parameters)) & set(param_list_to_odict(serializer_parameters))) != 0: + raise SwaggerGenerationError( + "your query_serializer contains fields that conflict with the " + "filter_backend or paginator_class on the view - %s %s" % (self.method, self.path) + ) + + return natural_parameters + serializer_parameters def should_filter(self): """Determine whether filter backend parameters should be included for this request. @@ -400,12 +428,26 @@ class SwaggerAutoSchema(object): def serializer_to_schema(self, serializer): """Convert a DRF Serializer instance to an :class:`.openapi.Schema`. - :param serializers.BaseSerializer serializer: + :param serializers.BaseSerializer serializer: the ``Serializer`` instance :rtype: openapi.Schema """ definitions = self.components.with_scope(openapi.SCHEMA_DEFINITIONS) return serializer_field_to_swagger(serializer, openapi.Schema, definitions) + def serializer_to_parameters(self, serializer, in_): + """Convert a DRF serializer into a list of :class:`.Parameter`\ s using :meth:`.field_to_parameter` + + :param serializers.BaseSerializer serializer: the ``Serializer`` instance + :param str in_: the location of the parameters, one of the `openapi.IN_*` constants + :rtype: list[openapi.Parameter] + """ + fields = getattr(serializer, 'fields', {}) + return [ + self.field_to_parameter(value, key, in_) + for key, value + in fields.items() + ] + def field_to_parameter(self, field, name, in_): """Convert a DRF serializer Field to a swagger :class:`.Parameter` object. diff --git a/src/drf_yasg/openapi.py b/src/drf_yasg/openapi.py index cd42ebe..3f886e2 100644 --- a/src/drf_yasg/openapi.py +++ b/src/drf_yasg/openapi.py @@ -116,6 +116,7 @@ class SwaggerDict(OrderedDict): @staticmethod def _as_odict(obj): + """Implementation detail of :meth:`.as_odict`""" if isinstance(obj, dict): result = OrderedDict() for attr, val in obj.items(): @@ -127,6 +128,10 @@ class SwaggerDict(OrderedDict): return obj def as_odict(self): + """Convert this object into an ``OrderedDict`` instance. + + :rtype: OrderedDict + """ return SwaggerDict._as_odict(self) def __reduce__(self): diff --git a/src/drf_yasg/utils.py b/src/drf_yasg/utils.py index e2c4818..96ba37e 100644 --- a/src/drf_yasg/utils.py +++ b/src/drf_yasg/utils.py @@ -45,8 +45,8 @@ def is_list_view(path, method, view): return True -def swagger_auto_schema(method=None, methods=None, auto_schema=None, request_body=None, manual_parameters=None, - operation_description=None, responses=None): +def swagger_auto_schema(method=None, methods=None, auto_schema=None, request_body=None, query_serializer=None, + manual_parameters=None, operation_description=None, responses=None): """Decorate a view method to customize the :class:`.Operation` object generated from it. `method` and `methods` are mutually exclusive and must only be present when decorating a view method that accepts @@ -67,6 +67,15 @@ def swagger_auto_schema(method=None, methods=None, auto_schema=None, request_bod If a ``Serializer`` class or instance is given, it will be automatically converted into a :class:`.Schema` used as a ``body`` :class:`.Parameter`, or into a list of ``form`` :class:`.Parameter`\ s, as appropriate. + :param .Serializer query_serializer: if you use a ``Serializer`` to parse query parameters, you can pass it here + and have :class:`.Parameter` objects be generated automatically from it. + + If any ``Field`` on the serializer cannot be represented as a ``query`` :class:`.Parameter` + (e.g. nested Serializers, file fields, ...), the schema generation will fail with an error. + + Schema generation will also fail if the name of any Field on the `query_serializer` conflicts with parameters + generated by ``filter_backends`` or ``paginator``. + :param list[.Parameter] manual_parameters: a list of manual parameters to override the automatically generated ones :class:`.Parameter`\ s are identified by their (``name``, ``in``) combination, and any parameters given @@ -94,14 +103,16 @@ def swagger_auto_schema(method=None, methods=None, auto_schema=None, request_bod data = { 'auto_schema': auto_schema, 'request_body': request_body, + 'query_serializer': query_serializer, 'manual_parameters': manual_parameters, 'operation_description': operation_description, 'responses': responses, } data = {k: v for k, v in data.items() if v is not None} + # if the method is a detail_route or list_route, it will have a bind_to_methods attribute bind_to_methods = getattr(view_method, 'bind_to_methods', []) - # if the method is actually a function based view + # if the method is actually a function based view (@api_view), it will have a 'cls' attribute view_cls = getattr(view_method, 'cls', None) http_method_names = getattr(view_cls, 'http_method_names', []) if bind_to_methods or http_method_names: @@ -121,11 +132,12 @@ def swagger_auto_schema(method=None, methods=None, auto_schema=None, request_bod "on multi-method %s, you must specify swagger_auto_schema on a per-method basis " \ "using one of the `method` or `methods` arguments" % _route assert bool(methods) != bool(method), "specify either method or methods" + assert not isinstance(methods, str), "`methods` expects to receive a list of methods;" \ + " use `method` for a single argument" if method: _methods = [method.lower()] else: _methods = [mth.lower() for mth in methods] - assert not isinstance(_methods, str), "`methods` expects to receive; use `method` for a single arg" assert not any(mth in existing_data for mth in _methods), "method defined multiple times" assert all(mth in available_methods for mth in _methods), "method not bound to %s" % _route @@ -134,7 +146,7 @@ def swagger_auto_schema(method=None, methods=None, auto_schema=None, request_bod existing_data[available_methods[0]] = data view_method.swagger_auto_schema = existing_data else: - assert methods is None, \ + assert method is None and methods is None, \ "the methods argument should only be specified when decorating a detail_route or list_route; you " \ "should also ensure that you put the swagger_auto_schema decorator AFTER (above) the _route decorator" view_method.swagger_auto_schema = data @@ -274,9 +286,13 @@ def serializer_field_to_swagger(field, swagger_object_type, definitions=None, ** elif isinstance(field, serializers.FileField): # swagger 2.0 does not support specifics about file fields, so ImageFile gets no special treatment # OpenAPI 3.0 does support it, so a future implementation could handle this better + err = SwaggerGenerationError("parameter of type file is supported only in a formData Parameter") if swagger_object_type != openapi.Parameter: - raise SwaggerGenerationError("parameter of type file is supported only in formData Parameter") - return SwaggerType(type=openapi.TYPE_FILE) + raise err # pragma: no cover + param = SwaggerType(type=openapi.TYPE_FILE) + if param['in'] != openapi.IN_FORM: + raise err # pragma: no cover + return param elif isinstance(field, serializers.DictField) and swagger_object_type == openapi.Schema: child_schema = serializer_field_to_swagger(field.child, ChildSwaggerType, definitions) return SwaggerType( @@ -307,3 +323,18 @@ def find_regex(regex_field): # regex_validator.regex should be a compiled re object... return getattr(getattr(regex_validator, 'regex', None), 'pattern', None) + + +def param_list_to_odict(parameters): + """Transform a list of :class:`.Parameter` objects into an ``OrderedDict`` keyed on the ``(name, in_)`` tuple of + each parameter. + + Raises an ``AssertionError`` if `parameters` contains duplicate parameters (by their name + in combination). + + :param list[.Parameter] parameters: the list of parameters + :return: `parameters` keyed by ``(name, in_)`` + :rtype: dict[tuple(str,str),.Parameter] + """ + result = OrderedDict(((param.name, param.in_), param) for param in parameters) + assert len(result) == len(parameters), "duplicate Parameters found" + return result diff --git a/testproj/users/serializers.py b/testproj/users/serializers.py index f472fe3..65694ae 100644 --- a/testproj/users/serializers.py +++ b/testproj/users/serializers.py @@ -12,3 +12,8 @@ class UserSerializerrr(serializers.ModelSerializer): class Meta: model = User fields = ('id', 'username', 'email', 'snippets', 'last_connected_ip', 'last_connected_at') + + +class UserListQuerySerializer(serializers.Serializer): + username = serializers.CharField(help_text="this field is generated from a query_serializer") + is_staff = serializers.BooleanField(help_text="this one too!") diff --git a/testproj/users/views.py b/testproj/users/views.py index 1536242..cac1162 100644 --- a/testproj/users/views.py +++ b/testproj/users/views.py @@ -7,13 +7,13 @@ from rest_framework.views import APIView from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema, no_body -from users.serializers import UserSerializerrr +from users.serializers import UserSerializerrr, UserListQuerySerializer class UserList(APIView): """UserList cbv classdoc""" - @swagger_auto_schema(responses={200: UserSerializerrr(many=True)}) + @swagger_auto_schema(query_serializer=UserListQuerySerializer, responses={200: UserSerializerrr(many=True)}) def get(self, request): queryset = User.objects.all() serializer = UserSerializerrr(queryset, many=True) diff --git a/tests/reference.yaml b/tests/reference.yaml index d6503a1..516686d 100644 --- a/tests/reference.yaml +++ b/tests/reference.yaml @@ -351,7 +351,17 @@ paths: get: operationId: users_list description: UserList cbv classdoc - parameters: [] + parameters: + - name: username + in: query + description: this field is generated from a query_serializer + required: true + type: string + - name: is_staff + in: query + description: this one too! + required: true + type: boolean responses: '200': description: ''