From 06112e5a2f710b3845eac73753fa21bce94a3001 Mon Sep 17 00:00:00 2001 From: Marti Raudsepp Date: Thu, 16 May 2024 12:01:54 +0300 Subject: [PATCH 1/6] Fix ChoiceField schema type with empty `choices=[]` --- drf_spectacular/plumbing.py | 11 +++++++---- tests/test_plumbing.py | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 4 deletions(-) diff --git a/drf_spectacular/plumbing.py b/drf_spectacular/plumbing.py index 07db4b15..cc3ddf7a 100644 --- a/drf_spectacular/plumbing.py +++ b/drf_spectacular/plumbing.py @@ -420,8 +420,13 @@ def build_parameter_type( def build_choice_field(field) -> _SchemaType: choices = list(OrderedDict.fromkeys(field.choices)) # preserve order and remove duplicates - if all(isinstance(choice, bool) for choice in choices): - type: Optional[str] = 'boolean' + if field.allow_blank and '' not in choices: + choices.append('') + + if not choices: + type = None + elif 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` @@ -432,8 +437,6 @@ def build_choice_field(field) -> _SchemaType: else: type = None - if field.allow_blank and '' not in choices: - choices.append('') if field.allow_null and None not in choices: choices.append(None) diff --git a/tests/test_plumbing.py b/tests/test_plumbing.py index 4e917cd3..c07d93b9 100644 --- a/tests/test_plumbing.py +++ b/tests/test_plumbing.py @@ -399,6 +399,24 @@ def test_choicefield_choices_enum(): assert 'type' not in schema +def test_choicefield_empty_choices(): + schema = build_choice_field(serializers.ChoiceField(choices=[])) + assert schema['enum'] == [] + assert 'type' not in schema + + schema = build_choice_field(serializers.ChoiceField(choices=[], allow_null=True)) + assert schema['enum'] == [None] + assert 'type' not in schema + + schema = build_choice_field(serializers.ChoiceField(choices=[], allow_blank=True)) + assert schema['enum'] == [''] + assert schema['type'] == 'string' + + schema = build_choice_field(serializers.ChoiceField(choices=[], allow_blank=True, allow_null=True)) + assert schema['enum'] == ['', None] + assert schema['type'] == 'string' + + def test_safe_ref(): schema = build_basic_type(str) schema['$ref'] = '#/components/schemas/Foo' From 3685aeaa7ed640d7f2adf0dfe2c799ceaa698ef7 Mon Sep 17 00:00:00 2001 From: Marti Raudsepp Date: Thu, 16 May 2024 12:11:17 +0300 Subject: [PATCH 2/6] Additional testcase --- tests/test_plumbing.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_plumbing.py b/tests/test_plumbing.py index c07d93b9..cbdafcfe 100644 --- a/tests/test_plumbing.py +++ b/tests/test_plumbing.py @@ -398,6 +398,12 @@ def test_choicefield_choices_enum(): assert schema['enum'] == ['bluepill', 'redpill', '', None] assert 'type' not in schema + schema = build_choice_field(serializers.ChoiceField( + choices=[1, 2], allow_blank=True + )) + assert schema['enum'] == [1, 2, ''] + assert 'type' not in schema + def test_choicefield_empty_choices(): schema = build_choice_field(serializers.ChoiceField(choices=[])) From 8db4917f9a59ef9a2841c2d2a6432f618d65476c Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Fri, 25 Oct 2024 22:52:20 +0200 Subject: [PATCH 3/6] fix unused OAuth2 scopes override #1319 --- .../contrib/django_oauth_toolkit.py | 7 +++- tests/contrib/test_oauth_toolkit.py | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+), 2 deletions(-) diff --git a/drf_spectacular/contrib/django_oauth_toolkit.py b/drf_spectacular/contrib/django_oauth_toolkit.py index 9b0019bc..d60c509f 100644 --- a/drf_spectacular/contrib/django_oauth_toolkit.py +++ b/drf_spectacular/contrib/django_oauth_toolkit.py @@ -37,8 +37,11 @@ def get_security_definition(self, auto_schema): flows[flow_type]['tokenUrl'] = spectacular_settings.OAUTH2_TOKEN_URL if spectacular_settings.OAUTH2_REFRESH_URL: flows[flow_type]['refreshUrl'] = spectacular_settings.OAUTH2_REFRESH_URL - scope_backend = get_scopes_backend() - flows[flow_type]['scopes'] = scope_backend.get_all_scopes() + if spectacular_settings.OAUTH2_SCOPES: + flows[flow_type]['scopes'] = spectacular_settings.OAUTH2_SCOPES + else: + scope_backend = get_scopes_backend() + flows[flow_type]['scopes'] = scope_backend.get_all_scopes() return { 'type': 'oauth2', diff --git a/tests/contrib/test_oauth_toolkit.py b/tests/contrib/test_oauth_toolkit.py index 20fe5062..7fc79541 100644 --- a/tests/contrib/test_oauth_toolkit.py +++ b/tests/contrib/test_oauth_toolkit.py @@ -133,3 +133,43 @@ def test_oauth2_toolkit_scopes_backend(no_warnings): assert 'implicit' in oauth2['flows'] flow = oauth2['flows']['implicit'] assert 'test_backend_scope' in flow['scopes'] + + +@mock.patch( + 'drf_spectacular.settings.spectacular_settings.OAUTH2_SCOPES', + {"read": "Read scope", "burn": "Burn scope"}, +) +@mock.patch( + 'drf_spectacular.settings.spectacular_settings.OAUTH2_FLOWS', + ['implicit'] +) +@mock.patch( + 'drf_spectacular.settings.spectacular_settings.OAUTH2_REFRESH_URL', + 'http://127.0.0.1:8000/o/refresh' +) +@mock.patch( + 'drf_spectacular.settings.spectacular_settings.OAUTH2_AUTHORIZATION_URL', + 'http://127.0.0.1:8000/o/authorize' +) +@mock.patch( + 'oauth2_provider.settings.oauth2_settings.SCOPES', + {"read": "Reading scope", "write": "Writing scope", "extra_scope": "Extra Scope"}, +) +@mock.patch( + 'oauth2_provider.settings.oauth2_settings.DEFAULT_SCOPES', + ["read", "write"] +) +@pytest.mark.contrib('oauth2_provider') +def test_oauth2_toolkit_custom_scopes(no_warnings): + router = routers.SimpleRouter() + router.register('TokenHasReadWriteScope', TokenHasReadWriteScopeViewset, basename="x1") + + urlpatterns = [ + *router.urls, + path('o/', include('oauth2_provider.urls', namespace='oauth2_provider')), + ] + schema = generate_schema(None, patterns=urlpatterns) + + assert schema['components']['securitySchemes']['oauth2']['flows']['implicit']['scopes'] == { + 'burn': 'Burn scope', 'read': 'Read scope' + } From bff8d6a3f486ad322838714cf27a4d4fefe4c71c Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Wed, 27 Nov 2024 16:30:37 +0700 Subject: [PATCH 4/6] fix tests for dj_rest_auth changes --- tests/contrib/test_rest_auth.py | 4 +- tests/contrib/test_rest_auth.yml | 57 ----------------------- tests/contrib/test_rest_auth_token.yml | 63 -------------------------- 3 files changed, 3 insertions(+), 121 deletions(-) diff --git a/tests/contrib/test_rest_auth.py b/tests/contrib/test_rest_auth.py index 983c189a..77179b66 100644 --- a/tests/contrib/test_rest_auth.py +++ b/tests/contrib/test_rest_auth.py @@ -11,7 +11,9 @@ transforms = [ # User model first_name differences - lambda x: re.sub(r'(first_name:\n *type: string\n *maxLength:) 30', r'\g<1> 150', x, re.M), + lambda x: re.sub(r'(first_name:\n *type: string\n *maxLength:) 30', r'\g<1> 150', x), + # Ignore descriptions as it varies too much between versions + lambda x: re.sub(r'description: \|-\n[\S\s\r\n]+?tags:', r'tags:', x), ] diff --git a/tests/contrib/test_rest_auth.yml b/tests/contrib/test_rest_auth.yml index bb2e960e..7c642693 100644 --- a/tests/contrib/test_rest_auth.yml +++ b/tests/contrib/test_rest_auth.yml @@ -6,14 +6,6 @@ paths: /rest-auth/login/: post: operationId: rest_auth_login_create - description: |- - Check the credentials and return the REST Token - if the credentials are valid and authenticated. - Calls Django Auth login method to register User ID - in Django session framework - - Accept the following POST parameters: username, password - Return the REST Framework Token Object's key. tags: - rest-auth requestBody: @@ -42,11 +34,6 @@ paths: /rest-auth/logout/: post: operationId: rest_auth_logout_create - description: |- - Calls Django logout method and delete the Token object - assigned to the current User object. - - Accepts/Returns nothing. tags: - rest-auth security: @@ -63,11 +50,6 @@ paths: /rest-auth/password/change/: post: operationId: rest_auth_password_change_create - description: |- - Calls Django Auth SetPasswordForm save method. - - Accepts the following POST parameters: new_password1, new_password2 - Returns the success/fail message. tags: - rest-auth requestBody: @@ -95,11 +77,6 @@ paths: /rest-auth/password/reset/: post: operationId: rest_auth_password_reset_create - description: |- - Calls Django Auth PasswordResetForm save method. - - Accepts the following POST parameters: email - Returns the success/fail message. tags: - rest-auth requestBody: @@ -128,13 +105,6 @@ paths: /rest-auth/password/reset/confirm/: post: operationId: rest_auth_password_reset_confirm_create - description: |- - Password reset e-mail link is confirmed, therefore - this resets the user's password. - - Accepts the following POST parameters: token, uid, - new_password1, new_password2 - Returns the success/fail message. tags: - rest-auth requestBody: @@ -246,15 +216,6 @@ paths: /rest-auth/user/: get: operationId: rest_auth_user_retrieve - description: |- - Reads and updates UserModel fields - Accepts GET, PUT, PATCH methods. - - Default accepted fields: username, first_name, last_name - Default display fields: pk, username, email, first_name, last_name - Read-only fields: pk, email - - Returns UserModel fields. tags: - rest-auth security: @@ -269,15 +230,6 @@ paths: description: '' put: operationId: rest_auth_user_update - description: |- - Reads and updates UserModel fields - Accepts GET, PUT, PATCH methods. - - Default accepted fields: username, first_name, last_name - Default display fields: pk, username, email, first_name, last_name - Read-only fields: pk, email - - Returns UserModel fields. tags: - rest-auth requestBody: @@ -304,15 +256,6 @@ paths: description: '' patch: operationId: rest_auth_user_partial_update - description: |- - Reads and updates UserModel fields - Accepts GET, PUT, PATCH methods. - - Default accepted fields: username, first_name, last_name - Default display fields: pk, username, email, first_name, last_name - Read-only fields: pk, email - - Returns UserModel fields. tags: - rest-auth requestBody: diff --git a/tests/contrib/test_rest_auth_token.yml b/tests/contrib/test_rest_auth_token.yml index 091370b4..5c50c024 100644 --- a/tests/contrib/test_rest_auth_token.yml +++ b/tests/contrib/test_rest_auth_token.yml @@ -6,14 +6,6 @@ paths: /rest-auth/login/: post: operationId: rest_auth_login_create - description: |- - Check the credentials and return the REST Token - if the credentials are valid and authenticated. - Calls Django Auth login method to register User ID - in Django session framework - - Accept the following POST parameters: username, password - Return the REST Framework Token Object's key. tags: - rest-auth requestBody: @@ -42,11 +34,6 @@ paths: /rest-auth/logout/: post: operationId: rest_auth_logout_create - description: |- - Calls Django logout method and delete the Token object - assigned to the current User object. - - Accepts/Returns nothing. tags: - rest-auth security: @@ -63,11 +50,6 @@ paths: /rest-auth/password/change/: post: operationId: rest_auth_password_change_create - description: |- - Calls Django Auth SetPasswordForm save method. - - Accepts the following POST parameters: new_password1, new_password2 - Returns the success/fail message. tags: - rest-auth requestBody: @@ -95,11 +77,6 @@ paths: /rest-auth/password/reset/: post: operationId: rest_auth_password_reset_create - description: |- - Calls Django Auth PasswordResetForm save method. - - Accepts the following POST parameters: email - Returns the success/fail message. tags: - rest-auth requestBody: @@ -128,13 +105,6 @@ paths: /rest-auth/password/reset/confirm/: post: operationId: rest_auth_password_reset_confirm_create - description: |- - Password reset e-mail link is confirmed, therefore - this resets the user's password. - - Accepts the following POST parameters: token, uid, - new_password1, new_password2 - Returns the success/fail message. tags: - rest-auth requestBody: @@ -246,9 +216,6 @@ paths: /rest-auth/token/refresh/: post: operationId: rest_auth_token_refresh_create - description: |- - Takes a refresh type JSON web token and returns an access type JSON web - token if the refresh token is valid. tags: - rest-auth requestBody: @@ -273,9 +240,6 @@ paths: /rest-auth/token/verify/: post: operationId: rest_auth_token_verify_create - description: |- - Takes a token and indicates if it is valid. This view provides no - information about a token's fitness for a particular use. tags: - rest-auth requestBody: @@ -300,15 +264,6 @@ paths: /rest-auth/user/: get: operationId: rest_auth_user_retrieve - description: |- - Reads and updates UserModel fields - Accepts GET, PUT, PATCH methods. - - Default accepted fields: username, first_name, last_name - Default display fields: pk, username, email, first_name, last_name - Read-only fields: pk, email - - Returns UserModel fields. tags: - rest-auth security: @@ -323,15 +278,6 @@ paths: description: '' put: operationId: rest_auth_user_update - description: |- - Reads and updates UserModel fields - Accepts GET, PUT, PATCH methods. - - Default accepted fields: username, first_name, last_name - Default display fields: pk, username, email, first_name, last_name - Read-only fields: pk, email - - Returns UserModel fields. tags: - rest-auth requestBody: @@ -358,15 +304,6 @@ paths: description: '' patch: operationId: rest_auth_user_partial_update - description: |- - Reads and updates UserModel fields - Accepts GET, PUT, PATCH methods. - - Default accepted fields: username, first_name, last_name - Default display fields: pk, username, email, first_name, last_name - Read-only fields: pk, email - - Returns UserModel fields. tags: - rest-auth requestBody: From cc5a5d1c94f3156ff664a543cbb98cffb3bbf2fc Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Wed, 27 Nov 2024 13:18:22 +0700 Subject: [PATCH 5/6] consider pk_field on PrimaryKeyRelatedField when set #1335 --- drf_spectacular/openapi.py | 7 +++++-- tests/test_regressions.py | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/drf_spectacular/openapi.py b/drf_spectacular/openapi.py index ff0e5e36..bb75548c 100644 --- a/drf_spectacular/openapi.py +++ b/drf_spectacular/openapi.py @@ -695,7 +695,10 @@ def _map_serializer_field(self, field, direction, bypass_extensions=False): # read_only fields do not have a Manager by design. go around and get field # from parent. also avoid calling Manager. __bool__ as it might be customized # to hit the database. - if getattr(field, 'queryset', None) is not None: + if not is_slug and getattr(field, 'pk_field') is not None: + schema = self._map_serializer_field(field.pk_field, direction) + return append_meta(schema, meta) + elif getattr(field, 'queryset', None) is not None: if is_slug: model = field.queryset.model source = [field.slug_field] @@ -714,7 +717,7 @@ def _map_serializer_field(self, field, direction, bypass_extensions=False): f'Could not derive type for under-specified {field.__class__.__name__} ' f'"{field.field_name}". The serializer has no associated model (Meta class) ' f'and this particular field has no type without a model association. Consider ' - f'changing the field or adding a Meta class. defaulting to string.' + f'changing the field or adding a Meta class. Defaulting to string.' ) return append_meta(build_basic_type(OpenApiTypes.STR), meta) diff --git a/tests/test_regressions.py b/tests/test_regressions.py index 0dd53299..eb7f3457 100644 --- a/tests/test_regressions.py +++ b/tests/test_regressions.py @@ -3444,3 +3444,20 @@ class XViewset(viewsets.ModelViewSet): 'readOnly': True } } + + +def test_primary_key_related_field_with_custom_pk_field(no_warnings): + class XSerializer(serializers.Serializer): + field = serializers.PrimaryKeyRelatedField( + read_only=True, + pk_field=serializers.IntegerField(), + ) + + class XViewset(viewsets.ModelViewSet): + serializer_class = XSerializer + queryset = SimpleModel.objects.all() + + schema = generate_schema('/x', XViewset) + assert schema['components']['schemas']['X']['properties']['field'] == { + 'readOnly': True, 'type': 'integer' + } From 1023c681d65fcfa419586df68c47250846beef93 Mon Sep 17 00:00:00 2001 From: "T. Franzel" Date: Thu, 14 Nov 2024 23:37:27 +0100 Subject: [PATCH 6/6] Extend query params explosion of non-DRF serializer #1315 --- drf_spectacular/openapi.py | 8 ++++++-- tests/contrib/test_pydantic.py | 19 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/drf_spectacular/openapi.py b/drf_spectacular/openapi.py index ff0e5e36..ed00a828 100644 --- a/drf_spectacular/openapi.py +++ b/drf_spectacular/openapi.py @@ -221,14 +221,18 @@ def _process_override_parameters(self, direction='request'): parameter = force_instance(parameter) mapped = self._map_serializer(parameter, 'request') for property_name, property_schema in mapped['properties'].items(): - field = parameter.fields.get(property_name) + try: + # be graceful when serializer might be non-DRF (via extension). + field = parameter.fields.get(property_name) + except Exception: + field = None result[property_name, OpenApiParameter.QUERY] = build_parameter_type( name=property_name, schema=property_schema, description=property_schema.pop('description', None), location=OpenApiParameter.QUERY, allow_blank=getattr(field, 'allow_blank', True), - required=field.required, + required=bool(property_name in mapped.get('required', [])), ) else: warn(f'could not resolve parameter annotation {parameter}. Skipping.') diff --git a/tests/contrib/test_pydantic.py b/tests/contrib/test_pydantic.py index 337d4ca4..49ade8cc 100644 --- a/tests/contrib/test_pydantic.py +++ b/tests/contrib/test_pydantic.py @@ -45,3 +45,22 @@ def post(self, request): schema = generate_schema('/x', view=XAPIView) assert_schema(schema, 'tests/contrib/test_pydantic.yml') + + +@pytest.mark.contrib('pydantic') +@pytest.mark.skipif(sys.version_info < (3, 7), reason='python 3.7+ is required by package') +def test_pydantic_as_query_parameters(no_warnings): + class QueryParams(BaseModel): + foo: int + bar: str + + class XAPIView(APIView): + @extend_schema(responses=str, parameters=[QueryParams]) + def get(self, request): + pass # pragma: no cover + + schema = generate_schema('/x', view=XAPIView) + assert schema['paths']['/x']['get']['parameters'] == [ + {'in': 'query', 'name': 'bar', 'required': True, 'schema': {'title': 'Bar', 'type': 'string'}}, + {'in': 'query', 'name': 'foo', 'required': True, 'schema': {'title': 'Foo', 'type': 'integer'}} + ]