Repository: mirumee/ariadne
Pull request #1070 (2023-04-15 21:03:30)

Make query validator configurable
Please consider this small contribution made in the footsteps of https://github.com/mirumee/ariadne/pull/977. The general idea is that validation can equally benefit from customization to achieve for example memoization as it can get quite expensive for large queries under high QPS. Currently this is not possible without patching ariadne/graphql validate functions. There could have been perhaps a higher level abstraction to memoize both parsing and validation in a single step, but given present semantics this seems more appropriate.

This PR adds query_validator option to the WSGI and ASGI GraphQL applications. This feature opens gate to implementing custom base query validator that (for example) allows to cache validation of query documents.

codecov[bot]: ## [Codecov](https://codecov.io/gh/mirumee/ariadne/pull/1070?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) Report
> Merging [#1070](https://codecov.io/gh/mirumee/ariadne/pull/1070?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) (3c8667f) into [master](https://codecov.io/gh/mirumee/ariadne/commit/a9f042752c599283794a4f2a50008c062a2a764a?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) (a9f0427) will **decrease** coverage by `0.01%`.
> The diff coverage is `98.92%`.

```diff
@@            Coverage Diff             @@
##           master    #1070      +/-   ##
==========================================
- Coverage   98.11%   98.11%   -0.01%     
==========================================
  Files         115      115              
  Lines        7377     7464      +87     
==========================================
+ Hits         7238     7323      +85     
- Misses        139      141       +2     
```


| [Impacted Files](https://codecov.io/gh/mirumee/ariadne/pull/1070?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) | Coverage Δ | |
|---|---|---|
| [ariadne/asgi/graphql.py](https://codecov.io/gh/mirumee/ariadne/pull/1070?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee#diff-YXJpYWRuZS9hc2dpL2dyYXBocWwucHk=) | `90.62% <ø> (ø)` | |
| [ariadne/asgi/handlers/graphql\_transport\_ws.py](https://codecov.io/gh/mirumee/ariadne/pull/1070?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee#diff-YXJpYWRuZS9hc2dpL2hhbmRsZXJzL2dyYXBocWxfdHJhbnNwb3J0X3dzLnB5) | `99.47% <ø> (-0.53%)` | :arrow_down: |
| [ariadne/asgi/handlers/graphql\_ws.py](https://codecov.io/gh/mirumee/ariadne/pull/1070?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee#diff-YXJpYWRuZS9hc2dpL2hhbmRsZXJzL2dyYXBocWxfd3MucHk=) | `98.70% <ø> (ø)` | |
| [ariadne/asgi/handlers/http.py](https://codecov.io/gh/mirumee/ariadne/pull/1070?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee#diff-YXJpYWRuZS9hc2dpL2hhbmRsZXJzL2h0dHAucHk=) | `97.22% <ø> (ø)` | |
| [ariadne/types.py](https://codecov.io/gh/mirumee/ariadne/pull/1070?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee#diff-YXJpYWRuZS90eXBlcy5weQ==) | `93.58% <80.00%> (-0.94%)` | :arrow_down: |
| [ariadne/asgi/handlers/base.py](https://codecov.io/gh/mirumee/ariadne/pull/1070?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee#diff-YXJpYWRuZS9hc2dpL2hhbmRsZXJzL2Jhc2UucHk=) | `98.57% <100.00%> (+0.04%)` | :arrow_up: |
| [ariadne/graphql.py](https://codecov.io/gh/mirumee/ariadne/pull/1070?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee#diff-YXJpYWRuZS9ncmFwaHFsLnB5) | `88.88% <100.00%> (+0.06%)` | :arrow_up: |
| [ariadne/wsgi.py](https://codecov.io/gh/mirumee/ariadne/pull/1070?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee#diff-YXJpYWRuZS93c2dpLnB5) | `92.07% <100.00%> (+0.03%)` | :arrow_up: |
| [tests/asgi/test\_configuration.py](https://codecov.io/gh/mirumee/ariadne/pull/1070?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee#diff-dGVzdHMvYXNnaS90ZXN0X2NvbmZpZ3VyYXRpb24ucHk=) | `100.00% <100.00%> (ø)` | |
| [tests/asgi/test\_websockets\_graphql\_transport\_ws.py](https://codecov.io/gh/mirumee/ariadne/pull/1070?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee#diff-dGVzdHMvYXNnaS90ZXN0X3dlYnNvY2tldHNfZ3JhcGhxbF90cmFuc3BvcnRfd3MucHk=) | `100.00% <100.00%> (ø)` | |
| ... and [2 more](https://codecov.io/gh/mirumee/ariadne/pull/1070?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) | |

:mega: We’re building smart automated test selection to slash your CI/CD build times. [Learn more](https://about.codecov.io/iterative-testing/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee)

rafalp: I understand the reasoning behind having custom validation function instead of `graphql.validate`. Once could have custom parser that returns queries including some unique signature that custom validator function could check and then skip most of validation if query with given signature was already checked previously.
rafalp: I've left some suggestions.
rafalp: Did you have an opportunity to test if mypy accepts custom validation function passed to Ariadne, eg in your project?
kravnaut: > Did you have an opportunity to test if mypy accepts custom validation function passed to Ariadne, eg in your project?

Yes, mypy shows no warning when using exact method signature as graphql.validate, and when I try to make a change to make it slightly incompatible I get an error like this:

for 
```python
    def my_query_validator(
        schema: GraphQLSchema,
        document_ast: DocumentNode,
        rules: Optional[Collection[Type[ASTValidationRule]]] = None,
        max_errors: Optional[int] = None,
        type_info: Optional[TypeInfo] = None,
    ) -> List[GraphQLError]:
        return []
```
I get no errors

for
```python
    def my_query_validator(
        schema: GraphQLSchema,
        document_ast: DocumentNode,
        rules: Optional[Collection[Type[ASTValidationRule]]] = None,
        max_errors: Optional[str] = None,
        type_info: Optional[TypeInfo] = None,
    ) -> List[GraphQLError]:
        return []
```
I get
```
my_package/my_module.py:123: error: Argument "query_validator" to "graphql" has incompatible type "Callable[[GraphQLSchema, DocumentNode, Optional[Collection[Type[ASTValidationRule]]], Optional[str], Optional[TypeInfo]], List[GraphQLError]]"; expected "Optional[QueryValidator]"  [arg-type]
```


rafalp: Okay, thanks!

Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 78a162e..fa6ed03 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,9 @@
 # CHANGELOG
 
+## 0.20 (UNRELEASED)
+- Added `query_validator` option to ASGI and WSGI `GraphQL` applications that enables customization of query validation step.
+
+
 ## 0.19.1 (2023-03-28)
 - Fixed `.graphql` definitions files not being included in the dist files
 
diff --git a/ariadne/asgi/graphql.py b/ariadne/asgi/graphql.py
index 53d7213..8114a9d 100644
--- a/ariadne/asgi/graphql.py
+++ b/ariadne/asgi/graphql.py
@@ -12,6 +12,7 @@ from ..types import (
     ContextValue,
     ErrorFormatter,
     QueryParser,
+    QueryValidator,
     RootValue,
     ValidationRules,
 )
@@ -36,6 +37,7 @@ class GraphQL:
         context_value: Optional[ContextValue] = None,
         root_value: Optional[RootValue] = None,
         query_parser: Optional[QueryParser] = None,
+        query_validator: Optional[QueryValidator] = None,
         validation_rules: Optional[ValidationRules] = None,
         debug: bool = False,
         introspection: bool = True,
@@ -64,6 +66,9 @@ class GraphQL:
         `query_parser`: a `QueryParser` to use by this server. Defaults to
         `graphql.parse`.
 
+        `query_validator`: a `QueryValidator` to use by this server. Defaults to
+        `graphql.validate`.
+
         `validation_rules`: a `ValidationRules` list or callable returning a
         list of extra validation rules server should use to validate the
         GraphQL queries. Defaults to `None`.
@@ -119,6 +124,7 @@ class GraphQL:
             context_value,
             root_value,
             query_parser,
+            query_validator,
             validation_rules,
             debug,
             introspection,
@@ -132,6 +138,7 @@ class GraphQL:
             context_value,
             root_value,
             query_parser,
+            query_validator,
             validation_rules,
             debug,
             introspection,
diff --git a/ariadne/asgi/handlers/base.py b/ariadne/asgi/handlers/base.py
index 0095067..3d626dd 100644
--- a/ariadne/asgi/handlers/base.py
+++ b/ariadne/asgi/handlers/base.py
@@ -17,6 +17,7 @@ from ...types import (
     OnDisconnect,
     OnOperation,
     QueryParser,
+    QueryValidator,
     RootValue,
     ValidationRules,
 )
@@ -37,6 +38,7 @@ class GraphQLHandler(ABC):
         self.logger: Union[None, str, Logger, LoggerAdapter] = None
         self.root_value: Optional[RootValue] = None
         self.query_parser: Optional[QueryParser] = None
+        self.query_validator: Optional[QueryValidator] = None
         self.validation_rules: Optional[ValidationRules] = None
         self.execution_context_class: Optional[Type[ExecutionContext]] = None
         self.middleware_manager_class: Optional[Type[MiddlewareManager]] = None
@@ -75,6 +77,7 @@ class GraphQLHandler(ABC):
         context_value: Optional[ContextValue] = None,
         root_value: Optional[RootValue] = None,
         query_parser: Optional[QueryParser] = None,
+        query_validator: Optional[QueryValidator] = None,
         validation_rules: Optional[ValidationRules] = None,
         debug: bool = False,
         introspection: bool = True,
@@ -96,6 +99,7 @@ class GraphQLHandler(ABC):
         self.explorer = explorer
         self.logger = logger
         self.query_parser = query_parser
+        self.query_validator = query_validator
         self.root_value = root_value
         self.schema = schema
         self.validation_rules = validation_rules
diff --git a/ariadne/asgi/handlers/graphql_transport_ws.py b/ariadne/asgi/handlers/graphql_transport_ws.py
index 378254c..4f05238 100644
--- a/ariadne/asgi/handlers/graphql_transport_ws.py
+++ b/ariadne/asgi/handlers/graphql_transport_ws.py
@@ -343,6 +343,7 @@ class GraphQLTransportWSHandler(GraphQLWebsocketHandler):
                 context_value=context_value,
                 root_value=self.root_value,
                 query_document=query_document,
+                query_validator=self.query_validator,
                 validation_rules=self.validation_rules,
                 debug=self.debug,
                 introspection=self.introspection,
diff --git a/ariadne/asgi/handlers/graphql_ws.py b/ariadne/asgi/handlers/graphql_ws.py
index eb6c32f..26177f7 100644
--- a/ariadne/asgi/handlers/graphql_ws.py
+++ b/ariadne/asgi/handlers/graphql_ws.py
@@ -301,6 +301,7 @@ class GraphQLWSHandler(GraphQLWebsocketHandler):
             context_value=context_value,
             root_value=self.root_value,
             query_document=query_document,
+            query_validator=self.query_validator,
             validation_rules=self.validation_rules,
             debug=self.debug,
             introspection=self.introspection,
diff --git a/ariadne/asgi/handlers/http.py b/ariadne/asgi/handlers/http.py
index f8d71ca..8b49802 100644
--- a/ariadne/asgi/handlers/http.py
+++ b/ariadne/asgi/handlers/http.py
@@ -281,6 +281,7 @@ class GraphQLHTTPHandler(GraphQLHttpHandlerBase):
             context_value=context_value,
             root_value=self.root_value,
             query_parser=self.query_parser,
+            query_validator=self.query_validator,
             query_document=query_document,
             validation_rules=self.validation_rules,
             debug=self.debug,
diff --git a/ariadne/graphql.py b/ariadne/graphql.py
index e77d66f..f3d25a7 100644
--- a/ariadne/graphql.py
+++ b/ariadne/graphql.py
@@ -40,6 +40,7 @@ from .types import (
     GraphQLResult,
     MiddlewareList,
     QueryParser,
+    QueryValidator,
     RootValue,
     SubscriptionResult,
     ValidationRules,
@@ -64,6 +65,7 @@ async def graphql(
     context_value: Optional[Any] = None,
     root_value: Optional[RootValue] = None,
     query_parser: Optional[QueryParser] = None,
+    query_validator: Optional[QueryValidator] = None,
     query_document: Optional[DocumentNode] = None,
     debug: bool = False,
     introspection: bool = True,
@@ -104,6 +106,9 @@ async def graphql(
     `query_parser`: a `QueryParser` to use instead of default one. Is called
     with two arguments: `context_value`, and `data` dict.
 
+    `query_validator`: a `QueryValidator` to use instead of default one. Is called
+    with five arguments: `schema`, 'document_ast', 'rules', 'max_errors' and 'type_info'.
+
     `query_document`: an already parsed GraphQL query. Setting this option will
     prevent `graphql` from parsing `query` string from `data` second time.
 
@@ -158,7 +163,11 @@ async def graphql(
                 )
 
             validation_errors = validate_query(
-                schema, document, validation_rules, enable_introspection=introspection
+                schema,
+                document,
+                validation_rules,
+                enable_introspection=introspection,
+                query_validator=query_validator,
             )
             if validation_errors:
                 return handle_graphql_errors(
@@ -222,6 +231,7 @@ def graphql_sync(
     context_value: Optional[Any] = None,
     root_value: Optional[RootValue] = None,
     query_parser: Optional[QueryParser] = None,
+    query_validator: Optional[QueryValidator] = None,
     query_document: Optional[DocumentNode] = None,
     debug: bool = False,
     introspection: bool = True,
@@ -262,6 +272,9 @@ def graphql_sync(
     `query_parser`: a `QueryParser` to use instead of default one. Is called
     with two arguments: `context_value`, and `data` dict.
 
+    `query_validator`: a `QueryValidator` to use instead of default one. Is called
+    with five arguments: `schema`, 'document_ast', 'rules', 'max_errors' and 'type_info'.
+
     `query_document`: an already parsed GraphQL query. Setting this option will
     prevent `graphql_sync` from parsing `query` string from `data` second time.
 
@@ -316,7 +329,11 @@ def graphql_sync(
                 )
 
             validation_errors = validate_query(
-                schema, document, validation_rules, enable_introspection=introspection
+                schema,
+                document,
+                validation_rules,
+                enable_introspection=introspection,
+                query_validator=query_validator,
             )
             if validation_errors:
                 return handle_graphql_errors(
@@ -387,6 +404,7 @@ async def subscribe(
     context_value: Optional[Any] = None,
     root_value: Optional[RootValue] = None,
     query_parser: Optional[QueryParser] = None,
+    query_validator: Optional[QueryValidator] = None,
     query_document: Optional[DocumentNode] = None,
     debug: bool = False,
     introspection: bool = True,
@@ -422,6 +440,9 @@ async def subscribe(
     `query_parser`: a `QueryParser` to use instead of default one. Is called
     with two arguments: `context_value`, and `data` dict.
 
+    `query_validator`: a `QueryValidator` to use instead of default one. Is called
+    with five arguments: `schema`, 'document_ast', 'rules', 'max_errors' and 'type_info'.
+
     `query_document`: an already parsed GraphQL query. Setting this option will
     prevent `subscribe` from parsing `query` string from `data` second time.
 
@@ -461,7 +482,11 @@ async def subscribe(
             )
 
         validation_errors = validate_query(
-            schema, document, validation_rules, enable_introspection=introspection
+            schema,
+            document,
+            validation_rules,
+            enable_introspection=introspection,
+            query_validator=query_validator,
         )
         if validation_errors:
             for error_ in validation_errors:  # mypy issue #5080
@@ -569,7 +594,10 @@ def validate_query(
     max_errors: Optional[int] = None,
     type_info: Optional[TypeInfo] = None,
     enable_introspection: bool = True,
+    query_validator: Optional[QueryValidator] = None,
 ) -> List[GraphQLError]:
+    validate_fn: QueryValidator = query_validator or validate
+
     if not enable_introspection:
         rules = (
             tuple(rules) + (IntrospectionDisabledRule,)
@@ -579,7 +607,7 @@ def validate_query(
     if rules:
         # run validation against rules from spec and custom rules
         supplemented_rules = specified_rules + tuple(rules)
-        return validate(
+        return validate_fn(
             schema,
             document_ast,
             rules=supplemented_rules,
@@ -587,7 +615,7 @@ def validate_query(
             type_info=type_info,
         )
     # run validation using spec rules only
-    return validate(schema, document_ast, rules=specified_rules, type_info=type_info)
+    return validate_fn(schema, document_ast, rules=specified_rules, type_info=type_info)
 
 
 def validate_data(data: Optional[dict]) -> None:
diff --git a/ariadne/types.py b/ariadne/types.py
index b46421c..7b9de55 100644
--- a/ariadne/types.py
+++ b/ariadne/types.py
@@ -22,6 +22,7 @@ from graphql import (
     GraphQLResolveInfo,
     GraphQLSchema,
 )
+from graphql.utilities.type_info import TypeInfo
 from graphql.validation.rules import ASTValidationRule
 
 from starlette.websockets import WebSocket
@@ -35,6 +36,7 @@ __all__ = [
     "ContextValue",
     "RootValue",
     "QueryParser",
+    "QueryValidator",
     "ValidationRules",
     "ExtensionList",
     "Extensions",
@@ -300,6 +302,65 @@ allowed_queries_parser.add_query(
 """
 QueryParser = Callable[[ContextValue, Dict[str, Any]], DocumentNode]
 
+
+class QueryValidator(Protocol):
+    """Type of `query_validator` option of GraphQL servers.
+
+    Enables customization of server's GraphQL query validation logic. If not set or `None`,
+    default graphql.validate is used instead.
+
+    # Custom validator
+
+    Custom validator is a function or callable accepting up to 5 arguments:
+
+    `schema`: GraphQLSchema for the graphql schema
+    `document_ast`: DocumentNode result of query parser
+    `rules`: optional list of AST validation rules of type ASTValidationRule
+    `max_errors`: optional maximum number of errors to return
+    `type_info`: optional type info, pending deprecation in graphql 3.3
+
+    Validator is required to return `List[GraphQLError]` which should be empty
+    if there were no errors found
+
+    # Example validator
+
+    Below code defines custom validator that mutates same Document with to "memoize" validation
+    (this works best in a tandem with a memoized parser for end-to-end memoization
+     of parsing and validation):
+
+    ```python
+    from graphql import GraphQLError, validate
+    from graphql import GraphQLSchema, DocumentNode, GraphQLError
+    from graphql.utilities.type_info import TypeInfo
+    from graphql.validation.rules import ASTValidationRule
+
+    def memoized_queries_validator(
+        schema: GraphQLSchema,
+        document_ast: DocumentNode,
+        rules: Optional[Collection[Type[ASTValidationRule]]] = None,
+        max_errors: Optional[int] = None,
+        type_info: Optional[TypeInfo] = None,
+    ) -> List[GraphQLError]:
+        past_validation = getattr(document_ast, "_past_validation", None)
+        if past_validation:
+            return past_validation
+        validation = validate(schema, document_ast, *args, **kwargs)
+        setattr(document_ast, "_past_validation", validation)
+        return validation
+    ```
+    """
+
+    def __call__(
+        self,
+        schema: GraphQLSchema,
+        document_ast: DocumentNode,
+        rules: Optional[Collection[Type[ASTValidationRule]]] = None,
+        max_errors: Optional[int] = None,
+        type_info: Optional[TypeInfo] = None,
+    ) -> List[GraphQLError]:
+        ...
+
+
 """Type of `validation_rules` option of GraphQL servers.
 
 Enables extending of server's query validation logic.
diff --git a/ariadne/wsgi.py b/ariadne/wsgi.py
index c771818..ead0bb4 100644
--- a/ariadne/wsgi.py
+++ b/ariadne/wsgi.py
@@ -31,6 +31,7 @@ from .types import (
     GraphQLResult,
     MiddlewareList,
     QueryParser,
+    QueryValidator,
     RootValue,
     ValidationRules,
 )
@@ -67,6 +68,7 @@ class GraphQL:
         context_value: Optional[ContextValue] = None,
         root_value: Optional[RootValue] = None,
         query_parser: Optional[QueryParser] = None,
+        query_validator: Optional[QueryValidator] = None,
         validation_rules: Optional[ValidationRules] = None,
         debug: bool = False,
         introspection: bool = True,
@@ -96,6 +98,9 @@ class GraphQL:
         `query_parser`: a `QueryParser` to use by this server. Defaults to
         `graphql.parse`.
 
+        `query_validator`: a `QueryValidator` to use by this server. Defaults to
+        `graphql.validate`.
+
         `validation_rules`: a `ValidationRules` list or callable returning a
         list of extra validation rules server should use to validate the
         GraphQL queries. Defaults to `None`.
@@ -141,6 +146,7 @@ class GraphQL:
         self.context_value = context_value
         self.root_value = root_value
         self.query_parser = query_parser
+        self.query_validator = query_validator
         self.validation_rules = validation_rules
         self.debug = debug
         self.introspection = introspection
@@ -405,6 +411,7 @@ class GraphQL:
             context_value=context_value,
             root_value=self.root_value,
             query_parser=self.query_parser,
+            query_validator=self.query_validator,
             validation_rules=self.validation_rules,
             debug=self.debug,
             introspection=self.introspection,


Test patch:
diff --git a/tests/asgi/test_configuration.py b/tests/asgi/test_configuration.py
index df27037..2724cef 100644
--- a/tests/asgi/test_configuration.py
+++ b/tests/asgi/test_configuration.py
@@ -4,7 +4,7 @@ from datetime import timedelta
 from unittest.mock import ANY, Mock
 
 import pytest
-from graphql import parse
+from graphql import GraphQLError, parse
 from starlette.testclient import TestClient
 from starlette.websockets import WebSocketDisconnect
 
@@ -262,6 +262,25 @@ def test_custom_query_parser_is_used_for_http_query(schema):
     mock_parser.assert_called_once()
 
 
+@pytest.mark.parametrize(
+    ("errors"),
+    [
+        ([]),
+        ([GraphQLError("Nope")]),
+    ],
+)
+def test_custom_query_validator_is_used_for_http_query_error(schema, errors):
+    mock_validator = Mock(return_value=errors)
+    app = GraphQL(schema, query_validator=mock_validator)
+    client = TestClient(app)
+    response = client.post("/", json={"query": "{ testContext }"})
+    if errors:
+        assert response.json() == {"errors": [{"message": "Nope"}]}
+    else:
+        assert response.json() == {"data": {"testContext": None}}
+    mock_validator.assert_called_once()
+
+
 def test_custom_validation_rule_is_called_by_query_validation(
     mocker, schema, validation_rule
 ):
diff --git a/tests/asgi/test_websockets_graphql_transport_ws.py b/tests/asgi/test_websockets_graphql_transport_ws.py
index ff04093..0a10f8b 100644
--- a/tests/asgi/test_websockets_graphql_transport_ws.py
+++ b/tests/asgi/test_websockets_graphql_transport_ws.py
@@ -2,7 +2,7 @@
 from unittest.mock import Mock
 
 import pytest
-from graphql import parse
+from graphql import parse, GraphQLError
 from graphql.language import OperationType
 from starlette.testclient import TestClient
 from starlette.websockets import WebSocketDisconnect
@@ -190,6 +190,55 @@ def test_custom_query_parser_is_used_for_subscription_over_websocket_transport_w
         assert response["id"] == "test2"
 
 
+@pytest.mark.parametrize(
+    ("errors"),
+    [
+        ([]),
+        ([GraphQLError("Nope")]),
+    ],
+)
+def test_custom_query_validator_is_used_for_subscription_over_websocket_transport_ws(
+    schema, errors
+):
+    mock_validator = Mock(return_value=errors)
+    websocket_handler = GraphQLTransportWSHandler()
+    app = GraphQL(
+        schema,
+        query_validator=mock_validator,
+        context_value={"test": "I'm context"},
+        root_value={"test": "I'm root"},
+        websocket_handler=websocket_handler,
+    )
+
+    with TestClient(app).websocket_connect("/", ["graphql-transport-ws"]) as ws:
+        ws.send_json({"type": GraphQLTransportWSHandler.GQL_CONNECTION_INIT})
+        response = ws.receive_json()
+        assert response["type"] == GraphQLTransportWSHandler.GQL_CONNECTION_ACK
+        ws.send_json(
+            {
+                "type": GraphQLTransportWSHandler.GQL_SUBSCRIBE,
+                "id": "test2",
+                "payload": {
+                    "operationName": None,
+                    "query": "subscription { testContext }",
+                    "variables": None,
+                },
+            }
+        )
+        response = ws.receive_json()
+        if not errors:
+            assert response["type"] == GraphQLTransportWSHandler.GQL_NEXT
+            assert response["id"] == "test2"
+            assert response["payload"]["data"] == {"testContext": "I'm context"}
+            response = ws.receive_json()
+            assert response["type"] == GraphQLTransportWSHandler.GQL_COMPLETE
+            assert response["id"] == "test2"
+        else:
+            assert response["type"] == GraphQLTransportWSHandler.GQL_ERROR
+            assert response["id"] == "test2"
+            assert response["payload"]["message"] == "Nope"
+
+
 def test_custom_query_parser_is_used_for_query_over_websocket_transport_ws(
     schema,
 ):
diff --git a/tests/asgi/test_websockets_graphql_ws.py b/tests/asgi/test_websockets_graphql_ws.py
index 9f37645..f0d8a11 100644
--- a/tests/asgi/test_websockets_graphql_ws.py
+++ b/tests/asgi/test_websockets_graphql_ws.py
@@ -2,7 +2,7 @@
 from unittest.mock import Mock
 
 import pytest
-from graphql import parse
+from graphql import parse, GraphQLError
 from starlette.testclient import TestClient
 
 from ariadne.asgi import GraphQL
@@ -168,6 +168,51 @@ def test_custom_query_parser_is_used_for_subscription_over_websocket(
         ws.send_json({"type": GraphQLWSHandler.GQL_CONNECTION_TERMINATE})
 
 
+@pytest.mark.parametrize(
+    ("errors"),
+    [
+        ([]),
+        ([GraphQLError("Nope")]),
+    ],
+)
+def test_custom_query_validator_is_used_for_subscription_over_websocket(schema, errors):
+    mock_validator = Mock(return_value=errors)
+    websocket_handler = GraphQLWSHandler()
+    app = GraphQL(
+        schema,
+        query_validator=mock_validator,
+        context_value={"test": "I'm context"},
+        root_value={"test": "I'm root"},
+        websocket_handler=websocket_handler,
+    )
+
+    with TestClient(app).websocket_connect("/", ["graphql-ws"]) as ws:
+        ws.send_json({"type": GraphQLWSHandler.GQL_CONNECTION_INIT})
+        response = ws.receive_json()
+        assert response["type"] == GraphQLWSHandler.GQL_CONNECTION_ACK
+        ws.send_json(
+            {
+                "type": GraphQLWSHandler.GQL_START,
+                "id": "test2",
+                "payload": {
+                    "operationName": None,
+                    "query": "subscription { testRoot }",
+                    "variables": None,
+                },
+            }
+        )
+        response = ws.receive_json()
+        if not errors:
+            assert response["type"] == GraphQLWSHandler.GQL_DATA
+            assert response["id"] == "test2"
+            assert response["payload"]["data"] == {"testRoot": "I'm root"}
+        else:
+            assert response["type"] == GraphQLWSHandler.GQL_ERROR
+            assert response["id"] == "test2"
+            assert response["payload"]["message"] == "Nope"
+        ws.send_json({"type": GraphQLWSHandler.GQL_CONNECTION_TERMINATE})
+
+
 def test_custom_query_parser_is_used_for_query_over_websocket(
     schema,
 ):
@@ -203,6 +248,51 @@ def test_custom_query_parser_is_used_for_query_over_websocket(
         ws.send_json({"type": GraphQLWSHandler.GQL_CONNECTION_TERMINATE})
 
 
+@pytest.mark.parametrize(
+    ("errors"),
+    [
+        ([]),
+        ([GraphQLError("Nope")]),
+    ],
+)
+def test_custom_query_validator_is_used_for_query_over_websocket(schema, errors):
+    mock_validator = Mock(return_value=errors)
+    websocket_handler = GraphQLWSHandler()
+    app = GraphQL(
+        schema,
+        query_validator=mock_validator,
+        context_value={"test": "I'm context"},
+        root_value={"test": "I'm root"},
+        websocket_handler=websocket_handler,
+    )
+
+    with TestClient(app).websocket_connect("/", ["graphql-ws"]) as ws:
+        ws.send_json({"type": GraphQLWSHandler.GQL_CONNECTION_INIT})
+        response = ws.receive_json()
+        assert response["type"] == GraphQLWSHandler.GQL_CONNECTION_ACK
+        ws.send_json(
+            {
+                "type": GraphQLWSHandler.GQL_START,
+                "id": "test2",
+                "payload": {
+                    "operationName": None,
+                    "query": "query { testRoot }",
+                    "variables": None,
+                },
+            }
+        )
+        response = ws.receive_json()
+        if errors:
+            assert response["type"] == GraphQLWSHandler.GQL_DATA
+            assert response["id"] == "test2"
+            assert response["payload"]["errors"] == [{"message": "Nope"}]
+        else:
+            assert response["type"] == GraphQLWSHandler.GQL_DATA
+            assert response["id"] == "test2"
+            assert response["payload"]["data"] == {"testRoot": "I'm root"}
+        ws.send_json({"type": GraphQLWSHandler.GQL_CONNECTION_TERMINATE})
+
+
 def test_immediate_disconnect(client):
     with client.websocket_connect("/", ["graphql-ws"]) as ws:
         ws.send_json({"type": GraphQLWSHandler.GQL_CONNECTION_INIT})
diff --git a/tests/wsgi/test_configuration.py b/tests/wsgi/test_configuration.py
index 2562e74..85e33e3 100644
--- a/tests/wsgi/test_configuration.py
+++ b/tests/wsgi/test_configuration.py
@@ -4,7 +4,10 @@ from io import BytesIO
 from unittest.mock import ANY, Mock
 
 import pytest
-from graphql import parse
+from graphql import (
+    GraphQLError,
+    parse,
+)
 from werkzeug.test import Client
 from werkzeug.wrappers import Response
 
@@ -113,6 +116,24 @@ def test_custom_query_parser_is_used(schema):
     mock_parser.assert_called_once()
 
 
+@pytest.mark.parametrize(
+    ("errors"),
+    [
+        ([]),
+        ([GraphQLError("Nope")]),
+    ],
+)
+def test_custom_query_validator_is_used(schema, errors):
+    mock_validator = Mock(return_value=errors)
+    app = GraphQL(schema, query_validator=mock_validator)
+    _, result = app.execute_query({}, {"query": "{ testContext }"})
+    if errors:
+        assert result == {"errors": [{"message": "Nope"}]}
+    else:
+        assert result == {"data": {"testContext": None}}
+    mock_validator.assert_called_once()
+
+
 def test_custom_validation_rule_is_called_by_query_validation(
     mocker, schema, validation_rule
 ):