Repository: mirumee/ariadne

Issue:
Support QUERY execution over the GET
GraphQL spec describes a way for executing the queries over `GET`, via `query=`.

Quick research shows that this usage is supported by Apollo Server, but only for `query` operations. This protects it from 2005 happening all over again with `?action=delete&target=123` links-traps being posted around the internet, but requires additional logic in Ariadne to detect and block mutations and subscriptions.

Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/mirumee/ariadne/pull/1160?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) Report
Attention: `24 lines` in your changes are missing coverage. Please review.
> Comparison is base [(`1698ed5`)](https://app.codecov.io/gh/mirumee/ariadne/commit/1698ed5ff67a98df71b83f0546e7ab33c32d7b18?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) 97.88% compared to head [(`30cedbd`)](https://app.codecov.io/gh/mirumee/ariadne/pull/1160?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) 97.56%.
> Report is 5 commits behind head on main.

| [Files](https://app.codecov.io/gh/mirumee/ariadne/pull/1160?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) | Patch % | Lines |
|---|---|---|
| [ariadne/asgi/handlers/http.py](https://app.codecov.io/gh/mirumee/ariadne/pull/1160?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee#diff-YXJpYWRuZS9hc2dpL2hhbmRsZXJzL2h0dHAucHk=) | 50.00% | [12 Missing :warning: ](https://app.codecov.io/gh/mirumee/ariadne/pull/1160?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) |
| [ariadne/graphql.py](https://app.codecov.io/gh/mirumee/ariadne/pull/1160?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee#diff-YXJpYWRuZS9ncmFwaHFsLnB5) | 14.28% | [12 Missing :warning: ](https://app.codecov.io/gh/mirumee/ariadne/pull/1160?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #1160      +/-   ##
==========================================
- Coverage   97.88%   97.56%   -0.32%     
==========================================
  Files         109      109              
  Lines        7905     7938      +33     
==========================================
+ Hits         7738     7745       +7     
- Misses        167      193      +26     
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/mirumee/ariadne/pull/1160?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=mirumee).


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 84039c4..829c70d 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # CHANGELOG
 
+## 0.23 (UNRELEASED)
+
+- Added `execute_get_queries` setting to the `GraphQL` apps that controls execution of the GraphQL "query" operations made with GET requests. Defaults to `False`.
+- Added support for the Apollo Federation versions up to 2.6.
+
+
 ## 0.22 (2024-01-31)
 
 - Deprecated `EnumType.bind_to_default_values` method. It will be removed in a future release.
diff --git a/ariadne/asgi/graphql.py b/ariadne/asgi/graphql.py
index 8114a9d..797ccb1 100644
--- a/ariadne/asgi/graphql.py
+++ b/ariadne/asgi/graphql.py
@@ -39,6 +39,7 @@ class GraphQL:
         query_parser: Optional[QueryParser] = None,
         query_validator: Optional[QueryValidator] = None,
         validation_rules: Optional[ValidationRules] = None,
+        execute_get_queries: bool = False,
         debug: bool = False,
         introspection: bool = True,
         explorer: Optional[Explorer] = None,
@@ -73,6 +74,9 @@ class GraphQL:
         list of extra validation rules server should use to validate the
         GraphQL queries. Defaults to `None`.
 
+        `execute_get_queries`: a `bool` that controls if `query` operations
+        sent using the `GET` method should be executed. Defaults to `False`.
+
         `debug`: a `bool` controlling in server should run in debug mode or
         not. Controls details included in error data returned to clients.
         Defaults to `False`.
@@ -126,6 +130,7 @@ class GraphQL:
             query_parser,
             query_validator,
             validation_rules,
+            execute_get_queries,
             debug,
             introspection,
             explorer,
@@ -140,6 +145,7 @@ class GraphQL:
             query_parser,
             query_validator,
             validation_rules,
+            execute_get_queries,
             debug,
             introspection,
             explorer,
diff --git a/ariadne/asgi/handlers/base.py b/ariadne/asgi/handlers/base.py
index 3d626dd..18f5fb5 100644
--- a/ariadne/asgi/handlers/base.py
+++ b/ariadne/asgi/handlers/base.py
@@ -40,6 +40,7 @@ class GraphQLHandler(ABC):
         self.query_parser: Optional[QueryParser] = None
         self.query_validator: Optional[QueryValidator] = None
         self.validation_rules: Optional[ValidationRules] = None
+        self.execute_get_queries: bool = False
         self.execution_context_class: Optional[Type[ExecutionContext]] = None
         self.middleware_manager_class: Optional[Type[MiddlewareManager]] = None
 
@@ -79,6 +80,7 @@ class GraphQLHandler(ABC):
         query_parser: Optional[QueryParser] = None,
         query_validator: Optional[QueryValidator] = None,
         validation_rules: Optional[ValidationRules] = None,
+        execute_get_queries: bool = False,
         debug: bool = False,
         introspection: bool = True,
         explorer: Optional[Explorer] = None,
@@ -94,6 +96,7 @@ class GraphQLHandler(ABC):
         self.context_value = context_value
         self.debug = debug
         self.error_formatter = error_formatter
+        self.execute_get_queries = execute_get_queries
         self.execution_context_class = execution_context_class
         self.introspection = introspection
         self.explorer = explorer
diff --git a/ariadne/asgi/handlers/http.py b/ariadne/asgi/handlers/http.py
index 8b49802..b00ee11 100644
--- a/ariadne/asgi/handlers/http.py
+++ b/ariadne/asgi/handlers/http.py
@@ -1,6 +1,6 @@
 import json
 from inspect import isawaitable
-from typing import Any, Optional, Type, cast
+from typing import Any, Optional, Type, Union, cast
 
 from graphql import DocumentNode, MiddlewareManager
 from starlette.datastructures import UploadFile
@@ -114,9 +114,12 @@ class GraphQLHTTPHandler(GraphQLHttpHandlerBase):
 
         `request`: the `Request` instance from Starlette or FastAPI.
         """
-        if request.method == "GET" and self.introspection and self.explorer:
-            # only render explorer when introspection is enabled
-            return await self.render_explorer(request, self.explorer)
+        if request.method == "GET":
+            if self.execute_get_queries and request.query_params.get("query"):
+                return await self.graphql_http_server(request)
+            if self.introspection and self.explorer:
+                # only render explorer when introspection is enabled
+                return await self.render_explorer(request, self.explorer)
 
         if request.method == "POST":
             return await self.graphql_http_server(request)
@@ -182,6 +185,12 @@ class GraphQLHTTPHandler(GraphQLHttpHandlerBase):
             return await self.extract_data_from_json_request(request)
         if content_type == DATA_TYPE_MULTIPART:
             return await self.extract_data_from_multipart_request(request)
+        if (
+            request.method == "GET"
+            and self.execute_get_queries
+            and request.query_params.get("query")
+        ):
+            return self.extract_data_from_get_request(request)
 
         raise HttpBadRequestError(
             "Posted content must be of type {} or {}".format(
@@ -189,7 +198,7 @@ class GraphQLHTTPHandler(GraphQLHttpHandlerBase):
             )
         )
 
-    async def extract_data_from_json_request(self, request: Request):
+    async def extract_data_from_json_request(self, request: Request) -> dict:
         """Extracts GraphQL data from JSON request.
 
         Returns a `dict` with GraphQL query data that was not yet validated.
@@ -203,7 +212,9 @@ class GraphQLHTTPHandler(GraphQLHttpHandlerBase):
         except (TypeError, ValueError) as ex:
             raise HttpBadRequestError("Request body is not a valid JSON") from ex
 
-    async def extract_data_from_multipart_request(self, request: Request):
+    async def extract_data_from_multipart_request(
+        self, request: Request
+    ) -> Union[dict, list]:
         """Extracts GraphQL data from `multipart/form-data` request.
 
         Returns an unvalidated `dict` with GraphQL query data.
@@ -240,6 +251,35 @@ class GraphQLHTTPHandler(GraphQLHttpHandlerBase):
 
         return combine_multipart_data(operations, files_map, request_files)
 
+    def extract_data_from_get_request(self, request: Request) -> dict:
+        """Extracts GraphQL data from GET request's querystring.
+
+        Returns a `dict` with GraphQL query data that was not yet validated.
+
+        # Required arguments
+
+        `request`: the `Request` instance from Starlette or FastAPI.
+        """
+        query = request.query_params["query"].strip()
+        operation_name = request.query_params.get("operationName", "").strip()
+        variables = request.query_params.get("variables", "").strip()
+
+        clean_variables = None
+
+        if variables:
+            try:
+                clean_variables = json.loads(variables)
+            except (TypeError, ValueError) as ex:
+                raise HttpBadRequestError(
+                    "Variables query arg is not a valid JSON"
+                ) from ex
+
+        return {
+            "query": query,
+            "operationName": operation_name or None,
+            "variables": clean_variables,
+        }
+
     async def execute_graphql_query(
         self,
         request: Any,
@@ -275,6 +315,11 @@ class GraphQLHTTPHandler(GraphQLHttpHandlerBase):
         if self.schema is None:
             raise TypeError("schema is not set, call configure method to initialize it")
 
+        if isinstance(request, Request):
+            require_query = request.method == "GET"
+        else:
+            require_query = False
+
         return await graphql(
             self.schema,
             data,
@@ -284,6 +329,7 @@ class GraphQLHTTPHandler(GraphQLHttpHandlerBase):
             query_validator=self.query_validator,
             query_document=query_document,
             validation_rules=self.validation_rules,
+            require_query=require_query,
             debug=self.debug,
             introspection=self.introspection,
             logger=self.logger,
diff --git a/ariadne/graphql.py b/ariadne/graphql.py
index f3d25a7..36e4498 100644
--- a/ariadne/graphql.py
+++ b/ariadne/graphql.py
@@ -22,6 +22,7 @@ from graphql import (
     GraphQLError,
     GraphQLSchema,
     MiddlewareManager,
+    OperationDefinitionNode,
     TypeInfo,
     execute,
     execute_sync,
@@ -71,6 +72,7 @@ async def graphql(
     introspection: bool = True,
     logger: Union[None, str, Logger, LoggerAdapter] = None,
     validation_rules: Optional[ValidationRules] = None,
+    require_query: bool = False,
     error_formatter: ErrorFormatter = format_error,
     middleware: MiddlewareList = None,
     middleware_manager_class: Optional[Type[MiddlewareManager]] = None,
@@ -123,6 +125,9 @@ async def graphql(
     `validation_rules`: a `list` of or callable returning list of custom
     validation rules to use to validate query before it's executed.
 
+    `require_query`: a `bool` controlling if GraphQL operation to execute must be
+    a query (vs. mutation or subscription).
+
     `error_formatter`: an `ErrorFormatter` callable to use to convert GraphQL
     errors encountered during query execution to JSON-serializable format.
 
@@ -178,6 +183,9 @@ async def graphql(
                     extension_manager=extension_manager,
                 )
 
+            if require_query:
+                validate_operation_is_query(document, operation_name)
+
             if callable(root_value):
                 try:
                     root_value = root_value(  # type: ignore
@@ -237,6 +245,7 @@ def graphql_sync(
     introspection: bool = True,
     logger: Union[None, str, Logger, LoggerAdapter] = None,
     validation_rules: Optional[ValidationRules] = None,
+    require_query: bool = False,
     error_formatter: ErrorFormatter = format_error,
     middleware: MiddlewareList = None,
     middleware_manager_class: Optional[Type[MiddlewareManager]] = None,
@@ -289,6 +298,9 @@ def graphql_sync(
     `validation_rules`: a `list` of or callable returning list of custom
     validation rules to use to validate query before it's executed.
 
+    `require_query`: a `bool` controlling if GraphQL operation to execute must be
+    a query (vs. mutation or subscription).
+
     `error_formatter`: an `ErrorFormatter` callable to use to convert GraphQL
     errors encountered during query execution to JSON-serializable format.
 
@@ -344,6 +356,9 @@ def graphql_sync(
                     extension_manager=extension_manager,
                 )
 
+            if require_query:
+                validate_operation_is_query(document, operation_name)
+
             if callable(root_value):
                 try:
                     root_value = root_value(  # type: ignore
@@ -639,3 +654,29 @@ def validate_variables(variables) -> None:
 def validate_operation_name(operation_name) -> None:
     if operation_name is not None and not isinstance(operation_name, str):
         raise GraphQLError('"%s" is not a valid operation name.' % operation_name)
+
+
+def validate_operation_is_query(
+    document_ast: DocumentNode, operation_name: Optional[str]
+):
+    query_operations: List[Optional[str]] = []
+    for definition in document_ast.definitions:
+        if (
+            isinstance(definition, OperationDefinitionNode)
+            and definition.operation.name == "QUERY"
+        ):
+            if definition.name:
+                query_operations.append(definition.name.value)
+            else:
+                query_operations.append(None)
+
+    if operation_name:
+        if operation_name not in query_operations:
+            raise GraphQLError(
+                f"Operation '{operation_name}' is not defined or "
+                "is not of a 'query' type."
+            )
+    elif len(query_operations) != 1:
+        raise GraphQLError(
+            "'operationName' is required if 'query' defines multiple operations."
+        )
diff --git a/ariadne/wsgi.py b/ariadne/wsgi.py
index ead0bb4..b11b92c 100644
--- a/ariadne/wsgi.py
+++ b/ariadne/wsgi.py
@@ -1,6 +1,7 @@
 import json
 from inspect import isawaitable
 from typing import Any, Callable, Dict, List, Optional, Type, Union, cast
+from urllib.parse import parse_qsl
 
 from graphql import (
     ExecutionContext,
@@ -75,6 +76,7 @@ class GraphQL:
         explorer: Optional[Explorer] = None,
         logger: Optional[str] = None,
         error_formatter: ErrorFormatter = format_error,
+        execute_get_queries: bool = False,
         extensions: Optional[Extensions] = None,
         middleware: Optional[Middlewares] = None,
         middleware_manager_class: Optional[Type[MiddlewareManager]] = None,
@@ -125,6 +127,9 @@ class GraphQL:
         GraphQL errors returned to clients. If not set, default formatter
         implemented by Ariadne is used.
 
+        `execute_get_queries`: a `bool` that controls if `query` operations
+        sent using the `GET` method should be executed. Defaults to `False`.
+
         `extensions`: an `Extensions` list or callable returning a
         list of extensions server should use during query execution. Defaults
         to no extensions.
@@ -152,6 +157,7 @@ class GraphQL:
         self.introspection = introspection
         self.logger = logger
         self.error_formatter = error_formatter
+        self.execute_get_queries = execute_get_queries
         self.extensions = extensions
         self.middleware = middleware
         self.middleware_manager_class = middleware_manager_class or MiddlewareManager
@@ -234,7 +240,7 @@ class GraphQL:
 
         `start_response`: a callable used to begin new HTTP response.
         """
-        if environ["REQUEST_METHOD"] == "GET" and self.introspection:
+        if environ["REQUEST_METHOD"] == "GET":
             return self.handle_get(environ, start_response)
         if environ["REQUEST_METHOD"] == "POST":
             return self.handle_post(environ, start_response)
@@ -242,7 +248,62 @@ class GraphQL:
         return self.handle_not_allowed_method(environ, start_response)
 
     def handle_get(self, environ: dict, start_response) -> List[bytes]:
-        """Handles WSGI HTTP GET request and returns a a response to the client.
+        """Handles WSGI HTTP GET request and returns a response to the client.
+
+        Returns list of bytes with response body.
+
+        # Required arguments
+
+        `environ`: a WSGI environment dictionary.
+
+        `start_response`: a callable used to begin new HTTP response.
+        """
+        query_params = parse_query_string(environ)
+        if self.execute_get_queries and query_params and query_params.get("query"):
+            return self.handle_get_query(environ, start_response, query_params)
+        if self.introspection:
+            return self.handle_get_explorer(environ, start_response)
+
+        return self.handle_not_allowed_method(environ, start_response)
+
+    def handle_get_query(
+        self, environ: dict, start_response, query_params: dict
+    ) -> List[bytes]:
+        data = self.extract_data_from_get(query_params)
+        result = self.execute_query(environ, data)
+        return self.return_response_from_result(start_response, result)
+
+    def extract_data_from_get(self, query_params: dict) -> dict:
+        """Extracts GraphQL data from GET request's querystring.
+
+        Returns a `dict` with GraphQL query data that was not yet validated.
+
+        # Required arguments
+
+        `query_params`: a `dict` with parsed query string.
+        """
+        query = query_params["query"].strip()
+        operation_name = query_params.get("operationName", "").strip()
+        variables = query_params.get("variables", "").strip()
+
+        clean_variables = None
+
+        if variables:
+            try:
+                clean_variables = json.loads(variables)
+            except (TypeError, ValueError, json.JSONDecodeError) as ex:
+                raise HttpBadRequestError(
+                    "Variables query arg is not a valid JSON"
+                ) from ex
+
+        return {
+            "query": query,
+            "operationName": operation_name or None,
+            "variables": clean_variables,
+        }
+
+    def handle_get_explorer(self, environ: dict, start_response) -> List[bytes]:
+        """Handles WSGI HTTP GET explorer request and returns a response to the client.
 
         Returns list of bytes with response body.
 
@@ -413,6 +474,7 @@ class GraphQL:
             query_parser=self.query_parser,
             query_validator=self.query_validator,
             validation_rules=self.validation_rules,
+            require_query=environ["REQUEST_METHOD"] == "GET",
             debug=self.debug,
             introspection=self.introspection,
             logger=self.logger,
@@ -588,6 +650,17 @@ class GraphQLMiddleware:
         return self.graphql_app(environ, start_response)
 
 
+def parse_query_string(environ: dict) -> Optional[dict]:
+    query_string = environ.get("QUERY_STRING")
+    if not query_string:
+        return None
+
+    try:
+        return dict(parse_qsl(query_string))
+    except (TypeError, ValueError):
+        return None
+
+
 def parse_multipart_request(environ: dict) -> "FormData":
     content_type = environ.get("CONTENT_TYPE")
     headers = {"Content-Type": content_type}


Test patch:
diff --git a/tests/asgi/test_configuration.py b/tests/asgi/test_configuration.py
index eb7e1a8..e06e435 100644
--- a/tests/asgi/test_configuration.py
+++ b/tests/asgi/test_configuration.py
@@ -317,6 +317,92 @@ def test_custom_validation_rules_function_is_called_with_context_value(
     get_validation_rules.assert_called_once_with({"test": "TEST-CONTEXT"}, ANY, ANY)
 
 
+def test_query_over_get_is_executed_if_enabled(schema):
+    app = GraphQL(schema, execute_get_queries=True)
+    client = TestClient(app)
+    response = client.get("/?query={status}")
+    assert response.json() == {"data": {"status": True}}
+
+
+def test_query_over_get_is_executed_with_variables(schema):
+    app = GraphQL(schema, execute_get_queries=True)
+    client = TestClient(app)
+    response = client.get(
+        "/?query=query Hello($name:String) {hello(name: $name)}"
+        "&operationName=Hello"
+        '&variables={"name": "John"}'
+    )
+    assert response.json() == {"data": {"hello": "Hello, John!"}}
+
+
+def test_query_over_get_is_executed_without_operation_name(schema):
+    app = GraphQL(schema, execute_get_queries=True)
+    client = TestClient(app)
+    response = client.get(
+        "/?query=query Hello($name:String) {hello(name: $name)}"
+        '&variables={"name": "John"}'
+    )
+    assert response.json() == {"data": {"hello": "Hello, John!"}}
+
+
+def test_query_over_get_fails_if_operation_name_is_invalid(schema):
+    app = GraphQL(schema, execute_get_queries=True)
+    client = TestClient(app)
+    response = client.get(
+        "/?query=query Hello($name:String) {hello(name: $name)}"
+        "&operationName=Invalid"
+        '&variables={"name": "John"}'
+    )
+    assert response.json() == {
+        "errors": [
+            {
+                "message": (
+                    "Operation 'Invalid' is not defined or is not of a 'query' type."
+                )
+            }
+        ]
+    }
+
+
+def test_query_over_get_fails_if_operation_is_mutation(schema):
+    app = GraphQL(schema, execute_get_queries=True)
+    client = TestClient(app)
+    response = client.get(
+        "/?query=mutation Echo($text:String!) {echo(text: $text)}"
+        "&operationName=Echo"
+        '&variables={"text": "John"}'
+    )
+    assert response.json() == {
+        "errors": [
+            {
+                "message": (
+                    "Operation 'Echo' is not defined or is not of a 'query' type."
+                )
+            }
+        ]
+    }
+
+
+def test_query_over_get_fails_if_variables_are_not_json_serialized(schema):
+    app = GraphQL(schema, execute_get_queries=True)
+    client = TestClient(app)
+    response = client.get(
+        "/?query=query Hello($name:String) {hello(name: $name)}"
+        "&operationName=Hello"
+        '&variables={"name" "John"}'
+    )
+    assert response.status_code == 400
+    assert response.content == b"Variables query arg is not a valid JSON"
+
+
+def test_query_over_get_is_not_executed_if_not_enabled(schema):
+    app = GraphQL(schema, execute_get_queries=False)
+    client = TestClient(app)
+    response = client.get("/?query={ status }")
+    assert response.status_code == 200
+    assert response.headers["CONTENT-TYPE"] == "text/html; charset=utf-8"
+
+
 def execute_failing_query(app):
     client = TestClient(app)
     client.post("/", json={"query": "{ error }"})
diff --git a/tests/wsgi/test_configuration.py b/tests/wsgi/test_configuration.py
index 67f85e7..aaeb874 100644
--- a/tests/wsgi/test_configuration.py
+++ b/tests/wsgi/test_configuration.py
@@ -27,21 +27,27 @@ class TestClient(Client):
 
 def test_custom_context_value_is_passed_to_resolvers(schema):
     app = GraphQL(schema, context_value={"test": "TEST-CONTEXT"})
-    _, result = app.execute_query({}, {"query": "{ testContext }"})
+    _, result = app.execute_query(
+        {"REQUEST_METHOD": "POST"},
+        {"query": "{ testContext }"},
+    )
     assert result == {"data": {"testContext": "TEST-CONTEXT"}}
 
 
 def test_custom_context_value_function_is_set_and_called_by_app(schema):
     get_context_value = Mock(return_value=True)
     app = GraphQL(schema, context_value=get_context_value)
-    app.execute_query({}, {"query": "{ status }"})
+    app.execute_query(
+        {"REQUEST_METHOD": "POST"},
+        {"query": "{ status }"},
+    )
     get_context_value.assert_called_once()
 
 
 def test_custom_context_value_function_is_called_with_request_value(schema):
     get_context_value = Mock(return_value=True)
     app = GraphQL(schema, context_value=get_context_value)
-    request = {"CONTENT_TYPE": DATA_TYPE_JSON}
+    request = {"CONTENT_TYPE": DATA_TYPE_JSON, "REQUEST_METHOD": "POST"}
     app.execute_query(request, {"query": "{ status }"})
     get_context_value.assert_called_once_with(request, {"query": "{ status }"})
 
@@ -49,7 +55,10 @@ def test_custom_context_value_function_is_called_with_request_value(schema):
 def test_custom_context_value_function_result_is_passed_to_resolvers(schema):
     get_context_value = Mock(return_value={"test": "TEST-CONTEXT"})
     app = GraphQL(schema, context_value=get_context_value)
-    _, result = app.execute_query({}, {"query": "{ testContext }"})
+    _, result = app.execute_query(
+        {"REQUEST_METHOD": "POST"},
+        {"query": "{ testContext }"},
+    )
     assert result == {"data": {"testContext": "TEST-CONTEXT"}}
 
 
@@ -62,19 +71,28 @@ def test_warning_is_raised_if_custom_context_value_function_has_deprecated_signa
     app = GraphQL(schema, context_value=get_context_value)
 
     with pytest.deprecated_call():
-        app.execute_query({}, {"query": "{ status }"})
+        app.execute_query(
+            {"REQUEST_METHOD": "POST"},
+            {"query": "{ status }"},
+        )
 
 
 def test_custom_root_value_is_passed_to_resolvers(schema):
     app = GraphQL(schema, root_value={"test": "TEST-ROOT"})
-    _, result = app.execute_query({}, {"query": "{ testRoot }"})
+    _, result = app.execute_query(
+        {"REQUEST_METHOD": "POST"},
+        {"query": "{ testRoot }"},
+    )
     assert result == {"data": {"testRoot": "TEST-ROOT"}}
 
 
 def test_custom_root_value_function_is_set_and_called_by_app(schema):
     get_root_value = Mock(return_value=True)
     app = GraphQL(schema, root_value=get_root_value)
-    app.execute_query({}, {"query": "{ status }"})
+    app.execute_query(
+        {"REQUEST_METHOD": "POST"},
+        {"query": "{ status }"},
+    )
     get_root_value.assert_called_once()
 
 
@@ -83,7 +101,10 @@ def test_custom_root_value_function_is_called_with_context_value(schema):
     app = GraphQL(
         schema, context_value={"test": "TEST-CONTEXT"}, root_value=get_root_value
     )
-    app.execute_query({}, {"query": "{ status }"})
+    app.execute_query(
+        {"REQUEST_METHOD": "POST"},
+        {"query": "{ status }"},
+    )
     get_root_value.assert_called_once_with({"test": "TEST-CONTEXT"}, None, None, ANY)
 
 
@@ -98,13 +119,19 @@ def test_warning_is_raised_if_custom_root_value_function_has_deprecated_signatur
     )
 
     with pytest.deprecated_call():
-        app.execute_query({}, {"query": "{ status }"})
+        app.execute_query(
+            {"REQUEST_METHOD": "POST"},
+            {"query": "{ status }"},
+        )
 
 
 def test_custom_query_parser_is_used(schema):
     mock_parser = Mock(return_value=parse("{ status }"))
     app = GraphQL(schema, query_parser=mock_parser)
-    _, result = app.execute_query({}, {"query": "{ testContext }"})
+    _, result = app.execute_query(
+        {"REQUEST_METHOD": "POST"},
+        {"query": "{ testContext }"},
+    )
     assert result == {"data": {"status": True}}
     mock_parser.assert_called_once()
 
@@ -119,7 +146,10 @@ def test_custom_query_parser_is_used(schema):
 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 }"})
+    _, result = app.execute_query(
+        {"REQUEST_METHOD": "POST"},
+        {"query": "{ testContext }"},
+    )
     if errors:
         assert result == {"errors": [{"message": "Nope"}]}
     else:
@@ -132,7 +162,10 @@ def test_custom_validation_rule_is_called_by_query_validation(
 ):
     spy_validation_rule = mocker.spy(validation_rule, "__init__")
     app = GraphQL(schema, validation_rules=[validation_rule])
-    app.execute_query({}, {"query": "{ status }"})
+    app.execute_query(
+        {"REQUEST_METHOD": "POST"},
+        {"query": "{ status }"},
+    )
     spy_validation_rule.assert_called_once()
 
 
@@ -142,7 +175,10 @@ def test_custom_validation_rules_function_is_set_and_called_on_query_execution(
     spy_validation_rule = mocker.spy(validation_rule, "__init__")
     get_validation_rules = Mock(return_value=[validation_rule])
     app = GraphQL(schema, validation_rules=get_validation_rules)
-    app.execute_query({}, {"query": "{ status }"})
+    app.execute_query(
+        {"REQUEST_METHOD": "POST"},
+        {"query": "{ status }"},
+    )
     get_validation_rules.assert_called_once()
     spy_validation_rule.assert_called_once()
 
@@ -156,10 +192,159 @@ def test_custom_validation_rules_function_is_called_with_context_value(
         context_value={"test": "TEST-CONTEXT"},
         validation_rules=get_validation_rules,
     )
-    app.execute_query({}, {"query": "{ status }"})
+    app.execute_query(
+        {"REQUEST_METHOD": "POST"},
+        {"query": "{ status }"},
+    )
     get_validation_rules.assert_called_once_with({"test": "TEST-CONTEXT"}, ANY, ANY)
 
 
+def test_query_over_get_is_executed_if_enabled(schema):
+    send_response = Mock()
+    app = GraphQL(schema, execute_get_queries=True)
+    response = app(
+        {
+            "REQUEST_METHOD": "GET",
+            "QUERY_STRING": "query={status}",
+        },
+        send_response,
+    )
+    send_response.assert_called_once_with(
+        "200 OK", [("Content-Type", "application/json; charset=UTF-8")]
+    )
+    assert json.loads(response[0]) == {"data": {"status": True}}
+
+
+def test_query_over_get_is_executed_with_variables(schema):
+    send_response = Mock()
+    app = GraphQL(schema, execute_get_queries=True)
+    response = app(
+        {
+            "REQUEST_METHOD": "GET",
+            "QUERY_STRING": (
+                "query=query Hello($name:String) {hello(name: $name)}"
+                "&operationName=Hello"
+                '&variables={"name": "John"}'
+            ),
+        },
+        send_response,
+    )
+    send_response.assert_called_once_with(
+        "200 OK", [("Content-Type", "application/json; charset=UTF-8")]
+    )
+    assert json.loads(response[0]) == {"data": {"hello": "Hello, John!"}}
+
+
+def test_query_over_get_is_executed_without_operation_name(schema):
+    send_response = Mock()
+    app = GraphQL(schema, execute_get_queries=True)
+    response = app(
+        {
+            "REQUEST_METHOD": "GET",
+            "QUERY_STRING": (
+                "query=query Hello($name:String) {hello(name: $name)}"
+                '&variables={"name": "John"}'
+            ),
+        },
+        send_response,
+    )
+    send_response.assert_called_once_with(
+        "200 OK", [("Content-Type", "application/json; charset=UTF-8")]
+    )
+    assert json.loads(response[0]) == {"data": {"hello": "Hello, John!"}}
+
+
+def test_query_over_get_fails_if_operation_name_is_invalid(schema):
+    send_response = Mock()
+    app = GraphQL(schema, execute_get_queries=True)
+    response = app(
+        {
+            "REQUEST_METHOD": "GET",
+            "QUERY_STRING": (
+                "query=query Hello($name:String) {hello(name: $name)}"
+                "&operationName=Invalid"
+                '&variables={"name": "John"}'
+            ),
+        },
+        send_response,
+    )
+    send_response.assert_called_once_with(
+        "400 Bad Request", [("Content-Type", "application/json; charset=UTF-8")]
+    )
+    assert json.loads(response[0]) == {
+        "errors": [
+            {
+                "message": (
+                    "Operation 'Invalid' is not defined or is not of a 'query' type."
+                )
+            }
+        ]
+    }
+
+
+def test_query_over_get_fails_if_operation_is_mutation(schema):
+    send_response = Mock()
+    app = GraphQL(schema, execute_get_queries=True)
+    response = app(
+        {
+            "REQUEST_METHOD": "GET",
+            "QUERY_STRING": (
+                "query=mutation Echo($text:String!) {echo(text: $text)}"
+                "&operationName=Echo"
+                '&variables={"text": "John"}'
+            ),
+        },
+        send_response,
+    )
+    send_response.assert_called_once_with(
+        "400 Bad Request", [("Content-Type", "application/json; charset=UTF-8")]
+    )
+    assert json.loads(response[0]) == {
+        "errors": [
+            {
+                "message": (
+                    "Operation 'Echo' is not defined or is not of a 'query' type."
+                )
+            }
+        ]
+    }
+
+
+def test_query_over_get_fails_if_variables_are_not_json_serialized(schema):
+    send_response = Mock()
+    app = GraphQL(schema, execute_get_queries=True)
+    response = app(
+        {
+            "REQUEST_METHOD": "GET",
+            "QUERY_STRING": (
+                "query=query Hello($name:String) {hello(name: $name)}"
+                "&operationName=Hello"
+                '&variables={"name" "John"}'
+            ),
+        },
+        send_response,
+    )
+    send_response.assert_called_once_with(
+        "400 Bad Request", [("Content-Type", "text/plain; charset=UTF-8")]
+    )
+    assert response[0] == b"Variables query arg is not a valid JSON"
+
+
+def test_query_over_get_is_not_executed_if_not_enabled(schema):
+    send_response = Mock()
+    app = GraphQL(schema, execute_get_queries=False)
+    app.handle_request(
+        {
+            "REQUEST_METHOD": "GET",
+            "QUERY_STRING": "query={status}",
+        },
+        send_response,
+    )
+    send_response.assert_called_once_with(
+        "200 OK", [("Content-Type", "text/html; charset=UTF-8")]
+    )
+
+
 def execute_failing_query(app):
     data = json.dumps({"query": "{ error }"})
     app(
@@ -244,7 +429,10 @@ def middleware(next_fn, *args, **kwargs):
 
 def test_middlewares_are_passed_to_query_executor(schema):
     app = GraphQL(schema, middleware=[middleware])
-    _, result = app.execute_query({}, {"query": '{ hello(name: "BOB") }'})
+    _, result = app.execute_query(
+        {"REQUEST_METHOD": "POST"},
+        {"query": '{ hello(name: "BOB") }'},
+    )
     assert result == {"data": {"hello": "**Hello, BOB!**"}}
 
 
@@ -253,7 +441,10 @@ def test_middleware_function_result_is_passed_to_query_executor(schema):
         return [middleware]
 
     app = GraphQL(schema, middleware=get_middleware)
-    _, result = app.execute_query({}, {"query": '{ hello(name: "BOB") }'})
+    _, result = app.execute_query(
+        {"REQUEST_METHOD": "POST"},
+        {"query": '{ hello(name: "BOB") }'},
+    )
     assert result == {"data": {"hello": "**Hello, BOB!**"}}