From d21b8eb084488e9a5998a679d88031801eef5fe4 Mon Sep 17 00:00:00 2001 From: "William S. Vincent" Date: Tue, 28 Jan 2020 09:05:32 -0500 Subject: [PATCH 01/61] update Django for APIs book to 3.0 edition (#7164) --- docs/community/tutorials-and-resources.md | 4 ++-- docs/img/books/dfa-cover.jpg | Bin 0 -> 50099 bytes docs/img/books/rad-cover.png | Bin 14060 -> 0 bytes 3 files changed, 2 insertions(+), 2 deletions(-) create mode 100644 docs/img/books/dfa-cover.jpg delete mode 100644 docs/img/books/rad-cover.png diff --git a/docs/community/tutorials-and-resources.md b/docs/community/tutorials-and-resources.md index 7993f54fb..6fdac6004 100644 --- a/docs/community/tutorials-and-resources.md +++ b/docs/community/tutorials-and-resources.md @@ -11,8 +11,8 @@ There are a wide range of resources available for learning and using Django REST - - + + diff --git a/docs/img/books/dfa-cover.jpg b/docs/img/books/dfa-cover.jpg new file mode 100644 index 0000000000000000000000000000000000000000..09ed268f2f9a3eb94cef28117b5c2ccc54e2c5e9 GIT binary patch literal 50099 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 literal 0 HcmV?d00001 diff --git a/docs/img/books/rad-cover.png b/docs/img/books/rad-cover.png deleted file mode 100644 index 75b19df64a683f3a9e1681bf14c58da65026d007..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 14060 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 From 160f912a60754c22b2e351bbd9e4b3b4879450f8 Mon Sep 17 00:00:00 2001 From: Stella Date: Wed, 29 Jan 2020 10:20:51 +0100 Subject: [PATCH 02/61] Schemas: Handle default=false for boolean fields (#7165) --- rest_framework/schemas/openapi.py | 2 +- tests/schemas/test_openapi.py | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 351174aac..6f3e854b0 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -378,7 +378,7 @@ class AutoSchema(ViewInspector): schema['writeOnly'] = True if field.allow_null: schema['nullable'] = True - if field.default and field.default != empty and not callable(field.default): + if field.default is not None and field.default != empty and not callable(field.default): schema['default'] = field.default if field.help_text: schema['description'] = str(field.help_text) diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index f734fd169..dd3e87c7e 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -70,6 +70,19 @@ class TestFieldMapping(TestCase): data = inspector._map_serializer(Serializer()) assert isinstance(data['properties']['text']['description'], str), "description must be str" + def test_boolean_default_field(self): + class Serializer(serializers.Serializer): + default_true = serializers.BooleanField(default=True) + default_false = serializers.BooleanField(default=False) + without_default = serializers.BooleanField() + + inspector = AutoSchema() + + 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" + @pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.') class TestOperationIntrospection(TestCase): From bc4d52558bbf3d8a1311c23194123ea7517b2697 Mon Sep 17 00:00:00 2001 From: Dhaval Mehta Date: Wed, 29 Jan 2020 23:45:56 +0530 Subject: [PATCH 03/61] Schemas: Add mapping of type for ChoiceField. (#7161) --- rest_framework/schemas/openapi.py | 38 ++++++++++++++++++++++++++----- tests/schemas/test_openapi.py | 24 ++++++++++++++++++- 2 files changed, 55 insertions(+), 7 deletions(-) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 6f3e854b0..fa887d63a 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -1,4 +1,6 @@ import warnings +from collections import OrderedDict +from decimal import Decimal from operator import attrgetter from urllib.parse import urljoin @@ -209,6 +211,34 @@ class AutoSchema(ViewInspector): return paginator.get_schema_operation_parameters(view) + def _map_choicefield(self, field): + choices = list(OrderedDict.fromkeys(field.choices)) # preserve order and remove duplicates + if all(isinstance(choice, bool) for choice in choices): + type = 'boolean' + elif all(isinstance(choice, int) for choice in choices): + type = 'integer' + elif all(isinstance(choice, (int, float, Decimal)) for choice in choices): # `number` includes `integer` + # Ref: https://tools.ietf.org/html/draft-wright-json-schema-validation-00#section-5.21 + type = 'number' + elif all(isinstance(choice, str) for choice in choices): + type = 'string' + else: + type = None + + mapping = { + # The value of `enum` keyword MUST be an array and SHOULD be unique. + # Ref: https://tools.ietf.org/html/draft-wright-json-schema-validation-00#section-5.20 + 'enum': choices + } + + # If We figured out `type` then and only then we should set it. It must be a string. + # Ref: https://swagger.io/docs/specification/data-models/data-types/#mixed-type + # It is optional but it can not be null. + # Ref: https://tools.ietf.org/html/draft-wright-json-schema-validation-00#section-5.21 + if type: + mapping['type'] = type + return mapping + def _map_field(self, field): # Nested Serializers, `many` or not. @@ -242,15 +272,11 @@ class AutoSchema(ViewInspector): if isinstance(field, serializers.MultipleChoiceField): return { 'type': 'array', - 'items': { - 'enum': list(field.choices) - }, + 'items': self._map_choicefield(field) } if isinstance(field, serializers.ChoiceField): - return { - 'enum': list(field.choices), - } + return self._map_choicefield(field) # ListField. if isinstance(field, serializers.ListField): diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index dd3e87c7e..b4cb2823f 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -1,3 +1,5 @@ +import uuid + import pytest from django.conf.urls import url from django.test import RequestFactory, TestCase, override_settings @@ -44,6 +46,8 @@ class TestBasics(TestCase): class TestFieldMapping(TestCase): def test_list_field_mapping(self): + uuid1 = uuid.uuid4() + uuid2 = uuid.uuid4() inspector = AutoSchema() cases = [ (serializers.ListField(), {'items': {}, 'type': 'array'}), @@ -53,7 +57,25 @@ class TestFieldMapping(TestCase): (serializers.ListField(child=serializers.IntegerField(max_value=4294967295)), {'items': {'type': 'integer', 'maximum': 4294967295, 'format': 'int64'}, 'type': 'array'}), (serializers.ListField(child=serializers.ChoiceField(choices=[('a', 'Choice A'), ('b', 'Choice B')])), - {'items': {'enum': ['a', 'b']}, 'type': 'array'}), + {'items': {'enum': ['a', 'b'], 'type': 'string'}, 'type': 'array'}), + (serializers.ListField(child=serializers.ChoiceField(choices=[(1, 'One'), (2, 'Two')])), + {'items': {'enum': [1, 2], 'type': 'integer'}, 'type': 'array'}), + (serializers.ListField(child=serializers.ChoiceField(choices=[(1.1, 'First'), (2.2, 'Second')])), + {'items': {'enum': [1.1, 2.2], 'type': 'number'}, 'type': 'array'}), + (serializers.ListField(child=serializers.ChoiceField(choices=[(True, 'true'), (False, 'false')])), + {'items': {'enum': [True, False], 'type': 'boolean'}, 'type': 'array'}), + (serializers.ListField(child=serializers.ChoiceField(choices=[(uuid1, 'uuid1'), (uuid2, 'uuid2')])), + {'items': {'enum': [uuid1, uuid2]}, 'type': 'array'}), + (serializers.ListField(child=serializers.ChoiceField(choices=[(1, 'One'), ('a', 'Choice A')])), + {'items': {'enum': [1, 'a']}, 'type': 'array'}), + (serializers.ListField(child=serializers.ChoiceField(choices=[ + (1, 'One'), ('a', 'Choice A'), (1.1, 'First'), (1.1, 'First'), (1, 'One'), ('a', 'Choice A'), (1, 'One') + ])), + {'items': {'enum': [1, 'a', 1.1]}, 'type': 'array'}), + (serializers.ListField(child=serializers.ChoiceField(choices=[ + (1, 'One'), (2, 'Two'), (3, 'Three'), (2, 'Two'), (3, 'Three'), (1, 'One'), + ])), + {'items': {'enum': [1, 2, 3], 'type': 'integer'}, 'type': 'array'}), (serializers.IntegerField(min_value=2147483648), {'type': 'integer', 'minimum': 2147483648, 'format': 'int64'}), ] From 79d37bce4cdc14c9642b206ad5690f2efe514d1a Mon Sep 17 00:00:00 2001 From: Kentalot Date: Thu, 30 Jan 2020 03:14:17 -0800 Subject: [PATCH 04/61] OpenAPI: Include type key in schema object properties dict. (#7169) --- rest_framework/schemas/openapi.py | 1 + tests/schemas/test_openapi.py | 3 +++ 2 files changed, 4 insertions(+) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index fa887d63a..9c6610eaf 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -413,6 +413,7 @@ class AutoSchema(ViewInspector): properties[field.field_name] = schema result = { + 'type': 'object', 'properties': properties } if required: diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index b4cb2823f..0bee0a167 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -335,6 +335,7 @@ class TestOperationIntrospection(TestCase): 'schema': { 'type': 'array', 'items': { + 'type': 'object', 'properties': { 'text': { 'type': 'string', @@ -386,6 +387,7 @@ class TestOperationIntrospection(TestCase): 'item': { 'type': 'array', 'items': { + 'type': 'object', 'properties': { 'text': { 'type': 'string', @@ -532,6 +534,7 @@ class TestOperationIntrospection(TestCase): 'content': { 'application/json': { 'schema': { + 'type': 'object', 'properties': { 'text': { 'type': 'string', From 4137ef41efa77bd404bdc078159081740abdf930 Mon Sep 17 00:00:00 2001 From: Thorsten <51322849+tfranzel-cashlink@users.noreply.github.com> Date: Mon, 3 Feb 2020 14:41:47 +0100 Subject: [PATCH 05/61] Disable yaml aliases for schema generation. (#7131) --- rest_framework/renderers.py | 6 +++++- tests/schemas/test_openapi.py | 15 ++++++++++++++- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/rest_framework/renderers.py b/rest_framework/renderers.py index 29ac90ea8..a96fa6e65 100644 --- a/rest_framework/renderers.py +++ b/rest_framework/renderers.py @@ -1053,7 +1053,11 @@ class OpenAPIRenderer(BaseRenderer): assert yaml, 'Using OpenAPIRenderer, but `pyyaml` is not installed.' def render(self, data, media_type=None, renderer_context=None): - return yaml.dump(data, default_flow_style=False, sort_keys=False).encode('utf-8') + # disable yaml advanced feature 'alias' for clean, portable, and readable output + class Dumper(yaml.Dumper): + def ignore_aliases(self, data): + return True + return yaml.dump(data, default_flow_style=False, sort_keys=False, Dumper=Dumper).encode('utf-8') class JSONOpenAPIRenderer(BaseRenderer): diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index 0bee0a167..cfa2e89ef 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _ from rest_framework import filters, generics, pagination, routers, serializers from rest_framework.compat import uritemplate from rest_framework.parsers import JSONParser, MultiPartParser -from rest_framework.renderers import JSONRenderer +from rest_framework.renderers import JSONRenderer, OpenAPIRenderer from rest_framework.request import Request from rest_framework.schemas.openapi import AutoSchema, SchemaGenerator @@ -473,6 +473,19 @@ class TestOperationIntrospection(TestCase): assert len(success_response['content'].keys()) == 1 assert 'application/json' in success_response['content'] + def test_openapi_yaml_rendering_without_aliases(self): + renderer = OpenAPIRenderer() + + reused_object = {'test': 'test'} + data = { + 'o1': reused_object, + 'o2': reused_object, + } + assert ( + renderer.render(data) == b'o1:\n test: test\no2:\n test: test\n' or + renderer.render(data) == b'o2:\n test: test\no1:\n test: test\n' # py <= 3.5 + ) + def test_serializer_filefield(self): path = '/{id}/' method = 'POST' From f81ca786427db40b648b5bcc0e67044163215457 Mon Sep 17 00:00:00 2001 From: Thorsten <51322849+tfranzel-cashlink@users.noreply.github.com> Date: Wed, 12 Feb 2020 20:35:54 +0100 Subject: [PATCH 06/61] Add file option to generateschema (#7130) --- docs/api-guide/schemas.md | 2 +- .../management/commands/generateschema.py | 8 +++++++- tests/schemas/test_managementcommand.py | 17 +++++++++++++++++ 3 files changed, 25 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index e33a2a611..e63fd83e6 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -30,7 +30,7 @@ into the commonly used YAML-based OpenAPI format. If your schema is static, you can use the `generateschema` management command: ```bash -./manage.py generateschema > openapi-schema.yml +./manage.py generateschema --file openapi-schema.yml ``` Once you've generated a schema in this way you can annotate it with any diff --git a/rest_framework/management/commands/generateschema.py b/rest_framework/management/commands/generateschema.py index a7763492c..024306b65 100644 --- a/rest_framework/management/commands/generateschema.py +++ b/rest_framework/management/commands/generateschema.py @@ -25,6 +25,7 @@ class Command(BaseCommand): parser.add_argument('--format', dest="format", choices=['openapi', 'openapi-json'], default='openapi', type=str) parser.add_argument('--urlconf', dest="urlconf", default=None, type=str) parser.add_argument('--generator_class', dest="generator_class", default=None, type=str) + parser.add_argument('--file', dest="file", default=None, type=str) def handle(self, *args, **options): if options['generator_class']: @@ -40,7 +41,12 @@ class Command(BaseCommand): schema = generator.get_schema(request=None, public=True) renderer = self.get_renderer(options['format']) output = renderer.render(schema, renderer_context={}) - self.stdout.write(output.decode()) + + if options['file']: + with open(options['file'], 'wb') as f: + f.write(output) + else: + self.stdout.write(output.decode()) def get_renderer(self, format): if self.get_mode() == COREAPI_MODE: diff --git a/tests/schemas/test_managementcommand.py b/tests/schemas/test_managementcommand.py index 6cdf7f8b1..115f871e5 100644 --- a/tests/schemas/test_managementcommand.py +++ b/tests/schemas/test_managementcommand.py @@ -1,4 +1,6 @@ import io +import os +import tempfile import pytest from django.conf.urls import url @@ -73,6 +75,21 @@ class GenerateSchemaTests(TestCase): out_json = yaml.safe_load(self.out.getvalue()) assert out_json == CustomSchemaGenerator.SCHEMA + def test_writes_schema_to_file_on_parameter(self): + fd, path = tempfile.mkstemp() + try: + call_command('generateschema', '--file={}'.format(path), stdout=self.out) + # nothing on stdout + assert not self.out.getvalue() + + call_command('generateschema', stdout=self.out) + expected_out = self.out.getvalue() + # file output identical to stdout output + with os.fdopen(fd) as fh: + assert expected_out and fh.read() == expected_out + finally: + os.remove(path) + @pytest.mark.skipif(yaml is None, reason='PyYAML is required.') @override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.AutoSchema'}) def test_coreapi_renders_default_schema_with_custom_title_url_and_description(self): From d7b218f5eba78a99ae202e10c5d4b4d186735f7d Mon Sep 17 00:00:00 2001 From: Kevin Kennell Date: Mon, 17 Feb 2020 17:10:52 +0100 Subject: [PATCH 07/61] decode base64 credentials as utf8; adjust tests (#7193) * decode base64 credentials as utf8; adjust tests * basicauth: add dedicated test for utf8 credentials * basicauth: add fallback to latin-1 encoding if utf-8 fails --- rest_framework/authentication.py | 6 +++++- tests/authentication/test_authentication.py | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/rest_framework/authentication.py b/rest_framework/authentication.py index 1e30728d3..a2ba53480 100644 --- a/rest_framework/authentication.py +++ b/rest_framework/authentication.py @@ -74,7 +74,11 @@ class BasicAuthentication(BaseAuthentication): raise exceptions.AuthenticationFailed(msg) try: - auth_parts = base64.b64decode(auth[1]).decode(HTTP_HEADER_ENCODING).partition(':') + try: + auth_decoded = base64.b64decode(auth[1]).decode('utf-8') + except UnicodeDecodeError: + auth_decoded = base64.b64decode(auth[1]).decode('latin-1') + auth_parts = auth_decoded.partition(':') except (TypeError, UnicodeDecodeError, binascii.Error): msg = _('Invalid basic header. Credentials not correctly base64 encoded.') raise exceptions.AuthenticationFailed(msg) diff --git a/tests/authentication/test_authentication.py b/tests/authentication/test_authentication.py index 37e265e17..4760ea319 100644 --- a/tests/authentication/test_authentication.py +++ b/tests/authentication/test_authentication.py @@ -159,6 +159,25 @@ class BasicAuthTests(TestCase): ) assert response.status_code == status.HTTP_401_UNAUTHORIZED + def test_decoding_of_utf8_credentials(self): + username = 'walterwhité' + email = 'walterwhite@example.com' + password = 'pässwörd' + User.objects.create_user( + username, email, password + ) + credentials = ('%s:%s' % (username, password)) + base64_credentials = base64.b64encode( + credentials.encode('utf-8') + ).decode(HTTP_HEADER_ENCODING) + auth = 'Basic %s' % base64_credentials + response = self.csrf_client.post( + '/basic/', + {'example': 'example'}, + HTTP_AUTHORIZATION=auth + ) + assert response.status_code == status.HTTP_200_OK + @override_settings(ROOT_URLCONF=__name__) class SessionAuthTests(TestCase): From 39dd34f161792146b7d33bf062366f54c2bde3f1 Mon Sep 17 00:00:00 2001 From: Dalei Date: Wed, 19 Feb 2020 19:56:12 +0800 Subject: [PATCH 08/61] Update docs for OpenAPI (#6814) (#7191) --- docs/api-guide/schemas.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index e63fd83e6..6c228d448 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -18,12 +18,12 @@ Django REST Framework provides support for automatic generation of ## Generating an OpenAPI Schema -### Install `pyyaml` +### Install dependencies -You'll need to install `pyyaml`, so that you can render your generated schema -into the commonly used YAML-based OpenAPI format. + pip install pyyaml uritemplate - pip install pyyaml +* `pyyaml` is used to generate schema into YAML-based OpenAPI format. +* `uritemplate` is used internally to get parameters in path. ### Generating a static schema with the `generateschema` management command From 4faa67419633d5440d8290e0b2bc6fb5e6433d03 Mon Sep 17 00:00:00 2001 From: Yoo In Keun Date: Thu, 20 Feb 2020 04:16:42 +0900 Subject: [PATCH 09/61] Fixed docs typo. (#7188) --- docs/api-guide/schemas.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 6c228d448..91c2bbabf 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -122,7 +122,7 @@ on a per-view basis. ### Schema Level Customization -In order to customize the top-level schema sublass +In order to customize the top-level schema subclass `rest_framework.schemas.openapi.SchemaGenerator` and provide it as an argument to the `generateschema` command or `get_schema_view()` helper function. From 92a4a5d42346c9c89f38683cf2d6cbeb976ee9ab Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Thu, 20 Feb 2020 02:23:06 -0800 Subject: [PATCH 10/61] Fix docs 404 (#7197) * Use 'site_url' instead of hardcoding DRF homepage * Use 'url' template filter instead of 'base_url' This fixes static file loading for the 404 page. * Only insert funding
if toc is present * Link quickstart to valid API guide page * Fix 404 search modal link * Use 'base_url' instead of 'site_url' on 404 page --- docs/tutorial/quickstart.md | 6 +++--- docs_theme/404.html | 2 +- docs_theme/main.html | 20 ++++++++++---------- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/docs/tutorial/quickstart.md b/docs/tutorial/quickstart.md index 505f7f91d..546144670 100644 --- a/docs/tutorial/quickstart.md +++ b/docs/tutorial/quickstart.md @@ -137,12 +137,12 @@ Finally, we're including default login and logout views for use with the browsab ## Pagination Pagination allows you to control how many objects per page are returned. To enable it add the following lines to `tutorial/settings.py` - + REST_FRAMEWORK = { 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 10 } - + ## Settings Add `'rest_framework'` to `INSTALLED_APPS`. The settings module will be in `tutorial/settings.py` @@ -224,5 +224,5 @@ If you want to get a more in depth understanding of how REST framework fits toge [image]: ../img/quickstart.png [tutorial]: 1-serialization.md -[guide]: ../#api-guide +[guide]: ../api-guide/requests.md [httpie]: https://github.com/jakubroztocil/httpie#installation diff --git a/docs_theme/404.html b/docs_theme/404.html index a89c0a418..bbb6b70ff 100644 --- a/docs_theme/404.html +++ b/docs_theme/404.html @@ -4,6 +4,6 @@

404

Page not found

-
+

Try the homepage, or search the documentation.

{% endblock %} diff --git a/docs_theme/main.html b/docs_theme/main.html index 21e9171a2..c2a29e1ae 100644 --- a/docs_theme/main.html +++ b/docs_theme/main.html @@ -5,17 +5,17 @@ {% if page.title %}{{ page.title }} - {% endif %}{{ config.site_name }} - + - - - - + + + + - - - - + + + + {% for path in config.extra_javascript %} From 764dabd29e127a0b1a07794f8268a1b1535d9507 Mon Sep 17 00:00:00 2001 From: Prayash Mohapatra Date: Thu, 20 Feb 2020 10:55:13 +0000 Subject: [PATCH 11/61] Update writeable nested serializer doc (#7198) --- docs/api-guide/serializers.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index 4679b1ed1..96a0e0222 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -333,7 +333,7 @@ Here's an example for an `.update()` method on our previous `UserSerializer` cla def update(self, instance, validated_data): profile_data = validated_data.pop('profile') # Unless the application properly enforces that this field is - # always set, the follow could raise a `DoesNotExist`, which + # always set, the following could raise a `DoesNotExist`, which # would need to be handled. profile = instance.profile From e32ffbb12b43cd64d9476e674dc27b140b1e3658 Mon Sep 17 00:00:00 2001 From: Samuel Dion-Girardeau Date: Mon, 24 Feb 2020 19:33:00 -0500 Subject: [PATCH 12/61] Fix docs code example (#7201) --- docs/api-guide/serializers.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index 96a0e0222..5cf949f97 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -382,8 +382,8 @@ This manager class now more nicely encapsulates that user instances and profile def create(self, validated_data): return User.objects.create( username=validated_data['username'], - email=validated_data['email'] - is_premium_member=validated_data['profile']['is_premium_member'] + email=validated_data['email'], + is_premium_member=validated_data['profile']['is_premium_member'], has_support_contract=validated_data['profile']['has_support_contract'] ) From 2a5c2f3f701cca6f531c5e12790a2d63b0d6b4fd Mon Sep 17 00:00:00 2001 From: Dhaval Mehta <20968146+dhaval-mehta@users.noreply.github.com> Date: Fri, 28 Feb 2020 16:36:03 +0530 Subject: [PATCH 13/61] Added OpenAPI tags to schemas. (#7184) --- docs/api-guide/schemas.md | 75 +++++++++++++++++++++++++++++++ rest_framework/schemas/openapi.py | 20 +++++++++ tests/schemas/test_openapi.py | 51 +++++++++++++++++++++ 3 files changed, 146 insertions(+) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 91c2bbabf..2e5ffc79b 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -215,6 +215,81 @@ This also applies to extra actions for `ViewSet`s: If you wish to provide a base `AutoSchema` subclass to be used throughout your project you may adjust `settings.DEFAULT_SCHEMA_CLASS` appropriately. + +### Grouping Operations With Tags + +Tags can be used to group logical operations. Each tag name in the list MUST be unique. + +--- +#### Django REST Framework generates tags automatically with the following logic: + +Tag name will be first element from the path. Also, any `_` in path name will be replaced by a `-`. +Consider below examples. + +Example 1: Consider a user management system. The following table will illustrate the tag generation logic. +Here first element from the paths is: `users`. Hence tag wil be `users` + +Http Method | Path | Tags +-------------------------------------|-------------------|------------- +PUT, PATCH, GET(Retrieve), DELETE | /users/{id}/ | ['users'] +POST, GET(List) | /users/ | ['users'] + +Example 2: Consider a restaurant management system. The System has restaurants. Each restaurant has branches. +Consider REST APIs to deal with a branch of a particular restaurant. +Here first element from the paths is: `restaurants`. Hence tag wil be `restaurants`. + +Http Method | Path | Tags +-------------------------------------|----------------------------------------------------|------------------- +PUT, PATCH, GET(Retrieve), DELETE: | /restaurants/{restaurant_id}/branches/{branch_id} | ['restaurants'] +POST, GET(List): | /restaurants/{restaurant_id}/branches/ | ['restaurants'] + +Example 3: Consider Order items for an e commerce company. + +Http Method | Path | Tags +-------------------------------------|-------------------------|------------- +PUT, PATCH, GET(Retrieve), DELETE | /order_items/{id}/ | ['order-items'] +POST, GET(List) | /order_items/ | ['order-items'] + + +--- +#### Overriding auto generated tags: +You can override auto-generated tags by passing `tags` argument to the constructor of `AutoSchema`. `tags` argument must be a list or tuple of string. +```python +from rest_framework.schemas.openapi import AutoSchema +from rest_framework.views import APIView + +class MyView(APIView): + schema = AutoSchema(tags=['tag1', 'tag2']) + ... +``` + +If you need more customization, you can override the `get_tags` method of `AutoSchema` class. Consider the following example: + +```python +from rest_framework.schemas.openapi import AutoSchema +from rest_framework.views import APIView + +class MySchema(AutoSchema): + ... + def get_tags(self, path, method): + if method == 'POST': + tags = ['tag1', 'tag2'] + elif method == 'GET': + tags = ['tag2', 'tag3'] + elif path == '/example/path/': + tags = ['tag3', 'tag4'] + else: + tags = ['tag5', 'tag6', 'tag7'] + + return tags + +class MyView(APIView): + schema = MySchema() + ... +``` + + [openapi]: https://github.com/OAI/OpenAPI-Specification [openapi-specification-extensions]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#specification-extensions [openapi-operation]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject +[openapi-tags]: https://swagger.io/specification/#tagObject diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 9c6610eaf..5277f17a6 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -71,6 +71,12 @@ class SchemaGenerator(BaseSchemaGenerator): class AutoSchema(ViewInspector): + def __init__(self, tags=None): + if tags and not all(isinstance(tag, str) for tag in tags): + raise ValueError('tags must be a list or tuple of string.') + self._tags = tags + super().__init__() + request_media_types = [] response_media_types = [] @@ -98,6 +104,7 @@ class AutoSchema(ViewInspector): if request_body: operation['requestBody'] = request_body operation['responses'] = self._get_responses(path, method) + operation['tags'] = self.get_tags(path, method) return operation @@ -564,3 +571,16 @@ class AutoSchema(ViewInspector): 'description': "" } } + + def get_tags(self, path, method): + # If user have specified tags, use them. + if self._tags: + return self._tags + + # First element of a specific path could be valid tag. This is a fallback solution. + # PUT, PATCH, GET(Retrieve), DELETE: /user_profile/{id}/ tags = [user-profile] + # POST, GET(List): /user_profile/ tags = [user-profile] + if path.startswith('/'): + path = path[1:] + + return [path.split('/')[0].replace('_', '-')] diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index cfa2e89ef..7f73c8c30 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -126,6 +126,7 @@ class TestOperationIntrospection(TestCase): 'operationId': 'listDocStringExamples', 'description': 'A description of my GET operation.', 'parameters': [], + 'tags': ['example'], 'responses': { '200': { 'description': '', @@ -166,6 +167,7 @@ class TestOperationIntrospection(TestCase): 'type': 'string', }, }], + 'tags': ['example'], 'responses': { '200': { 'description': '', @@ -696,6 +698,55 @@ class TestOperationIntrospection(TestCase): assert properties['ip']['type'] == 'string' assert 'format' not in properties['ip'] + def test_overridden_tags(self): + class ExampleStringTagsViewSet(views.ExampleGenericAPIView): + schema = AutoSchema(tags=['example1', 'example2']) + + url_patterns = [ + url(r'^test/?$', ExampleStringTagsViewSet.as_view()), + ] + generator = SchemaGenerator(patterns=url_patterns) + schema = generator.get_schema(request=create_request('/')) + assert schema['paths']['/test/']['get']['tags'] == ['example1', 'example2'] + + def test_overridden_get_tags_method(self): + class MySchema(AutoSchema): + def get_tags(self, path, method): + if path.endswith('/new/'): + tags = ['tag1', 'tag2'] + elif path.endswith('/old/'): + tags = ['tag2', 'tag3'] + else: + tags = ['tag4', 'tag5'] + + return tags + + class ExampleStringTagsViewSet(views.ExampleGenericViewSet): + schema = MySchema() + + router = routers.SimpleRouter() + router.register('example', ExampleStringTagsViewSet, basename="example") + generator = SchemaGenerator(patterns=router.urls) + schema = generator.get_schema(request=create_request('/')) + assert schema['paths']['/example/new/']['get']['tags'] == ['tag1', 'tag2'] + assert schema['paths']['/example/old/']['get']['tags'] == ['tag2', 'tag3'] + + def test_auto_generated_apiview_tags(self): + class RestaurantAPIView(views.ExampleGenericAPIView): + pass + + class BranchAPIView(views.ExampleGenericAPIView): + pass + + url_patterns = [ + url(r'^any-dash_underscore/?$', RestaurantAPIView.as_view()), + url(r'^restaurants/branches/?$', BranchAPIView.as_view()) + ] + generator = SchemaGenerator(patterns=url_patterns) + schema = generator.get_schema(request=create_request('/')) + assert schema['paths']['/any-dash_underscore/']['get']['tags'] == ['any-dash-underscore'] + assert schema['paths']['/restaurants/branches/']['get']['tags'] == ['restaurants'] + @pytest.mark.skipif(uritemplate is None, reason='uritemplate not installed.') @override_settings(REST_FRAMEWORK={'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.openapi.AutoSchema'}) From 94a09149b62496b5434a690de84b5972a5d5b554 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Legi=C4=99cki?= Date: Mon, 2 Mar 2020 16:32:26 +0100 Subject: [PATCH 14/61] OpenAPI: Use 201 status code for POST requests. (#7206) --- rest_framework/schemas/openapi.py | 4 ++-- tests/schemas/test_openapi.py | 9 +++++---- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 5277f17a6..cb0407b62 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -558,9 +558,9 @@ class AutoSchema(ViewInspector): response_schema = paginator.get_paginated_response_schema(response_schema) else: response_schema = item_schema - + status_code = '201' if method == 'POST' else '200' return { - '200': { + status_code: { 'content': { ct: {'schema': response_schema} for ct in self.response_media_types diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index 7f73c8c30..6f5f42dac 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -275,9 +275,10 @@ class TestOperationIntrospection(TestCase): inspector.view = view responses = inspector._get_responses(path, method) - assert responses['200']['content']['application/json']['schema']['required'] == ['text'] - assert list(responses['200']['content']['application/json']['schema']['properties'].keys()) == ['text'] - assert 'description' in responses['200'] + assert '201' in responses + assert responses['201']['content']['application/json']['schema']['required'] == ['text'] + assert list(responses['201']['content']['application/json']['schema']['properties'].keys()) == ['text'] + assert 'description' in responses['201'] def test_response_body_nested_serializer(self): path = '/' @@ -302,7 +303,7 @@ class TestOperationIntrospection(TestCase): inspector.view = view responses = inspector._get_responses(path, method) - schema = responses['200']['content']['application/json']['schema'] + schema = responses['201']['content']['application/json']['schema'] assert sorted(schema['required']) == ['nested', 'text'] assert sorted(list(schema['properties'].keys())) == ['nested', 'text'] assert schema['properties']['nested']['type'] == 'object' From 5b16a1724202d6f4ef58d22caa492893ee5f3aa8 Mon Sep 17 00:00:00 2001 From: Martin Desrumaux <9059840+gnuletik@users.noreply.github.com> Date: Mon, 2 Mar 2020 16:40:18 +0100 Subject: [PATCH 15/61] OpenAPI: Allow customizing operation name. (#7190) --- docs/api-guide/schemas.md | 33 +++++++++++++++ rest_framework/schemas/openapi.py | 42 +++++++++++++------ tests/schemas/test_openapi.py | 68 ++++++++++++++++++++++++++++++- 3 files changed, 129 insertions(+), 14 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 2e5ffc79b..5766a6a61 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -288,8 +288,41 @@ class MyView(APIView): ... ``` +### OperationId + +The schema generator generates an [operationid](openapi-operationid) for each operation. This `operationId` is deduced from the model name, serializer name or view name. The operationId may looks like "ListItems", "RetrieveItem", "UpdateItem", etc.. + +If you have several views with the same model, the generator may generate duplicate operationId. +In order to work around this, you can override the second part of the operationId: operation name. + +```python +from rest_framework.schemas.openapi import AutoSchema + +class ExampleView(APIView): + """APIView subclass with custom schema introspection.""" + schema = AutoSchema(operation_id_base="Custom") +``` + +The previous example will generate the following operationId: "ListCustoms", "RetrieveCustom", "UpdateCustom", "PartialUpdateCustom", "DestroyCustom". +You need to provide the singular form of he operation name. For the list operation, a "s" will be appended at the end of the operation. + +If you need more configuration over the `operationId` field, you can override the `get_operation_id_base` and `get_operation_id` methods from the `AutoSchema` class: + +```python +class CustomSchema(AutoSchema): + def get_operation_id_base(self, path, method, action): + pass + + def get_operation_id(self, path, method): + pass + +class CustomView(APIView): + """APIView subclass with custom schema introspection.""" + schema = CustomSchema() +``` [openapi]: https://github.com/OAI/OpenAPI-Specification [openapi-specification-extensions]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#specification-extensions [openapi-operation]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject [openapi-tags]: https://swagger.io/specification/#tagObject +[openapi-operationid]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#fixed-fields-17 diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index cb0407b62..293f7c2a4 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -71,10 +71,14 @@ class SchemaGenerator(BaseSchemaGenerator): class AutoSchema(ViewInspector): - def __init__(self, tags=None): + def __init__(self, operation_id_base=None, tags=None): + """ + :param operation_id_base: user-defined name in operationId. If empty, it will be deducted from the Model/Serializer/View name. + """ if tags and not all(isinstance(tag, str) for tag in tags): raise ValueError('tags must be a list or tuple of string.') self._tags = tags + self.operation_id_base = operation_id_base super().__init__() request_media_types = [] @@ -91,7 +95,7 @@ class AutoSchema(ViewInspector): def get_operation(self, path, method): operation = {} - operation['operationId'] = self._get_operation_id(path, method) + operation['operationId'] = self.get_operation_id(path, method) operation['description'] = self.get_description(path, method) parameters = [] @@ -108,21 +112,17 @@ class AutoSchema(ViewInspector): return operation - def _get_operation_id(self, path, method): + def get_operation_id_base(self, path, method, action): """ - Compute an operation ID from the model, serializer or view name. + Compute the base part for operation ID from the model, serializer or view name. """ - method_name = getattr(self.view, 'action', method.lower()) - if is_list_view(path, method, self.view): - action = 'list' - elif method_name not in self.method_mapping: - action = method_name - else: - action = self.method_mapping[method.lower()] + model = getattr(getattr(self.view, 'queryset', None), 'model', None) + + if self.operation_id_base is not None: + name = self.operation_id_base # Try to deduce the ID from the view's model - model = getattr(getattr(self.view, 'queryset', None), 'model', None) - if model is not None: + elif model is not None: name = model.__name__ # Try with the serializer class name @@ -147,6 +147,22 @@ class AutoSchema(ViewInspector): if action == 'list' and not name.endswith('s'): # listThings instead of listThing name += 's' + return name + + def get_operation_id(self, path, method): + """ + Compute an operation ID from the view type and get_operation_id_base method. + """ + method_name = getattr(self.view, 'action', method.lower()) + if is_list_view(path, method, self.view): + action = 'list' + elif method_name not in self.method_mapping: + action = method_name + else: + action = self.method_mapping[method.lower()] + + name = self.get_operation_id_base(path, method, action) + return action + name def _get_path_parameters(self, path, method): diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index 6f5f42dac..05f1ccfed 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -575,9 +575,75 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - operationId = inspector._get_operation_id(path, method) + operationId = inspector.get_operation_id(path, method) assert operationId == 'listExamples' + def test_operation_id_custom_operation_id_base(self): + path = '/' + method = 'GET' + + view = create_view( + views.ExampleGenericAPIView, + method, + create_request(path), + ) + inspector = AutoSchema(operation_id_base="Ulysse") + inspector.view = view + + operationId = inspector.get_operation_id(path, method) + assert operationId == 'listUlysses' + + def test_operation_id_custom_name(self): + path = '/' + method = 'GET' + + view = create_view( + views.ExampleGenericAPIView, + method, + create_request(path), + ) + inspector = AutoSchema(operation_id_base='Ulysse') + inspector.view = view + + operationId = inspector.get_operation_id(path, method) + assert operationId == 'listUlysses' + + def test_operation_id_override_get(self): + class CustomSchema(AutoSchema): + def get_operation_id(self, path, method): + return 'myCustomOperationId' + + path = '/' + method = 'GET' + view = create_view( + views.ExampleGenericAPIView, + method, + create_request(path), + ) + inspector = CustomSchema() + inspector.view = view + + operationId = inspector.get_operation_id(path, method) + assert operationId == 'myCustomOperationId' + + def test_operation_id_override_base(self): + class CustomSchema(AutoSchema): + def get_operation_id_base(self, path, method, action): + return 'Item' + + path = '/' + method = 'GET' + view = create_view( + views.ExampleGenericAPIView, + method, + create_request(path), + ) + inspector = CustomSchema() + inspector.view = view + + operationId = inspector.get_operation_id(path, method) + assert operationId == 'listItem' + def test_repeat_operation_ids(self): router = routers.SimpleRouter() router.register('account', views.ExampleGenericViewSet, basename="account") From 797518af6d996308781e283601057ff82ed8684c Mon Sep 17 00:00:00 2001 From: Martin Desrumaux <9059840+gnuletik@users.noreply.github.com> Date: Mon, 2 Mar 2020 16:44:06 +0100 Subject: [PATCH 16/61] OpenAPI: Warn user about duplicate operationIds. (#7207) --- rest_framework/schemas/openapi.py | 28 ++++++++++++++++++++++++++++ tests/schemas/test_openapi.py | 19 +++++++++++++++++++ tests/schemas/views.py | 30 ++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+) diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 293f7c2a4..d3a373aaa 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -34,6 +34,32 @@ class SchemaGenerator(BaseSchemaGenerator): return info + def check_duplicate_operation_id(self, paths): + ids = {} + for route in paths: + for method in paths[route]: + if 'operationId' not in paths[route][method]: + continue + operation_id = paths[route][method]['operationId'] + if operation_id in ids: + warnings.warn( + 'You have a duplicated operationId in your OpenAPI schema: {operation_id}\n' + '\tRoute: {route1}, Method: {method1}\n' + '\tRoute: {route2}, Method: {method2}\n' + '\tAn operationId has to be unique accros your schema. Your schema may not work in other tools.' + .format( + route1=ids[operation_id]['route'], + method1=ids[operation_id]['method'], + route2=route, + method2=method, + operation_id=operation_id + ) + ) + ids[operation_id] = { + 'route': route, + 'method': method + } + def get_schema(self, request=None, public=False): """ Generate a OpenAPI schema. @@ -57,6 +83,8 @@ class SchemaGenerator(BaseSchemaGenerator): paths.setdefault(path, {}) paths[path][method.lower()] = operation + self.check_duplicate_operation_id(paths) + # Compile final schema. schema = { 'openapi': '3.0.2', diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index 05f1ccfed..b3f30b258 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -1,4 +1,5 @@ import uuid +import warnings import pytest from django.conf.urls import url @@ -659,6 +660,24 @@ class TestOperationIntrospection(TestCase): assert schema_str.count("newExample") == 1 assert schema_str.count("oldExample") == 1 + def test_duplicate_operation_id(self): + patterns = [ + url(r'^duplicate1/?$', views.ExampleOperationIdDuplicate1.as_view()), + url(r'^duplicate2/?$', views.ExampleOperationIdDuplicate2.as_view()), + ] + + generator = SchemaGenerator(patterns=patterns) + request = create_request('/') + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + generator.get_schema(request=request) + + assert len(w) == 1 + assert issubclass(w[-1].category, UserWarning) + print(str(w[-1].message)) + assert 'You have a duplicated operationId' in str(w[-1].message) + def test_serializer_datefield(self): path = '/' method = 'GET' diff --git a/tests/schemas/views.py b/tests/schemas/views.py index e8307ccbd..5835a5572 100644 --- a/tests/schemas/views.py +++ b/tests/schemas/views.py @@ -4,6 +4,7 @@ from django.core.validators import ( DecimalValidator, MaxLengthValidator, MaxValueValidator, MinLengthValidator, MinValueValidator, RegexValidator ) +from django.db import models from rest_framework import generics, permissions, serializers from rest_framework.decorators import action @@ -137,3 +138,32 @@ class ExampleValidatedAPIView(generics.GenericAPIView): url='http://localhost', uuid=uuid.uuid4(), ip4='127.0.0.1', ip6='::1', ip='192.168.1.1') return Response(serializer.data) + + +# Serializer with model. +class OpenAPIExample(models.Model): + first_name = models.CharField(max_length=30) + + +class ExampleSerializerModel(serializers.Serializer): + date = serializers.DateField() + datetime = serializers.DateTimeField() + hstore = serializers.HStoreField() + uuid_field = serializers.UUIDField(default=uuid.uuid4) + + class Meta: + model = OpenAPIExample + + +class ExampleOperationIdDuplicate1(generics.GenericAPIView): + serializer_class = ExampleSerializerModel + + def get(self, *args, **kwargs): + pass + + +class ExampleOperationIdDuplicate2(generics.GenericAPIView): + serializer_class = ExampleSerializerModel + + def get(self, *args, **kwargs): + pass From 8aa8be7653cf441e81cabd9be945f809cb617192 Mon Sep 17 00:00:00 2001 From: Martin Desrumaux <9059840+gnuletik@users.noreply.github.com> Date: Mon, 2 Mar 2020 19:35:27 +0100 Subject: [PATCH 17/61] Implement OpenAPI Components (#7124) --- docs/api-guide/schemas.md | 63 +++++++++ rest_framework/schemas/openapi.py | 94 ++++++++---- tests/schemas/test_openapi.py | 228 ++++++++++++++++++++++-------- tests/schemas/views.py | 48 +++++++ 4 files changed, 347 insertions(+), 86 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 5766a6a61..1d1e09b46 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -316,6 +316,65 @@ class CustomSchema(AutoSchema): def get_operation_id(self, path, method): pass +class MyView(APIView): + schema = AutoSchema(component_name="Ulysses") +``` + +### Components + +Since DRF 3.12, Schema uses the [OpenAPI Components](openapi-components). This method defines components in the schema and [references them](openapi-reference) inside request and response objects. By default, the component's name is deduced from the Serializer's name. + +Using OpenAPI's components provides the following advantages: +* The schema is more readable and lightweight. +* If you use the schema to generate an SDK (using [openapi-generator](openapi-generator) or [swagger-codegen](swagger-codegen)). The generator can name your SDK's models. + +### Handling component's schema errors + +You may get the following error while generating the schema: +``` +"Serializer" is an invalid class name for schema generation. +Serializer's class name should be unique and explicit. e.g. "ItemSerializer". +``` + +This error occurs when the Serializer name is "Serializer". You should choose a component's name unique across your schema and different than "Serializer". + +You may also get the following warning: +``` +Schema component "ComponentName" has been overriden with a different value. +``` + +This warning occurs when different components have the same name in one schema. Your component name should be unique across your project. This is likely an error that may lead to an invalid schema. + +You have two ways to solve the previous issues: +* You can rename your serializer with a unique name and another name than "Serializer". +* You can set the `component_name` kwarg parameter of the AutoSchema constructor (see below). +* You can override the `get_component_name` method of the AutoSchema class (see below). + +#### Set a custom component's name for your view + +To override the component's name in your view, you can use the `component_name` parameter of the AutoSchema constructor: + +```python +from rest_framework.schemas.openapi import AutoSchema + +class MyView(APIView): + schema = AutoSchema(component_name="Ulysses") +``` + +#### Override the default implementation + +If you want to have more control and customization about how the schema's components are generated, you can override the `get_component_name` and `get_components` method from the AutoSchema class. + +```python +from rest_framework.schemas.openapi import AutoSchema + +class CustomSchema(AutoSchema): + def get_components(self, path, method): + # Implement your custom implementation + + def get_component_name(self, serializer): + # Implement your custom implementation + class CustomView(APIView): """APIView subclass with custom schema introspection.""" schema = CustomSchema() @@ -326,3 +385,7 @@ class CustomView(APIView): [openapi-operation]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#operationObject [openapi-tags]: https://swagger.io/specification/#tagObject [openapi-operationid]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#fixed-fields-17 +[openapi-components]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#componentsObject +[openapi-reference]: https://github.com/OAI/OpenAPI-Specification/blob/master/versions/3.0.2.md#referenceObject +[openapi-generator]: https://github.com/OpenAPITools/openapi-generator +[swagger-codegen]: https://github.com/swagger-api/swagger-codegen diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index d3a373aaa..6bed12092 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -1,3 +1,4 @@ +import re import warnings from collections import OrderedDict from decimal import Decimal @@ -65,9 +66,9 @@ class SchemaGenerator(BaseSchemaGenerator): Generate a OpenAPI schema. """ self._initialise_endpoints() + components_schemas = {} # Iterate endpoints generating per method path operations. - # TODO: …and reference components. paths = {} _, view_endpoints = self._get_paths_and_endpoints(None if public else request) for path, method, view in view_endpoints: @@ -75,6 +76,16 @@ class SchemaGenerator(BaseSchemaGenerator): continue operation = view.schema.get_operation(path, method) + components = view.schema.get_components(path, method) + for k in components.keys(): + if k not in components_schemas: + continue + if components_schemas[k] == components[k]: + continue + warnings.warn('Schema component "{}" has been overriden with a different value.'.format(k)) + + components_schemas.update(components) + # Normalise path for any provided mount url. if path.startswith('/'): path = path[1:] @@ -92,6 +103,11 @@ class SchemaGenerator(BaseSchemaGenerator): 'paths': paths, } + if len(components_schemas) > 0: + schema['components'] = { + 'schemas': components_schemas + } + return schema # View Inspectors @@ -99,14 +115,16 @@ class SchemaGenerator(BaseSchemaGenerator): class AutoSchema(ViewInspector): - def __init__(self, operation_id_base=None, tags=None): + def __init__(self, tags=None, operation_id_base=None, component_name=None): """ :param operation_id_base: user-defined name in operationId. If empty, it will be deducted from the Model/Serializer/View name. + :param component_name: user-defined component's name. If empty, it will be deducted from the Serializer's class name. """ if tags and not all(isinstance(tag, str) for tag in tags): raise ValueError('tags must be a list or tuple of string.') self._tags = tags self.operation_id_base = operation_id_base + self.component_name = component_name super().__init__() request_media_types = [] @@ -140,6 +158,43 @@ class AutoSchema(ViewInspector): return operation + def get_component_name(self, serializer): + """ + Compute the component's name from the serializer. + Raise an exception if the serializer's class name is "Serializer" (case-insensitive). + """ + if self.component_name is not None: + return self.component_name + + # use the serializer's class name as the component name. + component_name = serializer.__class__.__name__ + # We remove the "serializer" string from the class name. + pattern = re.compile("serializer", re.IGNORECASE) + component_name = pattern.sub("", component_name) + + if component_name == "": + raise Exception( + '"{}" is an invalid class name for schema generation. ' + 'Serializer\'s class name should be unique and explicit. e.g. "ItemSerializer"' + .format(serializer.__class__.__name__) + ) + + return component_name + + def get_components(self, path, method): + """ + Return components with their properties from the serializer. + """ + 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) + return {component_name: content} + def get_operation_id_base(self, path, method, action): """ Compute the base part for operation ID from the model, serializer or view name. @@ -434,10 +489,6 @@ class AutoSchema(ViewInspector): def _map_serializer(self, serializer): # Assuming we have a valid serializer instance. - # TODO: - # - field is Nested or List serializer. - # - Handle read_only/write_only for request/response differences. - # - could do this with readOnly/writeOnly and then filter dict. required = [] properties = {} @@ -542,6 +593,9 @@ class AutoSchema(ViewInspector): .format(view.__class__.__name__, method, path)) return None + def _get_reference(self, serializer): + return {'$ref': '#/components/schemas/{}'.format(self.get_component_name(serializer))} + def _get_request_body(self, path, method): if method not in ('PUT', 'PATCH', 'POST'): return {} @@ -551,20 +605,13 @@ class AutoSchema(ViewInspector): serializer = self._get_serializer(path, method) if not isinstance(serializer, serializers.Serializer): - return {} - - content = self._map_serializer(serializer) - # No required fields for PATCH - if method == 'PATCH': - content.pop('required', None) - # No read_only fields for request. - for name, schema in content['properties'].copy().items(): - if 'readOnly' in schema: - del content['properties'][name] + item_schema = {} + else: + item_schema = self._get_reference(serializer) return { 'content': { - ct: {'schema': content} + ct: {'schema': item_schema} for ct in self.request_media_types } } @@ -580,17 +627,12 @@ class AutoSchema(ViewInspector): self.response_media_types = self.map_renderers(path, method) - item_schema = {} serializer = self._get_serializer(path, method) - if isinstance(serializer, serializers.Serializer): - item_schema = self._map_serializer(serializer) - # No write_only fields for response. - for name, schema in item_schema['properties'].copy().items(): - if 'writeOnly' in schema: - del item_schema['properties'][name] - if 'required' in item_schema: - item_schema['required'] = [f for f in item_schema['required'] if f != name] + if not isinstance(serializer, serializers.Serializer): + item_schema = {} + else: + item_schema = self._get_reference(serializer) if is_list_view(path, method, self.view): response_schema = { diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index b3f30b258..95101403a 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -85,12 +85,12 @@ class TestFieldMapping(TestCase): assert inspector._map_field(field) == mapping def test_lazy_string_field(self): - class Serializer(serializers.Serializer): + class ItemSerializer(serializers.Serializer): text = serializers.CharField(help_text=_('lazy string')) inspector = AutoSchema() - data = inspector._map_serializer(Serializer()) + data = inspector._map_serializer(ItemSerializer()) assert isinstance(data['properties']['text']['description'], str), "description must be str" def test_boolean_default_field(self): @@ -186,6 +186,33 @@ class TestOperationIntrospection(TestCase): path = '/' method = 'POST' + class ItemSerializer(serializers.Serializer): + text = serializers.CharField() + read_only = serializers.CharField(read_only=True) + + class View(generics.GenericAPIView): + serializer_class = ItemSerializer + + view = create_view( + View, + method, + create_request(path) + ) + inspector = AutoSchema() + inspector.view = view + + request_body = inspector._get_request_body(path, method) + print(request_body) + assert request_body['content']['application/json']['schema']['$ref'] == '#/components/schemas/Item' + + components = inspector.get_components(path, method) + assert components['Item']['required'] == ['text'] + assert sorted(list(components['Item']['properties'].keys())) == ['read_only', 'text'] + + def test_invalid_serializer_class_name(self): + path = '/' + method = 'POST' + class Serializer(serializers.Serializer): text = serializers.CharField() read_only = serializers.CharField(read_only=True) @@ -201,20 +228,22 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - request_body = inspector._get_request_body(path, method) - assert request_body['content']['application/json']['schema']['required'] == ['text'] - assert list(request_body['content']['application/json']['schema']['properties'].keys()) == ['text'] + serializer = inspector._get_serializer(path, method) + + with pytest.raises(Exception) as exc: + inspector.get_component_name(serializer) + assert "is an invalid class name for schema generation" in str(exc.value) def test_empty_required(self): path = '/' method = 'POST' - class Serializer(serializers.Serializer): + class ItemSerializer(serializers.Serializer): read_only = serializers.CharField(read_only=True) write_only = serializers.CharField(write_only=True, required=False) class View(generics.GenericAPIView): - serializer_class = Serializer + serializer_class = ItemSerializer view = create_view( View, @@ -224,23 +253,24 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - request_body = inspector._get_request_body(path, method) + components = inspector.get_components(path, method) + component = components['Item'] # there should be no empty 'required' property, see #6834 - assert 'required' not in request_body['content']['application/json']['schema'] + assert 'required' not in component for response in inspector._get_responses(path, method).values(): - assert 'required' not in response['content']['application/json']['schema'] + assert 'required' not in component def test_empty_required_with_patch_method(self): path = '/' method = 'PATCH' - class Serializer(serializers.Serializer): + class ItemSerializer(serializers.Serializer): read_only = serializers.CharField(read_only=True) write_only = serializers.CharField(write_only=True, required=False) class View(generics.GenericAPIView): - serializer_class = Serializer + serializer_class = ItemSerializer view = create_view( View, @@ -250,22 +280,23 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - request_body = inspector._get_request_body(path, method) + components = inspector.get_components(path, method) + component = components['Item'] # there should be no empty 'required' property, see #6834 - assert 'required' not in request_body['content']['application/json']['schema'] + assert 'required' not in component for response in inspector._get_responses(path, method).values(): - assert 'required' not in response['content']['application/json']['schema'] + assert 'required' not in component def test_response_body_generation(self): path = '/' method = 'POST' - class Serializer(serializers.Serializer): + class ItemSerializer(serializers.Serializer): text = serializers.CharField() write_only = serializers.CharField(write_only=True) class View(generics.GenericAPIView): - serializer_class = Serializer + serializer_class = ItemSerializer view = create_view( View, @@ -276,9 +307,11 @@ class TestOperationIntrospection(TestCase): inspector.view = view responses = inspector._get_responses(path, method) - assert '201' in responses - assert responses['201']['content']['application/json']['schema']['required'] == ['text'] - assert list(responses['201']['content']['application/json']['schema']['properties'].keys()) == ['text'] + assert responses['201']['content']['application/json']['schema']['$ref'] == '#/components/schemas/Item' + + components = inspector.get_components(path, method) + assert sorted(components['Item']['required']) == ['text', 'write_only'] + assert sorted(list(components['Item']['properties'].keys())) == ['text', 'write_only'] assert 'description' in responses['201'] def test_response_body_nested_serializer(self): @@ -288,12 +321,12 @@ class TestOperationIntrospection(TestCase): class NestedSerializer(serializers.Serializer): number = serializers.IntegerField() - class Serializer(serializers.Serializer): + class ItemSerializer(serializers.Serializer): text = serializers.CharField() nested = NestedSerializer() class View(generics.GenericAPIView): - serializer_class = Serializer + serializer_class = ItemSerializer view = create_view( View, @@ -304,7 +337,11 @@ class TestOperationIntrospection(TestCase): inspector.view = view responses = inspector._get_responses(path, method) - schema = responses['201']['content']['application/json']['schema'] + assert responses['201']['content']['application/json']['schema']['$ref'] == '#/components/schemas/Item' + components = inspector.get_components(path, method) + assert components['Item'] + + schema = components['Item'] assert sorted(schema['required']) == ['nested', 'text'] assert sorted(list(schema['properties'].keys())) == ['nested', 'text'] assert schema['properties']['nested']['type'] == 'object' @@ -339,19 +376,25 @@ class TestOperationIntrospection(TestCase): 'schema': { 'type': 'array', 'items': { - 'type': 'object', - 'properties': { - 'text': { - 'type': 'string', - }, - }, - 'required': ['text'], + '$ref': '#/components/schemas/Item' }, }, }, }, }, } + components = inspector.get_components(path, method) + assert components == { + 'Item': { + 'type': 'object', + 'properties': { + 'text': { + 'type': 'string', + }, + }, + 'required': ['text'], + } + } def test_paginated_list_response_body_generation(self): """Test that pagination properties are added for a paginated list view.""" @@ -391,13 +434,7 @@ class TestOperationIntrospection(TestCase): 'item': { 'type': 'array', 'items': { - 'type': 'object', - 'properties': { - 'text': { - 'type': 'string', - }, - }, - 'required': ['text'], + '$ref': '#/components/schemas/Item' }, }, }, @@ -405,6 +442,18 @@ class TestOperationIntrospection(TestCase): }, }, } + components = inspector.get_components(path, method) + assert components == { + 'Item': { + 'type': 'object', + 'properties': { + 'text': { + 'type': 'string', + }, + }, + 'required': ['text'], + } + } def test_delete_response_body_generation(self): """Test that a view's delete method generates a proper response body schema.""" @@ -508,10 +557,10 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - request_body = inspector._get_request_body(path, method) - mp_media = request_body['content']['multipart/form-data'] - attachment = mp_media['schema']['properties']['attachment'] - assert attachment['format'] == 'binary' + components = inspector.get_components(path, method) + component = components['Item'] + properties = component['properties'] + assert properties['attachment']['format'] == 'binary' def test_retrieve_response_body_generation(self): """ @@ -551,19 +600,26 @@ class TestOperationIntrospection(TestCase): 'content': { 'application/json': { 'schema': { - 'type': 'object', - 'properties': { - 'text': { - 'type': 'string', - }, - }, - 'required': ['text'], + '$ref': '#/components/schemas/Item' }, }, }, }, } + components = inspector.get_components(path, method) + assert components == { + 'Item': { + 'type': 'object', + 'properties': { + 'text': { + 'type': 'string', + }, + }, + 'required': ['text'], + } + } + def test_operation_id_generation(self): path = '/' method = 'GET' @@ -689,9 +745,9 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) - response_schema = responses['200']['content']['application/json']['schema'] - properties = response_schema['items']['properties'] + components = inspector.get_components(path, method) + component = components['Example'] + properties = component['properties'] assert properties['date']['type'] == properties['datetime']['type'] == 'string' assert properties['date']['format'] == 'date' assert properties['datetime']['format'] == 'date-time' @@ -707,9 +763,9 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) - response_schema = responses['200']['content']['application/json']['schema'] - properties = response_schema['items']['properties'] + components = inspector.get_components(path, method) + component = components['Example'] + properties = component['properties'] assert properties['hstore']['type'] == 'object' def test_serializer_callable_default(self): @@ -723,9 +779,9 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) - response_schema = responses['200']['content']['application/json']['schema'] - properties = response_schema['items']['properties'] + components = inspector.get_components(path, method) + component = components['Example'] + properties = component['properties'] assert 'default' not in properties['uuid_field'] def test_serializer_validators(self): @@ -739,9 +795,9 @@ class TestOperationIntrospection(TestCase): inspector = AutoSchema() inspector.view = view - responses = inspector._get_responses(path, method) - response_schema = responses['200']['content']['application/json']['schema'] - properties = response_schema['items']['properties'] + components = inspector.get_components(path, method) + component = components['ExampleValidated'] + properties = component['properties'] assert properties['integer']['type'] == 'integer' assert properties['integer']['maximum'] == 99 @@ -819,6 +875,7 @@ class TestOperationIntrospection(TestCase): def test_auto_generated_apiview_tags(self): class RestaurantAPIView(views.ExampleGenericAPIView): + schema = AutoSchema(operation_id_base="restaurant") pass class BranchAPIView(views.ExampleGenericAPIView): @@ -932,3 +989,54 @@ class TestGenerator(TestCase): assert schema['info']['title'] == '' assert schema['info']['version'] == '' + + def test_serializer_model(self): + """Construction of the top level dictionary.""" + patterns = [ + url(r'^example/?$', views.ExampleGenericAPIViewModel.as_view()), + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request('/') + schema = generator.get_schema(request=request) + + print(schema) + assert 'components' in schema + assert 'schemas' in schema['components'] + assert 'ExampleModel' in schema['components']['schemas'] + + def test_component_name(self): + patterns = [ + url(r'^example/?$', views.ExampleAutoSchemaComponentName.as_view()), + ] + + generator = SchemaGenerator(patterns=patterns) + + request = create_request('/') + schema = generator.get_schema(request=request) + + print(schema) + assert 'components' in schema + assert 'schemas' in schema['components'] + assert 'Ulysses' in schema['components']['schemas'] + + def test_duplicate_component_name(self): + patterns = [ + url(r'^duplicate1/?$', views.ExampleAutoSchemaDuplicate1.as_view()), + url(r'^duplicate2/?$', views.ExampleAutoSchemaDuplicate2.as_view()), + ] + + generator = SchemaGenerator(patterns=patterns) + request = create_request('/') + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter('always') + schema = generator.get_schema(request=request) + + assert len(w) == 1 + assert issubclass(w[-1].category, UserWarning) + assert 'has been overriden with a different value.' in str(w[-1].message) + + assert 'components' in schema + assert 'schemas' in schema['components'] + assert 'Duplicate' in schema['components']['schemas'] diff --git a/tests/schemas/views.py b/tests/schemas/views.py index 5835a5572..1c8235b42 100644 --- a/tests/schemas/views.py +++ b/tests/schemas/views.py @@ -9,6 +9,7 @@ from django.db import models from rest_framework import generics, permissions, serializers from rest_framework.decorators import action from rest_framework.response import Response +from rest_framework.schemas.openapi import AutoSchema from rest_framework.views import APIView from rest_framework.viewsets import GenericViewSet @@ -167,3 +168,50 @@ class ExampleOperationIdDuplicate2(generics.GenericAPIView): def get(self, *args, **kwargs): pass + + +class ExampleGenericAPIViewModel(generics.GenericAPIView): + serializer_class = ExampleSerializerModel + + def get(self, *args, **kwargs): + from datetime import datetime + now = datetime.now() + + serializer = self.get_serializer(data=now.date(), datetime=now) + return Response(serializer.data) + + +class ExampleAutoSchemaComponentName(generics.GenericAPIView): + serializer_class = ExampleSerializerModel + schema = AutoSchema(component_name="Ulysses") + + def get(self, *args, **kwargs): + from datetime import datetime + now = datetime.now() + + serializer = self.get_serializer(data=now.date(), datetime=now) + return Response(serializer.data) + + +class ExampleAutoSchemaDuplicate1(generics.GenericAPIView): + serializer_class = ExampleValidatedSerializer + schema = AutoSchema(component_name="Duplicate") + + def get(self, *args, **kwargs): + from datetime import datetime + now = datetime.now() + + serializer = self.get_serializer(data=now.date(), datetime=now) + return Response(serializer.data) + + +class ExampleAutoSchemaDuplicate2(generics.GenericAPIView): + serializer_class = ExampleSerializerModel + schema = AutoSchema(component_name="Duplicate") + + def get(self, *args, **kwargs): + from datetime import datetime + now = datetime.now() + + serializer = self.get_serializer(data=now.date(), datetime=now) + return Response(serializer.data) From 609f708a27bd38496b912c44742287c57e7af912 Mon Sep 17 00:00:00 2001 From: Martin Desrumaux <9059840+gnuletik@users.noreply.github.com> Date: Tue, 3 Mar 2020 13:27:34 +0100 Subject: [PATCH 18/61] Fix schema generation for ObtainAuthToken view. (#7211) --- rest_framework/authtoken/serializers.py | 12 ++++++++-- rest_framework/authtoken/views.py | 18 ++++++++++++--- tests/schemas/test_openapi.py | 30 +++++++++++++++++++++++++ 3 files changed, 55 insertions(+), 5 deletions(-) diff --git a/rest_framework/authtoken/serializers.py b/rest_framework/authtoken/serializers.py index bb552f3e5..63e64d668 100644 --- a/rest_framework/authtoken/serializers.py +++ b/rest_framework/authtoken/serializers.py @@ -5,11 +5,19 @@ from rest_framework import serializers class AuthTokenSerializer(serializers.Serializer): - username = serializers.CharField(label=_("Username")) + username = serializers.CharField( + label=_("Username"), + write_only=True + ) password = serializers.CharField( label=_("Password"), style={'input_type': 'password'}, - trim_whitespace=False + trim_whitespace=False, + write_only=True + ) + token = serializers.CharField( + label=_("Token"), + read_only=True ) def validate(self, attrs): diff --git a/rest_framework/authtoken/views.py b/rest_framework/authtoken/views.py index a8c751d51..50f9acbd9 100644 --- a/rest_framework/authtoken/views.py +++ b/rest_framework/authtoken/views.py @@ -4,6 +4,7 @@ from rest_framework.authtoken.serializers import AuthTokenSerializer from rest_framework.compat import coreapi, coreschema from rest_framework.response import Response from rest_framework.schemas import ManualSchema +from rest_framework.schemas import coreapi as coreapi_schema from rest_framework.views import APIView @@ -13,7 +14,8 @@ class ObtainAuthToken(APIView): parser_classes = (parsers.FormParser, parsers.MultiPartParser, parsers.JSONParser,) renderer_classes = (renderers.JSONRenderer,) serializer_class = AuthTokenSerializer - if coreapi is not None and coreschema is not None: + + if coreapi_schema.is_enabled(): schema = ManualSchema( fields=[ coreapi.Field( @@ -38,9 +40,19 @@ class ObtainAuthToken(APIView): encoding="application/json", ) + def get_serializer_context(self): + return { + 'request': self.request, + 'format': self.format_kwarg, + 'view': self + } + + def get_serializer(self, *args, **kwargs): + kwargs['context'] = self.get_serializer_context() + return self.serializer_class(*args, **kwargs) + def post(self, request, *args, **kwargs): - serializer = self.serializer_class(data=request.data, - context={'request': request}) + serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.validated_data['user'] token, created = Token.objects.get_or_create(user=user) diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index 95101403a..35d676d6c 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -7,6 +7,7 @@ from django.test import RequestFactory, TestCase, override_settings from django.utils.translation import gettext_lazy as _ from rest_framework import filters, generics, pagination, routers, serializers +from rest_framework.authtoken.views import obtain_auth_token from rest_framework.compat import uritemplate from rest_framework.parsers import JSONParser, MultiPartParser from rest_framework.renderers import JSONRenderer, OpenAPIRenderer @@ -995,16 +996,45 @@ class TestGenerator(TestCase): patterns = [ url(r'^example/?$', views.ExampleGenericAPIViewModel.as_view()), ] + generator = SchemaGenerator(patterns=patterns) request = create_request('/') schema = generator.get_schema(request=request) print(schema) + assert 'components' in schema assert 'schemas' in schema['components'] assert 'ExampleModel' in schema['components']['schemas'] + def test_authtoken_serializer(self): + patterns = [ + url(r'^api-token-auth/', obtain_auth_token) + ] + generator = SchemaGenerator(patterns=patterns) + + request = create_request('/') + schema = generator.get_schema(request=request) + + print(schema) + + route = schema['paths']['/api-token-auth/']['post'] + body_schema = route['requestBody']['content']['application/json']['schema'] + + assert body_schema == { + '$ref': '#/components/schemas/AuthToken' + } + assert schema['components']['schemas']['AuthToken'] == { + 'type': 'object', + 'properties': { + 'username': {'type': 'string', 'writeOnly': True}, + 'password': {'type': 'string', 'writeOnly': True}, + 'token': {'type': 'string', 'readOnly': True}, + }, + 'required': ['username', 'password'] + } + def test_component_name(self): patterns = [ url(r'^example/?$', views.ExampleAutoSchemaComponentName.as_view()), From 6a23fa06495c6010239dda926f55f2d6baa79cf3 Mon Sep 17 00:00:00 2001 From: Martin Desrumaux <9059840+gnuletik@users.noreply.github.com> Date: Tue, 3 Mar 2020 17:51:51 +0100 Subject: [PATCH 19/61] OpenAPI: Make operationId camelCase, matching spec examples. (#7208) --- docs/api-guide/schemas.md | 5 +++-- rest_framework/schemas/openapi.py | 18 ++++++++++++------ tests/schemas/test_openapi.py | 19 ++++++++++++++++++- tests/schemas/views.py | 24 +++++++++++++++++++++++- 4 files changed, 56 insertions(+), 10 deletions(-) diff --git a/docs/api-guide/schemas.md b/docs/api-guide/schemas.md index 1d1e09b46..2d74882ad 100644 --- a/docs/api-guide/schemas.md +++ b/docs/api-guide/schemas.md @@ -290,7 +290,8 @@ class MyView(APIView): ### OperationId -The schema generator generates an [operationid](openapi-operationid) for each operation. This `operationId` is deduced from the model name, serializer name or view name. The operationId may looks like "ListItems", "RetrieveItem", "UpdateItem", etc.. +The schema generator generates an [operationid](openapi-operationid) for each operation. This `operationId` is deduced from the model name, serializer name or view name. The operationId may looks like "listItems", "retrieveItem", "updateItem", etc.. +The `operationId` is camelCase by convention. If you have several views with the same model, the generator may generate duplicate operationId. In order to work around this, you can override the second part of the operationId: operation name. @@ -303,7 +304,7 @@ class ExampleView(APIView): schema = AutoSchema(operation_id_base="Custom") ``` -The previous example will generate the following operationId: "ListCustoms", "RetrieveCustom", "UpdateCustom", "PartialUpdateCustom", "DestroyCustom". +The previous example will generate the following operationId: "listCustoms", "retrieveCustom", "updateCustom", "partialUpdateCustom", "destroyCustom". You need to provide the singular form of he operation name. For the list operation, a "s" will be appended at the end of the operation. If you need more configuration over the `operationId` field, you can override the `get_operation_id_base` and `get_operation_id` methods from the `AutoSchema` class: diff --git a/rest_framework/schemas/openapi.py b/rest_framework/schemas/openapi.py index 6bed12092..1d0ec35d5 100644 --- a/rest_framework/schemas/openapi.py +++ b/rest_framework/schemas/openapi.py @@ -131,11 +131,11 @@ class AutoSchema(ViewInspector): response_media_types = [] method_mapping = { - 'get': 'Retrieve', - 'post': 'Create', - 'put': 'Update', - 'patch': 'PartialUpdate', - 'delete': 'Destroy', + 'get': 'retrieve', + 'post': 'create', + 'put': 'update', + 'patch': 'partialUpdate', + 'delete': 'destroy', } def get_operation(self, path, method): @@ -195,6 +195,12 @@ class AutoSchema(ViewInspector): content = self._map_serializer(serializer) return {component_name: content} + def _to_camel_case(self, snake_str): + components = snake_str.split('_') + # We capitalize the first letter of each component except the first one + # with the 'title' method and join them together. + return components[0] + ''.join(x.title() for x in components[1:]) + def get_operation_id_base(self, path, method, action): """ Compute the base part for operation ID from the model, serializer or view name. @@ -240,7 +246,7 @@ class AutoSchema(ViewInspector): if is_list_view(path, method, self.view): action = 'list' elif method_name not in self.method_mapping: - action = method_name + action = self._to_camel_case(method_name) else: action = self.method_mapping[method.lower()] diff --git a/tests/schemas/test_openapi.py b/tests/schemas/test_openapi.py index 35d676d6c..c9f6d967e 100644 --- a/tests/schemas/test_openapi.py +++ b/tests/schemas/test_openapi.py @@ -158,7 +158,7 @@ class TestOperationIntrospection(TestCase): operation = inspector.get_operation(path, method) assert operation == { - 'operationId': 'RetrieveDocStringExampleDetail', + 'operationId': 'retrieveDocStringExampleDetail', 'description': 'A description of my GET operation.', 'parameters': [{ 'description': '', @@ -735,6 +735,23 @@ class TestOperationIntrospection(TestCase): print(str(w[-1].message)) assert 'You have a duplicated operationId' in str(w[-1].message) + def test_operation_id_viewset(self): + router = routers.SimpleRouter() + router.register('account', views.ExampleViewSet, basename="account") + urlpatterns = router.urls + + generator = SchemaGenerator(patterns=urlpatterns) + + request = create_request('/') + schema = generator.get_schema(request=request) + print(schema) + assert schema['paths']['/account/']['get']['operationId'] == 'listExampleViewSets' + assert schema['paths']['/account/']['post']['operationId'] == 'createExampleViewSet' + assert schema['paths']['/account/{id}/']['get']['operationId'] == 'retrieveExampleViewSet' + assert schema['paths']['/account/{id}/']['put']['operationId'] == 'updateExampleViewSet' + assert schema['paths']['/account/{id}/']['patch']['operationId'] == 'partialUpdateExampleViewSet' + assert schema['paths']['/account/{id}/']['delete']['operationId'] == 'destroyExampleViewSet' + def test_serializer_datefield(self): path = '/' method = 'GET' diff --git a/tests/schemas/views.py b/tests/schemas/views.py index 1c8235b42..5645f59bf 100644 --- a/tests/schemas/views.py +++ b/tests/schemas/views.py @@ -11,7 +11,7 @@ from rest_framework.decorators import action from rest_framework.response import Response from rest_framework.schemas.openapi import AutoSchema from rest_framework.views import APIView -from rest_framework.viewsets import GenericViewSet +from rest_framework.viewsets import GenericViewSet, ViewSet class ExampleListView(APIView): @@ -215,3 +215,25 @@ class ExampleAutoSchemaDuplicate2(generics.GenericAPIView): serializer = self.get_serializer(data=now.date(), datetime=now) return Response(serializer.data) + + +class ExampleViewSet(ViewSet): + serializer_class = ExampleSerializerModel + + def list(self, request): + pass + + def create(self, request): + pass + + def retrieve(self, request, pk=None): + pass + + def update(self, request, pk=None): + pass + + def partial_update(self, request, pk=None): + pass + + def destroy(self, request, pk=None): + pass From ddfb9672ae703ce15392072dd110415147b5171a Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Wed, 4 Mar 2020 13:31:43 +0000 Subject: [PATCH 20/61] Release notes for 3.11.0 (#7214) --- docs/community/release-notes.md | 41 ++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/docs/community/release-notes.md b/docs/community/release-notes.md index 4be05d56b..97ec774fe 100644 --- a/docs/community/release-notes.md +++ b/docs/community/release-notes.md @@ -34,10 +34,32 @@ You can determine your currently installed version using `pip show`: --- +## 3.11.x series + +### 3.11.0 + +**Date**: 12th December 2019 + +* Drop `.set_context` API [in favour of a `requires_context` marker](../3.11-announcement#validator-default-context). +* Changed default widget for TextField with choices to select box. [#6892][gh6892] +* Supported nested writes on non-relational fields, such as JSONField. [#6916][gh6916] +* Include request/response media types in OpenAPI schemas, based on configured parsers/renderers. [#6865][gh6865] +* Include operation descriptions in OpenAPI schemas, based on the docstring on the view. [#6898][gh6898] +* Fix representation of serializers with all optional fields in OpenAPI schemas. [#6941][gh6941], [#6944][gh6944] +* Fix representation of `serializers.HStoreField` in OpenAPI schemas. [#6914][gh6914] +* Fix OpenAPI generation when title or version is not provided. [#6912][gh6912] +* Use `int64` representation for large integers in OpenAPI schemas. [#7018][gh7018] +* Improved error messages if no `.to_representation` implementation is provided on a field subclass. [#6996][gh6996] +* Fix for serializer classes that use multiple inheritance. [#6980][gh6980] +* Fix for reversing Hyperlinked URL fields with percent encoded components in the path. [#7059][gh7059] +* Update bootstrap to 3.4.1. [#6923][gh6923] + ## 3.10.x series ### 3.10.3 +**Date**: 4th September 2019 + * Include API version in OpenAPI schema generation, defaulting to empty string. * Add pagination properties to OpenAPI response schemas. * Add missing "description" property to OpenAPI response schemas. @@ -47,9 +69,7 @@ You can determine your currently installed version using `pip show`: * Use consistent `lowerInitialCamelCase` style in OpenAPI operation IDs. * Fix `minLength`/`maxLength`/`minItems`/`maxItems` properties in OpenAPI schemas. * Only call `FileField.url` once in serialization, for improved performance. -* Fix an edge case where throttling calcualtions could error after a configuration change. - -* TODO +* Fix an edge case where throttling calculations could error after a configuration change. ### 3.10.2 @@ -2175,3 +2195,18 @@ For older release notes, [please see the version 2.x documentation][old-release- [gh6680]: https://github.com/encode/django-rest-framework/issues/6680 [gh6317]: https://github.com/encode/django-rest-framework/issues/6317 + + +[gh6892]: https://github.com/encode/django-rest-framework/issues/6892 +[gh6916]: https://github.com/encode/django-rest-framework/issues/6916 +[gh6865]: https://github.com/encode/django-rest-framework/issues/6865 +[gh6898]: https://github.com/encode/django-rest-framework/issues/6898 +[gh6941]: https://github.com/encode/django-rest-framework/issues/6941 +[gh6944]: https://github.com/encode/django-rest-framework/issues/6944 +[gh6914]: https://github.com/encode/django-rest-framework/issues/6914 +[gh6912]: https://github.com/encode/django-rest-framework/issues/6912 +[gh7018]: https://github.com/encode/django-rest-framework/issues/7018 +[gh6996]: https://github.com/encode/django-rest-framework/issues/6996 +[gh6980]: https://github.com/encode/django-rest-framework/issues/6980 +[gh7059]: https://github.com/encode/django-rest-framework/issues/7059 +[gh6923]: https://github.com/encode/django-rest-framework/issues/6923 From 73f7bf49417dec903ec913105d2b5b1601e2f420 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 5 Mar 2020 10:18:22 +0000 Subject: [PATCH 21/61] Extra action detection is too permissive. Add failing test + fix (#7217) * Add failing test * Add failing test++ * Make get_extra_action less permissive --- rest_framework/viewsets.py | 3 ++- tests/test_viewsets.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index d94c81df4..244c14d39 100644 --- a/rest_framework/viewsets.py +++ b/rest_framework/viewsets.py @@ -25,11 +25,12 @@ from django.utils.decorators import classonlymethod from django.views.decorators.csrf import csrf_exempt from rest_framework import generics, mixins, views +from rest_framework.decorators import MethodMapper from rest_framework.reverse import reverse def _is_extra_action(attr): - return hasattr(attr, 'mapping') + return hasattr(attr, 'mapping') and isinstance(attr.mapping, MethodMapper) class ViewSetMixin: diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py index eac36f095..921daf186 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -81,10 +81,20 @@ class ActionNamesViewSet(GenericViewSet): raise NotImplementedError +class ThingWithMapping: + def __init__(self): + self.mapping = {} + + +class ActionViewSetWithMapping(ActionViewSet): + mapper = ThingWithMapping() + + router = SimpleRouter() router.register(r'actions', ActionViewSet) router.register(r'actions-alt', ActionViewSet, basename='actions-alt') router.register(r'names', ActionNamesViewSet, basename='names') +router.register(r'mapping', ActionViewSetWithMapping, basename='mapping') urlpatterns = [ @@ -161,6 +171,18 @@ class GetExtraActionsTests(TestCase): self.assertEqual(actual, expected) + def test_should_only_return_decorated_methods(self): + view = ActionViewSetWithMapping() + actual = [action.__name__ for action in view.get_extra_actions()] + expected = [ + 'custom_detail_action', + 'custom_list_action', + 'detail_action', + 'list_action', + 'unresolvable_detail_action', + ] + self.assertEqual(actual, expected) + @override_settings(ROOT_URLCONF='tests.test_viewsets') class GetExtraActionUrlMapTests(TestCase): From 4a98533746db44c997882ba01d5515a29a61dcc3 Mon Sep 17 00:00:00 2001 From: Tom Christie Date: Thu, 5 Mar 2020 13:18:48 +0000 Subject: [PATCH 22/61] Fix - run test_head_request_against_viewset method (#7219) --- tests/test_viewsets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py index 921daf186..f9468f448 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -113,7 +113,7 @@ class InitializeViewSetsTestCase(TestCase): assert response.status_code == status.HTTP_200_OK assert response.data == {'ACTION': 'LIST'} - def testhead_request_against_viewset(self): + def test_head_request_against_viewset(self): request = factory.head('/', '', content_type='application/json') my_view = BasicViewSet.as_view(actions={ 'get': 'list', From 908f91d8ef13649b6d658981e28ff52296b19f9f Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Mon, 9 Mar 2020 02:43:02 -0700 Subject: [PATCH 23/61] Set action for HEAD requests (#7223) * Test viewset action attr * Add 'head' to viewset actions map --- rest_framework/viewsets.py | 7 ++++--- tests/test_viewsets.py | 24 ++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/rest_framework/viewsets.py b/rest_framework/viewsets.py index 244c14d39..cad032dd9 100644 --- a/rest_framework/viewsets.py +++ b/rest_framework/viewsets.py @@ -93,6 +93,10 @@ class ViewSetMixin: def view(request, *args, **kwargs): self = cls(**initkwargs) + + if 'get' in actions and 'head' not in actions: + actions['head'] = actions['get'] + # We also store the mapping of request methods to actions, # so that we can later set the action attribute. # eg. `self.action = 'list'` on an incoming GET request. @@ -104,9 +108,6 @@ class ViewSetMixin: handler = getattr(self, action) setattr(self, method, handler) - if hasattr(self, 'get') and not hasattr(self, 'head'): - self.head = self.get - self.request = request self.args = args self.kwargs = kwargs diff --git a/tests/test_viewsets.py b/tests/test_viewsets.py index f9468f448..1a621c518 100644 --- a/tests/test_viewsets.py +++ b/tests/test_viewsets.py @@ -37,14 +37,18 @@ class ActionViewSet(GenericViewSet): queryset = Action.objects.all() def list(self, request, *args, **kwargs): - return Response() + response = Response() + response.view = self + return response def retrieve(self, request, *args, **kwargs): return Response() @action(detail=False) def list_action(self, request, *args, **kwargs): - raise NotImplementedError + response = Response() + response.view = self + return response @action(detail=False, url_name='list-custom') def custom_list_action(self, request, *args, **kwargs): @@ -155,6 +159,22 @@ class InitializeViewSetsTestCase(TestCase): self.assertNotIn(attribute, dir(bare_view)) self.assertIn(attribute, dir(view)) + def test_viewset_action_attr(self): + view = ActionViewSet.as_view(actions={'get': 'list'}) + + get = view(factory.get('/')) + head = view(factory.head('/')) + assert get.view.action == 'list' + assert head.view.action == 'list' + + def test_viewset_action_attr_for_extra_action(self): + view = ActionViewSet.as_view(actions=dict(ActionViewSet.list_action.mapping)) + + get = view(factory.get('/')) + head = view(factory.head('/')) + assert get.view.action == 'list_action' + assert head.view.action == 'list_action' + class GetExtraActionsTests(TestCase): From 86aa549832b9468d14a9f66370cf15d6dbcb4bfa Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 11 Mar 2020 06:51:42 -0700 Subject: [PATCH 24/61] Drop Django 2.1 and below. (#7225) --- .travis.yml | 8 -------- README.md | 2 +- setup.py | 6 ++---- tox.ini | 11 +---------- 4 files changed, 4 insertions(+), 23 deletions(-) diff --git a/.travis.yml b/.travis.yml index 7266df2d5..2cdeee5b8 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,20 +5,12 @@ matrix: fast_finish: true include: - - { python: "3.5", env: DJANGO=1.11 } - - { python: "3.5", env: DJANGO=2.0 } - - { python: "3.5", env: DJANGO=2.1 } - { python: "3.5", env: DJANGO=2.2 } - - { python: "3.6", env: DJANGO=1.11 } - - { python: "3.6", env: DJANGO=2.0 } - - { python: "3.6", env: DJANGO=2.1 } - { python: "3.6", env: DJANGO=2.2 } - { python: "3.6", env: DJANGO=3.0 } - { python: "3.6", env: DJANGO=master } - - { python: "3.7", env: DJANGO=2.0 } - - { python: "3.7", env: DJANGO=2.1 } - { python: "3.7", env: DJANGO=2.2 } - { python: "3.7", env: DJANGO=3.0 } - { python: "3.7", env: DJANGO=master } diff --git a/README.md b/README.md index 9591bdc17..41a344fcf 100644 --- a/README.md +++ b/README.md @@ -55,7 +55,7 @@ There is a live example API for testing purposes, [available here][sandbox]. # Requirements * Python (3.5, 3.6, 3.7, 3.8) -* Django (1.11, 2.0, 2.1, 2.2, 3.0) +* Django (2.2, 3.0) We **highly recommend** and only officially support the latest patch release of each Python and Django series. diff --git a/setup.py b/setup.py index 65536885a..99826b4d0 100755 --- a/setup.py +++ b/setup.py @@ -82,17 +82,15 @@ setup( author_email='tom@tomchristie.com', # SEE NOTE BELOW (*) packages=find_packages(exclude=['tests*']), include_package_data=True, - install_requires=["django>=1.11"], + install_requires=["django>=2.2"], python_requires=">=3.5", zip_safe=False, classifiers=[ 'Development Status :: 5 - Production/Stable', 'Environment :: Web Environment', 'Framework :: Django', - 'Framework :: Django :: 1.11', - 'Framework :: Django :: 2.0', - 'Framework :: Django :: 2.1', 'Framework :: Django :: 2.2', + 'Framework :: Django :: 3.0', 'Intended Audience :: Developers', 'License :: OSI Approved :: BSD License', 'Operating System :: OS Independent', diff --git a/tox.ini b/tox.ini index 9b8069174..e5b8b6402 100644 --- a/tox.ini +++ b/tox.ini @@ -1,18 +1,12 @@ [tox] envlist = - {py35,py36}-django111, - {py35,py36,py37}-django20, - {py35,py36,py37}-django21 - {py35,py36,py37}-django22 + {py35,py36,py37}-django22, {py36,py37,py38}-django30, {py36,py37,py38}-djangomaster, base,dist,lint,docs, [travis:env] DJANGO = - 1.11: django111 - 2.0: django20 - 2.1: django21 2.2: django22 3.0: django30 master: djangomaster @@ -24,9 +18,6 @@ setenv = PYTHONDONTWRITEBYTECODE=1 PYTHONWARNINGS=once deps = - django111: Django>=1.11,<2.0 - django20: Django>=2.0,<2.1 - django21: Django>=2.1,<2.2 django22: Django>=2.2,<3.0 django30: Django>=3.0,<3.1 djangomaster: https://github.com/django/django/archive/master.tar.gz From be96939ec1482ce3453fb210460ab795f7704b4a Mon Sep 17 00:00:00 2001 From: 0dysseas <31179964+0dysseas@users.noreply.github.com> Date: Tue, 17 Mar 2020 18:49:19 +0200 Subject: [PATCH 25/61] Fix serializer example in docs (#7233) --- docs/api-guide/serializers.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/api-guide/serializers.md b/docs/api-guide/serializers.md index 5cf949f97..87d3d4056 100644 --- a/docs/api-guide/serializers.md +++ b/docs/api-guide/serializers.md @@ -238,10 +238,12 @@ Serializer classes can also include reusable validators that are applied to the class Meta: # Each room only has one event per day. - validators = UniqueTogetherValidator( - queryset=Event.objects.all(), - fields=['room_number', 'date'] - ) + validators = [ + UniqueTogetherValidator( + queryset=Event.objects.all(), + fields=['room_number', 'date'] + ) + ] For more information see the [validators documentation](validators.md). From 8b5d3437f9146401813b72cece95e4f746c8e067 Mon Sep 17 00:00:00 2001 From: Mahmoud Adel <20120831@std.sci.cu.edu.eg> Date: Wed, 18 Mar 2020 00:45:45 +0200 Subject: [PATCH 26/61] Add django-rest-auth fork to docs (#7227) --- docs/api-guide/authentication.md | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index c4dbe8856..ba8a65069 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -410,9 +410,15 @@ HTTP Signature (currently a [IETF draft][http-signature-ietf-draft]) provides a [Djoser][djoser] library provides a set of views to handle basic actions such as registration, login, logout, password reset and account activation. The package works with a custom user model and it uses token based authentication. This is a ready to use REST implementation of Django authentication system. -## django-rest-auth +## django-rest-auth / dj-rest-auth -[Django-rest-auth][django-rest-auth] library provides a set of REST API endpoints for registration, authentication (including social media authentication), password reset, retrieve and update user details, etc. By having these API endpoints, your client apps such as AngularJS, iOS, Android, and others can communicate to your Django backend site independently via REST APIs for user management. +This library provides a set of REST API endpoints for registration, authentication (including social media authentication), password reset, retrieve and update user details, etc. By having these API endpoints, your client apps such as AngularJS, iOS, Android, and others can communicate to your Django backend site independently via REST APIs for user management. + + +There are currently two forks of this project. + +* [Django-rest-auth][django-rest-auth] is the original project, [but is not currently receiving updates](https://github.com/Tivix/django-rest-auth/issues/568). +* [Dj-rest-auth][dj-rest-auth] is a newer fork of the project. ## django-rest-framework-social-oauth2 @@ -456,6 +462,7 @@ HTTP Signature (currently a [IETF draft][http-signature-ietf-draft]) provides a [mac]: https://tools.ietf.org/html/draft-hammer-oauth-v2-mac-token-05 [djoser]: https://github.com/sunscrapers/djoser [django-rest-auth]: https://github.com/Tivix/django-rest-auth +[dj-rest-auth]: https://github.com/jazzband/dj-rest-auth [django-rest-framework-social-oauth2]: https://github.com/PhilipGarnero/django-rest-framework-social-oauth2 [django-rest-knox]: https://github.com/James1345/django-rest-knox [drfpasswordless]: https://github.com/aaronn/django-rest-framework-passwordless From 5cc6ace9c45ac42cf59d52643ab9cbb6c565d23e Mon Sep 17 00:00:00 2001 From: Artur Barseghyan Date: Fri, 20 Mar 2020 19:28:51 +0100 Subject: [PATCH 27/61] Update third-party-packages.md (#7175) --- docs/community/third-party-packages.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/community/third-party-packages.md b/docs/community/third-party-packages.md index baa30fd0c..9fce55e94 100644 --- a/docs/community/third-party-packages.md +++ b/docs/community/third-party-packages.md @@ -272,6 +272,7 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque * [djangorestframework-mvt][djangorestframework-mvt] - An extension for creating views that serve Postgres data as Map Box Vector Tiles. * [drf-viewset-profiler][drf-viewset-profiler] - Lib to profile all methods from a viewset line by line. * [djangorestframework-features][djangorestframework-features] - Advanced schema generation and more based on named features. +* [django-elasticsearch-dsl-drf][django-elasticsearch-dsl-drf] - Integrate Elasticsearch DSL with Django REST framework. Package provides views, serializers, filter backends, pagination and other handy add-ons. [cite]: http://www.software-ecosystems.com/Software_Ecosystems/Ecosystems.html [cookiecutter]: https://github.com/jpadilla/cookiecutter-django-rest-framework @@ -354,3 +355,4 @@ To submit new content, [open an issue][drf-create-issue] or [create a pull reque [django-rest-framework-guardian]: https://github.com/rpkilby/django-rest-framework-guardian [drf-viewset-profiler]: https://github.com/fvlima/drf-viewset-profiler [djangorestframework-features]: https://github.com/cloudcode-hungary/django-rest-framework-features/ +[django-elasticsearch-dsl-drf]: https://github.com/barseghyanartur/django-elasticsearch-dsl-drf From 57e7cc21e1b73ccf49c7b9c20a6d3f578c125f20 Mon Sep 17 00:00:00 2001 From: Rotzbua Date: Tue, 24 Mar 2020 19:52:17 +0100 Subject: [PATCH 28/61] Remove unavailable script (#7244) --- docs_theme/main.html | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs_theme/main.html b/docs_theme/main.html index c2a29e1ae..b4e894781 100644 --- a/docs_theme/main.html +++ b/docs_theme/main.html @@ -17,10 +17,6 @@ - - - + diff --git a/rest_framework/templates/rest_framework/base.html b/rest_framework/templates/rest_framework/base.html index 5d9d80b05..9207f049b 100644 --- a/rest_framework/templates/rest_framework/base.html +++ b/rest_framework/templates/rest_framework/base.html @@ -293,7 +293,7 @@ csrfToken: "{% if request %}{{ csrf_token }}{% endif %}" }; - + diff --git a/rest_framework/templates/rest_framework/docs/error.html b/rest_framework/templates/rest_framework/docs/error.html index 6afd25e7b..6afc4a88b 100644 --- a/rest_framework/templates/rest_framework/docs/error.html +++ b/rest_framework/templates/rest_framework/docs/error.html @@ -66,6 +66,6 @@ at rest_framework/docs/error.html.

- + diff --git a/rest_framework/templates/rest_framework/docs/index.html b/rest_framework/templates/rest_framework/docs/index.html index 6804afe10..dfd363772 100644 --- a/rest_framework/templates/rest_framework/docs/index.html +++ b/rest_framework/templates/rest_framework/docs/index.html @@ -38,7 +38,7 @@ {% include "rest_framework/docs/auth/basic.html" %} {% include "rest_framework/docs/auth/session.html" %} - + From 00e6079e94a4937c89ec68a5ba31658827cf4708 Mon Sep 17 00:00:00 2001 From: Derek Date: Mon, 11 May 2020 09:29:31 -0600 Subject: [PATCH 60/61] Nginx basic auth tutorial moved (#7324) --- docs/api-guide/authentication.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/api-guide/authentication.md b/docs/api-guide/authentication.md index ba8a65069..ebb0ab4d6 100644 --- a/docs/api-guide/authentication.md +++ b/docs/api-guide/authentication.md @@ -304,7 +304,7 @@ If successfully authenticated, `RemoteUserAuthentication` provides the following Consult your web server's documentation for information about configuring an authentication method, e.g.: * [Apache Authentication How-To](https://httpd.apache.org/docs/2.4/howto/auth.html) -* [NGINX (Restricting Access)](https://www.nginx.com/resources/admin-guide/#restricting_access) +* [NGINX (Restricting Access)](https://docs.nginx.com/nginx/admin-guide/security-controls/configuring-http-basic-authentication/) # Custom authentication From 089162e6e319a1c35f60398319cf70a13d404fa5 Mon Sep 17 00:00:00 2001 From: Ryan P Kilby Date: Wed, 13 May 2020 03:11:26 -0700 Subject: [PATCH 61/61] Fix ModelSerializer unique_together handling for field sources (#7143) * Fix ModelSerializer unique_together field sources Updates ModelSerializer to check for serializer fields that map to the model field sources in the unique_together lists. * Ensure field name ordering consistency --- rest_framework/serializers.py | 51 ++++++++++++++++++++++++++--------- tests/test_validators.py | 43 +++++++++++++++++++++++++++++ 2 files changed, 82 insertions(+), 12 deletions(-) diff --git a/rest_framework/serializers.py b/rest_framework/serializers.py index 8c2486bea..c1cea1e83 100644 --- a/rest_framework/serializers.py +++ b/rest_framework/serializers.py @@ -13,7 +13,7 @@ response content is handled by parsers and renderers. import copy import inspect import traceback -from collections import OrderedDict +from collections import OrderedDict, defaultdict from collections.abc import Mapping from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured @@ -1508,28 +1508,55 @@ class ModelSerializer(Serializer): # which may map onto a model field. Any dotted field name lookups # cannot map to a field, and must be a traversal, so we're not # including those. - field_names = { - field.source for field in self._writable_fields + field_sources = OrderedDict( + (field.field_name, field.source) for field in self._writable_fields if (field.source != '*') and ('.' not in field.source) - } + ) # Special Case: Add read_only fields with defaults. - field_names |= { - field.source for field in self.fields.values() + field_sources.update(OrderedDict( + (field.field_name, field.source) for field in self.fields.values() if (field.read_only) and (field.default != empty) and (field.source != '*') and ('.' not in field.source) - } + )) + + # Invert so we can find the serializer field names that correspond to + # the model field names in the unique_together sets. This also allows + # us to check that multiple fields don't map to the same source. + source_map = defaultdict(list) + for name, source in field_sources.items(): + source_map[source].append(name) # Note that we make sure to check `unique_together` both on the # base model class, but also on any parent classes. validators = [] for parent_class in model_class_inheritance_tree: for unique_together in parent_class._meta.unique_together: - if field_names.issuperset(set(unique_together)): - validator = UniqueTogetherValidator( - queryset=parent_class._default_manager, - fields=unique_together + # Skip if serializer does not map to all unique together sources + if not set(source_map).issuperset(set(unique_together)): + continue + + for source in unique_together: + assert len(source_map[source]) == 1, ( + "Unable to create `UniqueTogetherValidator` for " + "`{model}.{field}` as `{serializer}` has multiple " + "fields ({fields}) that map to this model field. " + "Either remove the extra fields, or override " + "`Meta.validators` with a `UniqueTogetherValidator` " + "using the desired field names." + .format( + model=self.Meta.model.__name__, + serializer=self.__class__.__name__, + field=source, + fields=', '.join(source_map[source]), + ) ) - validators.append(validator) + + field_names = tuple(source_map[f][0] for f in unique_together) + validator = UniqueTogetherValidator( + queryset=parent_class._default_manager, + fields=field_names + ) + validators.append(validator) return validators def get_unique_for_date_validators(self): diff --git a/tests/test_validators.py b/tests/test_validators.py index 21c00073d..4962cf581 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -344,6 +344,49 @@ class TestUniquenessTogetherValidation(TestCase): ] } + def test_default_validator_with_fields_with_source(self): + class TestSerializer(serializers.ModelSerializer): + name = serializers.CharField(source='race_name') + + class Meta: + model = UniquenessTogetherModel + fields = ['name', 'position'] + + serializer = TestSerializer() + expected = dedent(""" + TestSerializer(): + name = CharField(source='race_name') + position = IntegerField() + class Meta: + validators = [] + """) + assert repr(serializer) == expected + + def test_default_validator_with_multiple_fields_with_same_source(self): + class TestSerializer(serializers.ModelSerializer): + name = serializers.CharField(source='race_name') + other_name = serializers.CharField(source='race_name') + + class Meta: + model = UniquenessTogetherModel + fields = ['name', 'other_name', 'position'] + + serializer = TestSerializer(data={ + 'name': 'foo', + 'other_name': 'foo', + 'position': 1, + }) + with pytest.raises(AssertionError) as excinfo: + serializer.is_valid() + + expected = ( + "Unable to create `UniqueTogetherValidator` for " + "`UniquenessTogetherModel.race_name` as `TestSerializer` has " + "multiple fields (name, other_name) that map to this model field. " + "Either remove the extra fields, or override `Meta.validators` " + "with a `UniqueTogetherValidator` using the desired field names.") + assert str(excinfo.value) == expected + def test_allow_explict_override(self): """ Ensure validators can be explicitly removed..

Try the homepage, or search the documentation.