Skip to content

Commit

Permalink
Add support for validation rules (#1475)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
kiendang authored Dec 20, 2023
1 parent 3a64994 commit feb7252
Show file tree
Hide file tree
Showing 7 changed files with 172 additions and 1 deletion.
1 change: 1 addition & 0 deletions docs/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ For more advanced use, check out the Relay tutorial.
authorization
debug
introspection
validation
testing
settings
11 changes: 11 additions & 0 deletions docs/settings.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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``
29 changes: 29 additions & 0 deletions docs/validation.rst
Original file line number Diff line number Diff line change
@@ -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()),
]
1 change: 1 addition & 0 deletions graphene_django/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
"GRAPHIQL_INPUT_VALUE_DEPRECATION": False,
"ATOMIC_MUTATIONS": False,
"TESTING_ENDPOINT": "/graphql",
"MAX_VALIDATION_ERRORS": None,
}

if settings.DEBUG:
Expand Down
94 changes: 94 additions & 0 deletions graphene_django/tests/test_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
26 changes: 26 additions & 0 deletions graphene_django/tests/urls_validation.py
Original file line number Diff line number Diff line change
@@ -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()),
]
11 changes: 10 additions & 1 deletion graphene_django/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ class GraphQLView(View):
batch = False
subscription_path = None
execution_context_class = None
validation_rules = None

def __init__(
self,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit feb7252

Please sign in to comment.