diff --git a/.gitignore b/.gitignore index 608847c..1789e38 100644 --- a/.gitignore +++ b/.gitignore @@ -1,19 +1,203 @@ -*.pyc -*.pyo +# Created by https://www.gitignore.io/api/python,intellij+all,visualstudiocode +# Edit at https://www.gitignore.io/?templates=python,intellij+all,visualstudiocode + +### Intellij+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### Intellij+all Patch ### +# Ignores the whole .idea folder and all .iml files +# See https://github.com/joeblau/gitignore.io/issues/186 and https://github.com/joeblau/gitignore.io/issues/360 + +.idea/ + +# Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-249601023 + +*.iml +modules.xml +.idea/misc.xml +*.ipr + +# Sonarlint plugin +.idea/sonarlint + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg *.egg -*.egg-info +MANIFEST -.cache +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ .coverage -.idea -.mypy_cache -.pytest_cache -.tox -.venv +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +### VisualStudioCode ### .vscode -/build/ -/dist/ +### VisualStudioCode Patch ### +# Ignore all local history of files +.history -docs +# End of https://www.gitignore.io/api/python,intellij+all,visualstudiocode diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 29efffa..c4685c0 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -30,6 +30,7 @@ "GraphQLResponse", "ServerResponse", "format_execution_result", + "format_error_default", ] @@ -230,11 +231,11 @@ def get_response( as a parameter. """ - if not params.query: - raise HttpQueryError(400, "Must provide query string.") - # noinspection PyBroadException try: + if not params.query: + raise HttpQueryError(400, "Must provide query string.") + # Parse document to trigger a new HttpQueryError if allow_only_query is True try: document = parse(params.query) diff --git a/graphql_server/flask/__init__.py b/graphql_server/flask/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/flask/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py new file mode 100644 index 0000000..d1d971a --- /dev/null +++ b/graphql_server/flask/graphqlview.py @@ -0,0 +1,151 @@ +from functools import partial + +from flask import Response, request +from flask.views import View +from graphql.error import GraphQLError +from graphql.type.schema import GraphQLSchema + +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(View): + schema = None + executor = None + root_value = None + pretty = False + graphiql = False + graphiql_version = None + graphiql_template = None + graphiql_html_title = None + middleware = None + batch = 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." + + # noinspection PyUnusedLocal + def get_root_value(self): + return self.root_value + + def get_context_value(self): + return request + + def get_middleware(self): + return self.middleware + + def get_executor(self): + return self.executor + + def render_graphiql(self, params, result): + return render_graphiql( + params=params, + result=result, + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + ) + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + def dispatch_request(self): + try: + request_method = request.method.lower() + data = self.parse_body() + + show_graphiql = request_method == "get" and self.should_display_graphiql() + catch = show_graphiql + + pretty = self.pretty or show_graphiql or request.args.get("pretty") + + extra_options = {} + executor = self.get_executor() + if executor: + # We only include it optionally since + # executor is not a valid argument in all backends + extra_options["executor"] = executor + + execution_results, all_params = run_http_query( + self.schema, + request_method, + data, + query_data=request.args, + batch_enabled=self.batch, + catch=catch, + # Execute options + root_value=self.get_root_value(), + context_value=self.get_context_value(), + middleware=self.get_middleware(), + **extra_options + ) + result, status_code = encode_execution_results( + execution_results, + is_batch=isinstance(data, list), + format_error=self.format_error, + encode=partial(self.encode, pretty=pretty), + ) + + if show_graphiql: + return self.render_graphiql(params=all_params[0], result=result) + + return Response(result, status=status_code, content_type="application/json") + + except HttpQueryError as e: + parsed_error = GraphQLError(e.message) + return Response( + self.encode(dict(errors=[self.format_error(parsed_error)])), + status=e.status_code, + headers=e.headers, + content_type="application/json", + ) + + # Flask + def parse_body(self): + # We use mimetype here since we don't need the other + # information provided by content_type + content_type = request.mimetype + if content_type == "application/graphql": + return {"query": request.data.decode("utf8")} + + elif content_type == "application/json": + return load_json_body(request.data.decode("utf8")) + + elif content_type in ( + "application/x-www-form-urlencoded", + "multipart/form-data", + ): + return request.form + + return {} + + def should_display_graphiql(self): + if not self.graphiql or "raw" in request.args: + return False + + return self.request_wants_html() + + def request_wants_html(self): + best = request.accept_mimetypes.best_match(["application/json", "text/html"]) + return ( + best == "text/html" + and request.accept_mimetypes[best] + > request.accept_mimetypes["application/json"] + ) diff --git a/graphql_server/flask/render_graphiql.py b/graphql_server/flask/render_graphiql.py new file mode 100644 index 0000000..d395d44 --- /dev/null +++ b/graphql_server/flask/render_graphiql.py @@ -0,0 +1,148 @@ +from flask import render_template_string + +GRAPHIQL_VERSION = "0.11.11" + +TEMPLATE = """ + + + + {{graphiql_html_title|default("GraphiQL", true)}} + + + + + + + + + + + +""" + + +def render_graphiql( + params, + result, + graphiql_version=None, + graphiql_template=None, + graphiql_html_title=None, +): + graphiql_version = graphiql_version or GRAPHIQL_VERSION + template = graphiql_template or TEMPLATE + + return render_template_string( + template, + graphiql_version=graphiql_version, + graphiql_html_title=graphiql_html_title, + result=result, + params=params, + ) diff --git a/setup.py b/setup.py index 2bedab1..d8568a9 100644 --- a/setup.py +++ b/setup.py @@ -17,6 +17,12 @@ "check-manifest>=0.40,<1", ] + tests_requires +install_flask_requires = [ + "flask>=0.7.0", +] + +install_all_requires = install_requires + install_flask_requires + setup( name="graphql-server-core", version="2.0.0", @@ -40,10 +46,12 @@ keywords="api graphql protocol rest", packages=find_packages(exclude=["tests"]), install_requires=install_requires, - tests_require=tests_requires, + tests_require=install_all_requires + tests_requires, extras_require={ - 'test': tests_requires, - 'dev': dev_requires, + "all": install_all_requires, + "test": install_all_requires + tests_requires, + "dev": dev_requires, + "flask": install_flask_requires, }, include_package_data=True, zip_safe=False, diff --git a/tests/flask/__init__.py b/tests/flask/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/flask/app.py b/tests/flask/app.py new file mode 100644 index 0000000..01f6fa8 --- /dev/null +++ b/tests/flask/app.py @@ -0,0 +1,18 @@ +from flask import Flask + +from graphql_server.flask import GraphQLView +from tests.flask.schema import Schema + + +def create_app(path="/graphql", **kwargs): + app = Flask(__name__) + app.debug = True + app.add_url_rule( + path, view_func=GraphQLView.as_view("graphql", schema=Schema, **kwargs) + ) + return app + + +if __name__ == "__main__": + app = create_app(graphiql=True) + app.run() diff --git a/tests/flask/schema.py b/tests/flask/schema.py new file mode 100644 index 0000000..5d4c52c --- /dev/null +++ b/tests/flask/schema.py @@ -0,0 +1,41 @@ +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!") + + +QueryRootType = GraphQLObjectType( + name="QueryRoot", + fields={ + "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context.args.get("q"), + ), + "context": GraphQLField( + GraphQLNonNull(GraphQLString), resolve=lambda obj, info: info.context + ), + "test": GraphQLField( + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who="World": "Hello %s" % who, + ), + }, +) + +MutationRootType = GraphQLObjectType( + name="MutationRoot", + fields={ + "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) + }, +) + +Schema = GraphQLSchema(QueryRootType, MutationRootType) diff --git a/tests/flask/test_graphiqlview.py b/tests/flask/test_graphiqlview.py new file mode 100644 index 0000000..4a55710 --- /dev/null +++ b/tests/flask/test_graphiqlview.py @@ -0,0 +1,60 @@ +import pytest +from flask import url_for + +from .app import create_app + + +@pytest.fixture +def app(): + # import app factory pattern + app = create_app(graphiql=True) + + # pushes an application context manually + ctx = app.app_context() + ctx.push() + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +def test_graphiql_is_enabled(app, client): + with app.test_request_context(): + response = client.get( + url_for("graphql", externals=False), headers={"Accept": "text/html"} + ) + assert response.status_code == 200 + + +def test_graphiql_renders_pretty(app, client): + with app.test_request_context(): + response = client.get( + url_for("graphql", query="{test}"), headers={"Accept": "text/html"} + ) + assert response.status_code == 200 + pretty_response = ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) + + assert pretty_response in response.data.decode("utf-8") + + +def test_graphiql_default_title(app, client): + with app.test_request_context(): + response = client.get(url_for("graphql"), headers={"Accept": "text/html"}) + assert "GraphiQL" in response.data.decode("utf-8") + + +@pytest.mark.parametrize( + "app", [create_app(graphiql=True, graphiql_html_title="Awesome")] +) +def test_graphiql_custom_title(app, client): + with app.test_request_context(): + response = client.get(url_for("graphql"), headers={"Accept": "text/html"}) + assert "Awesome" in response.data.decode("utf-8") diff --git a/tests/flask/test_graphqlview.py b/tests/flask/test_graphqlview.py new file mode 100644 index 0000000..0f65072 --- /dev/null +++ b/tests/flask/test_graphqlview.py @@ -0,0 +1,581 @@ +import json +from io import StringIO +from urllib.parse import urlencode + +import pytest +from flask import url_for + +from .app import create_app + + +@pytest.fixture +def app(request): + # import app factory pattern + app = create_app() + + # pushes an application context manually + ctx = app.app_context() + ctx.push() + return app + + +@pytest.fixture +def client(app): + return app.test_client() + + +def url_string(app, **url_params): + with app.test_request_context(): + string = url_for("graphql") + + if url_params: + string += "?" + urlencode(url_params) + + return string + + +def response_json(response): + return json.loads(response.data.decode()) + + +def json_dump_kwarg(**kwargs): + return json.dumps(kwargs) + + +def json_dump_kwarg_list(**kwargs): + return json.dumps([kwargs]) + + +def test_allows_get_with_query_param(app, client): + response = client.get(url_string(app, query="{test}")) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_get_with_variable_values(app, client): + response = client.get( + url_string( + app, + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_allows_get_with_operation_name(app, client): + response = client.get( + url_string( + app, + 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_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +def test_reports_validation_errors(app, client): + response = client.get(url_string(app, query="{ test, unknownOne, unknownTwo }")) + + assert response.status_code == 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, + }, + ] + } + + +def test_errors_when_missing_operation_name(app, client): + response = client.get( + url_string( + app, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + ) + ) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Must provide operation name if query contains multiple operations.", # noqa: E501 + "locations": None, + "path": None, + } + ] + } + + +def test_errors_when_sending_a_mutation_via_get(app, client): + response = client.get( + url_string( + app, + query=""" + mutation TestMutation { writeTest { test } } + """, + ) + ) + assert response.status_code == 405 + assert response_json(response) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + "locations": None, + "path": None, + } + ] + } + + +def test_errors_when_selecting_a_mutation_within_a_get(app, client): + response = client.get( + url_string( + app, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestMutation", + ) + ) + + assert response.status_code == 405 + assert response_json(response) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + "locations": None, + "path": None, + } + ] + } + + +def test_allows_mutation_to_exist_within_a_get(app, client): + response = client.get( + url_string( + app, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestQuery", + ) + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_post_with_json_encoding(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg(query="{test}"), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_sending_a_mutation_via_post(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} + + +def test_allows_post_with_url_encoding(app, client): + response = client.post( + url_string(app), + data=urlencode(dict(query="{test}")), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_supports_post_json_query_with_string_variables(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_json_query_with_json_variables(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_url_encoded_query_with_string_variables(app, client): + response = client.post( + url_string(app), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_json_quey_with_get_variable_values(app, client): + response = client.post( + url_string(app, variables=json.dumps({"who": "Dolly"})), + data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_post_url_encoded_query_with_get_variable_values(app, client): + response = client.post( + url_string(app, variables=json.dumps({"who": "Dolly"})), + data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + content_type="application/x-www-form-urlencoded", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_supports_post_raw_text_query_with_get_variable_values(app, client): + response = client.post( + url_string(app, variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + content_type="application/graphql", + ) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"test": "Hello Dolly"}} + + +def test_allows_post_with_operation_name(app, client): + response = client.post( + url_string(app), + 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", + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +def test_allows_post_with_get_operation_name(app, client): + response = client.post( + url_string(app, 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") + } + """, + content_type="application/graphql", + ) + + assert response.status_code == 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, client): + response = client.get(url_string(app, query="{test}")) + + assert response.data.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +@pytest.mark.parametrize("app", [create_app(pretty=False)]) +def test_not_pretty_by_default(app, client): + response = client.get(url_string(app, query="{test}")) + + assert response.data.decode() == '{"data":{"test":"Hello World"}}' + + +def test_supports_pretty_printing_by_request(app, client): + response = client.get(url_string(app, query="{test}", pretty="1")) + + assert response.data.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +def test_handles_field_errors_caught_by_graphql(app, client): + response = client.get(url_string(app, query="{thrower}")) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "locations": [{"column": 2, "line": 1}], + "path": ["thrower"], + "message": "Throws!", + } + ] + } + + +def test_handles_syntax_errors_caught_by_graphql(app, client): + response = client.get(url_string(app, query="syntaxerror")) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "locations": [{"column": 1, "line": 1}], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + "path": None, + } + ] + } + + +def test_handles_errors_caused_by_a_lack_of_query(app, client): + response = client.get(url_string(app)) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + {"message": "Must provide query string.", "locations": None, "path": None} + ] + } + + +def test_handles_batch_correctly_if_is_disabled(app, client): + response = client.post(url_string(app), data="[]", content_type="application/json") + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Batch GraphQL requests are not enabled.", + "locations": None, + "path": None, + } + ] + } + + +def test_handles_incomplete_json_bodies(app, client): + response = client.post( + url_string(app), data='{"query":', content_type="application/json" + ) + + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + {"message": "POST body sent invalid JSON.", "locations": None, "path": None} + ] + } + + +def test_handles_plain_post_text(app, client): + response = client.post( + url_string(app, variables=json.dumps({"who": "Dolly"})), + data="query helloWho($who: String){ test(who: $who) }", + content_type="text/plain", + ) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + {"message": "Must provide query string.", "locations": None, "path": None} + ] + } + + +def test_handles_poorly_formed_variables(app, client): + response = client.get( + url_string( + app, + query="query helloWho($who: String){ test(who: $who) }", + variables="who:You", + ) + ) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + {"message": "Variables are invalid JSON.", "locations": None, "path": None} + ] + } + + +def test_handles_unsupported_http_methods(app, client): + response = client.put(url_string(app, query="{test}")) + assert response.status_code == 405 + assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] + assert response_json(response) == { + "errors": [ + { + "message": "GraphQL only supports GET and POST requests.", + "locations": None, + "path": None, + } + ] + } + + +def test_passes_request_into_request_context(app, client): + response = client.get(url_string(app, query="{request}", q="testing")) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"request": "testing"}} + + +@pytest.mark.parametrize( + "app", [create_app(get_context_value=lambda: "CUSTOM CONTEXT")] +) +def test_passes_custom_context_into_context(app, client): + response = client.get(url_string(app, query="{context}")) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"context": "CUSTOM CONTEXT"}} + + +def test_post_multipart_data(app, client): + query = "mutation TestMutation { writeTest { test } }" + response = client.post( + url_string(app), + data={"query": query, "file": (StringIO(), "text1.txt")}, + content_type="multipart/form-data", + ) + + assert response.status_code == 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, client): + response = client.post( + url_string(app), + data=json_dump_kwarg_list( + # id=1, + query="{test}" + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [ + { + # 'id': 1, + "data": {"test": "Hello World"} + } + ] + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_supports_post_json_query_with_json_variables(app, client): + response = client.post( + url_string(app), + data=json_dump_kwarg_list( + # id=1, + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [ + { + # 'id': 1, + "data": {"test": "Hello Dolly"} + } + ] + + +@pytest.mark.parametrize("app", [create_app(batch=True)]) +def test_batch_allows_post_with_operation_name(app, client): + response = client.post( + url_string(app), + 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", + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [ + { + # 'id': 1, + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + ]