Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for validation rules #1475

Merged
merged 7 commits into from
Dec 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about we also check that the json representation of the response does not contain the __schema in its data (or data is null/empty, however it appears in practice)? That way we ensure it's not merely returning a 400 and the error message, but is also omitting the introspection data from the response as intended. (Same goes for the other 400 error tests below)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point!


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