diff --git a/graphql_server/sanic/__init__.py b/graphql_server/sanic/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/sanic/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py new file mode 100644 index 0000000..fd22af2 --- /dev/null +++ b/graphql_server/sanic/graphqlview.py @@ -0,0 +1,190 @@ +import copy +from cgi import parse_header +from collections.abc import MutableMapping +from functools import partial + +from graphql import GraphQLError +from graphql.type.schema import GraphQLSchema +from sanic.response import HTTPResponse +from sanic.views import HTTPMethodView + +from graphql_server import ( + HttpQueryError, + encode_execution_results, + format_error_default, + json_encode, + load_json_body, + run_http_query, +) + +from .render_graphiql import render_graphiql + + +class GraphQLView(HTTPMethodView): + schema = None + root_value = None + context = None + pretty = False + graphiql = False + graphiql_version = None + graphiql_template = None + middleware = None + batch = False + jinja_env = None + max_age = 86400 + enable_async = False + + methods = ["GET", "POST", "PUT", "DELETE"] + + def __init__(self, **kwargs): + super(GraphQLView, self).__init__() + for key, value in kwargs.items(): + if hasattr(self, key): + setattr(self, key, value) + + assert isinstance( + self.schema, GraphQLSchema + ), "A Schema is required to be provided to GraphQLView." + + def get_root_value(self): + return self.root_value + + def get_context(self, request): + context = ( + copy.copy(self.context) + if self.context and isinstance(self.context, MutableMapping) + else {} + ) + if isinstance(context, MutableMapping) and "request" not in context: + context.update({"request": request}) + return context + + def get_middleware(self): + return self.middleware + + async def render_graphiql(self, params, result): + return await render_graphiql( + jinja_env=self.jinja_env, + params=params, + result=result, + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + ) + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + async def dispatch_request(self, request, *args, **kwargs): + try: + request_method = request.method.lower() + data = self.parse_body(request) + + show_graphiql = request_method == "get" and self.should_display_graphiql( + request + ) + catch = show_graphiql + + pretty = self.pretty or show_graphiql or request.args.get("pretty") + + if request_method != "options": + execution_results, all_params = run_http_query( + self.schema, + request_method, + data, + query_data=request.args, + batch_enabled=self.batch, + catch=catch, + # Execute options + run_sync=not self.enable_async, + root_value=self.get_root_value(), + context_value=self.get_context(request), + middleware=self.get_middleware(), + ) + exec_res = ( + [await ex for ex in execution_results] + if self.enable_async + else execution_results + ) + result, status_code = encode_execution_results( + exec_res, + is_batch=isinstance(data, list), + format_error=self.format_error, + encode=partial(self.encode, pretty=pretty), # noqa: ignore + ) + + if show_graphiql: + return await self.render_graphiql( + params=all_params[0], result=result + ) + + return HTTPResponse( + result, status=status_code, content_type="application/json" + ) + + else: + return self.process_preflight(request) + + except HttpQueryError as e: + parsed_error = GraphQLError(e.message) + return HTTPResponse( + self.encode(dict(errors=[self.format_error(parsed_error)])), + status=e.status_code, + headers=e.headers, + content_type="application/json", + ) + + # noinspection PyBroadException + def parse_body(self, request): + content_type = self.get_mime_type(request) + if content_type == "application/graphql": + return {"query": request.body.decode("utf8")} + + elif content_type == "application/json": + return load_json_body(request.body.decode("utf8")) + + elif content_type in ( + "application/x-www-form-urlencoded", + "multipart/form-data", + ): + return request.form + + return {} + + @staticmethod + def get_mime_type(request): + # We use mime type here since we don't need the other + # information provided by content_type + if "content-type" not in request.headers: + return None + + mime_type, _ = parse_header(request.headers["content-type"]) + return mime_type + + def should_display_graphiql(self, request): + if not self.graphiql or "raw" in request.args: + return False + + return self.request_wants_html(request) + + @staticmethod + def request_wants_html(request): + accept = request.headers.get("accept", {}) + return "text/html" in accept or "*/*" in accept + + def process_preflight(self, request): + """ Preflight request support for apollo-client + https://www.w3.org/TR/cors/#resource-preflight-requests """ + origin = request.headers.get("Origin", "") + method = request.headers.get("Access-Control-Request-Method", "").upper() + + if method and method in self.methods: + return HTTPResponse( + status=200, + headers={ + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": ", ".join(self.methods), + "Access-Control-Max-Age": str(self.max_age), + }, + ) + else: + return HTTPResponse(status=400) diff --git a/graphql_server/sanic/render_graphiql.py b/graphql_server/sanic/render_graphiql.py new file mode 100644 index 0000000..ca21ee3 --- /dev/null +++ b/graphql_server/sanic/render_graphiql.py @@ -0,0 +1,185 @@ +import json +import re + +from sanic.response import html + +GRAPHIQL_VERSION = "0.7.1" + +TEMPLATE = """ + + + + + + + + + + + + + + +""" + + +def escape_js_value(value): + quotation = False + if value.startswith('"') and value.endswith('"'): + quotation = True + value = value[1 : len(value) - 1] + + value = value.replace("\\\\n", "\\\\\\n").replace("\\n", "\\\\n") + if quotation: + value = '"' + value.replace('\\\\"', '"').replace('"', '\\"') + '"' + + return value + + +def process_var(template, name, value, jsonify=False): + pattern = r"{{\s*" + name + r"(\s*|[^}]+)*\s*}}" + if jsonify and value not in ["null", "undefined"]: + value = json.dumps(value) + value = escape_js_value(value) + + return re.sub(pattern, value, template) + + +def simple_renderer(template, **values): + replace = ["graphiql_version"] + replace_jsonify = ["query", "result", "variables", "operation_name"] + + for r in replace: + template = process_var(template, r, values.get(r, "")) + + for r in replace_jsonify: + template = process_var(template, r, values.get(r, ""), True) + + return template + + +async def render_graphiql( + jinja_env=None, + graphiql_version=None, + graphiql_template=None, + params=None, + result=None, +): + graphiql_version = graphiql_version or GRAPHIQL_VERSION + template = graphiql_template or TEMPLATE + template_vars = { + "graphiql_version": graphiql_version, + "query": params and params.query, + "variables": params and params.variables, + "operation_name": params and params.operation_name, + "result": result, + } + + if jinja_env: + template = jinja_env.from_string(template) + if jinja_env.is_async: + source = await template.render_async(**template_vars) + else: + source = template.render(**template_vars) + else: + source = simple_renderer(template, **template_vars) + + return html(source) diff --git a/setup.cfg b/setup.cfg index 78bddbd..b943008 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,6 +1,7 @@ [flake8] exclude = docs max-line-length = 88 +ignore = E203, E501, W503 [isort] known_first_party=graphql_server diff --git a/setup.py b/setup.py index 15397cc..fbf8637 100644 --- a/setup.py +++ b/setup.py @@ -7,6 +7,8 @@ tests_requires = [ "pytest>=5.3,<5.4", "pytest-cov>=2.8,<3", + "aiohttp>=3.5.0,<4", + "Jinja2>=2.10.1,<3", ] dev_requires = [ @@ -21,7 +23,14 @@ "flask>=0.7.0", ] -install_all_requires = install_requires + install_flask_requires +install_sanic_requires = [ + "sanic>=19.9.0,<20", +] + +install_all_requires = \ + install_requires + \ + install_flask_requires + \ + install_sanic_requires setup( name="graphql-server-core", @@ -52,6 +61,7 @@ "test": install_all_requires + tests_requires, "dev": install_all_requires + dev_requires, "flask": install_flask_requires, + "sanic": install_sanic_requires, }, include_package_data=True, zip_safe=False, diff --git a/tests/sanic/__init__.py b/tests/sanic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/sanic/app.py b/tests/sanic/app.py new file mode 100644 index 0000000..f5a74cf --- /dev/null +++ b/tests/sanic/app.py @@ -0,0 +1,28 @@ +from urllib.parse import urlencode + +from sanic import Sanic +from sanic.testing import SanicTestClient + +from graphql_server.sanic import GraphQLView + +from .schema import Schema + + +def create_app(path="/graphql", **kwargs): + app = Sanic(__name__) + app.debug = True + + schema = kwargs.pop("schema", None) or Schema + app.add_route(GraphQLView.as_view(schema=schema, **kwargs), path) + + app.client = SanicTestClient(app) + return app + + +def url_string(uri="/graphql", **url_params): + string = "/graphql" + + if url_params: + string += "?" + urlencode(url_params) + + return string diff --git a/tests/sanic/schema.py b/tests/sanic/schema.py new file mode 100644 index 0000000..a129d92 --- /dev/null +++ b/tests/sanic/schema.py @@ -0,0 +1,72 @@ +import asyncio + +from graphql.type.definition import ( + GraphQLArgument, + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, +) +from graphql.type.scalars import GraphQLString +from graphql.type.schema import GraphQLSchema + + +def resolve_raises(*_): + raise Exception("Throws!") + + +# Sync schema +QueryRootType = GraphQLObjectType( + name="QueryRoot", + fields={ + "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"].args.get("q"), + ), + "context": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + "test": GraphQLField( + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who=None: "Hello %s" % (who or "World"), + ), + }, +) + +MutationRootType = GraphQLObjectType( + name="MutationRoot", + fields={ + "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) + }, +) + +Schema = GraphQLSchema(QueryRootType, MutationRootType) + + +# Schema with async methods +async def resolver_field_async_1(_obj, info): + await asyncio.sleep(0.001) + return "hey" + + +async def resolver_field_async_2(_obj, info): + await asyncio.sleep(0.003) + return "hey2" + + +def resolver_field_sync(_obj, info): + return "hey3" + + +AsyncQueryType = GraphQLObjectType( + name="AsyncQueryType", + fields={ + "a": GraphQLField(GraphQLString, resolve=resolver_field_async_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_async_2), + "c": GraphQLField(GraphQLString, resolve=resolver_field_sync), + }, +) + +AsyncSchema = GraphQLSchema(AsyncQueryType) diff --git a/tests/sanic/test_graphiqlview.py b/tests/sanic/test_graphiqlview.py new file mode 100644 index 0000000..60ecc75 --- /dev/null +++ b/tests/sanic/test_graphiqlview.py @@ -0,0 +1,88 @@ +import pytest +from jinja2 import Environment + +from .app import create_app, url_string +from .schema import AsyncSchema + + +@pytest.fixture +def pretty_response(): + return ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) + + +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +def test_graphiql_is_enabled(app): + _, response = app.client.get( + uri=url_string(query="{test}"), headers={"Accept": "text/html"} + ) + assert response.status == 200 + + +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +def test_graphiql_simple_renderer(app, pretty_response): + _, response = app.client.get( + uri=url_string(query="{test}"), headers={"Accept": "text/html"} + ) + assert response.status == 200 + assert pretty_response in response.body.decode("utf-8") + + +@pytest.mark.parametrize("app", [create_app(graphiql=True, jinja_env=Environment())]) +def test_graphiql_jinja_renderer(app, pretty_response): + _, response = app.client.get( + uri=url_string(query="{test}"), headers={"Accept": "text/html"} + ) + assert response.status == 200 + assert pretty_response in response.body.decode("utf-8") + + +@pytest.mark.parametrize( + "app", [create_app(graphiql=True, jinja_env=Environment(enable_async=True))] +) +def test_graphiql_jinja_async_renderer(app, pretty_response): + _, response = app.client.get( + uri=url_string(query="{test}"), headers={"Accept": "text/html"} + ) + assert response.status == 200 + assert pretty_response in response.body.decode("utf-8") + + +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +def test_graphiql_html_is_not_accepted(app): + _, response = app.client.get( + uri=url_string(), headers={"Accept": "application/json"} + ) + assert response.status == 400 + + +@pytest.mark.parametrize( + "app", [create_app(graphiql=True, schema=AsyncSchema, enable_async=True)] +) +def test_graphiql_asyncio_schema(app): + query = "{a,b,c}" + _, response = app.client.get( + uri=url_string(query=query), headers={"Accept": "text/html"} + ) + + expected_response = ( + ( + "{\n" + ' "data": {\n' + ' "a": "hey",\n' + ' "b": "hey2",\n' + ' "c": "hey3"\n' + " }\n" + "}" + ) + .replace('"', '\\"') + .replace("\n", "\\n") + ) + + assert response.status == 200 + assert expected_response in response.body.decode("utf-8") diff --git a/tests/sanic/test_graphqlview.py b/tests/sanic/test_graphqlview.py new file mode 100644 index 0000000..7325e6d --- /dev/null +++ b/tests/sanic/test_graphqlview.py @@ -0,0 +1,610 @@ +import json +from urllib.parse import urlencode + +import pytest + +from .app import create_app, url_string +from .schema import AsyncSchema + + +def response_json(response): + return json.loads(response.body.decode()) + + +def json_dump_kwarg(**kwargs): + return json.dumps(kwargs) + + +def json_dump_kwarg_list(**kwargs): + return json.dumps([kwargs]) + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_get_with_query_param(app): + _, response = app.client.get(uri=url_string(query="{test}")) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_get_with_variable_values(app): + _, response = app.client.get( + uri=url_string( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_get_with_operation_name(app): + _, response = app.client.get( + uri=url_string( + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ) + ) + + assert response.status == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_reports_validation_errors(app): + _, response = app.client.get( + uri=url_string(query="{ test, unknownOne, unknownTwo }") + ) + + assert response.status == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 9}], + "path": None, + }, + { + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 21}], + "path": None, + }, + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_errors_when_missing_operation_name(app): + _, response = app.client.get( + uri=url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """ + ) + ) + + assert response.status == 400 + assert response_json(response) == { + "errors": [ + { + "locations": None, + "message": "Must provide operation name if query contains multiple operations.", + "path": None, + } + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_errors_when_sending_a_mutation_via_get(app): + _, response = app.client.get( + uri=url_string( + query=""" + mutation TestMutation { writeTest { test } } + """ + ) + ) + assert response.status == 405 + assert response_json(response) == { + "errors": [ + { + "locations": None, + "message": "Can only perform a mutation operation from a POST request.", + "path": None, + } + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_errors_when_selecting_a_mutation_within_a_get(app): + _, response = app.client.get( + uri=url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestMutation", + ) + ) + + assert response.status == 405 + assert response_json(response) == { + "errors": [ + { + "locations": None, + "message": "Can only perform a mutation operation from a POST request.", + "path": None, + } + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_mutation_to_exist_within_a_get(app): + _, response = app.client.get( + uri=url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestQuery", + ) + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_post_with_json_encoding(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg(query="{test}"), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_sending_a_mutation_via_post(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_post_with_url_encoding(app): + # Example of how sanic does send data using url enconding + # can be found at their repo. + # https://github.com/huge-success/sanic/blob/master/tests/test_requests.py#L927 + payload = "query={test}" + _, response = app.client.post( + uri=url_string(), + data=payload, + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_supports_post_json_query_with_string_variables(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_supports_post_json_query_with_json_variables(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_supports_post_url_encoded_query_with_string_variables(app): + _, response = app.client.post( + uri=url_string(), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_supports_post_json_query_with_get_variable_values(app): + _, response = app.client.post( + uri=url_string(variables=json.dumps({"who": "Dolly"})), + data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_post_url_encoded_query_with_get_variable_values(app): + _, response = app.client.post( + uri=url_string(variables=json.dumps({"who": "Dolly"})), + data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_supports_post_raw_text_query_with_get_variable_values(app): + _, response = app.client.post( + uri=url_string(variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + headers={"content-type": "application/graphql"}, + ) + + assert response.status == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_post_with_operation_name(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg( + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_allows_post_with_get_operation_name(app): + _, response = app.client.post( + uri=url_string(operationName="helloWorld"), + data=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + headers={"content-type": "application/graphql"}, + ) + + assert response.status == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.parametrize("app", [create_app(pretty=True)]) +def test_supports_pretty_printing(app): + _, response = app.client.get(uri=url_string(query="{test}")) + + assert response.body.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +@pytest.mark.parametrize("app", [create_app(pretty=False)]) +def test_not_pretty_by_default(app): + _, response = app.client.get(url_string(query="{test}")) + + assert response.body.decode() == '{"data":{"test":"Hello World"}}' + + +@pytest.mark.parametrize("app", [create_app()]) +def test_supports_pretty_printing_by_request(app): + _, response = app.client.get(uri=url_string(query="{test}", pretty="1")) + + assert response.body.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_field_errors_caught_by_graphql(app): + _, response = app.client.get(uri=url_string(query="{thrower}")) + assert response.status == 200 + assert response_json(response) == { + "data": None, + "errors": [ + { + "locations": [{"column": 2, "line": 1}], + "message": "Throws!", + "path": ["thrower"], + } + ], + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_syntax_errors_caught_by_graphql(app): + _, response = app.client.get(uri=url_string(query="syntaxerror")) + assert response.status == 400 + assert response_json(response) == { + "errors": [ + { + "locations": [{"column": 1, "line": 1}], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + "path": None, + } + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_errors_caused_by_a_lack_of_query(app): + _, response = app.client.get(uri=url_string()) + + assert response.status == 400 + assert response_json(response) == { + "errors": [ + {"locations": None, "message": "Must provide query string.", "path": None} + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_batch_correctly_if_is_disabled(app): + _, response = app.client.post( + uri=url_string(), data="[]", headers={"content-type": "application/json"} + ) + + assert response.status == 400 + assert response_json(response) == { + "errors": [ + { + "locations": None, + "message": "Batch GraphQL requests are not enabled.", + "path": None, + } + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_incomplete_json_bodies(app): + _, response = app.client.post( + uri=url_string(), data='{"query":', headers={"content-type": "application/json"} + ) + + assert response.status == 400 + assert response_json(response) == { + "errors": [ + {"locations": None, "message": "POST body sent invalid JSON.", "path": None} + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_plain_post_text(app): + _, response = app.client.post( + uri=url_string(variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + headers={"content-type": "text/plain"}, + ) + assert response.status == 400 + assert response_json(response) == { + "errors": [ + {"locations": None, "message": "Must provide query string.", "path": None} + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_poorly_formed_variables(app): + _, response = app.client.get( + uri=url_string( + query="query helloWho($who: String){ test(who: $who) }", variables="who:You" + ) + ) + assert response.status == 400 + assert response_json(response) == { + "errors": [ + {"locations": None, "message": "Variables are invalid JSON.", "path": None} + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_handles_unsupported_http_methods(app): + _, response = app.client.put(uri=url_string(query="{test}")) + assert response.status == 405 + assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] + assert response_json(response) == { + "errors": [ + { + "locations": None, + "message": "GraphQL only supports GET and POST requests.", + "path": None, + } + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +def test_passes_request_into_request_context(app): + _, response = app.client.get(uri=url_string(query="{request}", q="testing")) + + assert response.status == 200 + assert response_json(response) == {"data": {"request": "testing"}} + + +@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) +def test_supports_pretty_printing_on_custom_context_response(app): + _, response = app.client.get(uri=url_string(query="{context}")) + + assert response.status == 200 + assert "data" in response_json(response) + assert response_json(response)["data"]["context"] == "" + + +@pytest.mark.parametrize("app", [create_app()]) +def test_post_multipart_data(app): + query = "mutation TestMutation { writeTest { test } }" + + data = ( + "------sanicgraphql\r\n" + + 'Content-Disposition: form-data; name="query"\r\n' + + "\r\n" + + query + + "\r\n" + + "------sanicgraphql--\r\n" + + "Content-Type: text/plain; charset=utf-8\r\n" + + 'Content-Disposition: form-data; name="file"; filename="text1.txt"; filename*=utf-8\'\'text1.txt\r\n' + + "\r\n" + + "\r\n" + + "------sanicgraphql--\r\n" + ) + + _, response = app.client.post( + uri=url_string(), + data=data, + headers={"content-type": "multipart/form-data; boundary=----sanicgraphql"}, + ) + + assert response.status == 200 + assert response_json(response) == { + "data": {u"writeTest": {u"test": u"Hello World"}} + } + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_allows_post_with_json_encoding(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg_list(id=1, query="{test}"), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == [{"data": {"test": "Hello World"}}] + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_supports_post_json_query_with_json_variables(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg_list( + id=1, + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == [{"data": {"test": "Hello Dolly"}}] + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_allows_post_with_operation_name(app): + _, response = app.client.post( + uri=url_string(), + data=json_dump_kwarg_list( + id=1, + query=""" + query helloYou { test(who: "You"), ...shared } + query helloWorld { test(who: "World"), ...shared } + query helloDolly { test(who: "Dolly"), ...shared } + fragment shared on QueryRoot { + shared: test(who: "Everyone") + } + """, + operationName="helloWorld", + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert response_json(response) == [ + {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + ] + + +@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) +def test_async_schema(app): + query = "{a,b,c}" + _, response = app.client.get(uri=url_string(query=query)) + + assert response.status == 200 + assert response_json(response) == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} + + +@pytest.mark.parametrize("app", [create_app()]) +def test_preflight_request(app): + _, response = app.client.options( + uri=url_string(), headers={"Access-Control-Request-Method": "POST"} + ) + + assert response.status == 200 + + +@pytest.mark.parametrize("app", [create_app()]) +def test_preflight_incorrect_request(app): + _, response = app.client.options( + uri=url_string(), headers={"Access-Control-Request-Method": "OPTIONS"} + ) + + assert response.status == 400