diff --git a/docs/index.rst b/docs/index.rst index 373969e1d..df97a5707 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -33,5 +33,6 @@ For more advanced use, check out the Relay tutorial. authorization debug introspection + validation testing settings diff --git a/docs/settings.rst b/docs/settings.rst index e5f0faf25..521e434d9 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -142,6 +142,15 @@ Default: ``False`` # ] +``DJANGO_CHOICE_FIELD_ENUM_CONVERT`` +-------------------------------------- + +When set to ``True`` Django choice fields are automatically converted into Enum types. + +Can be disabled globally by setting it to ``False``. + +Default: ``True`` + ``DJANGO_CHOICE_FIELD_ENUM_V2_NAMING`` -------------------------------------- @@ -269,3 +278,14 @@ Default: ``False`` .. _GraphiQLDocs: https://graphiql-test.netlify.app/typedoc/modules/graphiql_react#graphiqlprovider-2 + + +``MAX_VALIDATION_ERRORS`` +------------------------------------ + +In case ``validation_rules`` are provided to ``GraphQLView``, if this is set to a non-negative ``int`` value, +``graphql.validation.validate`` will stop validation after this number of errors has been reached. +If not set or set to ``None``, the maximum number of errors will follow ``graphql.validation.validate`` default +*i.e.* 100. + +Default: ``None`` diff --git a/docs/validation.rst b/docs/validation.rst new file mode 100644 index 000000000..71373420e --- /dev/null +++ b/docs/validation.rst @@ -0,0 +1,29 @@ +Query Validation +================ + +Graphene-Django supports query validation by allowing passing a list of validation rules (subclasses of `ValidationRule `_ from graphql-core) to the ``validation_rules`` option in ``GraphQLView``. + +.. code:: python + + from django.urls import path + from graphene.validation import DisableIntrospection + from graphene_django.views import GraphQLView + + urlpatterns = [ + path("graphql", GraphQLView.as_view(validation_rules=(DisableIntrospection,))), + ] + +or + +.. code:: python + + from django.urls import path + from graphene.validation import DisableIntrospection + from graphene_django.views import GraphQLView + + class View(GraphQLView): + validation_rules = (DisableIntrospection,) + + urlpatterns = [ + path("graphql", View.as_view()), + ] diff --git a/graphene_django/__init__.py b/graphene_django/__init__.py index 7aff915e1..7b41edb47 100644 --- a/graphene_django/__init__.py +++ b/graphene_django/__init__.py @@ -2,7 +2,7 @@ from .types import DjangoObjectType from .utils import bypass_get_queryset -__version__ = "3.1.6" +__version__ = "3.2.0" __all__ = [ "__version__", diff --git a/graphene_django/converter.py b/graphene_django/converter.py index f4775e83d..121c1de10 100644 --- a/graphene_django/converter.py +++ b/graphene_django/converter.py @@ -133,13 +133,17 @@ def convert_choice_field_to_enum(field, name=None): def convert_django_field_with_choices( - field, registry=None, convert_choices_to_enum=True + field, registry=None, convert_choices_to_enum=None ): if registry is not None: converted = registry.get_converted_field(field) if converted: return converted choices = getattr(field, "choices", None) + if convert_choices_to_enum is None: + convert_choices_to_enum = bool( + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT + ) if choices and convert_choices_to_enum: EnumCls = convert_choice_field_to_enum(field) required = not (field.blank or field.null) diff --git a/graphene_django/settings.py b/graphene_django/settings.py index de2c52163..da3370031 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -30,6 +30,8 @@ # Max items returned in ConnectionFields / FilterConnectionFields "RELAY_CONNECTION_MAX_LIMIT": 100, "CAMELCASE_ERRORS": True, + # Automatically convert Choice fields of Django into Enum fields + "DJANGO_CHOICE_FIELD_ENUM_CONVERT": True, # Set to True to enable v2 naming convention for choice field Enum's "DJANGO_CHOICE_FIELD_ENUM_V2_NAMING": False, "DJANGO_CHOICE_FIELD_ENUM_CUSTOM_NAME": None, @@ -43,6 +45,7 @@ "GRAPHIQL_INPUT_VALUE_DEPRECATION": False, "ATOMIC_MUTATIONS": False, "TESTING_ENDPOINT": "/graphql", + "MAX_VALIDATION_ERRORS": None, } if settings.DEBUG: diff --git a/graphene_django/tests/test_types.py b/graphene_django/tests/test_types.py index 34828dbb4..5c36bb92e 100644 --- a/graphene_django/tests/test_types.py +++ b/graphene_django/tests/test_types.py @@ -661,6 +661,122 @@ class Query(ObjectType): }""" ) + def test_django_objecttype_convert_choices_global_false( + self, graphene_settings, PetModel + ): + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT = False + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + fields = "__all__" + + class Query(ObjectType): + pet = Field(Pet) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + type Query { + pet: Pet + } + + type Pet { + id: ID! + kind: String! + cuteness: Int! + }""" + ) + + def test_django_objecttype_convert_choices_true_global_false( + self, graphene_settings, PetModel + ): + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT = False + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + fields = "__all__" + convert_choices_to_enum = True + + class Query(ObjectType): + pet = Field(Pet) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + type Query { + pet: Pet + } + + type Pet { + id: ID! + kind: TestsPetModelKindChoices! + cuteness: TestsPetModelCutenessChoices! + } + + \"""An enumeration.\""" + enum TestsPetModelKindChoices { + \"""Cat\""" + CAT + + \"""Dog\""" + DOG + } + + \"""An enumeration.\""" + enum TestsPetModelCutenessChoices { + \"""Kind of cute\""" + A_1 + + \"""Pretty cute\""" + A_2 + + \"""OMG SO CUTE!!!\""" + A_3 + }""" + ) + + def test_django_objecttype_convert_choices_enum_list_global_false( + self, graphene_settings, PetModel + ): + graphene_settings.DJANGO_CHOICE_FIELD_ENUM_CONVERT = False + + class Pet(DjangoObjectType): + class Meta: + model = PetModel + convert_choices_to_enum = ["kind"] + fields = "__all__" + + class Query(ObjectType): + pet = Field(Pet) + + schema = Schema(query=Query) + + assert str(schema) == dedent( + """\ + type Query { + pet: Pet + } + + type Pet { + id: ID! + kind: TestsPetModelKindChoices! + cuteness: Int! + } + + \"""An enumeration.\""" + enum TestsPetModelKindChoices { + \"""Cat\""" + CAT + + \"""Dog\""" + DOG + }""" + ) + @with_local_registry def test_django_objecttype_name_connection_propagation(): diff --git a/graphene_django/tests/test_views.py b/graphene_django/tests/test_views.py index d64a4f02b..c2b42bce4 100644 --- a/graphene_django/tests/test_views.py +++ b/graphene_django/tests/test_views.py @@ -827,3 +827,97 @@ def test_query_errors_atomic_request(set_rollback_mock, client): def test_query_errors_non_atomic(set_rollback_mock, client): client.get(url_string(query="force error")) set_rollback_mock.assert_not_called() + + +VALIDATION_URLS = [ + "/graphql/validation/", + "/graphql/validation/alternative/", + "/graphql/validation/inherited/", +] + +QUERY_WITH_TWO_INTROSPECTIONS = """ +query Instrospection { + queryType: __schema { + queryType {name} + } + mutationType: __schema { + mutationType {name} + } +} +""" + +N_INTROSPECTIONS = 2 + +INTROSPECTION_DISALLOWED_ERROR_MESSAGE = "introspection is disabled" +MAX_VALIDATION_ERRORS_EXCEEDED_MESSAGE = "too many validation errors" + + +@pytest.mark.urls("graphene_django.tests.urls_validation") +def test_allow_introspection(client): + response = client.post( + url_string("/graphql/", query="{__schema {queryType {name}}}") + ) + assert response.status_code == 200 + + assert response_json(response) == { + "data": {"__schema": {"queryType": {"name": "QueryRoot"}}} + } + + +@pytest.mark.parametrize("url", VALIDATION_URLS) +@pytest.mark.urls("graphene_django.tests.urls_validation") +def test_validation_disallow_introspection(client, url): + response = client.post(url_string(url, query="{__schema {queryType {name}}}")) + + assert response.status_code == 400 + + json_response = response_json(response) + assert "data" not in json_response + assert "errors" in json_response + assert len(json_response["errors"]) == 1 + + error_message = json_response["errors"][0]["message"] + assert INTROSPECTION_DISALLOWED_ERROR_MESSAGE in error_message + + +@pytest.mark.parametrize("url", VALIDATION_URLS) +@pytest.mark.urls("graphene_django.tests.urls_validation") +@patch( + "graphene_django.settings.graphene_settings.MAX_VALIDATION_ERRORS", N_INTROSPECTIONS +) +def test_within_max_validation_errors(client, url): + response = client.post(url_string(url, query=QUERY_WITH_TWO_INTROSPECTIONS)) + + assert response.status_code == 400 + + json_response = response_json(response) + assert "data" not in json_response + assert "errors" in json_response + assert len(json_response["errors"]) == N_INTROSPECTIONS + + error_messages = [error["message"].lower() for error in json_response["errors"]] + + n_introspection_error_messages = sum( + INTROSPECTION_DISALLOWED_ERROR_MESSAGE in msg for msg in error_messages + ) + assert n_introspection_error_messages == N_INTROSPECTIONS + + assert all( + MAX_VALIDATION_ERRORS_EXCEEDED_MESSAGE not in msg for msg in error_messages + ) + + +@pytest.mark.parametrize("url", VALIDATION_URLS) +@pytest.mark.urls("graphene_django.tests.urls_validation") +@patch("graphene_django.settings.graphene_settings.MAX_VALIDATION_ERRORS", 1) +def test_exceeds_max_validation_errors(client, url): + response = client.post(url_string(url, query=QUERY_WITH_TWO_INTROSPECTIONS)) + + assert response.status_code == 400 + + json_response = response_json(response) + assert "data" not in json_response + assert "errors" in json_response + + error_messages = (error["message"].lower() for error in json_response["errors"]) + assert any(MAX_VALIDATION_ERRORS_EXCEEDED_MESSAGE in msg for msg in error_messages) diff --git a/graphene_django/tests/urls_validation.py b/graphene_django/tests/urls_validation.py new file mode 100644 index 000000000..74f58b20a --- /dev/null +++ b/graphene_django/tests/urls_validation.py @@ -0,0 +1,26 @@ +from django.urls import path + +from graphene.validation import DisableIntrospection + +from ..views import GraphQLView +from .schema_view import schema + + +class View(GraphQLView): + schema = schema + + +class NoIntrospectionView(View): + validation_rules = (DisableIntrospection,) + + +class NoIntrospectionViewInherited(NoIntrospectionView): + pass + + +urlpatterns = [ + path("graphql/", View.as_view()), + path("graphql/validation/", View.as_view(validation_rules=(DisableIntrospection,))), + path("graphql/validation/alternative/", NoIntrospectionView.as_view()), + path("graphql/validation/inherited/", NoIntrospectionViewInherited.as_view()), +] diff --git a/graphene_django/types.py b/graphene_django/types.py index 02b7693e3..e310fe478 100644 --- a/graphene_django/types.py +++ b/graphene_django/types.py @@ -23,7 +23,7 @@ def construct_fields( - model, registry, only_fields, exclude_fields, convert_choices_to_enum + model, registry, only_fields, exclude_fields, convert_choices_to_enum=None ): _model_fields = get_model_fields(model) @@ -47,7 +47,7 @@ def construct_fields( continue _convert_choices_to_enum = convert_choices_to_enum - if not isinstance(_convert_choices_to_enum, bool): + if isinstance(_convert_choices_to_enum, list): # then `convert_choices_to_enum` is a list of field names to convert if name in _convert_choices_to_enum: _convert_choices_to_enum = True @@ -146,7 +146,7 @@ def __init_subclass_with_meta__( connection_class=None, use_connection=None, interfaces=(), - convert_choices_to_enum=True, + convert_choices_to_enum=None, _meta=None, **options, ): diff --git a/graphene_django/views.py b/graphene_django/views.py index 9fc617207..1ec659881 100644 --- a/graphene_django/views.py +++ b/graphene_django/views.py @@ -96,6 +96,7 @@ class GraphQLView(View): batch = False subscription_path = None execution_context_class = None + validation_rules = None def __init__( self, @@ -107,6 +108,7 @@ def __init__( batch=False, subscription_path=None, execution_context_class=None, + validation_rules=None, ): if not schema: schema = graphene_settings.SCHEMA @@ -135,6 +137,8 @@ def __init__( ), "A Schema is required to be provided to GraphQLView." assert not all((graphiql, batch)), "Use either graphiql or batch processing" + self.validation_rules = validation_rules or self.validation_rules + # noinspection PyUnusedLocal def get_root_value(self, request): return self.root_value @@ -332,7 +336,12 @@ def execute_graphql_request( ) ) - validation_errors = validate(schema, document) + validation_errors = validate( + schema, + document, + self.validation_rules, + graphene_settings.MAX_VALIDATION_ERRORS, + ) if validation_errors: return ExecutionResult(data=None, errors=validation_errors)