diff --git a/docs/api-guide/renderers.md b/docs/api-guide/renderers.md index a3321e860..a508a9ff9 100644 --- a/docs/api-guide/renderers.md +++ b/docs/api-guide/renderers.md @@ -273,7 +273,7 @@ By default this will include the following keys: `view`, `request`, `response`, The following is an example plaintext renderer that will return a response with the `data` parameter as the content of the response. - from django.utils.encoding import smart_unicode + from django.utils.encoding import smart_text from rest_framework import renderers @@ -282,7 +282,7 @@ The following is an example plaintext renderer that will return a response with format = 'txt' def render(self, data, media_type=None, renderer_context=None): - return data.encode(self.charset) + return smart_text(data, encoding=self.charset) ## Setting the character set diff --git a/docs/index.md b/docs/index.md index bccc1fb46..899118896 100644 --- a/docs/index.md +++ b/docs/index.md @@ -93,7 +93,7 @@ each Python and Django series. The following packages are optional: -* [coreapi][coreapi] (1.32.0+) - Schema generation support. +* [PyYAML][pyyaml], [uritemplate][uriteemplate] (5.1+, 3.0.0+) - Schema generation support. * [Markdown][markdown] (3.0.0+) - Markdown support for the browsable API. * [Pygments][pygments] (2.4.0+) - Add syntax highlighting to Markdown processing. * [django-filter][django-filter] (1.0.1+) - Filtering support. @@ -237,7 +237,8 @@ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. [redhat]: https://www.redhat.com/ [heroku]: https://www.heroku.com/ [eventbrite]: https://www.eventbrite.co.uk/about/ -[coreapi]: https://pypi.org/project/coreapi/ +[pyyaml]: https://pypi.org/project/PyYAML/ +[uriteemplate]: https://pypi.org/project/uritemplate/ [markdown]: https://pypi.org/project/Markdown/ [pygments]: https://pypi.org/project/Pygments/ [django-filter]: https://pypi.org/project/django-filter/ diff --git a/rest_framework/__init__.py b/rest_framework/__init__.py index b6f3f65ce..8f2bc4466 100644 --- a/rest_framework/__init__.py +++ b/rest_framework/__init__.py @@ -25,9 +25,9 @@ ISO_8601 = 'iso-8601' default_app_config = 'rest_framework.apps.RestFrameworkConfig' -class RemovedInDRF312Warning(DeprecationWarning): +class RemovedInDRF313Warning(DeprecationWarning): pass -class RemovedInDRF313Warning(PendingDeprecationWarning): +class RemovedInDRF314Warning(PendingDeprecationWarning): pass diff --git a/rest_framework/filters.py b/rest_framework/filters.py index c15723ec3..8ef01743c 100644 --- a/rest_framework/filters.py +++ b/rest_framework/filters.py @@ -8,7 +8,6 @@ from functools import reduce from django.core.exceptions import ImproperlyConfigured from django.db import models from django.db.models.constants import LOOKUP_SEP -from django.db.models.sql.constants import ORDER_PATTERN from django.template import loader from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ @@ -256,7 +255,13 @@ class OrderingFilter(BaseFilterBackend): def remove_invalid_fields(self, queryset, fields, view, request): valid_fields = [item[0] for item in self.get_valid_fields(queryset, view, {'request': request})] - return [term for term in fields if term.lstrip('-') in valid_fields and ORDER_PATTERN.match(term)] + + def term_valid(term): + if term.startswith("-"): + term = term[1:] + return term in valid_fields + + return [term for term in fields if term_valid(term)] def filter_queryset(self, request, queryset, view): ordering = self.get_ordering(request, queryset, view) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 31627f58e..1ed331418 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -12,7 +12,9 @@ from django.core.validators import ( from django.db import models from django.utils.encoding import force_str -from rest_framework import exceptions, renderers, serializers +from rest_framework import ( + RemovedInDRF314Warning, exceptions, renderers, serializers +) from rest_framework.compat import uritemplate from rest_framework.fields import _UnvalidatedField, empty from rest_framework.settings import api_settings @@ -146,15 +148,15 @@ class AutoSchema(ViewInspector): operation['description'] = self.get_description(path, method) parameters = [] - parameters += self._get_path_parameters(path, method) - parameters += self._get_pagination_parameters(path, method) - parameters += self._get_filter_parameters(path, method) + parameters += self.get_path_parameters(path, method) + parameters += self.get_pagination_parameters(path, method) + parameters += self.get_filter_parameters(path, method) operation['parameters'] = parameters - request_body = self._get_request_body(path, method) + request_body = self.get_request_body(path, method) if request_body: operation['requestBody'] = request_body - operation['responses'] = self._get_responses(path, method) + operation['responses'] = self.get_responses(path, method) operation['tags'] = self.get_tags(path, method) return operation @@ -186,14 +188,18 @@ class AutoSchema(ViewInspector): """ Return components with their properties from the serializer. """ - serializer = self._get_serializer(path, method) + + if method.lower() == 'delete': + return {} + + serializer = self.get_serializer(path, method) if not isinstance(serializer, serializers.Serializer): return {} component_name = self.get_component_name(serializer) - content = self._map_serializer(serializer) + content = self.map_serializer(serializer) return {component_name: content} def _to_camel_case(self, snake_str): @@ -216,8 +222,8 @@ class AutoSchema(ViewInspector): name = model.__name__ # Try with the serializer class name - elif self._get_serializer(path, method) is not None: - name = self._get_serializer(path, method).__class__.__name__ + elif self.get_serializer(path, method) is not None: + name = self.get_serializer(path, method).__class__.__name__ if name.endswith('Serializer'): name = name[:-10] @@ -255,7 +261,7 @@ class AutoSchema(ViewInspector): return action + name - def _get_path_parameters(self, path, method): + def get_path_parameters(self, path, method): """ Return a list of parameters from templated path variables. """ @@ -291,15 +297,15 @@ class AutoSchema(ViewInspector): return parameters - def _get_filter_parameters(self, path, method): - if not self._allows_filters(path, method): + def get_filter_parameters(self, path, method): + if not self.allows_filters(path, method): return [] parameters = [] for filter_backend in self.view.filter_backends: parameters += filter_backend().get_schema_operation_parameters(self.view) return parameters - def _allows_filters(self, path, method): + def allows_filters(self, path, method): """ Determine whether to include filter Fields in schema. @@ -312,19 +318,19 @@ class AutoSchema(ViewInspector): return self.view.action in ["list", "retrieve", "update", "partial_update", "destroy"] return method.lower() in ["get", "put", "patch", "delete"] - def _get_pagination_parameters(self, path, method): + def get_pagination_parameters(self, path, method): view = self.view if not is_list_view(path, method, view): return [] - paginator = self._get_paginator() + paginator = self.get_paginator() if not paginator: return [] return paginator.get_schema_operation_parameters(view) - def _map_choicefield(self, field): + def map_choicefield(self, field): choices = list(OrderedDict.fromkeys(field.choices)) # preserve order and remove duplicates mapping = { # The value of `enum` keyword MUST be an array and SHOULD be unique. @@ -351,16 +357,16 @@ class AutoSchema(ViewInspector): return mapping - def _map_field(self, field): + def map_field(self, field): # Nested Serializers, `many` or not. if isinstance(field, serializers.ListSerializer): return { 'type': 'array', - 'items': self._map_serializer(field.child) + 'items': self.map_serializer(field.child) } if isinstance(field, serializers.Serializer): - data = self._map_serializer(field) + data = self.map_serializer(field) data['type'] = 'object' return data @@ -368,7 +374,7 @@ class AutoSchema(ViewInspector): if isinstance(field, serializers.ManyRelatedField): return { 'type': 'array', - 'items': self._map_field(field.child_relation) + 'items': self.map_field(field.child_relation) } if isinstance(field, serializers.PrimaryKeyRelatedField): model = getattr(field.queryset, 'model', None) @@ -384,11 +390,11 @@ class AutoSchema(ViewInspector): if isinstance(field, serializers.MultipleChoiceField): return { 'type': 'array', - 'items': self._map_choicefield(field) + 'items': self.map_choicefield(field) } if isinstance(field, serializers.ChoiceField): - return self._map_choicefield(field) + return self.map_choicefield(field) # ListField. if isinstance(field, serializers.ListField): @@ -397,7 +403,7 @@ class AutoSchema(ViewInspector): 'items': {}, } if not isinstance(field.child, _UnvalidatedField): - mapping['items'] = self._map_field(field.child) + mapping['items'] = self.map_field(field.child) return mapping # DateField and DateTimeField type is string @@ -442,11 +448,17 @@ class AutoSchema(ViewInspector): content['format'] = field.protocol return content - # DecimalField has multipleOf based on decimal_places if isinstance(field, serializers.DecimalField): - content = { - 'type': 'number' - } + if getattr(field, 'coerce_to_string', api_settings.COERCE_DECIMAL_TO_STRING): + content = { + 'type': 'string', + 'format': 'decimal', + } + else: + content = { + 'type': 'number' + } + if field.decimal_places: content['multipleOf'] = float('.' + (field.decimal_places - 1) * '0' + '1') if field.max_whole_digits: @@ -457,7 +469,7 @@ class AutoSchema(ViewInspector): if isinstance(field, serializers.FloatField): content = { - 'type': 'number' + 'type': 'number', } self._map_min_max(field, content) return content @@ -493,7 +505,7 @@ class AutoSchema(ViewInspector): if field.min_value: content['minimum'] = field.min_value - def _map_serializer(self, serializer): + def map_serializer(self, serializer): # Assuming we have a valid serializer instance. required = [] properties = {} @@ -505,7 +517,7 @@ class AutoSchema(ViewInspector): if field.required: required.append(field.field_name) - schema = self._map_field(field) + schema = self.map_field(field) if field.read_only: schema['readOnly'] = True if field.write_only: @@ -516,7 +528,7 @@ class AutoSchema(ViewInspector): schema['default'] = field.default if field.help_text: schema['description'] = str(field.help_text) - self._map_field_validators(field, schema) + self.map_field_validators(field, schema) properties[field.field_name] = schema @@ -529,7 +541,7 @@ class AutoSchema(ViewInspector): return result - def _map_field_validators(self, field, schema): + def map_field_validators(self, field, schema): """ map field validators """ @@ -556,7 +568,8 @@ class AutoSchema(ViewInspector): schema['maximum'] = v.limit_value elif isinstance(v, MinValueValidator): schema['minimum'] = v.limit_value - elif isinstance(v, DecimalValidator): + elif isinstance(v, DecimalValidator) and \ + not getattr(field, 'coerce_to_string', api_settings.COERCE_DECIMAL_TO_STRING): if v.decimal_places: schema['multipleOf'] = float('.' + (v.decimal_places - 1) * '0' + '1') if v.max_digits: @@ -566,7 +579,7 @@ class AutoSchema(ViewInspector): schema['maximum'] = int(digits * '9') + 1 schema['minimum'] = -schema['maximum'] - def _get_paginator(self): + def get_paginator(self): pagination_class = getattr(self.view, 'pagination_class', None) if pagination_class: return pagination_class() @@ -584,7 +597,7 @@ class AutoSchema(ViewInspector): media_types.append(renderer.media_type) return media_types - def _get_serializer(self, path, method): + def get_serializer(self, path, method): view = self.view if not hasattr(view, 'get_serializer'): @@ -602,13 +615,13 @@ class AutoSchema(ViewInspector): def _get_reference(self, serializer): return {'$ref': '#/components/schemas/{}'.format(self.get_component_name(serializer))} - def _get_request_body(self, path, method): + def get_request_body(self, path, method): if method not in ('PUT', 'PATCH', 'POST'): return {} self.request_media_types = self.map_parsers(path, method) - serializer = self._get_serializer(path, method) + serializer = self.get_serializer(path, method) if not isinstance(serializer, serializers.Serializer): item_schema = {} @@ -622,8 +635,7 @@ class AutoSchema(ViewInspector): } } - def _get_responses(self, path, method): - # TODO: Handle multiple codes and pagination classes. + def get_responses(self, path, method): if method == 'DELETE': return { '204': { @@ -633,7 +645,7 @@ class AutoSchema(ViewInspector): self.response_media_types = self.map_renderers(path, method) - serializer = self._get_serializer(path, method) + serializer = self.get_serializer(path, method) if not isinstance(serializer, serializers.Serializer): item_schema = {} @@ -645,7 +657,7 @@ class AutoSchema(ViewInspector): 'type': 'array', 'items': item_schema, } - paginator = self._get_paginator() + paginator = self.get_paginator() if paginator: response_schema = paginator.get_paginated_response_schema(response_schema) else: @@ -676,3 +688,99 @@ class AutoSchema(ViewInspector): path = path[1:] return [path.split('/')[0].replace('_', '-')] + + def _get_path_parameters(self, path, method): + warnings.warn( + "Method `_get_path_parameters()` has been renamed to `get_path_parameters()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.get_path_parameters(path, method) + + def _get_filter_parameters(self, path, method): + warnings.warn( + "Method `_get_filter_parameters()` has been renamed to `get_filter_parameters()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.get_filter_parameters(path, method) + + def _get_responses(self, path, method): + warnings.warn( + "Method `_get_responses()` has been renamed to `get_responses()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.get_responses(path, method) + + def _get_request_body(self, path, method): + warnings.warn( + "Method `_get_request_body()` has been renamed to `get_request_body()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.get_request_body(path, method) + + def _get_serializer(self, path, method): + warnings.warn( + "Method `_get_serializer()` has been renamed to `get_serializer()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.get_serializer(path, method) + + def _get_paginator(self): + warnings.warn( + "Method `_get_paginator()` has been renamed to `get_paginator()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.get_paginator() + + def _map_field_validators(self, field, schema): + warnings.warn( + "Method `_map_field_validators()` has been renamed to `map_field_validators()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.map_field_validators(field, schema) + + def _map_serializer(self, serializer): + warnings.warn( + "Method `_map_serializer()` has been renamed to `map_serializer()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.map_serializer(serializer) + + def _map_field(self, field): + warnings.warn( + "Method `_map_field()` has been renamed to `map_field()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.map_field(field) + + def _map_choicefield(self, field): + warnings.warn( + "Method `_map_choicefield()` has been renamed to `map_choicefield()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.map_choicefield(field) + + def _get_pagination_parameters(self, path, method): + warnings.warn( + "Method `_get_pagination_parameters()` has been renamed to `get_pagination_parameters()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.get_pagination_parameters(path, method) + + def _allows_filters(self, path, method): + warnings.warn( + "Method `_allows_filters()` has been renamed to `allows_filters()`. " + "The old name will be removed in DRF v3.14.", + RemovedInDRF314Warning, stacklevel=2 + ) + return self.allows_filters(path, method) diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index 411bac6dd..11a7d0054 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -87,7 +87,7 @@ class TestFieldMapping(TestCase): ] for field, mapping in cases: with self.subTest(field=field): - assert inspector._map_field(field) == mapping + assert inspector.map_field(field) == mapping @override_settings(REST_FRAMEWORK={'COERCE_DECIMAL_TO_STRING': False}) def test_decimal_schema_for_choice_field(self): @@ -104,7 +104,7 @@ class TestFieldMapping(TestCase): inspector = AutoSchema() - data = inspector._map_serializer(ItemSerializer()) + data = inspector.map_serializer(ItemSerializer()) assert isinstance(data['properties']['text']['description'], str), "description must be str" def test_boolean_default_field(self): @@ -115,7 +115,7 @@ class TestFieldMapping(TestCase): inspector = AutoSchema() - data = inspector._map_serializer(Serializer()) + data = inspector.map_serializer(Serializer()) assert data['properties']['default_true']['default'] is True, "default must be true" assert data['properties']['default_false']['default'] is False, "default must be false" assert 'default' not in data['properties']['without_default'], "default must not be defined" @@ -215,7 +215,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - request_body = inspector._get_request_body(path, method) + request_body = inspector.get_request_body(path, method) print(request_body) assert request_body['content']['application/json']['schema']['$ref'] == '#/components/schemas/Item' @@ -242,7 +242,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - serializer = inspector._get_serializer(path, method) + serializer = inspector.get_serializer(path, method) with pytest.raises(Exception) as exc: inspector.get_component_name(serializer) @@ -272,7 +272,7 @@ class TestOperationIntrospection(TestCase): # there should be no empty 'required' property, see #6834 assert 'required' not in component - for response in inspector._get_responses(path, method).values(): + for response in inspector.get_responses(path, method).values(): assert 'required' not in component def test_empty_required_with_patch_method(self): @@ -298,7 +298,7 @@ class TestOperationIntrospection(TestCase): component = components['Item'] # there should be no empty 'required' property, see #6834 assert 'required' not in component - for response in inspector._get_responses(path, method).values(): + for response in inspector.get_responses(path, method).values(): assert 'required' not in component def test_response_body_generation(self): @@ -320,7 +320,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) + responses = inspector.get_responses(path, method) assert responses['201']['content']['application/json']['schema']['$ref'] == '#/components/schemas/Item' components = inspector.get_components(path, method) @@ -350,7 +350,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) + responses = inspector.get_responses(path, method) assert responses['201']['content']['application/json']['schema']['$ref'] == '#/components/schemas/Item' components = inspector.get_components(path, method) assert components['Item'] @@ -381,7 +381,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) + responses = inspector.get_responses(path, method) assert responses == { '200': { 'description': '', @@ -437,7 +437,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) + responses = inspector.get_responses(path, method) assert responses == { '200': { 'description': '', @@ -485,7 +485,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) + responses = inspector.get_responses(path, method) assert responses == { '204': { 'description': '', @@ -509,7 +509,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - request_body = inspector._get_request_body(path, method) + request_body = inspector.get_request_body(path, method) assert len(request_body['content'].keys()) == 2 assert 'multipart/form-data' in request_body['content'] @@ -532,7 +532,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) + responses = inspector.get_responses(path, method) # TODO this should be changed once the multiple response # schema support is there success_response = responses['200'] @@ -607,7 +607,7 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) + responses = inspector.get_responses(path, method) assert responses == { '200': { 'description': '', @@ -851,6 +851,16 @@ class TestOperationIntrospection(TestCase): assert properties['decimal2']['type'] == 'number' assert properties['decimal2']['multipleOf'] == .0001 + assert properties['decimal3'] == { + 'type': 'string', 'format': 'decimal', 'maximum': 1000000, 'minimum': -1000000, 'multipleOf': 0.01 + } + assert properties['decimal4'] == { + 'type': 'string', 'format': 'decimal', 'maximum': 1000000, 'minimum': -1000000, 'multipleOf': 0.01 + } + assert properties['decimal5'] == { + 'type': 'string', 'format': 'decimal', 'maximum': 10000, 'minimum': -10000, 'multipleOf': 0.01 + } + assert properties['email']['type'] == 'string' assert properties['email']['format'] == 'email' assert properties['email']['default'] == 'foo@bar.com' @@ -1100,3 +1110,15 @@ class TestGenerator(TestCase): assert 'components' in schema assert 'schemas' in schema['components'] assert 'Duplicate' in schema['components']['schemas'] + + def test_component_should_not_be_generated_for_delete_method(self): + class ExampleView(generics.DestroyAPIView): + schema = AutoSchema(operation_id_base='example') + + url_patterns = [ + url(r'^example/?$', ExampleView.as_view()), + ] + generator = SchemaGenerator(patterns=url_patterns) + schema = generator.get_schema(request=create_request('/')) + assert 'components' not in schema + assert 'content' not in schema['paths']['/example/']['delete']['responses']['204'] diff --git a/tests/schemas/views.py b/tests/schemas/views.py index 5645f59bf..18b3beae4 100644 --- a/tests/schemas/views.py +++ b/tests/schemas/views.py @@ -119,9 +119,13 @@ class ExampleValidatedSerializer(serializers.Serializer): MinLengthValidator(limit_value=2), ) ) - decimal1 = serializers.DecimalField(max_digits=6, decimal_places=2) - decimal2 = serializers.DecimalField(max_digits=5, decimal_places=0, + decimal1 = serializers.DecimalField(max_digits=6, decimal_places=2, coerce_to_string=False) + decimal2 = serializers.DecimalField(max_digits=5, decimal_places=0, coerce_to_string=False, validators=(DecimalValidator(max_digits=17, decimal_places=4),)) + decimal3 = serializers.DecimalField(max_digits=8, decimal_places=2, coerce_to_string=True) + decimal4 = serializers.DecimalField(max_digits=8, decimal_places=2, coerce_to_string=True, + validators=(DecimalValidator(max_digits=17, decimal_places=4),)) + decimal5 = serializers.DecimalField(max_digits=6, decimal_places=2) email = serializers.EmailField(default='foo@bar.com') url = serializers.URLField(default='http://www.example.com', allow_null=True) uuid = serializers.UUIDField()