diff --git a/docs/index.rst b/docs/index.rst index 373969e..df97a57 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 e5f0faf..79c52e2 100644 --- a/docs/settings.rst +++ b/docs/settings.rst @@ -269,3 +269,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 0000000..7137342 --- /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/settings.py b/graphene_django/settings.py index de2c521..f7e3ee7 100644 --- a/graphene_django/settings.py +++ b/graphene_django/settings.py @@ -43,6 +43,7 @@ DEFAULTS = { "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_views.py b/graphene_django/tests/test_views.py index d64a4f0..c2b42bc 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 0000000..74f58b2 --- /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/views.py b/graphene_django/views.py index 9fc6172..1ec6598 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 @@ class GraphQLView(View): batch=False, subscription_path=None, execution_context_class=None, + validation_rules=None, ): if not schema: schema = graphene_settings.SCHEMA @@ -135,6 +137,8 @@ class GraphQLView(View): ), "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 @@ class GraphQLView(View): ) ) - 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)