From ca4cf5d6cd24fea8f661ea620d800e35f3827327 Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sun, 15 Mar 2020 22:41:52 -0500 Subject: [PATCH 01/13] refactor: add flask-graphql as optional feature --- graphql_server/flask_graphql/__init__.py | 4 + graphql_server/flask_graphql/blueprint.py | 17 ++ graphql_server/flask_graphql/graphqlview.py | 153 ++++++++++++++++++ .../flask_graphql/render_graphiql.py | 138 ++++++++++++++++ setup.py | 5 + 5 files changed, 317 insertions(+) create mode 100644 graphql_server/flask_graphql/__init__.py create mode 100644 graphql_server/flask_graphql/blueprint.py create mode 100644 graphql_server/flask_graphql/graphqlview.py create mode 100644 graphql_server/flask_graphql/render_graphiql.py diff --git a/graphql_server/flask_graphql/__init__.py b/graphql_server/flask_graphql/__init__.py new file mode 100644 index 0000000..4c30423 --- /dev/null +++ b/graphql_server/flask_graphql/__init__.py @@ -0,0 +1,4 @@ +from .blueprint import GraphQL +from .graphqlview import GraphQLView + +__all__ = ['GraphQL', 'GraphQLView'] diff --git a/graphql_server/flask_graphql/blueprint.py b/graphql_server/flask_graphql/blueprint.py new file mode 100644 index 0000000..b02266a --- /dev/null +++ b/graphql_server/flask_graphql/blueprint.py @@ -0,0 +1,17 @@ +import warnings + +from flask import Blueprint + +from .graphqlview import GraphQLView + + +class GraphQL(object): + def __init__(self, app, schema, **options): + self.app = app + warnings.warn('GraphQL Blueprint is now deprecated, please use GraphQLView directly') + self.blueprint = Blueprint('graphql', __name__, + template_folder='templates') + + app.add_url_rule('/graphql', view_func=GraphQLView.as_view('graphql', schema=schema, **options)) + + self.app.register_blueprint(self.blueprint) diff --git a/graphql_server/flask_graphql/graphqlview.py b/graphql_server/flask_graphql/graphqlview.py new file mode 100644 index 0000000..e2daee5 --- /dev/null +++ b/graphql_server/flask_graphql/graphqlview.py @@ -0,0 +1,153 @@ +from functools import partial + +from flask import Response, request +from flask.views import View +from graphql_server import (HttpQueryError, default_format_error, + encode_execution_results, json_encode, + load_json_body, run_http_query) + +from graphql.type.schema import GraphQLSchema + +from .render_graphiql import render_graphiql + + +class GraphQLView(View): + schema = None + executor = None + root_value = None + pretty = False + graphiql = False + backend = None + 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_backend(self): + return self.backend + + 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(default_format_error) + 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, + backend=self.get_backend(), + + # 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: + return Response( + self.encode({ + 'errors': [self.format_error(e)] + }), + 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_graphql/render_graphiql.py b/graphql_server/flask_graphql/render_graphiql.py new file mode 100644 index 0000000..9a37509 --- /dev/null +++ b/graphql_server/flask_graphql/render_graphiql.py @@ -0,0 +1,138 @@ +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 + ) \ No newline at end of file diff --git a/setup.py b/setup.py index 2bedab1..1b60d79 100644 --- a/setup.py +++ b/setup.py @@ -17,6 +17,10 @@ "check-manifest>=0.40,<1", ] + tests_requires +install_flask_requires = [ + "flask>=0.7.0", +] + setup( name="graphql-server-core", version="2.0.0", @@ -44,6 +48,7 @@ extras_require={ 'test': tests_requires, 'dev': dev_requires, + "flask": install_flask_requires, }, include_package_data=True, zip_safe=False, From caa1629286c197b39d942c71bae0005373c84e20 Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sun, 15 Mar 2020 22:42:31 -0500 Subject: [PATCH 02/13] refactor(server): default_format_error to __all__ --- graphql_server/__init__.py | 1 + graphql_server/flask_graphql/graphqlview.py | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 29efffa..5154765 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -30,6 +30,7 @@ "GraphQLResponse", "ServerResponse", "format_execution_result", + "format_error_default", ] diff --git a/graphql_server/flask_graphql/graphqlview.py b/graphql_server/flask_graphql/graphqlview.py index e2daee5..0483813 100644 --- a/graphql_server/flask_graphql/graphqlview.py +++ b/graphql_server/flask_graphql/graphqlview.py @@ -2,7 +2,7 @@ from flask import Response, request from flask.views import View -from graphql_server import (HttpQueryError, default_format_error, +from graphql_server import (HttpQueryError, format_error_default, encode_execution_results, json_encode, load_json_body, run_http_query) @@ -59,7 +59,7 @@ def render_graphiql(self, params, result): graphiql_html_title=self.graphiql_html_title, ) - format_error = staticmethod(default_format_error) + format_error = staticmethod(format_error_default) encode = staticmethod(json_encode) def dispatch_request(self): From 1a7797e78edf757a44cd43a9f525e716f9e4d5bd Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Mon, 16 Mar 2020 09:34:00 -0500 Subject: [PATCH 03/13] chore: rename dir flask-graphql to flask --- graphql_server/{flask_graphql => flask}/__init__.py | 0 graphql_server/{flask_graphql => flask}/blueprint.py | 0 graphql_server/{flask_graphql => flask}/graphqlview.py | 0 graphql_server/{flask_graphql => flask}/render_graphiql.py | 0 4 files changed, 0 insertions(+), 0 deletions(-) rename graphql_server/{flask_graphql => flask}/__init__.py (100%) rename graphql_server/{flask_graphql => flask}/blueprint.py (100%) rename graphql_server/{flask_graphql => flask}/graphqlview.py (100%) rename graphql_server/{flask_graphql => flask}/render_graphiql.py (100%) diff --git a/graphql_server/flask_graphql/__init__.py b/graphql_server/flask/__init__.py similarity index 100% rename from graphql_server/flask_graphql/__init__.py rename to graphql_server/flask/__init__.py diff --git a/graphql_server/flask_graphql/blueprint.py b/graphql_server/flask/blueprint.py similarity index 100% rename from graphql_server/flask_graphql/blueprint.py rename to graphql_server/flask/blueprint.py diff --git a/graphql_server/flask_graphql/graphqlview.py b/graphql_server/flask/graphqlview.py similarity index 100% rename from graphql_server/flask_graphql/graphqlview.py rename to graphql_server/flask/graphqlview.py diff --git a/graphql_server/flask_graphql/render_graphiql.py b/graphql_server/flask/render_graphiql.py similarity index 100% rename from graphql_server/flask_graphql/render_graphiql.py rename to graphql_server/flask/render_graphiql.py From 3f256a3fc8247d64412b47d1332a6aae367c0105 Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Mon, 16 Mar 2020 09:34:46 -0500 Subject: [PATCH 04/13] chore: add extras require all key --- setup.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/setup.py b/setup.py index 1b60d79..11c3e67 100644 --- a/setup.py +++ b/setup.py @@ -21,6 +21,8 @@ "flask>=0.7.0", ] +install_all_requires = install_requires + install_flask_requires + setup( name="graphql-server-core", version="2.0.0", @@ -44,10 +46,11 @@ 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": tests_requires, + "dev": dev_requires, "flask": install_flask_requires, }, include_package_data=True, From ddb3e0051b9edb7ec5c31db77c63de4a1271e9a5 Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sun, 12 Apr 2020 13:45:57 -0500 Subject: [PATCH 05/13] chore: update gitignore --- .gitignore | 208 +++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 196 insertions(+), 12 deletions(-) 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 From 14eec028590c7d446dfa94569cc62a4bf49b71f7 Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sun, 12 Apr 2020 15:59:56 -0500 Subject: [PATCH 06/13] fix(sc): move params query check to try-except --- graphql_server/__init__.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 5154765..c4685c0 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -231,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) From 4c3a1117310942634ed6dd5aa65d16d9a3e777ed Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sun, 12 Apr 2020 16:00:31 -0500 Subject: [PATCH 07/13] refactor(flask): remove unused backend param --- graphql_server/flask/graphqlview.py | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 0483813..0bead58 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -6,6 +6,7 @@ encode_execution_results, json_encode, load_json_body, run_http_query) +from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema from .render_graphiql import render_graphiql @@ -17,7 +18,6 @@ class GraphQLView(View): root_value = None pretty = False graphiql = False - backend = None graphiql_version = None graphiql_template = None graphiql_html_title = None @@ -44,9 +44,6 @@ def get_context_value(self): def get_middleware(self): return self.middleware - def get_backend(self): - return self.backend - def get_executor(self): return self.executor @@ -86,7 +83,6 @@ def dispatch_request(self): query_data=request.args, batch_enabled=self.batch, catch=catch, - backend=self.get_backend(), # Execute options root_value=self.get_root_value(), @@ -114,10 +110,9 @@ def dispatch_request(self): ) except HttpQueryError as e: + parsed_error = GraphQLError(e.message) return Response( - self.encode({ - 'errors': [self.format_error(e)] - }), + self.encode(dict(errors=[self.format_error(parsed_error)])), status=e.status_code, headers=e.headers, content_type='application/json' From 2cc63c6e883cda8da0f5815acbcdddded0303eac Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sun, 12 Apr 2020 16:19:45 -0500 Subject: [PATCH 08/13] tests(flask): graphiqlview and graphqlview --- tests/flask/__init__.py | 0 tests/flask/app.py | 15 + tests/flask/schema.py | 38 ++ tests/flask/test_graphiqlview.py | 54 +++ tests/flask/test_graphqlview.py | 635 +++++++++++++++++++++++++++++++ 5 files changed, 742 insertions(+) create mode 100644 tests/flask/__init__.py create mode 100644 tests/flask/app.py create mode 100644 tests/flask/schema.py create mode 100644 tests/flask/test_graphiqlview.py create mode 100644 tests/flask/test_graphqlview.py 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..19dccd7 --- /dev/null +++ b/tests/flask/app.py @@ -0,0 +1,15 @@ +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..f87e071 --- /dev/null +++ b/tests/flask/schema.py @@ -0,0 +1,38 @@ +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..32729ac --- /dev/null +++ b/tests/flask/test_graphiqlview.py @@ -0,0 +1,54 @@ +import pytest + +from .app import create_app +from flask import url_for + + +@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') \ No newline at end of file diff --git a/tests/flask/test_graphqlview.py b/tests/flask/test_graphqlview.py new file mode 100644 index 0000000..b334bfc --- /dev/null +++ b/tests/flask/test_graphqlview.py @@ -0,0 +1,635 @@ +import pytest +import json + +try: + from StringIO import StringIO +except ImportError: + from io import StringIO + +try: + from urllib import urlencode +except ImportError: + from urllib.parse import urlencode + +from .app import create_app +from flask import url_for + + +@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.', + '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_benchmark(client, benchmark): +# url = url_string() +# data = urlencode(dict(query='{test}')) +# def fun(): +# return client.post(url_string(), data=data, content_type='application/x-www-form-urlencoded') + +# response = benchmark(fun) +# 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' + } + }] From 07fe2991844ebd4c707ea25739b3aeb9d50ac571 Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sun, 12 Apr 2020 16:24:51 -0500 Subject: [PATCH 09/13] styles: apply black, isort, flake8 formatting --- graphql_server/flask/__init__.py | 2 +- graphql_server/flask/blueprint.py | 12 +- graphql_server/flask/graphqlview.py | 67 +-- graphql_server/flask/render_graphiql.py | 19 +- tests/flask/app.py | 9 +- tests/flask/schema.py | 43 +- tests/flask/test_graphiqlview.py | 34 +- tests/flask/test_graphqlview.py | 525 +++++++++++------------- 8 files changed, 350 insertions(+), 361 deletions(-) diff --git a/graphql_server/flask/__init__.py b/graphql_server/flask/__init__.py index 4c30423..1d0e90a 100644 --- a/graphql_server/flask/__init__.py +++ b/graphql_server/flask/__init__.py @@ -1,4 +1,4 @@ from .blueprint import GraphQL from .graphqlview import GraphQLView -__all__ = ['GraphQL', 'GraphQLView'] +__all__ = ["GraphQL", "GraphQLView"] diff --git a/graphql_server/flask/blueprint.py b/graphql_server/flask/blueprint.py index b02266a..437198f 100644 --- a/graphql_server/flask/blueprint.py +++ b/graphql_server/flask/blueprint.py @@ -8,10 +8,14 @@ class GraphQL(object): def __init__(self, app, schema, **options): self.app = app - warnings.warn('GraphQL Blueprint is now deprecated, please use GraphQLView directly') - self.blueprint = Blueprint('graphql', __name__, - template_folder='templates') + warnings.warn( + "GraphQL Blueprint is now deprecated, please use GraphQLView directly" + ) + self.blueprint = Blueprint("graphql", __name__, template_folder="templates") - app.add_url_rule('/graphql', view_func=GraphQLView.as_view('graphql', schema=schema, **options)) + app.add_url_rule( + "/graphql", + view_func=GraphQLView.as_view("graphql", schema=schema, **options), + ) self.app.register_blueprint(self.blueprint) diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 0bead58..d1d971a 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -2,13 +2,18 @@ from flask import Response, request from flask.views import View -from graphql_server import (HttpQueryError, format_error_default, - encode_execution_results, json_encode, - load_json_body, run_http_query) - 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 @@ -24,7 +29,7 @@ class GraphQLView(View): middleware = None batch = False - methods = ['GET', 'POST', 'PUT', 'DELETE'] + methods = ["GET", "POST", "PUT", "DELETE"] def __init__(self, **kwargs): super(GraphQLView, self).__init__() @@ -32,7 +37,9 @@ def __init__(self, **kwargs): if hasattr(self, key): setattr(self, key, value) - assert isinstance(self.schema, GraphQLSchema), 'A Schema is required to be provided to GraphQLView.' + assert isinstance( + self.schema, GraphQLSchema + ), "A Schema is required to be provided to GraphQLView." # noinspection PyUnusedLocal def get_root_value(self): @@ -64,17 +71,17 @@ def dispatch_request(self): request_method = request.method.lower() data = self.parse_body() - show_graphiql = request_method == 'get' and self.should_display_graphiql() + show_graphiql = request_method == "get" and self.should_display_graphiql() catch = show_graphiql - pretty = self.pretty or show_graphiql or request.args.get('pretty') + 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 + extra_options["executor"] = executor execution_results, all_params = run_http_query( self.schema, @@ -83,7 +90,6 @@ def dispatch_request(self): query_data=request.args, batch_enabled=self.batch, catch=catch, - # Execute options root_value=self.get_root_value(), context_value=self.get_context_value(), @@ -94,20 +100,13 @@ def dispatch_request(self): execution_results, is_batch=isinstance(data, list), format_error=self.format_error, - encode=partial(self.encode, pretty=pretty) + encode=partial(self.encode, pretty=pretty), ) if show_graphiql: - return self.render_graphiql( - params=all_params[0], - result=result - ) + return self.render_graphiql(params=all_params[0], result=result) - return Response( - result, - status=status_code, - content_type='application/json' - ) + return Response(result, status=status_code, content_type="application/json") except HttpQueryError as e: parsed_error = GraphQLError(e.message) @@ -115,7 +114,7 @@ def dispatch_request(self): self.encode(dict(errors=[self.format_error(parsed_error)])), status=e.status_code, headers=e.headers, - content_type='application/json' + content_type="application/json", ) # Flask @@ -123,26 +122,30 @@ 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')} + 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 == "application/json": + return load_json_body(request.data.decode("utf8")) - elif content_type in ('application/x-www-form-urlencoded', 'multipart/form-data'): + 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: + 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'] + 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 index 9a37509..0ade0e7 100644 --- a/graphql_server/flask/render_graphiql.py +++ b/graphql_server/flask/render_graphiql.py @@ -1,8 +1,8 @@ from flask import render_template_string -GRAPHIQL_VERSION = '0.11.11' +GRAPHIQL_VERSION = "0.11.11" -TEMPLATE = '''