diff --git a/CHANGELOG.md b/CHANGELOG.md index 84039c460..829c70dca 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 8114a9d7f..797ccb107 100644 --- a/ariadne/asgi/graphql.py +++ b/ariadne/asgi/graphql.py @@ -39,6 +39,7 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( query_parser, query_validator, validation_rules, + execute_get_queries, debug, introspection, explorer, @@ -140,6 +145,7 @@ def __init__( 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 3d626ddc4..18f5fb554 100644 --- a/ariadne/asgi/handlers/base.py +++ b/ariadne/asgi/handlers/base.py @@ -40,6 +40,7 @@ def __init__(self) -> None: 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 @@ def configure( 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 @@ def configure( 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 8b49802b2..b00ee114e 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 @@ async def handle_request(self, request: Request) -> Response: `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 @@ async def extract_data_from_request(self, request: Request): 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 @@ async def extract_data_from_request(self, request: Request): ) ) - 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 @@ async def extract_data_from_json_request(self, request: Request): 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 @@ async def extract_data_from_multipart_request(self, request: Request): 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 @@ async def execute_graphql_query( 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 @@ async def execute_graphql_query( 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 f3d25a733..36e4498c6 100644 --- a/ariadne/graphql.py +++ b/ariadne/graphql.py @@ -22,6 +22,7 @@ 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 ead0bb46e..b11b92cc5 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 @@ def __init__( 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 @@ def __init__( 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 @@ def __init__( 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 @@ def handle_request(self, environ: dict, start_response: Callable) -> List[bytes] `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 @@ def handle_request(self, environ: dict, start_response: Callable) -> List[bytes] 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 @@ def execute_query(self, environ: dict, data: dict) -> GraphQLResult: 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 @@ def __call__(self, environ: dict, start_response: Callable) -> List[bytes]: 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} diff --git a/tests/asgi/test_configuration.py b/tests/asgi/test_configuration.py index eb7e1a8ed..e06e435fa 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 67f85e75e..aaeb874a2 100644 --- a/tests/wsgi/test_configuration.py +++ b/tests/wsgi/test_configuration.py @@ -27,21 +27,27 @@ def __init__(self, app): 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 get_context_value(request): 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 get_root_value(_context, _document): ) 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 get_middleware(*_): 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!**"}}