From 4edf7da2530a2d765ce38a9fa529a59277702502 Mon Sep 17 00:00:00 2001 From: Sergey Klyuykov Date: Wed, 21 Jun 2023 18:27:20 -0700 Subject: [PATCH 1/5] Feature: Provide to override default renderers via settings. --- src/drf_yasg/app_settings.py | 7 +++++++ src/drf_yasg/views.py | 8 +++++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/src/drf_yasg/app_settings.py b/src/drf_yasg/app_settings.py index a767ef38..5d065a6c 100644 --- a/src/drf_yasg/app_settings.py +++ b/src/drf_yasg/app_settings.py @@ -27,6 +27,12 @@ 'drf_yasg.inspectors.CoreAPICompatInspector', ], + 'DEFAULT_SPEC_RENDERERS': [ + 'drf_yasg.renderers.SwaggerYAMLRenderer', + 'drf_yasg.renderers.SwaggerJSONRenderer', + 'drf_yasg.renderers.OpenAPIRenderer', + ], + 'EXCLUDED_MEDIA_TYPES': ['html'], 'DEFAULT_INFO': None, @@ -88,6 +94,7 @@ 'DEFAULT_FIELD_INSPECTORS', 'DEFAULT_FILTER_INSPECTORS', 'DEFAULT_PAGINATOR_INSPECTORS', + 'DEFAULT_SPEC_RENDERERS', 'DEFAULT_INFO', ] diff --git a/src/drf_yasg/views.py b/src/drf_yasg/views.py index be4214e9..977f2f4a 100644 --- a/src/drf_yasg/views.py +++ b/src/drf_yasg/views.py @@ -11,11 +11,13 @@ from .app_settings import swagger_settings from .renderers import ( - OpenAPIRenderer, ReDocOldRenderer, ReDocRenderer, SwaggerJSONRenderer, SwaggerUIRenderer, SwaggerYAMLRenderer, - _SpecRenderer + ReDocOldRenderer, + ReDocRenderer, + SwaggerUIRenderer, + _SpecRenderer, ) -SPEC_RENDERERS = (SwaggerYAMLRenderer, SwaggerJSONRenderer, OpenAPIRenderer) +SPEC_RENDERERS = swagger_settings.DEFAULT_SPEC_RENDERERS UI_RENDERERS = { 'swagger': (SwaggerUIRenderer, ReDocRenderer), 'redoc': (ReDocRenderer, SwaggerUIRenderer), From 757db895dda5b480e46c18f50b457a491baa1604 Mon Sep 17 00:00:00 2001 From: Sergey Klyuykov Date: Wed, 21 Jun 2023 18:27:56 -0700 Subject: [PATCH 2/5] Feature: Enable tests for django 4.2. --- tox.ini | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tox.ini b/tox.ini index bb32c133..0d11e788 100644 --- a/tox.ini +++ b/tox.ini @@ -8,7 +8,7 @@ envlist = py{37,38,39}-django{22,30}-drf{310,311,312}, py{37,38,39}-django{31,32}-drf{311,312}, py{39,310}-django{40,41}-drf{313,314} - py311-django{40,41}-drf314 + py311-django{40,41,42}-drf314 py38-{lint, docs}, py310-djmaster @@ -29,6 +29,7 @@ deps = django32: Django>=3.2,<3.3 django40: Django>=4.0,<4.1 django41: Django>=4.1,<4.2 + django42: Django>=4.2,<5 drf310: djangorestframework>=3.10,<3.11 drf311: djangorestframework>=3.11,<3.12 From b19a1af32be2cd67524bf3056f98f98a7fec0237 Mon Sep 17 00:00:00 2001 From: Sergey Klyuykov Date: Wed, 21 Jun 2023 18:33:44 -0700 Subject: [PATCH 3/5] Docs: Add information how to override ``DEFAULT_SPEC_RENDERERS``. --- docs/settings.rst | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/settings.rst b/docs/settings.rst index fe45680f..122fc980 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -114,6 +114,18 @@ Paginator inspectors given to :func:`@swagger_auto_schema <.swagger_auto_schema> :class:`'drf_yasg.inspectors.CoreAPICompatInspector' <.inspectors.CoreAPICompatInspector>`, |br| \ ``]`` +DEFAULT_SPEC_RENDERERS +---------------------- + +List of spec renderers classes which used for plain schema rendering. + +**Default**: ``[`` |br| \ +:class:`'drf_yasg.renderers.SwaggerYAMLRenderer' <.renderers.SwaggerYAMLRenderer>`, |br| \ +:class:`'drf_yasg.renderers.SwaggerJSONRenderer' <.renderers.SwaggerJSONRenderer>`, |br| \ +:class:`'drf_yasg.renderers.OpenAPIRenderer' <.renderers.OpenAPIRenderer>`, |br| \ +``]`` + + Swagger document attributes =========================== From dbf3548afa3ccc2f083a116338e0d45d20111ea2 Mon Sep 17 00:00:00 2001 From: Sergey Klyuykov Date: Wed, 21 Jun 2023 18:37:07 -0700 Subject: [PATCH 4/5] Feature: Add ``drf_yasg.inspectors.query.DrfAPICompatInspector``. This inspector should be main and replace ``CoreAPICompatInspector`` in the future. --- src/drf_yasg/app_settings.py | 2 ++ src/drf_yasg/inspectors/__init__.py | 4 ++-- src/drf_yasg/inspectors/query.py | 37 ++++++++++++++++++++++++++++- testproj/articles/views.py | 6 ++--- testproj/testproj/settings/base.py | 1 + tox.ini | 4 ++-- 6 files changed, 46 insertions(+), 8 deletions(-) diff --git a/src/drf_yasg/app_settings.py b/src/drf_yasg/app_settings.py index 5d065a6c..dd14fdc8 100644 --- a/src/drf_yasg/app_settings.py +++ b/src/drf_yasg/app_settings.py @@ -20,10 +20,12 @@ 'drf_yasg.inspectors.StringDefaultFieldInspector', ], 'DEFAULT_FILTER_INSPECTORS': [ + 'drf_yasg.inspectors.DrfAPICompatInspector', 'drf_yasg.inspectors.CoreAPICompatInspector', ], 'DEFAULT_PAGINATOR_INSPECTORS': [ 'drf_yasg.inspectors.DjangoRestResponsePagination', + 'drf_yasg.inspectors.DrfAPICompatInspector', 'drf_yasg.inspectors.CoreAPICompatInspector', ], diff --git a/src/drf_yasg/inspectors/__init__.py b/src/drf_yasg/inspectors/__init__.py index ad6916d8..70bde798 100644 --- a/src/drf_yasg/inspectors/__init__.py +++ b/src/drf_yasg/inspectors/__init__.py @@ -7,7 +7,7 @@ InlineSerializerInspector, JSONFieldInspector, RecursiveFieldInspector, ReferencingSerializerInspector, RelatedFieldInspector, SerializerMethodFieldInspector, SimpleFieldInspector, StringDefaultFieldInspector ) -from .query import CoreAPICompatInspector, DjangoRestResponsePagination +from .query import DrfAPICompatInspector, CoreAPICompatInspector, DjangoRestResponsePagination from .view import SwaggerAutoSchema # these settings must be accessed only after defining/importing all the classes in this module to avoid ImportErrors @@ -20,7 +20,7 @@ 'BaseInspector', 'FilterInspector', 'PaginatorInspector', 'FieldInspector', 'SerializerInspector', 'ViewInspector', # filter and pagination inspectors - 'CoreAPICompatInspector', 'DjangoRestResponsePagination', + 'DrfAPICompatInspector', 'CoreAPICompatInspector', 'DjangoRestResponsePagination', # field inspectors 'InlineSerializerInspector', 'RecursiveFieldInspector', 'ReferencingSerializerInspector', 'RelatedFieldInspector', diff --git a/src/drf_yasg/inspectors/query.py b/src/drf_yasg/inspectors/query.py index 00a07b29..05fb56d1 100644 --- a/src/drf_yasg/inspectors/query.py +++ b/src/drf_yasg/inspectors/query.py @@ -1,4 +1,5 @@ from collections import OrderedDict +from functools import wraps try: import coreschema @@ -8,7 +9,39 @@ from .. import openapi from ..utils import force_real_str -from .base import FilterInspector, PaginatorInspector +from .base import FilterInspector, PaginatorInspector, NotHandled + + +def ignore_assert_decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + try: + return func(*args, **kwargs) + except AssertionError: + return NotHandled + + return wrapper + + +class DrfAPICompatInspector(PaginatorInspector, FilterInspector): + def param_to_schema(self, param): + return openapi.Parameter( + name=param['name'], + in_=param['in'], + description=param.get('description'), + required=param.get('required', False), + **param['schema'], + ) + + def get_paginator_parameters(self, paginator): + if hasattr(paginator, 'get_schema_operation_parameters'): + return list(map(self.param_to_schema, paginator.get_schema_operation_parameters(self.view))) + return NotHandled + + def get_filter_parameters(self, filter_backend): + if hasattr(filter_backend, 'get_schema_operation_parameters'): + return list(map(self.param_to_schema, filter_backend.get_schema_operation_parameters(self.view))) + return NotHandled class CoreAPICompatInspector(PaginatorInspector, FilterInspector): @@ -16,6 +49,7 @@ class CoreAPICompatInspector(PaginatorInspector, FilterInspector): ``get_schema_fields`` method. """ + @ignore_assert_decorator def get_paginator_parameters(self, paginator): fields = [] if hasattr(paginator, 'get_schema_fields'): @@ -23,6 +57,7 @@ def get_paginator_parameters(self, paginator): return [self.coreapi_field_to_parameter(field) for field in fields] + @ignore_assert_decorator def get_filter_parameters(self, filter_backend): fields = [] if hasattr(filter_backend, 'get_schema_fields'): diff --git a/testproj/articles/views.py b/testproj/articles/views.py index e7438cfc..69d9caa7 100644 --- a/testproj/articles/views.py +++ b/testproj/articles/views.py @@ -12,16 +12,16 @@ from articles.models import Article from drf_yasg import openapi from drf_yasg.app_settings import swagger_settings -from drf_yasg.inspectors import CoreAPICompatInspector, FieldInspector, NotHandled, SwaggerAutoSchema +from drf_yasg.inspectors import DrfAPICompatInspector, FieldInspector, NotHandled, SwaggerAutoSchema from drf_yasg.utils import no_body, swagger_auto_schema -class DjangoFilterDescriptionInspector(CoreAPICompatInspector): +class DjangoFilterDescriptionInspector(DrfAPICompatInspector): def get_filter_parameters(self, filter_backend): if isinstance(filter_backend, DjangoFilterBackend): result = super(DjangoFilterDescriptionInspector, self).get_filter_parameters(filter_backend) for param in result: - if not param.get('description', ''): + if not param.get('description', '') or param.get('description') == param.name: param.description = "Filter the returned list by {field_name}".format(field_name=param.name) return result diff --git a/testproj/testproj/settings/base.py b/testproj/testproj/settings/base.py index bf5c2084..4e874014 100644 --- a/testproj/testproj/settings/base.py +++ b/testproj/testproj/settings/base.py @@ -145,6 +145,7 @@ "DEFAULT_PAGINATOR_INSPECTORS": [ 'testproj.inspectors.UnknownPaginatorInspector', 'drf_yasg.inspectors.DjangoRestResponsePagination', + 'drf_yasg.inspectors.DrfAPICompatInspector', 'drf_yasg.inspectors.CoreAPICompatInspector', ] } diff --git a/tox.ini b/tox.ini index 0d11e788..c514e7c0 100644 --- a/tox.ini +++ b/tox.ini @@ -47,8 +47,8 @@ deps = # other dependencies -r requirements/validation.txt -r requirements/test.txt - coreapi>=2.3.3 - coreschema>=0.0.4 + drf310: coreapi>=2.3.3 + drf310: coreschema>=0.0.4 commands = pytest -n 2 --cov --cov-config .coveragerc --cov-append --cov-report="" {posargs} From 0c5b02e6118b0f07fd8146710876c28632ca86aa Mon Sep 17 00:00:00 2001 From: Sergey Klyuykov Date: Thu, 13 Jul 2023 21:08:04 -0700 Subject: [PATCH 5/5] Tests: Improve test coverage 95.91% -> 98.30%. --- .github/workflows/review.yml | 6 ++ src/drf_yasg/codecs.py | 30 ++++---- src/drf_yasg/inspectors/field.py | 27 +++++--- src/drf_yasg/inspectors/query.py | 49 +++++++------ testproj/snippets/models.py | 4 ++ testproj/users/urls.py | 1 + testproj/users/views.py | 15 ++++ tests/test_schema_generator.py | 18 +++++ tests/test_schema_views.py | 59 ++++++++++++++++ tests/test_versioning.py | 65 +++++++++++++----- tests/urlconfs/additional_fields_checks.py | 53 ++++++++++++++ tests/urlconfs/coreschema.py | 76 +++++++++++++++++++++ tests/urlconfs/overrided_serializer_name.py | 51 ++++++++++++++ tests/urlconfs/url_versioning.py | 5 ++ tests/urlconfs/url_versioning_extra.py | 12 ++++ tox.ini | 1 + 16 files changed, 412 insertions(+), 60 deletions(-) create mode 100644 tests/urlconfs/additional_fields_checks.py create mode 100644 tests/urlconfs/coreschema.py create mode 100644 tests/urlconfs/overrided_serializer_name.py create mode 100644 tests/urlconfs/url_versioning_extra.py diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml index 82cedaed..f9de28b7 100644 --- a/.github/workflows/review.yml +++ b/.github/workflows/review.yml @@ -29,6 +29,12 @@ jobs: PYTHON_VERSION: ${{ matrix.python }} run: tox -e $(tox -l | grep py${PYTHON_VERSION//.} | paste -sd "," -) + - name: Report coverage + if: ${{ matrix.python == 3.9 }} + run: | + pip install coverage + coverage report + - name: Check for incompatibilities with publishing to PyPi if: ${{ matrix.python == 3.8 }} run: | diff --git a/src/drf_yasg/codecs.py b/src/drf_yasg/codecs.py index 77c36cb5..fd989cb5 100644 --- a/src/drf_yasg/codecs.py +++ b/src/drf_yasg/codecs.py @@ -6,6 +6,18 @@ from django.utils.encoding import force_bytes import yaml +try: + from swagger_spec_validator.common import SwaggerValidationError as SSVErr + from swagger_spec_validator.validator20 import validate_spec as validate_ssv +except ImportError: # pragma: no cover + validate_ssv = None + +try: + from flex.core import parse as validate_flex + from flex.exceptions import ValidationError +except ImportError: # pragma: no cover + validate_flex = None + from . import openapi from .errors import SwaggerValidationError @@ -13,11 +25,6 @@ def _validate_flex(spec): - try: - from flex.core import parse as validate_flex - from flex.exceptions import ValidationError - except ImportError: - return try: validate_flex(spec) @@ -26,8 +33,6 @@ def _validate_flex(spec): def _validate_swagger_spec_validator(spec): - from swagger_spec_validator.common import SwaggerValidationError as SSVErr - from swagger_spec_validator.validator20 import validate_spec as validate_ssv try: validate_ssv(spec) except SSVErr as ex: @@ -36,8 +41,8 @@ def _validate_swagger_spec_validator(spec): #: VALIDATORS = { - 'flex': _validate_flex, - 'ssv': _validate_swagger_spec_validator, + "flex": _validate_flex if validate_flex else lambda s: None, + "ssv": _validate_swagger_spec_validator if validate_ssv else lambda s: None, } @@ -117,10 +122,7 @@ def _dump_dict(self, spec): :rtype: str""" if self.pretty: - out = json.dumps(spec, indent=4, separators=(',', ': '), ensure_ascii=False) - if out[-1] != '\n': - out += '\n' - return out + return f"{json.dumps(spec, indent=4, separators=(',', ': '), ensure_ascii=False)}\n" else: return json.dumps(spec, ensure_ascii=False) @@ -219,7 +221,7 @@ def yaml_sane_load(stream): :param stream: YAML stream (can be a string or a file-like object) :rtype: OrderedDict """ - return yaml.load(stream, Loader=YamlLoader) + return yaml.load(stream, Loader=SaneYamlLoader) class OpenAPICodecYaml(_OpenAPICodec): diff --git a/src/drf_yasg/inspectors/field.py b/src/drf_yasg/inspectors/field.py index 6ab58837..9b767a8e 100644 --- a/src/drf_yasg/inspectors/field.py +++ b/src/drf_yasg/inspectors/field.py @@ -3,6 +3,7 @@ import logging import operator import uuid +from contextlib import suppress from collections import OrderedDict from decimal import Decimal from inspect import signature as inspect_signature @@ -130,7 +131,7 @@ def make_schema_definition(serializer=field): actual_serializer = getattr(actual_schema, '_NP_serializer', None) this_serializer = get_serializer_class(field) - if actual_serializer and actual_serializer != this_serializer: # pragma: no cover + if actual_serializer and actual_serializer != this_serializer: explicit_refs = self._has_ref_name(actual_serializer) and self._has_ref_name(this_serializer) if not explicit_refs: raise SwaggerGenerationError( @@ -209,6 +210,14 @@ def get_parent_serializer(field): return None # pragma: no cover +def get_model_from_descriptor(descriptor): + with suppress(Exception): + try: + return descriptor.rel.related_model + except Exception: + return descriptor.field.remote_field.model + + def get_related_model(model, source): """Try to find the other side of a model relationship given the name of a related field. @@ -216,14 +225,12 @@ def get_related_model(model, source): :param str source: related field name :return: related model or ``None`` """ - try: - descriptor = getattr(model, source) - try: - return descriptor.rel.related_model - except Exception: - return descriptor.field.remote_field.model - except Exception: # pragma: no cover - return None + + with suppress(Exception): + if '.' in source and source.index('.'): + attr, source = source.split('.', maxsplit=1) + return get_related_model(get_model_from_descriptor(getattr(model, attr)), source) + return get_model_from_descriptor(getattr(model, source)) class RelatedFieldInspector(FieldInspector): @@ -281,7 +288,7 @@ def field_to_swagger_object(self, field, swagger_object_type, use_references, ** elif isinstance(field, serializers.HyperlinkedRelatedField): return SwaggerType(type=openapi.TYPE_STRING, format=openapi.FORMAT_URI) - return SwaggerType(type=openapi.TYPE_STRING) + return NotHandled # pragma: no cover def find_regex(regex_field): diff --git a/src/drf_yasg/inspectors/query.py b/src/drf_yasg/inspectors/query.py index 05fb56d1..fafcb02d 100644 --- a/src/drf_yasg/inspectors/query.py +++ b/src/drf_yasg/inspectors/query.py @@ -5,7 +5,6 @@ import coreschema except ImportError: coreschema = None -from rest_framework.pagination import CursorPagination, LimitOffsetPagination, PageNumberPagination from .. import openapi from ..utils import force_real_str @@ -100,23 +99,33 @@ class DjangoRestResponsePagination(PaginatorInspector): PageNumberPagination and CursorPagination """ + def fix_paginated_property(self, key: str, value: dict): + # Need to remove useless params from schema + value.pop('example', None) + if 'nullable' in value: + value['x-nullable'] = value.pop('nullable') + if key in {'next', 'previous'} and 'format' not in value: + value['format'] = 'uri' + return openapi.Schema(**value) + def get_paginated_response(self, paginator, response_schema): - assert response_schema.type == openapi.TYPE_ARRAY, "array return expected for paged response" - paged_schema = None - if isinstance(paginator, (LimitOffsetPagination, PageNumberPagination, CursorPagination)): - has_count = not isinstance(paginator, CursorPagination) - paged_schema = openapi.Schema( - type=openapi.TYPE_OBJECT, - properties=OrderedDict(( - ('count', openapi.Schema(type=openapi.TYPE_INTEGER) if has_count else None), - ('next', openapi.Schema(type=openapi.TYPE_STRING, format=openapi.FORMAT_URI, x_nullable=True)), - ('previous', openapi.Schema(type=openapi.TYPE_STRING, format=openapi.FORMAT_URI, x_nullable=True)), - ('results', response_schema), - )), - required=['results'] - ) - - if has_count: - paged_schema.required.insert(0, 'count') - - return paged_schema + if hasattr(paginator, 'get_paginated_response_schema'): + paginator_schema = paginator.get_paginated_response_schema(response_schema) + if paginator_schema['type'] == openapi.TYPE_OBJECT: + properties = { + k: self.fix_paginated_property(k, v) + for k, v in paginator_schema.pop('properties').items() + } + if 'required' not in paginator_schema: + paginator_schema.setdefault('required', []) + for prop in ('count', 'results'): + if prop in properties: + paginator_schema['required'].append(prop) + return openapi.Schema( + **paginator_schema, + properties=properties + ) + else: + return openapi.Schema(**paginator_schema) + + return response_schema diff --git a/testproj/snippets/models.py b/testproj/snippets/models.py index da398c50..eab1c753 100644 --- a/testproj/snippets/models.py +++ b/testproj/snippets/models.py @@ -16,6 +16,10 @@ class Snippet(models.Model): class Meta: ordering = ('created',) + @property + def owner_snippets(self): + return Snippet._default_manager.filter(owner=self.owner) + @property def nullable_secondary_language(self): return None diff --git a/testproj/users/urls.py b/testproj/users/urls.py index 8b5504a6..5a2cb49d 100644 --- a/testproj/users/urls.py +++ b/testproj/users/urls.py @@ -5,4 +5,5 @@ urlpatterns = [ path('', views.UserList.as_view()), path('/', views.user_detail), + path('/test_dummy', views.test_view_with_dummy_schema), ] diff --git a/testproj/users/views.py b/testproj/users/views.py index 64990abd..a05e471b 100644 --- a/testproj/users/views.py +++ b/testproj/users/views.py @@ -60,3 +60,18 @@ def user_detail(request, pk): user = get_object_or_404(User.objects, pk=pk) serializer = UserSerializer(user) return Response(serializer.data) + + +class DummyAutoSchema: + def __init__(self, *args, **kwargs): + pass + + def get_operation(self, keys): + pass + + +@swagger_auto_schema(methods=['get'], auto_schema=DummyAutoSchema) +@swagger_auto_schema(methods=['PUT'], auto_schema=None) +@api_view(['GET', 'PUT']) +def test_view_with_dummy_schema(request, pk): + return Response({}) diff --git a/tests/test_schema_generator.py b/tests/test_schema_generator.py index 4da45e9b..063e7c08 100644 --- a/tests/test_schema_generator.py +++ b/tests/test_schema_generator.py @@ -90,6 +90,24 @@ def test_security_requirements(swagger_settings, mock_schema_request): swagger = generator.get_schema(mock_schema_request, public=True) assert swagger['security'] == [] + swagger_settings['SECURITY_REQUIREMENTS'] = None + swagger_settings['SECURITY_DEFINITIONS'] = None + + swagger = generator.get_schema(mock_schema_request, public=True) + assert 'security' not in swagger + + +def test_default_url(swagger_settings, mock_schema_request): + swagger_settings['DEFAULT_API_URL'] = 'http://api.example.com' + generator = OpenAPISchemaGenerator( + info=openapi.Info(title="Test generator", default_version="v1"), + version="v2", + ) + + swagger = generator.get_schema(public=True) + assert swagger['host'] == 'api.example.com' + assert swagger['basePath'] == '/' + def _basename_or_base_name(basename): # freaking DRF... TODO: remove when dropping support for DRF 3.8 diff --git a/tests/test_schema_views.py b/tests/test_schema_views.py index f6f40a6a..2e467c5a 100644 --- a/tests/test_schema_views.py +++ b/tests/test_schema_views.py @@ -3,6 +3,11 @@ import pytest +try: + import coreschema +except ImportError: + coreschema = None + from drf_yasg.codecs import yaml_sane_load @@ -70,3 +75,57 @@ def test_non_public(client): response = client.get('/private/swagger.yaml') swagger = yaml_sane_load(response.content.decode('utf-8')) assert len(swagger['paths']) == 0 + + +@pytest.mark.skipif(coreschema is None, reason="Do not test without coreschema.") +@pytest.mark.urls('urlconfs.coreschema') +def test_paginator_schema(client, swagger_settings): + swagger_settings['DEFAULT_FILTER_INSPECTORS'] = [ + 'drf_yasg.inspectors.CoreAPICompatInspector', + 'drf_yasg.inspectors.DrfAPICompatInspector', + ] + swagger_settings['DEFAULT_PAGINATOR_INSPECTORS'] = [ + 'drf_yasg.inspectors.CoreAPICompatInspector', + 'drf_yasg.inspectors.DrfAPICompatInspector', + ] + + response = client.get('/versioned/url/v1.0/swagger.yaml') + swagger = yaml_sane_load(response.content.decode('utf-8')) + + assert swagger['paths']['/snippets/']['get']['responses']['200']['schema']['type'] == 'object' + assert swagger['paths']['/snippets/']['get']['responses']['200']['schema']['required'] == ['results'] + assert swagger['paths']['/snippets/']['get']['parameters'][0]['name'] == 'test_param' + assert swagger['paths']['/snippets/']['get']['parameters'][0]['type'] == 'string' + assert swagger['paths']['/snippets/']['get']['parameters'][1]['name'] == 'limit' + assert swagger['paths']['/snippets/']['get']['parameters'][1]['in'] == 'query' + assert swagger['paths']['/snippets/']['get']['parameters'][1]['type'] == 'integer' + + assert swagger['paths']['/other_snippets/']['get']['responses']['200']['schema']['type'] == 'array' + assert swagger['paths']['/other_snippets/']['get']['parameters'][0]['name'] == 'limit' + assert swagger['paths']['/other_snippets/']['get']['parameters'][0]['in'] == 'query' + assert swagger['paths']['/other_snippets/']['get']['parameters'][0]['type'] == 'integer' + + +@pytest.mark.urls('urlconfs.additional_fields_checks') +def test_extra_field_inspections(client, swagger_settings): + # swagger_settings[] + response = client.get('/versioned/url/v1.0/swagger.json') + swagger = json.loads(response.content.decode('utf-8')) + + assert swagger['definitions']['Snippets']['properties']['url']['type'] == 'string' + assert swagger['definitions']['Snippets']['properties']['url']['format'] == 'uri' + assert swagger['definitions']['Snippets']['properties']['ipv4']['type'] == 'string' + assert swagger['definitions']['Snippets']['properties']['uri']['type'] == 'string' + assert swagger['definitions']['Snippets']['properties']['uri']['format'] == 'uri' + assert swagger['definitions']['Snippets']['properties']['tracks']['type'] == 'array' + assert swagger['definitions']['Snippets']['properties']['tracks']['items']['type'] == 'string' + + assert swagger['definitions']['SnippetsV2']['properties']['url']['type'] == 'string' + assert swagger['definitions']['SnippetsV2']['properties']['url']['format'] == 'uri' + + assert swagger['definitions']['SnippetsV2']['properties']['other_owner_snippets']['type'] == 'array' + assert swagger['definitions']['SnippetsV2']['properties']['other_owner_snippets']['items']['type'] == 'integer' + + # Cannt check type of queryset in property descriptor. + assert swagger['definitions']['SnippetsV2']['properties']['owner_snippets']['type'] == 'array' + assert swagger['definitions']['SnippetsV2']['properties']['owner_snippets']['items']['type'] == 'string' diff --git a/tests/test_versioning.py b/tests/test_versioning.py index 79276c47..3e77f1f5 100644 --- a/tests/test_versioning.py +++ b/tests/test_versioning.py @@ -1,50 +1,47 @@ import pytest +from drf_yasg.errors import SwaggerGenerationError from drf_yasg.codecs import yaml_sane_load -def _get_versioned_schema(prefix, client, validate_schema): +def _get_versioned_schema(prefix, client, validate_schema, path='/snippets/'): response = client.get(prefix + '/swagger.yaml') assert response.status_code == 200 swagger = yaml_sane_load(response.content.decode('utf-8')) - _check_base(swagger, prefix, validate_schema) + _check_base(swagger, prefix, validate_schema, path) return swagger -def _get_versioned_schema_management(prefix, call_generate_swagger, validate_schema, kwargs): +def _get_versioned_schema_management(prefix, call_generate_swagger, validate_schema, kwargs, path='/snippets/'): output = call_generate_swagger(format='yaml', api_url='http://localhost' + prefix + '/swagger.yaml', **kwargs) swagger = yaml_sane_load(output) - _check_base(swagger, prefix, validate_schema) + _check_base(swagger, prefix, validate_schema, path) return swagger -def _check_base(swagger, prefix, validate_schema): +def _check_base(swagger, prefix, validate_schema, path): assert swagger['basePath'] == prefix validate_schema(swagger) - assert '/snippets/' in swagger['paths'] + assert path in swagger['paths'] return swagger -def _check_v1(swagger): +def _check_v1(swagger, path='/snippets/'): assert swagger['info']['version'] == '1.0' - versioned_post = swagger['paths']['/snippets/']['post'] + versioned_post = swagger['paths'][path]['post'] assert versioned_post['responses']['201']['schema']['$ref'] == '#/definitions/Snippet' assert 'v2field' not in swagger['definitions']['Snippet']['properties'] + assert '/snippets_excluded/' not in swagger['paths'] -def _check_v2(swagger): +def _check_v2(swagger, path='/snippets/'): assert swagger['info']['version'] == '2.0' - versioned_post = swagger['paths']['/snippets/']['post'] + versioned_post = swagger['paths'][path]['post'] assert versioned_post['responses']['201']['schema']['$ref'] == '#/definitions/SnippetV2' assert 'v2field' in swagger['definitions']['SnippetV2']['properties'] v2field = swagger['definitions']['SnippetV2']['properties']['v2field'] assert v2field['description'] == 'version 2.0 field' - - -@pytest.mark.urls('urlconfs.url_versioning') -def test_url_v1(client, validate_schema): - swagger = _get_versioned_schema('/versioned/url/v1.0', client, validate_schema) - _check_v1(swagger) + assert '/snippets_excluded/' not in swagger['paths'] @pytest.mark.urls('urlconfs.url_versioning') @@ -53,6 +50,29 @@ def test_url_v2(client, validate_schema): _check_v2(swagger) +@pytest.mark.urls('urlconfs.url_versioning_extra') +def test_url_v2_extra(client, validate_schema): + swagger = _get_versioned_schema('/versioned/url/v2.0', client, validate_schema, path='/extra/snippets/') + _check_v2(swagger, path='/extra/snippets/') + + +@pytest.mark.urls('urlconfs.overrided_serializer_name') +def test_url_same_ref_names(client, validate_schema): + for v in {'v1.0', 'v2.0'}: + try: + client.get(f'/versioned/url/{v}/swagger.yaml') + except SwaggerGenerationError: + pass + else: + raise AssertionError('drf_yasg.errors.SwaggerGenerationError is not raised') + + +@pytest.mark.urls('urlconfs.url_versioning') +def test_url_v1(client, validate_schema): + swagger = _get_versioned_schema('/versioned/url/v1.0', client, validate_schema) + _check_v1(swagger) + + @pytest.mark.urls('urlconfs.ns_versioning') def test_ns_v1(client, validate_schema): swagger = _get_versioned_schema('/versioned/ns/v1.0', client, validate_schema) @@ -72,6 +92,19 @@ def test_url_v2_management(call_generate_swagger, validate_schema): _check_v2(swagger) +@pytest.mark.urls('urlconfs.url_versioning_extra') +def test_url_v2_management_extra(call_generate_swagger, validate_schema): + kwargs = {'api_version': '2.0'} + swagger = _get_versioned_schema_management( + '/versioned/url/v2.0', + call_generate_swagger, + validate_schema, + kwargs, + path='/extra/snippets/' + ) + _check_v2(swagger, path='/extra/snippets/') + + @pytest.mark.urls('urlconfs.ns_versioning') def test_ns_v2_management(call_generate_swagger, validate_schema): kwargs = {'api_version': '2.0'} diff --git a/tests/urlconfs/additional_fields_checks.py b/tests/urlconfs/additional_fields_checks.py new file mode 100644 index 00000000..c7cea8e6 --- /dev/null +++ b/tests/urlconfs/additional_fields_checks.py @@ -0,0 +1,53 @@ +from django.urls import re_path +from rest_framework import serializers + +from testproj.urls import required_urlpatterns + +from .url_versioning import SnippetList, SnippetSerializer, VersionedSchemaView, VERSION_PREFIX_URL + + +class SnippetsSerializer(serializers.HyperlinkedModelSerializer, SnippetSerializer): + ipv4 = serializers.IPAddressField(required=False) + uri = serializers.URLField(required=False) + tracks = serializers.RelatedField( + read_only=True, + allow_null=True, + allow_empty=True, + many=True, + ) + + class Meta: + fields = tuple(SnippetSerializer().fields.keys()) + ('ipv4', 'uri', 'tracks', 'url',) + model = SnippetList.queryset.model + + +class SnippetsV2Serializer(SnippetSerializer): + url = serializers.HyperlinkedRelatedField(view_name='snippets-detail', source='*', read_only=True) + other_owner_snippets = serializers.PrimaryKeyRelatedField( + read_only=True, + source='owner.snippets', + many=True + ) + owner_snippets = serializers.PrimaryKeyRelatedField( + read_only=True, + many=True + ) + + +class SnippetsV1(SnippetList): + serializer_class = SnippetsSerializer + + def get_serializer_class(self): + return self.serializer_class + + +class SnippetsV2(SnippetsV1): + serializer_class = SnippetsV2Serializer + + +urlpatterns = required_urlpatterns + [ + re_path(VERSION_PREFIX_URL + r"snippets/$", SnippetsV1.as_view()), + re_path(VERSION_PREFIX_URL + r"other_snippets/$", SnippetsV2.as_view()), + re_path(VERSION_PREFIX_URL + r'swagger(?P.json|.yaml)$', VersionedSchemaView.without_ui(), + name='vschema-json'), +] diff --git a/tests/urlconfs/coreschema.py b/tests/urlconfs/coreschema.py new file mode 100644 index 00000000..bb55c8c9 --- /dev/null +++ b/tests/urlconfs/coreschema.py @@ -0,0 +1,76 @@ +from django.urls import re_path +import coreapi +import coreschema +from rest_framework import pagination + +from testproj.urls import required_urlpatterns + +from .url_versioning import SnippetList, VersionedSchemaView, VERSION_PREFIX_URL + + +class FilterBackendWithoutParams: + def filter_queryset(self, request, queryset, view): + return queryset + + +class OldFilterBackend(FilterBackendWithoutParams): + def get_schema_fields(self, view): + return [ + coreapi.Field( + name='test_param', + required=False, + location='query', + schema=coreschema.String( + title='Test', + description="Test description" + ) + ) + ] + + +class PaginatorV1(pagination.LimitOffsetPagination): + def get_paginated_response_schema(self, schema): + response_schema = super().get_paginated_response_schema(schema) + del response_schema['properties']['count'] + return response_schema + + +class PaginatorV2(pagination.LimitOffsetPagination): + def __getattribute__(self, item): + if item in {'get_paginated_response_schema', 'get_schema_operation_parameters'}: + raise AttributeError + return super().__getattribute__(item) + + +class PaginatorV3(PaginatorV1): + def get_paginated_response_schema(self, schema): + response_schema = super().get_paginated_response_schema(schema) + response_schema['required'] = ['results'] + return response_schema + + def __getattribute__(self, item): + if item == 'get_schema_fields': + raise AttributeError() + return super().__getattribute__(item) + + +class SnippetsV1(SnippetList): + filter_backends = list(SnippetList.filter_backends) + [FilterBackendWithoutParams, OldFilterBackend] + pagination_class = PaginatorV1 + + +class SnippetsV2(SnippetList): + pagination_class = PaginatorV2 + + +class SnippetsV3(SnippetList): + pagination_class = PaginatorV3 + + +urlpatterns = required_urlpatterns + [ + re_path(VERSION_PREFIX_URL + r"snippets/$", SnippetsV1.as_view()), + re_path(VERSION_PREFIX_URL + r"other_snippets/$", SnippetsV2.as_view()), + re_path(VERSION_PREFIX_URL + r"ya_snippets/$", SnippetsV3.as_view()), + re_path(VERSION_PREFIX_URL + r'swagger(?P.json|.yaml)$', VersionedSchemaView.without_ui(), + name='vschema-json'), +] diff --git a/tests/urlconfs/overrided_serializer_name.py b/tests/urlconfs/overrided_serializer_name.py new file mode 100644 index 00000000..10f3e37b --- /dev/null +++ b/tests/urlconfs/overrided_serializer_name.py @@ -0,0 +1,51 @@ +from django.urls import re_path +from rest_framework import fields, generics, versioning + +from snippets.models import Snippet +from snippets.serializers import SnippetSerializer +from testproj.urls import SchemaView, required_urlpatterns + + +class SnippetV1Serializer(SnippetSerializer): + v1field = fields.IntegerField(help_text="version 1.0 field") + + +class SnippetSerializerV2(SnippetV1Serializer): + v2field = fields.IntegerField(help_text="version 2.0 field") + + class Meta: + # Same name for check failing + ref_name = 'SnippetV1' + + +class SnippetList(generics.ListCreateAPIView): + """SnippetList classdoc""" + queryset = Snippet.objects.all() + serializer_class = SnippetV1Serializer + versioning_class = versioning.URLPathVersioning + + def perform_create(self, serializer): + serializer.save(owner=self.request.user) + + def post(self, request, *args, **kwargs): + """post method docstring""" + return super(SnippetList, self).post(request, *args, **kwargs) + + +class SnippetsV2(SnippetList): + serializer_class = SnippetSerializerV2 + + +VERSION_PREFIX_URL = r"^versioned/url/v(?P1.0|2.0)/" + + +class VersionedSchemaView(SchemaView): + versioning_class = versioning.URLPathVersioning + + +urlpatterns = required_urlpatterns + [ + re_path(VERSION_PREFIX_URL + r"snippets/$", SnippetList.as_view()), + re_path(VERSION_PREFIX_URL + r"other_snippets/$", SnippetsV2.as_view()), + re_path(VERSION_PREFIX_URL + r'swagger(?P.json|.yaml)$', VersionedSchemaView.without_ui(), + name='vschema-json'), +] diff --git a/tests/urlconfs/url_versioning.py b/tests/urlconfs/url_versioning.py index a49fc0f4..7d6aacea 100644 --- a/tests/urlconfs/url_versioning.py +++ b/tests/urlconfs/url_versioning.py @@ -35,6 +35,10 @@ def post(self, request, *args, **kwargs): return super(SnippetList, self).post(request, *args, **kwargs) +class ExcludedSnippets(SnippetList): + swagger_schema = None + + VERSION_PREFIX_URL = r"^versioned/url/v(?P1.0|2.0)/" @@ -44,6 +48,7 @@ class VersionedSchemaView(SchemaView): urlpatterns = required_urlpatterns + [ re_path(VERSION_PREFIX_URL + r"snippets/$", SnippetList.as_view()), + re_path(VERSION_PREFIX_URL + r"snippets_excluded/$", ExcludedSnippets.as_view()), re_path(VERSION_PREFIX_URL + r'swagger(?P.json|.yaml)$', VersionedSchemaView.without_ui(), name='vschema-json'), ] diff --git a/tests/urlconfs/url_versioning_extra.py b/tests/urlconfs/url_versioning_extra.py new file mode 100644 index 00000000..25bb67f9 --- /dev/null +++ b/tests/urlconfs/url_versioning_extra.py @@ -0,0 +1,12 @@ +from django.urls import re_path + +from testproj.urls import required_urlpatterns + +from .url_versioning import SnippetList, VERSION_PREFIX_URL, VersionedSchemaView + +urlpatterns = required_urlpatterns + [ + re_path(VERSION_PREFIX_URL + r"extra/snippets/$", SnippetList.as_view()), + re_path(VERSION_PREFIX_URL + r"extra2/snippets/$", SnippetList.as_view()), + re_path(VERSION_PREFIX_URL + r'swagger(?P.json|.yaml)$', VersionedSchemaView.without_ui(), + name='vschema-json'), +] diff --git a/tox.ini b/tox.ini index c514e7c0..60b0dee8 100644 --- a/tox.ini +++ b/tox.ini @@ -49,6 +49,7 @@ deps = -r requirements/test.txt drf310: coreapi>=2.3.3 drf310: coreschema>=0.0.4 + drf310: flex~=6.14.1 commands = pytest -n 2 --cov --cov-config .coveragerc --cov-append --cov-report="" {posargs}