From feb7252b8a12ebdfd056a34cf42c489ec4d001ba Mon Sep 17 00:00:00 2001
From: Kien Dang <mail@kien.ai>
Date: Wed, 20 Dec 2023 17:48:45 +0800
Subject: [PATCH] Add support for validation rules (#1475)

* Add support for validation rules

* Enable customizing validate max_errors through settings

* Add tests for validation rules

* Add examples for validation rules

* Allow setting validation_rules in class def

* Add tests for validation_rules inherited from parent class

* Make tests for validation rules stricter
---
 docs/index.rst                           |  1 +
 docs/settings.rst                        | 11 +++
 docs/validation.rst                      | 29 ++++++++
 graphene_django/settings.py              |  1 +
 graphene_django/tests/test_views.py      | 94 ++++++++++++++++++++++++
 graphene_django/tests/urls_validation.py | 26 +++++++
 graphene_django/views.py                 | 11 ++-
 7 files changed, 172 insertions(+), 1 deletion(-)
 create mode 100644 docs/validation.rst
 create mode 100644 graphene_django/tests/urls_validation.py

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..79c52e2e5 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 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 <https://github.com/graphql-python/graphql-core/blob/v3.2.3/src/graphql/validation/rules/__init__.py>`_ 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 de2c52163..f7e3ee746 100644
--- a/graphene_django/settings.py
+++ b/graphene_django/settings.py
@@ -43,6 +43,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_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/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)