From 865ee9d5602f352c958f6f7e15adbe9abe216784 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sun, 12 Apr 2020 12:59:17 -0500 Subject: [PATCH 01/48] Migration to graphql-core-v3 (#36) * feat: server-core compatible with graphql-core-v3 - Bump dependencies - Refactor code to use f-strings format (3.6+) - Rename public data structures BREAKING CHANGE: - Requires graphql-core-v3 - Drop support for Python 2 and below 3.6 - Remove executor check as graphql-core-v3 does not have SyncExecutor * chore: drop unsupported py versions on tox and travis * tests: apply minor fixes to older tests * chore: apply black formatting * chore: fix flake8 issues * chore: remove promise package * tests: achieve 100% coverage * chore: apply compatible isort-black options * chore: solve dev tools issues * chore: remove pypy3 from tox envlist * chore: remove pypy3 from travis * tests: re-add helper tests * chore: pin graphql-core to 3.1.0 * refactor: use graphql and graphql-sync functions * tests: remove Promise and use async await iterator * refactor: remove pytest-asyncio * chore: set graphql-core dependency semver Co-Authored-By: Jonathan Kim Co-authored-by: Jonathan Kim --- .travis.yml | 6 +- README.md | 2 + graphql_server/__init__.py | 298 +++++++++++++-------------------- setup.cfg | 5 + setup.py | 24 +-- tests/conftest.py | 4 - tests/schema.py | 33 ++-- tests/test_asyncio.py | 63 +++---- tests/test_error.py | 44 ++--- tests/test_helpers.py | 127 ++------------ tests/test_query.py | 334 +++++++++++++++---------------------- tests/utils.py | 17 +- tox.ini | 12 +- 13 files changed, 368 insertions(+), 601 deletions(-) delete mode 100644 tests/conftest.py diff --git a/.travis.yml b/.travis.yml index 7789878..29bac19 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,17 +1,13 @@ language: python sudo: false python: - - 2.7 - - 3.5 - 3.6 - 3.7 - 3.8 - 3.9-dev - - pypy - - pypy3 matrix: include: - - python: 3.6 + - python: 3.7 env: TOXENV=flake8,black,import-order,mypy,manifest cache: pip install: pip install tox-travis codecov diff --git a/README.md b/README.md index fdb3d40..9e228f1 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ The `graphql_server` package provides these public helper functions: * `json_encode` * `json_encode_pretty` +**NOTE:** the `json_encode_pretty` is kept as backward compatibility change as it uses `json_encode` with `pretty` parameter set to `True`. + All functions in the package are annotated with type hints and docstrings, and you can build HTML documentation from these using `bin/build_docs`. diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index cb802ee..29efffa 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -6,37 +6,19 @@ for building GraphQL servers or integrations into existing web frameworks using [GraphQL-Core](https://github.com/graphql-python/graphql-core). """ - - import json from collections import namedtuple +from collections.abc import MutableMapping +from typing import Any, Callable, Dict, List, Optional, Type, Union -import six - -from promise import promisify, is_thenable, Promise - -from graphql import get_default_backend -from graphql.error import format_error as default_format_error -from graphql.execution import ExecutionResult -from graphql.execution.executors.sync import SyncExecutor -from graphql.type import GraphQLSchema +from graphql import ExecutionResult, GraphQLError, GraphQLSchema, OperationType +from graphql import format_error as format_error_default +from graphql import get_operation_ast, parse +from graphql.graphql import graphql, graphql_sync +from graphql.pyutils import AwaitableOrValue from .error import HttpQueryError -try: # pragma: no cover (Python >= 3.3) - from collections.abc import MutableMapping -except ImportError: # pragma: no cover (Python < 3.3) - # noinspection PyUnresolvedReferences,PyProtectedMember - from collections import MutableMapping - -# Necessary for static type checking -# noinspection PyUnreachableCode -if False: # pragma: no cover - # flake8: noqa - from typing import Any, Callable, Dict, List, Optional, Type, Union - from graphql import GraphQLBackend - - __all__ = [ "run_http_query", "encode_execution_results", @@ -44,18 +26,17 @@ "json_encode", "json_encode_pretty", "HttpQueryError", - "RequestParams", - "ServerResults", + "GraphQLParams", + "GraphQLResponse", "ServerResponse", + "format_execution_result", ] # The public data structures -RequestParams = namedtuple("RequestParams", "query variables operation_name") - -ServerResults = namedtuple("ServerResults", "results params") - +GraphQLParams = namedtuple("GraphQLParams", "query variables operation_name") +GraphQLResponse = namedtuple("GraphQLResponse", "results params") ServerResponse = namedtuple("ServerResponse", "body status_code") @@ -63,14 +44,15 @@ def run_http_query( - schema, # type: GraphQLSchema - request_method, # type: str - data, # type: Union[Dict, List[Dict]] - query_data=None, # type: Optional[Dict] - batch_enabled=False, # type: bool - catch=False, # type: bool - **execute_options # type: Any -): + schema: GraphQLSchema, + request_method: str, + data: Union[Dict, List[Dict]], + query_data: Optional[Dict] = None, + batch_enabled: bool = False, + catch: bool = False, + run_sync: bool = True, + **execute_options, +) -> GraphQLResponse: """Execute GraphQL coming from an HTTP query against a given schema. You need to pass the schema (that is supposed to be already validated), @@ -87,7 +69,7 @@ def run_http_query( and the list of parameters that have been used for execution as second item. """ if not isinstance(schema, GraphQLSchema): - raise TypeError("Expected a GraphQL schema, but received {!r}.".format(schema)) + raise TypeError(f"Expected a GraphQL schema, but received {schema!r}.") if request_method not in ("get", "post"): raise HttpQueryError( 405, @@ -95,9 +77,7 @@ def run_http_query( headers={"Allow": "GET, POST"}, ) if catch: - catch_exc = ( - HttpQueryError - ) # type: Union[Type[HttpQueryError], Type[_NoException]] + catch_exc: Union[Type[HttpQueryError], Type[_NoException]] = HttpQueryError else: catch_exc = _NoException is_batch = isinstance(data, list) @@ -108,7 +88,7 @@ def run_http_query( if not is_batch: if not isinstance(data, (dict, MutableMapping)): raise HttpQueryError( - 400, "GraphQL params should be a dict. Received {!r}.".format(data) + 400, f"GraphQL params should be a dict. Received {data!r}." ) data = [data] elif not batch_enabled: @@ -117,50 +97,45 @@ def run_http_query( if not data: raise HttpQueryError(400, "Received an empty list in the batch request.") - extra_data = {} # type: Dict[str, Any] + extra_data: Dict[str, Any] = {} # If is a batch request, we don't consume the data from the query if not is_batch: extra_data = query_data or {} - all_params = [get_graphql_params(entry, extra_data) for entry in data] + all_params: List[GraphQLParams] = [ + get_graphql_params(entry, extra_data) for entry in data + ] + + results: List[Optional[AwaitableOrValue[ExecutionResult]]] = [ + get_response( + schema, params, catch_exc, allow_only_query, run_sync, **execute_options + ) + for params in all_params + ] + return GraphQLResponse(results, all_params) - if execute_options.get("return_promise"): - results = [ - get_response(schema, params, catch_exc, allow_only_query, **execute_options) - for params in all_params - ] - else: - executor = execute_options.get("executor") - response_executor = executor if executor else SyncExecutor() - - response_promises = [ - response_executor.execute( - get_response, - schema, - params, - catch_exc, - allow_only_query, - **execute_options - ) - for params in all_params - ] - response_executor.wait_until_finished() - results = [ - result.get() if is_thenable(result) else result - for result in response_promises - ] +def json_encode(data: Union[Dict, List], pretty: bool = False) -> str: + """Serialize the given data(a dictionary or a list) using JSON. - return ServerResults(results, all_params) + The given data (a dictionary or a list) will be serialized using JSON + and returned as a string that will be nicely formatted if you set pretty=True. + """ + if not pretty: + return json.dumps(data, separators=(",", ":")) + return json.dumps(data, indent=2, separators=(",", ": ")) + + +def json_encode_pretty(data: Union[Dict, List]) -> str: + return json_encode(data, True) def encode_execution_results( - execution_results, # type: List[Optional[ExecutionResult]] - format_error=None, # type: Callable[[Exception], Dict] - is_batch=False, # type: bool - encode=None, # type: Callable[[Dict], Any] -): - # type: (...) -> ServerResponse + execution_results: List[Optional[ExecutionResult]], + format_error: Callable[[GraphQLError], Dict] = format_error_default, + is_batch: bool = False, + encode: Callable[[Dict], Any] = json_encode, +) -> ServerResponse: """Serialize the ExecutionResults. This function takes the ExecutionResults that are returned by run_http_query() @@ -174,7 +149,7 @@ def encode_execution_results( a status code of 200 or 400 in case any result was invalid as the second item. """ results = [ - format_execution_result(execution_result, format_error or default_format_error) + format_execution_result(execution_result, format_error) for execution_result in execution_results ] result, status_codes = zip(*results) @@ -183,7 +158,7 @@ def encode_execution_results( if not is_batch: result = result[0] - return ServerResponse((encode or json_encode)(result), status_code) + return ServerResponse(encode(result), status_code) def load_json_body(data): @@ -199,24 +174,6 @@ def load_json_body(data): raise HttpQueryError(400, "POST body sent invalid JSON.") -def json_encode(data, pretty=False): - # type: (Union[Dict,List],Optional[bool]) -> str - """Serialize the given data(a dictionary or a list) using JSON. - - The given data (a dictionary or a list) will be serialized using JSON - and returned as a string that will be nicely formatted if you set pretty=True. - """ - if pretty: - return json_encode_pretty(data) - return json.dumps(data, separators=(",", ":")) - - -def json_encode_pretty(data): - # type: (Union[Dict,List]) -> str - """Serialize the given data using JSON with nice formatting.""" - return json.dumps(data, indent=2, separators=(",", ": ")) - - # Some more private helpers FormattedResult = namedtuple("FormattedResult", "result status_code") @@ -226,8 +183,7 @@ class _NoException(Exception): """Private exception used when we don't want to catch any real exception.""" -def get_graphql_params(data, query_data): - # type: (Dict, Dict) -> RequestParams +def get_graphql_params(data: Dict, query_data: Dict) -> GraphQLParams: """Fetch GraphQL query, variables and operation name parameters from given data. You need to pass both the data from the HTTP request body and the HTTP query string. @@ -240,18 +196,17 @@ def get_graphql_params(data, query_data): # document_id = data.get('documentId') operation_name = data.get("operationName") or query_data.get("operationName") - return RequestParams(query, load_json_variables(variables), operation_name) + return GraphQLParams(query, load_json_variables(variables), operation_name) -def load_json_variables(variables): - # type: (Optional[Union[str, Dict]]) -> Optional[Dict] +def load_json_variables(variables: Optional[Union[str, Dict]]) -> Optional[Dict]: """Return the given GraphQL variables as a dictionary. The function returns the given GraphQL variables, making sure they are deserialized from JSON to a dictionary first if necessary. In case of invalid JSON input, an HttpQueryError will be raised. """ - if variables and isinstance(variables, six.string_types): + if variables and isinstance(variables, str): try: return json.loads(variables) except Exception: @@ -259,82 +214,63 @@ def load_json_variables(variables): return variables # type: ignore -def execute_graphql_request( - schema, # type: GraphQLSchema - params, # type: RequestParams - allow_only_query=False, # type: bool - backend=None, # type: GraphQLBackend - **kwargs # type: Any -): - # type: (...) -> ExecutionResult - """Execute a GraphQL request and return an ExecutionResult. - - You need to pass the GraphQL schema and the GraphQLParams that you can get - with the get_graphql_params() function. If you only want to allow GraphQL query - operations, then set allow_only_query=True. You can also specify a custom - GraphQLBackend instance that shall be used by GraphQL-Core instead of the - default one. All other keyword arguments are passed on to the GraphQL-Core - function for executing GraphQL queries. - """ - if not params.query: - raise HttpQueryError(400, "Must provide query string.") - - try: - if not backend: - backend = get_default_backend() - document = backend.document_from_string(schema, params.query) - except Exception as e: - return ExecutionResult(errors=[e], invalid=True) - - if allow_only_query: - operation_type = document.get_operation_type(params.operation_name) - if operation_type and operation_type != "query": - raise HttpQueryError( - 405, - "Can only perform a {} operation from a POST request.".format( - operation_type - ), - headers={"Allow": "POST"}, - ) - - try: - return document.execute( - operation_name=params.operation_name, - variable_values=params.variables, - **kwargs - ) - except Exception as e: - return ExecutionResult(errors=[e], invalid=True) - - -@promisify -def execute_graphql_request_as_promise(*args, **kwargs): - return execute_graphql_request(*args, **kwargs) - - def get_response( - schema, # type: GraphQLSchema - params, # type: RequestParams - catch_exc, # type: Type[BaseException] - allow_only_query=False, # type: bool - **kwargs # type: Any -): - # type: (...) -> Optional[Union[ExecutionResult, Promise[ExecutionResult]]] + schema: GraphQLSchema, + params: GraphQLParams, + catch_exc: Type[BaseException], + allow_only_query: bool = False, + run_sync: bool = True, + **kwargs, +) -> Optional[AwaitableOrValue[ExecutionResult]]: """Get an individual execution result as response, with option to catch errors. - This does the same as execute_graphql_request() except that you can catch errors - that belong to an exception class that you need to pass as a parameter. + This does the same as graphql_impl() except that you can either + throw an error on the ExecutionResult if allow_only_query is set to True + or catch errors that belong to an exception class that you need to pass + as a parameter. """ - # Note: PyCharm will display a error due to the triple dot being used on Callable. - execute = ( - execute_graphql_request - ) # type: Callable[..., Union[Promise[ExecutionResult], ExecutionResult]] - if kwargs.get("return_promise", False): - execute = execute_graphql_request_as_promise + + if not params.query: + raise HttpQueryError(400, "Must provide query string.") # noinspection PyBroadException try: - execution_result = execute(schema, params, allow_only_query, **kwargs) + # Parse document to trigger a new HttpQueryError if allow_only_query is True + try: + document = parse(params.query) + except GraphQLError as e: + return ExecutionResult(data=None, errors=[e]) + except Exception as e: + e = GraphQLError(str(e), original_error=e) + return ExecutionResult(data=None, errors=[e]) + + if allow_only_query: + operation_ast = get_operation_ast(document, params.operation_name) + if operation_ast: + operation = operation_ast.operation.value + if operation != OperationType.QUERY.value: + raise HttpQueryError( + 405, + f"Can only perform a {operation} operation from a POST request.", # noqa + headers={"Allow": "POST"}, + ) + + if run_sync: + execution_result = graphql_sync( + schema=schema, + source=params.query, + variable_values=params.variables, + operation_name=params.operation_name, + **kwargs, + ) + else: + execution_result = graphql( # type: ignore + schema=schema, + source=params.query, + variable_values=params.variables, + operation_name=params.operation_name, + **kwargs, + ) except catch_exc: return None @@ -342,21 +278,23 @@ def get_response( def format_execution_result( - execution_result, # type: Optional[ExecutionResult] - format_error, # type: Optional[Callable[[Exception], Dict]] -): - # type: (...) -> FormattedResult + execution_result: Optional[ExecutionResult], + format_error: Optional[Callable[[GraphQLError], Dict]] = format_error_default, +) -> FormattedResult: """Format an execution result into a GraphQLResponse. This converts the given execution result into a FormattedResult that contains the ExecutionResult converted to a dictionary and an appropriate status code. """ status_code = 200 + response: Optional[Dict[str, Any]] = None - response = None if execution_result: - if execution_result.invalid: + if execution_result.errors: + fe = [format_error(e) for e in execution_result.errors] # type: ignore + response = {"errors": fe} status_code = 400 - response = execution_result.to_dict(format_error=format_error) + else: + response = {"data": execution_result.data} return FormattedResult(response, status_code) diff --git a/setup.cfg b/setup.cfg index 70e1f4a..78bddbd 100644 --- a/setup.cfg +++ b/setup.cfg @@ -4,9 +4,14 @@ max-line-length = 88 [isort] known_first_party=graphql_server +multi_line_output=3 +include_trailing_comma=True +force_grid_wrap=0 +use_parentheses=True [tool:pytest] norecursedirs = venv .venv .tox .git .cache .mypy_cache .pytest_cache +markers = asyncio [bdist_wheel] universal=1 diff --git a/setup.py b/setup.py index a6416c0..2bedab1 100644 --- a/setup.py +++ b/setup.py @@ -1,28 +1,27 @@ from setuptools import setup, find_packages install_requires = [ - "graphql-core>=2.3,<3", - "promise>=2.3,<3", + "graphql-core>=3.1.0,<4", ] tests_requires = [ - "pytest==4.6.9", - "pytest-cov==2.8.1" + "pytest>=5.3,<5.4", + "pytest-cov>=2.8,<3", ] dev_requires = [ - 'flake8==3.7.9', - 'isort<4.0.0', - 'black==19.10b0', - 'mypy==0.761', - 'check-manifest>=0.40,<1', + "flake8>=3.7,<4", + "isort>=4,<5", + "black==19.10b0", + "mypy>=0.761,<0.770", + "check-manifest>=0.40,<1", ] + tests_requires setup( name="graphql-server-core", version="2.0.0", description="GraphQL Server tools for powering your server", - long_description=open("README.md").read(), + long_description=open("README.md", encoding="utf-8").read(), long_description_content_type="text/markdown", url="https://github.com/graphql-python/graphql-server-core", download_url="https://github.com/graphql-python/graphql-server-core/releases", @@ -33,14 +32,9 @@ "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", - "Programming Language :: Python :: 2", - "Programming Language :: Python :: 2.7", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.5", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: Implementation :: PyPy", "License :: OSI Approved :: MIT License", ], keywords="api graphql protocol rest", diff --git a/tests/conftest.py b/tests/conftest.py deleted file mode 100644 index ae78c3d..0000000 --- a/tests/conftest.py +++ /dev/null @@ -1,4 +0,0 @@ -import sys - -if sys.version_info[:2] < (3, 4): - collect_ignore_glob = ["*_asyncio.py"] diff --git a/tests/schema.py b/tests/schema.py index c60b0ed..c7665ba 100644 --- a/tests/schema.py +++ b/tests/schema.py @@ -1,15 +1,15 @@ -from graphql.type.definition import ( +from graphql import ( GraphQLArgument, GraphQLField, GraphQLNonNull, GraphQLObjectType, + GraphQLSchema, + GraphQLString, ) -from graphql.type.scalars import GraphQLString -from graphql.type.schema import GraphQLSchema -def resolve_error(*_args): - raise ValueError("Throws!") +def resolve_thrower(*_args): + raise Exception("Throws!") def resolve_request(_obj, info): @@ -20,22 +20,16 @@ def resolve_context(_obj, info): return str(info.context) -def resolve_test(_obj, _info, who="World"): - return "Hello {}".format(who) - - -NonNullString = GraphQLNonNull(GraphQLString) - QueryRootType = GraphQLObjectType( name="QueryRoot", fields={ - "error": GraphQLField(NonNullString, resolver=resolve_error), - "request": GraphQLField(NonNullString, resolver=resolve_request), - "context": GraphQLField(NonNullString, resolver=resolve_context), + "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_thrower), + "request": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_request), + "context": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_context), "test": GraphQLField( - GraphQLString, - {"who": GraphQLArgument(GraphQLString)}, - resolver=resolve_test, + type_=GraphQLString, + args={"who": GraphQLArgument(GraphQLString)}, + resolve=lambda obj, info, who="World": "Hello %s" % who, ), }, ) @@ -43,10 +37,9 @@ def resolve_test(_obj, _info, who="World"): MutationRootType = GraphQLObjectType( name="MutationRoot", fields={ - "writeTest": GraphQLField( - type=QueryRootType, resolver=lambda *_args: QueryRootType - ) + "writeTest": GraphQLField(type_=QueryRootType, resolve=lambda *_: QueryRootType) }, ) schema = GraphQLSchema(QueryRootType, MutationRootType) +invalid_schema = GraphQLSchema() diff --git a/tests/test_asyncio.py b/tests/test_asyncio.py index db8fc02..e07a2f8 100644 --- a/tests/test_asyncio.py +++ b/tests/test_asyncio.py @@ -1,11 +1,14 @@ -from graphql.execution.executors.asyncio import AsyncioExecutor -from graphql.type.definition import GraphQLField, GraphQLNonNull, GraphQLObjectType +import asyncio + +from graphql.type.definition import ( + GraphQLField, + GraphQLNonNull, + GraphQLObjectType, +) from graphql.type.scalars import GraphQLString from graphql.type.schema import GraphQLSchema -from promise import Promise -import asyncio -from graphql_server import RequestParams, run_http_query +from graphql_server import GraphQLParams, run_http_query from .utils import as_dicts @@ -33,10 +36,10 @@ async def resolve_field_async(_obj, info): QueryRootType = GraphQLObjectType( name="QueryRoot", fields={ - "errorSync": GraphQLField(NonNullString, resolver=resolve_error_sync), - "errorAsync": GraphQLField(NonNullString, resolver=resolve_error_async), - "fieldSync": GraphQLField(NonNullString, resolver=resolve_field_sync), - "fieldAsync": GraphQLField(NonNullString, resolver=resolve_field_async), + "errorSync": GraphQLField(NonNullString, resolve=resolve_error_sync), + "errorAsync": GraphQLField(NonNullString, resolve=resolve_error_async), + "fieldSync": GraphQLField(NonNullString, resolve=resolve_field_sync), + "fieldAsync": GraphQLField(NonNullString, resolve=resolve_field_async), }, ) @@ -44,45 +47,25 @@ async def resolve_field_async(_obj, info): def test_get_responses_using_asyncio_executor(): - class TestExecutor(AsyncioExecutor): - called = False - waited = False - cleaned = False - - def wait_until_finished(self): - TestExecutor.waited = True - super().wait_until_finished() - - def clean(self): - TestExecutor.cleaned = True - super().clean() - - def execute(self, fn, *args, **kwargs): - TestExecutor.called = True - return super().execute(fn, *args, **kwargs) - query = "{fieldSync fieldAsync}" loop = asyncio.get_event_loop() async def get_results(): result_promises, params = run_http_query( - schema, - "get", - {}, - dict(query=query), - executor=TestExecutor(loop=loop), - return_promise=True, + schema, "get", {}, dict(query=query), run_sync=False ) - results = await Promise.all(result_promises) - return results, params + res = [await result for result in result_promises] + return res, params - results, params = loop.run_until_complete(get_results()) + try: + results, params = loop.run_until_complete(get_results()) + finally: + loop.close() - expected_results = [{"data": {"fieldSync": "sync", "fieldAsync": "async"}}] + expected_results = [ + {"data": {"fieldSync": "sync", "fieldAsync": "async"}, "errors": None} + ] assert as_dicts(results) == expected_results - assert params == [RequestParams(query=query, variables=None, operation_name=None)] - assert TestExecutor.called - assert not TestExecutor.waited - assert TestExecutor.cleaned + assert params == [GraphQLParams(query=query, variables=None, operation_name=None)] diff --git a/tests/test_error.py b/tests/test_error.py index a0f7017..4dfdc93 100644 --- a/tests/test_error.py +++ b/tests/test_error.py @@ -1,28 +1,34 @@ from graphql_server import HttpQueryError -def test_create_http_query_error(): - - error = HttpQueryError(420, "Some message", headers={"SomeHeader": "SomeValue"}) - assert error.status_code == 420 - assert error.message == "Some message" - assert error.headers == {"SomeHeader": "SomeValue"} +def test_can_create_http_query_error(): + error = HttpQueryError(400, "Bad error") + assert error.status_code == 400 + assert error.message == "Bad error" + assert not error.is_graphql_error + assert error.headers is None def test_compare_http_query_errors(): - - error = HttpQueryError(400, "Message", headers={"Header": "Value"}) - assert error == HttpQueryError(400, "Message", headers={"Header": "Value"}) - assert error != HttpQueryError(420, "Message", headers={"Header": "Value"}) - assert error != HttpQueryError(400, "Other Message", headers={"Header": "Value"}) - assert error != HttpQueryError(400, "Message", headers={"Header": "OtherValue"}) + error = HttpQueryError(400, "Bad error") + assert error == error + same_error = HttpQueryError(400, "Bad error") + assert error == same_error + different_error = HttpQueryError(400, "Not really bad error") + assert error != different_error + different_error = HttpQueryError(405, "Bad error") + assert error != different_error + different_error = HttpQueryError(400, "Bad error", headers={"Allow": "ALL"}) + assert error != different_error def test_hash_http_query_errors(): - - error = HttpQueryError(400, "Foo", headers={"Bar": "Baz"}) - - assert hash(error) == hash(HttpQueryError(400, "Foo", headers={"Bar": "Baz"})) - assert hash(error) != hash(HttpQueryError(420, "Foo", headers={"Bar": "Baz"})) - assert hash(error) != hash(HttpQueryError(400, "Boo", headers={"Bar": "Baz"})) - assert hash(error) != hash(HttpQueryError(400, "Foo", headers={"Bar": "Faz"})) + errors = { + HttpQueryError(400, "Bad error 1"), + HttpQueryError(400, "Bad error 2"), + HttpQueryError(403, "Bad error 1"), + } + assert HttpQueryError(400, "Bad error 1") in errors + assert HttpQueryError(400, "Bad error 2") in errors + assert HttpQueryError(403, "Bad error 1") in errors + assert HttpQueryError(403, "Bad error 2") not in errors diff --git a/tests/test_helpers.py b/tests/test_helpers.py index fc4b73e..d2c6b50 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,8 +1,8 @@ import json +from graphql import Source from graphql.error import GraphQLError from graphql.execution import ExecutionResult -from graphql.language.location import SourceLocation from pytest import raises from graphql_server import ( @@ -20,11 +20,6 @@ def test_json_encode(): assert result == '{"query":"{test}"}' -def test_json_encode_pretty(): - result = json_encode_pretty({"query": "{test}"}) - assert result == '{\n "query": "{test}"\n}' - - def test_json_encode_with_pretty_argument(): result = json_encode({"query": "{test}"}, pretty=False) assert result == '{"query":"{test}"}' @@ -88,7 +83,10 @@ def test_encode_execution_results_with_error(): None, [ GraphQLError( - "Some error", locations=[SourceLocation(1, 2)], path=["somePath"] + "Some error", + source=Source(body="Some error"), + positions=[1], + path=["somePath"], ) ], ), @@ -100,7 +98,6 @@ def test_encode_execution_results_with_error(): assert isinstance(output.body, str) assert isinstance(output.status_code, int) assert json.loads(output.body) == { - "data": None, "errors": [ { "message": "Some error", @@ -109,26 +106,6 @@ def test_encode_execution_results_with_error(): } ], } - assert output.status_code == 200 - - -def test_encode_execution_results_with_invalid(): - execution_results = [ - ExecutionResult( - None, - [GraphQLError("SyntaxError", locations=[SourceLocation(1, 2)])], - invalid=True, - ), - ExecutionResult({"result": 42}, None), - ] - - output = encode_execution_results(execution_results) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert json.loads(output.body) == { - "errors": [{"message": "SyntaxError", "locations": [{"line": 1, "column": 2}]}] - } assert output.status_code == 400 @@ -149,7 +126,10 @@ def test_encode_execution_results_with_format_error(): None, [ GraphQLError( - "Some msg", locations=[SourceLocation(1, 2)], path=["some", "path"] + "Some msg", + source=Source("Some msg"), + positions=[1], + path=["some", "path"], ) ], ) @@ -157,7 +137,7 @@ def test_encode_execution_results_with_format_error(): def format_error(error): return { - "msg": str(error), + "msg": error.message, "loc": "{}:{}".format(error.locations[0].line, error.locations[0].column), "pth": "/".join(error.path), } @@ -167,10 +147,9 @@ def format_error(error): assert isinstance(output.body, str) assert isinstance(output.status_code, int) assert json.loads(output.body) == { - "data": None, "errors": [{"msg": "Some msg", "loc": "1:2", "pth": "some/path"}], } - assert output.status_code == 200 + assert output.status_code == 400 def test_encode_execution_results_with_batch(): @@ -211,88 +190,6 @@ def test_encode_execution_results_with_batch_and_empty_result(): assert output.status_code == 200 -def test_encode_execution_results_with_batch_and_error(): - execution_results = [ - ExecutionResult({"result": 1}, None), - ExecutionResult( - None, - [ - GraphQLError( - "No data here", locations=[SourceLocation(1, 2)], path=["somePath"] - ) - ], - ), - ExecutionResult({"result": 3}, None), - ] - - output = encode_execution_results(execution_results, is_batch=True) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert json.loads(output.body) == [ - {"data": {"result": 1}}, - { - "data": None, - "errors": [ - { - "message": "No data here", - "locations": [{"line": 1, "column": 2}], - "path": ["somePath"], - } - ], - }, - {"data": {"result": 3}}, - ] - assert output.status_code == 200 - - -def test_encode_execution_results_with_batch_and_invalid(): - execution_results = [ - ExecutionResult({"result": 1}, None), - ExecutionResult( - None, - [ - GraphQLError( - "No data here", locations=[SourceLocation(1, 2)], path=["somePath"] - ) - ], - ), - ExecutionResult({"result": 3}, None), - ExecutionResult( - None, - [GraphQLError("SyntaxError", locations=[SourceLocation(1, 2)])], - invalid=True, - ), - ExecutionResult({"result": 5}, None), - ] - - output = encode_execution_results(execution_results, is_batch=True) - assert isinstance(output, ServerResponse) - assert isinstance(output.body, str) - assert isinstance(output.status_code, int) - assert json.loads(output.body) == [ - {"data": {"result": 1}}, - { - "data": None, - "errors": [ - { - "message": "No data here", - "locations": [{"line": 1, "column": 2}], - "path": ["somePath"], - } - ], - }, - {"data": {"result": 3}}, - { - "errors": [ - {"message": "SyntaxError", "locations": [{"line": 1, "column": 2}]} - ] - }, - {"data": {"result": 5}}, - ] - assert output.status_code == 400 - - def test_encode_execution_results_with_encode(): execution_results = [ExecutionResult({"result": None}, None)] @@ -307,7 +204,7 @@ def encode(result): assert output.status_code == 200 -def test_encode_execution_results_with_pretty(): +def test_encode_execution_results_with_pretty_encode(): execution_results = [ExecutionResult({"test": "Hello World"}, None)] output = encode_execution_results(execution_results, encode=json_encode_pretty) diff --git a/tests/test_query.py b/tests/test_query.py index e5bbb79..5e9618c 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -1,43 +1,59 @@ import json -from graphql.error import GraphQLError, GraphQLSyntaxError +from graphql.error import GraphQLError from graphql.execution import ExecutionResult -from promise import Promise from pytest import raises from graphql_server import ( + GraphQLParams, + GraphQLResponse, HttpQueryError, - RequestParams, - ServerResults, encode_execution_results, + format_execution_result, json_encode, - json_encode_pretty, load_json_body, run_http_query, ) -from .schema import schema +from .schema import invalid_schema, schema from .utils import as_dicts def test_request_params(): - assert issubclass(RequestParams, tuple) + assert issubclass(GraphQLParams, tuple) # noinspection PyUnresolvedReferences - assert RequestParams._fields == ("query", "variables", "operation_name") + assert GraphQLParams._fields == ("query", "variables", "operation_name") def test_server_results(): - assert issubclass(ServerResults, tuple) + assert issubclass(GraphQLResponse, tuple) # noinspection PyUnresolvedReferences - assert ServerResults._fields == ("results", "params") + assert GraphQLResponse._fields == ("results", "params") + + +def test_validate_schema(): + query = "{test}" + results, params = run_http_query(invalid_schema, "get", {}, dict(query=query)) + assert as_dicts(results) == [ + { + "data": None, + "errors": [ + { + "locations": None, + "message": "Query root type must be provided.", + "path": None, + } + ], + } + ] def test_allows_get_with_query_param(): query = "{test}" results, params = run_http_query(schema, "get", {}, dict(query=query)) - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] - assert params == [RequestParams(query=query, variables=None, operation_name=None)] + assert as_dicts(results) == [{"data": {"test": "Hello World"}, "errors": None}] + assert params == [GraphQLParams(query=query, variables=None, operation_name=None)] def test_allows_get_with_variable_values(): @@ -51,7 +67,7 @@ def test_allows_get_with_variable_values(): ), ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}, "errors": None}] def test_allows_get_with_operation_name(): @@ -73,7 +89,7 @@ def test_allows_get_with_operation_name(): ) assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + {"data": {"test": "Hello World", "shared": "Hello Everyone"}, "errors": None} ] @@ -84,16 +100,19 @@ def test_reports_validation_errors(): assert as_dicts(results) == [ { + "data": None, "errors": [ { - "message": 'Cannot query field "unknownOne" on type "QueryRoot".', + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 9}], + "path": None, }, { - "message": 'Cannot query field "unknownTwo" on type "QueryRoot".', + "message": "Cannot query field 'unknownTwo' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 21}], + "path": None, }, - ] + ], } ] @@ -132,14 +151,17 @@ def test_errors_when_missing_operation_name(): assert as_dicts(results) == [ { + "data": None, "errors": [ { + "locations": None, "message": ( "Must provide operation name" " if query contains multiple operations." - ) + ), + "path": None, } - ] + ], } ] assert isinstance(results[0].errors[0], GraphQLError) @@ -217,7 +239,7 @@ def test_allows_mutation_to_exist_within_a_get(): ), ) - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] + assert as_dicts(results) == [{"data": {"test": "Hello World"}, "errors": None}] def test_allows_sending_a_mutation_via_post(): @@ -228,7 +250,7 @@ def test_allows_sending_a_mutation_via_post(): query_data=dict(query="mutation TestMutation { writeTest { test } }"), ) - assert as_dicts(results) == [{"data": {"writeTest": {"test": "Hello World"}}}] + assert results == [({"writeTest": {"test": "Hello World"}}, None)] def test_allows_post_with_url_encoding(): @@ -236,7 +258,7 @@ def test_allows_post_with_url_encoding(): schema, "post", {}, query_data=dict(query="{test}") ) - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] + assert results == [({"test": "Hello World"}, None)] def test_supports_post_json_query_with_string_variables(): @@ -250,7 +272,20 @@ def test_supports_post_json_query_with_string_variables(): ), ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] + + +def test_supports_post_json_query_with_json_variables(): + result = load_json_body( + """ + { + "query": "query helloWho($who: String){ test(who: $who) }", + "variables": {"who": "Dolly"} + } + """ + ) + + assert result["variables"] == {"who": "Dolly"} def test_supports_post_url_encoded_query_with_string_variables(): @@ -264,7 +299,7 @@ def test_supports_post_url_encoded_query_with_string_variables(): ), ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_supports_post_json_query_with_get_variable_values(): @@ -275,7 +310,7 @@ def test_supports_post_json_query_with_get_variable_values(): query_data=dict(variables={"who": "Dolly"}), ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_post_url_encoded_query_with_get_variable_values(): @@ -286,7 +321,7 @@ def test_post_url_encoded_query_with_get_variable_values(): query_data=dict(variables='{"who": "Dolly"}'), ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_supports_post_raw_text_query_with_get_variable_values(): @@ -297,7 +332,7 @@ def test_supports_post_raw_text_query_with_get_variable_values(): query_data=dict(variables='{"who": "Dolly"}'), ) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_allows_post_with_operation_name(): @@ -317,9 +352,7 @@ def test_allows_post_with_operation_name(): ), ) - assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] + assert results == [({"test": "Hello World", "shared": "Hello Everyone"}, None)] def test_allows_post_with_get_operation_name(): @@ -339,55 +372,46 @@ def test_allows_post_with_get_operation_name(): query_data=dict(operationName="helloWorld"), ) - assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] + assert results == [({"test": "Hello World", "shared": "Hello Everyone"}, None)] def test_supports_pretty_printing_data(): - results, params = run_http_query(schema, "get", dict(query="{test}")) - body = encode_execution_results(results, encode=json_encode_pretty).body + results, params = run_http_query(schema, "get", data=dict(query="{test}")) + result = {"data": results[0].data} - assert body == "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + assert json_encode(result, pretty=True) == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) def test_not_pretty_data_by_default(): - results, params = run_http_query(schema, "get", dict(query="{test}")) - body = encode_execution_results(results).body + results, params = run_http_query(schema, "get", data=dict(query="{test}")) + result = {"data": results[0].data} - assert body == '{"data":{"test":"Hello World"}}' + assert json_encode(result) == '{"data":{"test":"Hello World"}}' def test_handles_field_errors_caught_by_graphql(): - results, params = run_http_query(schema, "get", dict(query="{error}")) + results, params = run_http_query(schema, "get", data=dict(query="{thrower}")) - assert as_dicts(results) == [ - { - "data": None, - "errors": [ - { - "message": "Throws!", - "locations": [{"line": 1, "column": 2}], - "path": ["error"], - } - ], - } + assert results == [ + (None, [{"message": "Throws!", "locations": [(1, 2)], "path": ["thrower"]}]) ] def test_handles_syntax_errors_caught_by_graphql(): - results, params = run_http_query(schema, "get", dict(query="syntaxerror")) + results, params = run_http_query(schema, "get", data=dict(query="syntaxerror")) - assert as_dicts(results) == [ - { - "errors": [ + assert results == [ + ( + None, + [ { - "locations": [{"line": 1, "column": 1}], - "message": "Syntax Error GraphQL (1:1)" - ' Unexpected Name "syntaxerror"\n\n1: syntaxerror\n ^\n', + "locations": [(1, 1)], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", } - ] - } + ], + ) ] @@ -400,10 +424,7 @@ def test_handles_errors_caused_by_a_lack_of_query(): def test_handles_errors_caused_by_invalid_query_type(): results, params = run_http_query(schema, "get", dict(query=42)) - - assert as_dicts(results) == [ - {"errors": [{"message": "The query must be a string"}]} - ] + assert results == [(None, [{"message": "Must provide Source. Received: 42."}])] def test_handles_batch_correctly_if_is_disabled(): @@ -447,10 +468,11 @@ def test_handles_poorly_formed_variables(): def test_handles_bad_schema(): with raises(TypeError) as exc_info: # noinspection PyTypeChecker - run_http_query("not a schema", "get", {"query": "{error}"}) # type: ignore + run_http_query("not a schema", "get", {}) # type: ignore - msg = str(exc_info.value) - assert msg == "Expected a GraphQL schema, but received 'not a schema'." + assert str(exc_info.value) == ( + "Expected a GraphQL schema, but received 'not a schema'." + ) def test_handles_unsupported_http_methods(): @@ -464,12 +486,54 @@ def test_handles_unsupported_http_methods(): ) +def test_format_execution_result(): + result = format_execution_result(None) + assert result == GraphQLResponse(None, 200) + data = {"answer": 42} + result = format_execution_result(ExecutionResult(data, None)) + assert result == GraphQLResponse({"data": data}, 200) + errors = [GraphQLError("bad")] + result = format_execution_result(ExecutionResult(None, errors)) + assert result == GraphQLResponse({"errors": errors}, 400) + + +def test_encode_execution_results(): + data = {"answer": 42} + errors = [GraphQLError("bad")] + results = [ExecutionResult(data, None), ExecutionResult(None, errors)] + result = encode_execution_results(results) + assert result == ('{"data":{"answer":42}}', 400) + + +def test_encode_execution_results_batch(): + data = {"answer": 42} + errors = [GraphQLError("bad")] + results = [ExecutionResult(data, None), ExecutionResult(None, errors)] + result = encode_execution_results(results, is_batch=True) + assert result == ( + '[{"data":{"answer":42}},' + '{"errors":[{"message":"bad","locations":null,"path":null}]}]', + 400, + ) + + +def test_encode_execution_results_not_encoded(): + data = {"answer": 42} + results = [ExecutionResult(data, None)] + result = encode_execution_results(results, encode=lambda r: r) + assert result == ({"data": data}, 200) + + def test_passes_request_into_request_context(): results, params = run_http_query( - schema, "get", {}, dict(query="{request}"), context_value={"q": "testing"} + schema, + "get", + {}, + query_data=dict(query="{request}"), + context_value={"q": "testing"}, ) - assert as_dicts(results) == [{"data": {"request": "testing"}}] + assert results == [({"request": "testing"}, None)] def test_supports_pretty_printing_context(): @@ -478,24 +542,24 @@ def __str__(self): return "CUSTOM CONTEXT" results, params = run_http_query( - schema, "get", {}, dict(query="{context}"), context_value=Context() + schema, "get", {}, query_data=dict(query="{context}"), context_value=Context() ) - assert as_dicts(results) == [{"data": {"context": "CUSTOM CONTEXT"}}] + assert results == [({"context": "CUSTOM CONTEXT"}, None)] def test_post_multipart_data(): query = "mutation TestMutation { writeTest { test } }" results, params = run_http_query(schema, "post", {}, query_data=dict(query=query)) - assert as_dicts(results) == [{"data": {"writeTest": {"test": "Hello World"}}}] + assert results == [({"writeTest": {"test": "Hello World"}}, None)] def test_batch_allows_post_with_json_encoding(): data = load_json_body('[{"query": "{test}"}]') results, params = run_http_query(schema, "post", data, batch_enabled=True) - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] + assert results == [({"test": "Hello World"}, None)] def test_batch_supports_post_json_query_with_json_variables(): @@ -505,7 +569,7 @@ def test_batch_supports_post_json_query_with_json_variables(): ) results, params = run_http_query(schema, "post", data, batch_enabled=True) - assert as_dicts(results) == [{"data": {"test": "Hello Dolly"}}] + assert results == [({"test": "Hello Dolly"}, None)] def test_batch_allows_post_with_operation_name(): @@ -525,124 +589,4 @@ def test_batch_allows_post_with_operation_name(): data = load_json_body(json_encode(data)) results, params = run_http_query(schema, "post", data, batch_enabled=True) - assert as_dicts(results) == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] - - -def test_get_responses_using_executor(): - class TestExecutor(object): - called = False - waited = False - cleaned = False - - def wait_until_finished(self): - TestExecutor.waited = True - - def clean(self): - TestExecutor.cleaned = True - - def execute(self, fn, *args, **kwargs): - TestExecutor.called = True - return fn(*args, **kwargs) - - query = "{test}" - results, params = run_http_query( - schema, "get", {}, dict(query=query), executor=TestExecutor(), - ) - - assert isinstance(results, list) - assert len(results) == 1 - assert isinstance(results[0], ExecutionResult) - - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] - assert params == [RequestParams(query=query, variables=None, operation_name=None)] - assert TestExecutor.called - assert TestExecutor.waited - assert not TestExecutor.cleaned - - -def test_get_responses_using_executor_return_promise(): - class TestExecutor(object): - called = False - waited = False - cleaned = False - - def wait_until_finished(self): - TestExecutor.waited = True - - def clean(self): - TestExecutor.cleaned = True - - def execute(self, fn, *args, **kwargs): - TestExecutor.called = True - return fn(*args, **kwargs) - - query = "{test}" - result_promises, params = run_http_query( - schema, - "get", - {}, - dict(query=query), - executor=TestExecutor(), - return_promise=True, - ) - - assert isinstance(result_promises, list) - assert len(result_promises) == 1 - assert isinstance(result_promises[0], Promise) - results = Promise.all(result_promises).get() - - assert as_dicts(results) == [{"data": {"test": "Hello World"}}] - assert params == [RequestParams(query=query, variables=None, operation_name=None)] - assert TestExecutor.called - assert not TestExecutor.waited - assert TestExecutor.cleaned - - -def test_syntax_error_using_executor_return_promise(): - class TestExecutor(object): - called = False - waited = False - cleaned = False - - def wait_until_finished(self): - TestExecutor.waited = True - - def clean(self): - TestExecutor.cleaned = True - - def execute(self, fn, *args, **kwargs): - TestExecutor.called = True - return fn(*args, **kwargs) - - query = "this is a syntax error" - result_promises, params = run_http_query( - schema, - "get", - {}, - dict(query=query), - executor=TestExecutor(), - return_promise=True, - ) - - assert isinstance(result_promises, list) - assert len(result_promises) == 1 - assert isinstance(result_promises[0], Promise) - results = Promise.all(result_promises).get() - - assert isinstance(results, list) - assert len(results) == 1 - result = results[0] - assert isinstance(result, ExecutionResult) - - assert result.data is None - assert isinstance(result.errors, list) - assert len(result.errors) == 1 - error = result.errors[0] - assert isinstance(error, GraphQLSyntaxError) - - assert params == [RequestParams(query=query, variables=None, operation_name=None)] - assert not TestExecutor.called - assert not TestExecutor.waited - assert not TestExecutor.cleaned + assert results == [({"test": "Hello World", "shared": "Hello Everyone"}, None)] diff --git a/tests/utils.py b/tests/utils.py index 136f09f..895c777 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -1,3 +1,16 @@ -def as_dicts(results): +from typing import List + +from graphql import ExecutionResult + + +def as_dicts(results: List[ExecutionResult]): """Convert execution results to a list of tuples of dicts for better comparison.""" - return [result.to_dict(dict_class=dict) for result in results] + return [ + { + "data": result.data, + "errors": [error.formatted for error in result.errors] + if result.errors + else result.errors, + } + for result in results + ] diff --git a/tox.ini b/tox.ini index 77a2bb6..2453c8b 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = black,flake8,import-order,mypy,manifest, - py{27,35,36,37,38,39-dev,py,py3} + py{36,37,38,39-dev} ; requires = tox-conda [testenv] @@ -17,31 +17,31 @@ commands = pytest --cov-report=term-missing --cov=graphql_server tests {posargs} [testenv:black] -basepython=python3.6 +basepython=python3.7 deps = -e.[dev] commands = black --check graphql_server tests [testenv:flake8] -basepython=python3.6 +basepython=python3.7 deps = -e.[dev] commands = flake8 setup.py graphql_server tests [testenv:import-order] -basepython=python3.6 +basepython=python3.7 deps = -e.[dev] commands = isort -rc graphql_server/ tests/ [testenv:mypy] -basepython=python3.6 +basepython=python3.7 deps = -e.[dev] commands = mypy graphql_server tests --ignore-missing-imports [testenv:manifest] -basepython = python3.6 +basepython = python3.7 deps = -e.[dev] commands = check-manifest -v From 66b8a2bf13d29e70d1ea424f0588a176ad988c00 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Tue, 5 May 2020 08:36:33 -0500 Subject: [PATCH 02/48] Merge flask-graphql (#37) * refactor: add flask-graphql as optional feature * refactor(server): default_format_error to __all__ * chore: rename dir flask-graphql to flask * chore: add extras require all key * chore: update gitignore * fix(sc): move params query check to try-except * refactor(flask): remove unused backend param * tests(flask): graphiqlview and graphqlview * styles: apply black, isort, flake8 formatting * chore: add all requires to test env * chore(flask): remove blueprint module * refactor(flask): remove py27 imports and unused test * styles: apply black, isort and flake8 formatting --- .gitignore | 208 ++++++++- graphql_server/__init__.py | 7 +- graphql_server/flask/__init__.py | 3 + graphql_server/flask/graphqlview.py | 151 ++++++ graphql_server/flask/render_graphiql.py | 148 ++++++ setup.py | 14 +- tests/flask/__init__.py | 0 tests/flask/app.py | 18 + tests/flask/schema.py | 41 ++ tests/flask/test_graphiqlview.py | 60 +++ tests/flask/test_graphqlview.py | 581 ++++++++++++++++++++++++ 11 files changed, 1213 insertions(+), 18 deletions(-) create mode 100644 graphql_server/flask/__init__.py create mode 100644 graphql_server/flask/graphqlview.py create mode 100644 graphql_server/flask/render_graphiql.py 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/.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"} + } + ] From ea817040b585e103575c6e611b09dcb65dde3627 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 10 May 2020 19:42:05 +0100 Subject: [PATCH 03/48] Remove references to executor in Flask view (#40) --- graphql_server/flask/graphqlview.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index d1d971a..1a2f9af 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -19,7 +19,6 @@ class GraphQLView(View): schema = None - executor = None root_value = None pretty = False graphiql = False @@ -51,9 +50,6 @@ def get_context_value(self): 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, @@ -76,13 +72,6 @@ def dispatch_request(self): 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, @@ -94,7 +83,6 @@ def dispatch_request(self): 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, From 6c13ef6481e9d51657525cfabbbd2e637deb6d29 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Sun, 10 May 2020 19:46:44 +0100 Subject: [PATCH 04/48] Return 200 errors (#39) --- graphql_server/__init__.py | 8 +++++++- setup.py | 2 +- tests/flask/test_graphqlview.py | 5 +++-- tests/test_helpers.py | 6 ++++-- tests/test_query.py | 9 +++++++++ 5 files changed, 24 insertions(+), 6 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index c4685c0..4e5ad8f 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -294,7 +294,13 @@ def format_execution_result( if execution_result.errors: fe = [format_error(e) for e in execution_result.errors] # type: ignore response = {"errors": fe} - status_code = 400 + + if execution_result.errors and any( + not getattr(e, "path", None) for e in execution_result.errors + ): + status_code = 400 + else: + response["data"] = execution_result.data else: response = {"data": execution_result.data} diff --git a/setup.py b/setup.py index d8568a9..15397cc 100644 --- a/setup.py +++ b/setup.py @@ -50,7 +50,7 @@ extras_require={ "all": install_all_requires, "test": install_all_requires + tests_requires, - "dev": dev_requires, + "dev": install_all_requires + dev_requires, "flask": install_flask_requires, }, include_package_data=True, diff --git a/tests/flask/test_graphqlview.py b/tests/flask/test_graphqlview.py index 0f65072..d2f478d 100644 --- a/tests/flask/test_graphqlview.py +++ b/tests/flask/test_graphqlview.py @@ -371,7 +371,7 @@ def test_supports_pretty_printing_by_request(app, client): 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.status_code == 200 assert response_json(response) == { "errors": [ { @@ -379,7 +379,8 @@ def test_handles_field_errors_caught_by_graphql(app, client): "path": ["thrower"], "message": "Throws!", } - ] + ], + "data": None, } diff --git a/tests/test_helpers.py b/tests/test_helpers.py index d2c6b50..ad62f62 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -105,8 +105,9 @@ def test_encode_execution_results_with_error(): "path": ["somePath"], } ], + "data": None, } - assert output.status_code == 400 + assert output.status_code == 200 def test_encode_execution_results_with_empty_result(): @@ -148,8 +149,9 @@ def format_error(error): assert isinstance(output.status_code, int) assert json.loads(output.body) == { "errors": [{"msg": "Some msg", "loc": "1:2", "pth": "some/path"}], + "data": None, } - assert output.status_code == 400 + assert output.status_code == 200 def test_encode_execution_results_with_batch(): diff --git a/tests/test_query.py b/tests/test_query.py index 5e9618c..7f5ab6f 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -92,6 +92,9 @@ def test_allows_get_with_operation_name(): {"data": {"test": "Hello World", "shared": "Hello Everyone"}, "errors": None} ] + response = encode_execution_results(results) + assert response.status_code == 200 + def test_reports_validation_errors(): results, params = run_http_query( @@ -116,6 +119,9 @@ def test_reports_validation_errors(): } ] + response = encode_execution_results(results) + assert response.status_code == 400 + def test_non_dict_params_in_non_batch_query(): with raises(HttpQueryError) as exc_info: @@ -398,6 +404,9 @@ def test_handles_field_errors_caught_by_graphql(): (None, [{"message": "Throws!", "locations": [(1, 2)], "path": ["thrower"]}]) ] + response = encode_execution_results(results) + assert response.status_code == 200 + def test_handles_syntax_errors_caught_by_graphql(): results, params = run_http_query(schema, "get", data=dict(query="syntaxerror")) From 35ed87d2372ed5443aa904ebeaa79d5d701f4bc0 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sat, 6 Jun 2020 11:40:21 -0500 Subject: [PATCH 05/48] Merge sanic-graphql (#38) * refactor: add sanic-graphql as optional feature * refactor: sanic tests and remove executor parameter * styles: apply black and flake8 formatting --- graphql_server/sanic/__init__.py | 3 + graphql_server/sanic/graphqlview.py | 190 ++++++++ graphql_server/sanic/render_graphiql.py | 185 +++++++ setup.cfg | 1 + setup.py | 12 +- tests/sanic/__init__.py | 0 tests/sanic/app.py | 28 ++ tests/sanic/schema.py | 72 +++ tests/sanic/test_graphiqlview.py | 88 ++++ tests/sanic/test_graphqlview.py | 610 ++++++++++++++++++++++++ 10 files changed, 1188 insertions(+), 1 deletion(-) create mode 100644 graphql_server/sanic/__init__.py create mode 100644 graphql_server/sanic/graphqlview.py create mode 100644 graphql_server/sanic/render_graphiql.py create mode 100644 tests/sanic/__init__.py create mode 100644 tests/sanic/app.py create mode 100644 tests/sanic/schema.py create mode 100644 tests/sanic/test_graphiqlview.py create mode 100644 tests/sanic/test_graphqlview.py 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 From 8e2f147a2c23ec7275fe1924dc0c190370ffd256 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Wed, 10 Jun 2020 12:25:34 -0500 Subject: [PATCH 06/48] Merge aiohttp-graphql (#42) * refactor: add aiohttp-graphql as optional feature * tests: cleanup aiohttp subpackage --- graphql_server/aiohttp/__init__.py | 3 + graphql_server/aiohttp/graphqlview.py | 217 +++++++ graphql_server/aiohttp/render_graphiql.py | 208 +++++++ setup.py | 8 +- tests/aiohttp/__init__.py | 1 + tests/aiohttp/app.py | 22 + tests/aiohttp/schema.py | 85 +++ tests/aiohttp/test_graphiqlview.py | 112 ++++ tests/aiohttp/test_graphqlview.py | 675 ++++++++++++++++++++++ 9 files changed, 1330 insertions(+), 1 deletion(-) create mode 100644 graphql_server/aiohttp/__init__.py create mode 100644 graphql_server/aiohttp/graphqlview.py create mode 100644 graphql_server/aiohttp/render_graphiql.py create mode 100644 tests/aiohttp/__init__.py create mode 100644 tests/aiohttp/app.py create mode 100644 tests/aiohttp/schema.py create mode 100644 tests/aiohttp/test_graphiqlview.py create mode 100644 tests/aiohttp/test_graphqlview.py diff --git a/graphql_server/aiohttp/__init__.py b/graphql_server/aiohttp/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/aiohttp/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py new file mode 100644 index 0000000..9581e12 --- /dev/null +++ b/graphql_server/aiohttp/graphqlview.py @@ -0,0 +1,217 @@ +import copy +from collections.abc import MutableMapping +from functools import partial + +from aiohttp import web +from graphql 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: + 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 + subscriptions = None + + accepted_methods = ["GET", "POST", "PUT", "DELETE"] + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + 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 + + # This method can be static + async def parse_body(self, request): + content_type = request.content_type + # request.text() is the aiohttp equivalent to + # request.body.decode("utf8") + if content_type == "application/graphql": + r_text = await request.text() + return {"query": r_text} + + if content_type == "application/json": + text = await request.text() + return load_json_body(text) + + if content_type in ( + "application/x-www-form-urlencoded", + "multipart/form-data", + ): + # TODO: seems like a multidict would be more appropriate + # than casting it and de-duping variables. Alas, it's what + # graphql-python wants. + return dict(await request.post()) + + return {} + + def render_graphiql(self, params, result): + return render_graphiql( + jinja_env=self.jinja_env, + params=params, + result=result, + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + subscriptions=self.subscriptions, + ) + + # TODO: + # use this method to replace flask and sanic + # checks as this is equivalent to `should_display_graphiql` and + # `request_wants_html` methods. + def is_graphiql(self, request): + return all( + [ + self.graphiql, + request.method.lower() == "get", + "raw" not in request.query, + any( + [ + "text/html" in request.headers.get("accept", {}), + "*/*" in request.headers.get("accept", {}), + ] + ), + ] + ) + + # TODO: Same stuff as above method. + def is_pretty(self, request): + return any( + [self.pretty, self.is_graphiql(request), request.query.get("pretty")] + ) + + async def __call__(self, request): + try: + data = await self.parse_body(request) + request_method = request.method.lower() + is_graphiql = self.is_graphiql(request) + is_pretty = self.is_pretty(request) + + # TODO: way better than if-else so better + # implement this too on flask and sanic + if request_method == "options": + return self.process_preflight(request) + + execution_results, all_params = run_http_query( + self.schema, + request_method, + data, + query_data=request.query, + batch_enabled=self.batch, + catch=is_graphiql, + # 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=is_pretty), # noqa: ignore + ) + + if is_graphiql: + return await self.render_graphiql(params=all_params[0], result=result) + + return web.Response( + text=result, status=status_code, content_type="application/json", + ) + + except HttpQueryError as err: + parsed_error = GraphQLError(err.message) + return web.Response( + body=self.encode(dict(errors=[self.format_error(parsed_error)])), + status=err.status_code, + headers=err.headers, + content_type="application/json", + ) + + def process_preflight(self, request): + """ + Preflight request support for apollo-client + https://www.w3.org/TR/cors/#resource-preflight-requests + """ + headers = request.headers + origin = headers.get("Origin", "") + method = headers.get("Access-Control-Request-Method", "").upper() + + if method and method in self.accepted_methods: + return web.Response( + status=200, + headers={ + "Access-Control-Allow-Origin": origin, + "Access-Control-Allow-Methods": ", ".join(self.accepted_methods), + "Access-Control-Max-Age": str(self.max_age), + }, + ) + return web.Response(status=400) + + @classmethod + def attach(cls, app, *, route_path="/graphql", route_name="graphql", **kwargs): + view = cls(**kwargs) + app.router.add_route("*", route_path, _asyncify(view), name=route_name) + + +def _asyncify(handler): + """Return an async version of the given handler. + + This is mainly here because ``aiohttp`` can't infer the async definition of + :py:meth:`.GraphQLView.__call__` and raises a :py:class:`DeprecationWarning` + in tests. Wrapping it into an async function avoids the noisy warning. + """ + + async def _dispatch(request): + return await handler(request) + + return _dispatch diff --git a/graphql_server/aiohttp/render_graphiql.py b/graphql_server/aiohttp/render_graphiql.py new file mode 100644 index 0000000..9da47d3 --- /dev/null +++ b/graphql_server/aiohttp/render_graphiql.py @@ -0,0 +1,208 @@ +import json +import re + +from aiohttp import web + +GRAPHIQL_VERSION = "0.17.5" + +TEMPLATE = """ + + + + + + + + + + + + + + + + +""" + + +def escape_js_value(value): + quotation = False + if value.startswith('"') and value.endswith('"'): + quotation = True + value = value[1:-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", "subscriptions"] + replace_jsonify = ["query", "result", "variables", "operation_name"] + + for rep in replace: + template = process_var(template, rep, values.get(rep, "")) + + for rep in replace_jsonify: + template = process_var(template, rep, values.get(rep, ""), True) + + return template + + +async def render_graphiql( + jinja_env=None, + graphiql_version=None, + graphiql_template=None, + params=None, + result=None, + subscriptions=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, + "subscriptions": subscriptions or "", + } + + 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 web.Response(text=source, content_type="text/html") diff --git a/setup.py b/setup.py index fbf8637..6135166 100644 --- a/setup.py +++ b/setup.py @@ -27,10 +27,15 @@ "sanic>=19.9.0,<20", ] +install_aiohttp_requires = [ + "aiohttp>=3.5.0,<4", +] + install_all_requires = \ install_requires + \ install_flask_requires + \ - install_sanic_requires + install_sanic_requires + \ + install_aiohttp_requires setup( name="graphql-server-core", @@ -62,6 +67,7 @@ "dev": install_all_requires + dev_requires, "flask": install_flask_requires, "sanic": install_sanic_requires, + "aiohttp": install_aiohttp_requires, }, include_package_data=True, zip_safe=False, diff --git a/tests/aiohttp/__init__.py b/tests/aiohttp/__init__.py new file mode 100644 index 0000000..943d58f --- /dev/null +++ b/tests/aiohttp/__init__.py @@ -0,0 +1 @@ +# aiohttp-graphql tests diff --git a/tests/aiohttp/app.py b/tests/aiohttp/app.py new file mode 100644 index 0000000..36d7de6 --- /dev/null +++ b/tests/aiohttp/app.py @@ -0,0 +1,22 @@ +from urllib.parse import urlencode + +from aiohttp import web + +from graphql_server.aiohttp import GraphQLView +from tests.aiohttp.schema import Schema + + +def create_app(schema=Schema, **kwargs): + app = web.Application() + # Only needed to silence aiohttp deprecation warnings + GraphQLView.attach(app, schema=schema, **kwargs) + return app + + +def url_string(**url_params): + base_url = "/graphql" + + if url_params: + return f"{base_url}?{urlencode(url_params)}" + + return base_url diff --git a/tests/aiohttp/schema.py b/tests/aiohttp/schema.py new file mode 100644 index 0000000..9198b12 --- /dev/null +++ b/tests/aiohttp/schema.py @@ -0,0 +1,85 @@ +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, *args: info.context["request"].query.get("q"), + ), + "context": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info, *args: 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 *args: QueryRootType + ) + }, +) + +SubscriptionsRootType = GraphQLObjectType( + name="SubscriptionsRoot", + fields={ + "subscriptionsTest": GraphQLField( + type_=QueryRootType, resolve=lambda *args: QueryRootType + ) + }, +) + +Schema = GraphQLSchema(QueryRootType, MutationRootType, SubscriptionsRootType) + + +# 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( + "AsyncQueryType", + { + "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/aiohttp/test_graphiqlview.py b/tests/aiohttp/test_graphiqlview.py new file mode 100644 index 0000000..04a9b50 --- /dev/null +++ b/tests/aiohttp/test_graphiqlview.py @@ -0,0 +1,112 @@ +import pytest +from aiohttp.test_utils import TestClient, TestServer +from jinja2 import Environment + +from tests.aiohttp.app import create_app, url_string +from tests.aiohttp.schema import AsyncSchema, Schema + + +@pytest.fixture +def app(): + app = create_app() + return app + + +@pytest.fixture +async def client(app): + client = TestClient(TestServer(app)) + await client.start_server() + yield client + await client.close() + + +@pytest.fixture +def view_kwargs(): + return { + "schema": Schema, + "graphiql": True, + } + + +@pytest.fixture +def pretty_response(): + return ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +async def test_graphiql_is_enabled(app, client): + response = await client.get( + url_string(query="{test}"), headers={"Accept": "text/html"} + ) + assert response.status == 200 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +async def test_graphiql_simple_renderer(app, client, pretty_response): + response = await client.get( + url_string(query="{test}"), headers={"Accept": "text/html"}, + ) + assert response.status == 200 + assert pretty_response in await response.text() + + +class TestJinjaEnv: + @pytest.mark.asyncio + @pytest.mark.parametrize( + "app", [create_app(graphiql=True, jinja_env=Environment())] + ) + async def test_graphiql_jinja_renderer(self, app, client, pretty_response): + response = await client.get( + url_string(query="{test}"), headers={"Accept": "text/html"}, + ) + assert response.status == 200 + assert pretty_response in await response.text() + + +@pytest.mark.asyncio +async def test_graphiql_html_is_not_accepted(client): + response = await client.get("/graphql", headers={"Accept": "application/json"},) + assert response.status == 400 + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +async def test_graphiql_get_mutation(app, client): + response = await client.get( + url_string(query="mutation TestMutation { writeTest { test } }"), + headers={"Accept": "text/html"}, + ) + assert response.status == 200 + assert "response: null" in await response.text() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(graphiql=True)]) +async def test_graphiql_get_subscriptions(client): + response = await client.get( + url_string( + query="subscription TestSubscriptions { subscriptionsTest { test } }" + ), + headers={"Accept": "text/html"}, + ) + assert response.status == 200 + assert "response: null" in await response.text() + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) +async def test_graphiql_async_schema(app, client): + response = await client.get( + url_string(query="{a,b,c}"), headers={"Accept": "text/html"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} diff --git a/tests/aiohttp/test_graphqlview.py b/tests/aiohttp/test_graphqlview.py new file mode 100644 index 0000000..0f6becb --- /dev/null +++ b/tests/aiohttp/test_graphqlview.py @@ -0,0 +1,675 @@ +import json +from urllib.parse import urlencode + +import pytest +from aiohttp import FormData +from aiohttp.test_utils import TestClient, TestServer + +from .app import create_app, url_string +from .schema import AsyncSchema + + +@pytest.fixture +def app(): + app = create_app() + return app + + +@pytest.fixture +async def client(app): + client = TestClient(TestServer(app)) + await client.start_server() + yield client + await client.close() + + +@pytest.mark.asyncio +async def test_allows_get_with_query_param(client): + response = await client.get(url_string(query="{test}")) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_get_with_variable_values(client): + response = await client.get( + url_string( + query="query helloWho($who: String) { test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_allows_get_with_operation_name(client): + response = await client.get( + 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 await response.json() == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_reports_validation_errors(client): + response = await client.get(url_string(query="{ test, unknownOne, unknownTwo }")) + + assert response.status == 400 + assert await response.json() == { + "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.asyncio +async def test_errors_when_missing_operation_name(client): + response = await client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + subscription TestSubscriptions { subscriptionsTest { test } } + """ + ) + ) + + assert response.status == 400 + assert await response.json() == { + "errors": [ + { + "message": ( + "Must provide operation name if query contains multiple " + "operations." + ), + "locations": None, + "path": None, + }, + ] + } + + +@pytest.mark.asyncio +async def test_errors_when_sending_a_mutation_via_get(client): + response = await client.get( + url_string( + query=""" + mutation TestMutation { writeTest { test } } + """ + ) + ) + assert response.status == 405 + assert await response.json() == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + "locations": None, + "path": None, + }, + ], + } + + +@pytest.mark.asyncio +async def test_errors_when_selecting_a_mutation_within_a_get(client): + response = await client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestMutation", + ) + ) + + assert response.status == 405 + assert await response.json() == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + "locations": None, + "path": None, + }, + ], + } + + +@pytest.mark.asyncio +async def test_errors_when_selecting_a_subscription_within_a_get(client): + response = await client.get( + url_string( + query=""" + subscription TestSubscriptions { subscriptionsTest { test } } + """, + operationName="TestSubscriptions", + ) + ) + + assert response.status == 405 + assert await response.json() == { + "errors": [ + { + "message": "Can only perform a subscription operation from a POST " + "request.", + "locations": None, + "path": None, + }, + ], + } + + +@pytest.mark.asyncio +async def test_allows_mutation_to_exist_within_a_get(client): + response = await client.get( + url_string( + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestQuery", + ) + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_post_with_json_encoding(client): + response = await client.post( + "/graphql", + data=json.dumps(dict(query="{test}")), + headers={"content-type": "application/json"}, + ) + + assert await response.json() == {"data": {"test": "Hello World"}} + assert response.status == 200 + + +@pytest.mark.asyncio +async def test_allows_sending_a_mutation_via_post(client): + response = await client.post( + "/graphql", + data=json.dumps(dict(query="mutation TestMutation { writeTest { test } }",)), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"writeTest": {"test": "Hello World"}}} + + +@pytest.mark.asyncio +async def test_allows_post_with_url_encoding(client): + data = FormData() + data.add_field("query", "{test}") + response = await client.post( + "/graphql", + data=data(), + headers={"content-type": "application/x-www-form-urlencoded"}, + ) + + assert await response.json() == {"data": {"test": "Hello World"}} + assert response.status == 200 + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_string_variables(client): + response = await client.post( + "/graphql", + data=json.dumps( + dict( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_json_variables(client): + response = await client.post( + "/graphql", + data=json.dumps( + dict( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ) + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_url_encoded_query_with_string_variables(client): + response = await client.post( + "/graphql", + 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 await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_quey_with_get_variable_values(client): + response = await client.post( + url_string(variables=json.dumps({"who": "Dolly"})), + data=json.dumps(dict(query="query helloWho($who: String){ test(who: $who) }",)), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_post_url_encoded_query_with_get_variable_values(client): + response = await client.post( + 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 await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_raw_text_query_with_get_variable_values(client): + response = await client.post( + 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 await response.json() == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_allows_post_with_operation_name(client): + response = await client.post( + "/graphql", + data=json.dumps( + dict( + 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 await response.json() == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_allows_post_with_get_operation_name(client): + response = await client.post( + 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 await response.json() == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_supports_pretty_printing(client): + response = await client.get(url_string(query="{test}", pretty="1")) + + text = await response.text() + assert text == "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + + +@pytest.mark.asyncio +async def test_not_pretty_by_default(client): + response = await client.get(url_string(query="{test}")) + + assert await response.text() == '{"data":{"test":"Hello World"}}' + + +@pytest.mark.asyncio +async def test_supports_pretty_printing_by_request(client): + response = await client.get(url_string(query="{test}", pretty="1")) + + assert await response.text() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +@pytest.mark.asyncio +async def test_handles_field_errors_caught_by_graphql(client): + response = await client.get(url_string(query="{thrower}")) + assert response.status == 200 + assert await response.json() == { + "data": None, + "errors": [ + { + "locations": [{"column": 2, "line": 1}], + "message": "Throws!", + "path": ["thrower"], + } + ], + } + + +@pytest.mark.asyncio +async def test_handles_syntax_errors_caught_by_graphql(client): + response = await client.get(url_string(query="syntaxerror")) + + assert response.status == 400 + assert await response.json() == { + "errors": [ + { + "locations": [{"column": 1, "line": 1}], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + "path": None, + }, + ], + } + + +@pytest.mark.asyncio +async def test_handles_errors_caused_by_a_lack_of_query(client): + response = await client.get("/graphql") + + assert response.status == 400 + assert await response.json() == { + "errors": [ + {"message": "Must provide query string.", "locations": None, "path": None} + ] + } + + +@pytest.mark.asyncio +async def test_handles_batch_correctly_if_is_disabled(client): + response = await client.post( + "/graphql", data="[]", headers={"content-type": "application/json"}, + ) + + assert response.status == 400 + assert await response.json() == { + "errors": [ + { + "message": "Batch GraphQL requests are not enabled.", + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_handles_incomplete_json_bodies(client): + response = await client.post( + "/graphql", data='{"query":', headers={"content-type": "application/json"}, + ) + + assert response.status == 400 + assert await response.json() == { + "errors": [ + { + "message": "POST body sent invalid JSON.", + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_handles_plain_post_text(client): + response = await client.post( + 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 await response.json() == { + "errors": [ + {"message": "Must provide query string.", "locations": None, "path": None} + ] + } + + +@pytest.mark.asyncio +async def test_handles_poorly_formed_variables(client): + response = await client.get( + url_string( + query="query helloWho($who: String){ test(who: $who) }", variables="who:You" + ), + ) + assert response.status == 400 + assert await response.json() == { + "errors": [ + {"message": "Variables are invalid JSON.", "locations": None, "path": None} + ] + } + + +@pytest.mark.asyncio +async def test_handles_unsupported_http_methods(client): + response = await client.put(url_string(query="{test}")) + assert response.status == 405 + assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] + assert await response.json() == { + "errors": [ + { + "message": "GraphQL only supports GET and POST requests.", + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.parametrize("app", [create_app()]) +@pytest.mark.asyncio +async def test_passes_request_into_request_context(app, client): + response = await client.get(url_string(query="{request}", q="testing")) + + assert response.status == 200 + assert await response.json() == { + "data": {"request": "testing"}, + } + + +class TestCustomContext: + @pytest.mark.parametrize( + "app", [create_app(context="CUSTOM CONTEXT")], + ) + @pytest.mark.asyncio + async def test_context_remapped(self, app, client): + response = await client.get(url_string(query="{context}")) + + _json = await response.json() + assert response.status == 200 + assert "Request" in _json["data"]["context"] + assert "CUSTOM CONTEXT" not in _json["data"]["context"] + + @pytest.mark.parametrize("app", [create_app(context={"request": "test"})]) + @pytest.mark.asyncio + async def test_request_not_replaced(self, app, client): + response = await client.get(url_string(query="{context}")) + + _json = await response.json() + assert response.status == 200 + assert _json["data"]["context"] == "test" + + +@pytest.mark.asyncio +async def test_post_multipart_data(client): + query = "mutation TestMutation { writeTest { test } }" + + data = ( + "------aiohttpgraphql\r\n" + + 'Content-Disposition: form-data; name="query"\r\n' + + "\r\n" + + query + + "\r\n" + + "------aiohttpgraphql--\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' # noqa: ignore + + "\r\n" + + "\r\n" + + "------aiohttpgraphql--\r\n" + ) + + response = await client.post( + "/graphql", + data=data, + headers={"content-type": "multipart/form-data; boundary=----aiohttpgraphql"}, + ) + + assert response.status == 200 + assert await response.json() == {"data": {u"writeTest": {u"test": u"Hello World"}}} + + +class TestBatchExecutor: + @pytest.mark.asyncio + @pytest.mark.parametrize("app", [create_app(batch=True)]) + async def test_batch_allows_post_with_json_encoding(self, app, client): + response = await client.post( + "/graphql", + data=json.dumps([dict(id=1, query="{test}")]), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == [{"data": {"test": "Hello World"}}] + + @pytest.mark.asyncio + @pytest.mark.parametrize("app", [create_app(batch=True)]) + async def test_batch_supports_post_json_query_with_json_variables( + self, app, client + ): + response = await client.post( + "/graphql", + data=json.dumps( + [ + dict( + id=1, + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ) + ] + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == [{"data": {"test": "Hello Dolly"}}] + + @pytest.mark.asyncio + @pytest.mark.parametrize("app", [create_app(batch=True)]) + async def test_batch_allows_post_with_operation_name(self, app, client): + response = await client.post( + "/graphql", + data=json.dumps( + [ + dict( + 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 await response.json() == [ + {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + ] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) +async def test_async_schema(app, client): + response = await client.get(url_string(query="{a,b,c}")) + + assert response.status == 200 + assert await response.json() == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} + + +@pytest.mark.asyncio +async def test_preflight_request(client): + response = await client.options( + "/graphql", headers={"Access-Control-Request-Method": "POST"}, + ) + + assert response.status == 200 + + +@pytest.mark.asyncio +async def test_preflight_incorrect_request(client): + response = await client.options( + "/graphql", headers={"Access-Control-Request-Method": "OPTIONS"}, + ) + + assert response.status == 400 From eaf75e6e1ee243e71eafe46c3401a97deb0e85df Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sun, 5 Jul 2020 09:19:24 -0500 Subject: [PATCH 07/48] Merge webob-graphql (#45) * refactor: add webob-graphql as optional feature * fix render template on webob * fix context on webob graphqlview * fix last missing test of webob graphiqlview * styles: apply black formatting --- graphql_server/webob/__init__.py | 3 + graphql_server/webob/graphqlview.py | 148 ++++++ graphql_server/webob/render_graphiql.py | 172 +++++++ setup.py | 6 + tests/aiohttp/test_graphiqlview.py | 11 +- tests/webob/__init__.py | 0 tests/webob/app.py | 46 ++ tests/webob/schema.py | 43 ++ tests/webob/test_graphiqlview.py | 43 ++ tests/webob/test_graphqlview.py | 571 ++++++++++++++++++++++++ 10 files changed, 1041 insertions(+), 2 deletions(-) create mode 100644 graphql_server/webob/__init__.py create mode 100644 graphql_server/webob/graphqlview.py create mode 100644 graphql_server/webob/render_graphiql.py create mode 100644 tests/webob/__init__.py create mode 100644 tests/webob/app.py create mode 100644 tests/webob/schema.py create mode 100644 tests/webob/test_graphiqlview.py create mode 100644 tests/webob/test_graphqlview.py diff --git a/graphql_server/webob/__init__.py b/graphql_server/webob/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/webob/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py new file mode 100644 index 0000000..a7cec7a --- /dev/null +++ b/graphql_server/webob/graphqlview.py @@ -0,0 +1,148 @@ +import copy +from collections.abc import MutableMapping +from functools import partial + +from graphql.error import GraphQLError +from graphql.type.schema import GraphQLSchema +from webob import Response + +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: + schema = None + request = None + root_value = None + context = None + pretty = False + graphiql = False + graphiql_version = None + graphiql_template = None + middleware = None + batch = False + enable_async = False + charset = "UTF-8" + + 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 + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + def dispatch_request(self, request): + 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.params.get("pretty") + + execution_results, all_params = run_http_query( + self.schema, + request_method, + data, + query_data=request.params, + 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(), + ) + result, status_code = encode_execution_results( + execution_results, + is_batch=isinstance(data, list), + format_error=self.format_error, + encode=partial(self.encode, pretty=pretty), # noqa + ) + + if show_graphiql: + return Response( + render_graphiql(params=all_params[0], result=result), + charset=self.charset, + content_type="text/html", + ) + + return Response( + result, + status=status_code, + charset=self.charset, + 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, + charset=self.charset, + headers=e.headers or {}, + content_type="application/json", + ) + + # WebOb + @staticmethod + def parse_body(request): + # We use mimetype here since we don't need the other + # information provided by content_type + content_type = request.content_type + 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.params + + return {} + + def should_display_graphiql(self, request): + if not self.graphiql or "raw" in request.params: + return False + + return self.request_wants_html() + + def request_wants_html(self): + best = self.request.accept.best_match(["application/json", "text/html"]) + return best == "text/html" diff --git a/graphql_server/webob/render_graphiql.py b/graphql_server/webob/render_graphiql.py new file mode 100644 index 0000000..5e9c735 --- /dev/null +++ b/graphql_server/webob/render_graphiql.py @@ -0,0 +1,172 @@ +import json +import re + +GRAPHIQL_VERSION = "0.17.5" + +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 + + +def render_graphiql( + 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, + } + + source = simple_renderer(template, **template_vars) + return source diff --git a/setup.py b/setup.py index 6135166..8977038 100644 --- a/setup.py +++ b/setup.py @@ -27,6 +27,10 @@ "sanic>=19.9.0,<20", ] +install_webob_requires = [ + "webob>=1.8.6,<2", +] + install_aiohttp_requires = [ "aiohttp>=3.5.0,<4", ] @@ -35,6 +39,7 @@ install_requires + \ install_flask_requires + \ install_sanic_requires + \ + install_webob_requires + \ install_aiohttp_requires setup( @@ -67,6 +72,7 @@ "dev": install_all_requires + dev_requires, "flask": install_flask_requires, "sanic": install_sanic_requires, + "webob": install_webob_requires, "aiohttp": install_aiohttp_requires, }, include_package_data=True, diff --git a/tests/aiohttp/test_graphiqlview.py b/tests/aiohttp/test_graphiqlview.py index 04a9b50..dfe442a 100644 --- a/tests/aiohttp/test_graphiqlview.py +++ b/tests/aiohttp/test_graphiqlview.py @@ -61,15 +61,22 @@ async def test_graphiql_simple_renderer(app, client, pretty_response): class TestJinjaEnv: @pytest.mark.asyncio @pytest.mark.parametrize( - "app", [create_app(graphiql=True, jinja_env=Environment())] + "app", [create_app(graphiql=True, jinja_env=Environment(enable_async=True))] ) - async def test_graphiql_jinja_renderer(self, app, client, pretty_response): + async def test_graphiql_jinja_renderer_async(self, app, client, pretty_response): response = await client.get( url_string(query="{test}"), headers={"Accept": "text/html"}, ) assert response.status == 200 assert pretty_response in await response.text() + async def test_graphiql_jinja_renderer_sync(self, app, client, pretty_response): + response = client.get( + url_string(query="{test}"), headers={"Accept": "text/html"}, + ) + assert response.status == 200 + assert pretty_response in response.text() + @pytest.mark.asyncio async def test_graphiql_html_is_not_accepted(client): diff --git a/tests/webob/__init__.py b/tests/webob/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/webob/app.py b/tests/webob/app.py new file mode 100644 index 0000000..c490515 --- /dev/null +++ b/tests/webob/app.py @@ -0,0 +1,46 @@ +from urllib.parse import urlencode + +from webob import Request + +from graphql_server.webob import GraphQLView +from tests.webob.schema import Schema + + +def url_string(**url_params): + string = "/graphql" + + if url_params: + string += "?" + urlencode(url_params) + + return string + + +class Client(object): + def __init__(self, **kwargs): + self.schema = kwargs.pop("schema", None) or Schema + self.settings = kwargs.pop("settings", None) or {} + + def get(self, url, **extra): + request = Request.blank(url, method="GET", **extra) + context = self.settings.pop("context", request) + response = GraphQLView( + request=request, schema=self.schema, context=context, **self.settings + ) + return response.dispatch_request(request) + + def post(self, url, **extra): + extra["POST"] = extra.pop("data") + request = Request.blank(url, method="POST", **extra) + context = self.settings.pop("context", request) + response = GraphQLView( + request=request, schema=self.schema, context=context, **self.settings + ) + return response.dispatch_request(request) + + def put(self, url, **extra): + request = Request.blank(url, method="PUT", **extra) + context = self.settings.pop("context", request) + response = GraphQLView( + request=request, schema=self.schema, context=context, **self.settings + ) + return response.dispatch_request(request) diff --git a/tests/webob/schema.py b/tests/webob/schema.py new file mode 100644 index 0000000..f00f14f --- /dev/null +++ b/tests/webob/schema.py @@ -0,0 +1,43 @@ +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"].params.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) diff --git a/tests/webob/test_graphiqlview.py b/tests/webob/test_graphiqlview.py new file mode 100644 index 0000000..dbfe627 --- /dev/null +++ b/tests/webob/test_graphiqlview.py @@ -0,0 +1,43 @@ +import pytest + +from .app import Client, url_string + + +@pytest.fixture +def settings(): + return {} + + +@pytest.fixture +def client(settings): + return Client(settings=settings) + + +@pytest.fixture +def pretty_response(): + return ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) + + +@pytest.mark.parametrize("settings", [dict(graphiql=True)]) +def test_graphiql_is_enabled(client, settings): + response = client.get(url_string(query="{test}"), headers={"Accept": "text/html"}) + assert response.status_code == 200 + + +@pytest.mark.parametrize("settings", [dict(graphiql=True)]) +def test_graphiql_simple_renderer(client, settings, pretty_response): + response = client.get(url_string(query="{test}"), headers={"Accept": "text/html"}) + assert response.status_code == 200 + assert pretty_response in response.body.decode("utf-8") + + +@pytest.mark.parametrize("settings", [dict(graphiql=True)]) +def test_graphiql_html_is_not_accepted(client, settings): + response = client.get(url_string(), headers={"Accept": "application/json"}) + assert response.status_code == 400 diff --git a/tests/webob/test_graphqlview.py b/tests/webob/test_graphqlview.py new file mode 100644 index 0000000..6b5f37c --- /dev/null +++ b/tests/webob/test_graphqlview.py @@ -0,0 +1,571 @@ +import json +from urllib.parse import urlencode + +import pytest + +from .app import Client, url_string + + +@pytest.fixture +def settings(): + return {} + + +@pytest.fixture +def client(settings): + return Client(settings=settings) + + +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]) + + +def test_allows_get_with_query_param(client): + response = client.get(url_string(query="{test}")) + assert response.status_code == 200, response.status + assert response_json(response) == {"data": {"test": "Hello World"}} + + +def test_allows_get_with_variable_values(client): + response = client.get( + url_string( + 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(client): + response = client.get( + 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_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +def test_reports_validation_errors(client): + response = client.get(url_string(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(client): + response = client.get( + url_string( + 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(client): + response = client.get( + url_string( + 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(client): + response = client.get( + url_string( + 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(client): + response = client.get( + url_string( + 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(client): + response = client.post( + url_string(), + 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(client): + response = client.post( + url_string(), + 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(client): + response = client.post( + url_string(), + 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(client): + response = client.post( + url_string(), + 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(client): + response = client.post( + url_string(), + 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(client): + response = client.post( + url_string(), + 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(client): + response = client.post( + url_string(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(client): + response = client.post( + url_string(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(client): + response = client.post( + url_string(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(client): + response = client.post( + 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", + ), + 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(client): + response = client.post( + 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") + } + """, + content_type="application/graphql", + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.parametrize("settings", [dict(pretty=True)]) +def test_supports_pretty_printing(client, settings): + response = client.get(url_string(query="{test}")) + + assert response.body.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +@pytest.mark.parametrize("settings", [dict(pretty=False)]) +def test_not_pretty_by_default(client, settings): + response = client.get(url_string(query="{test}")) + + assert response.body.decode() == '{"data":{"test":"Hello World"}}' + + +def test_supports_pretty_printing_by_request(client): + response = client.get(url_string(query="{test}", pretty="1")) + + assert response.body.decode() == ( + "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" + ) + + +def test_handles_field_errors_caught_by_graphql(client): + response = client.get(url_string(query="{thrower}")) + assert response.status_code == 200 + assert response_json(response) == { + "data": None, + "errors": [ + { + "message": "Throws!", + "locations": [{"column": 2, "line": 1}], + "path": ["thrower"], + } + ], + } + + +def test_handles_syntax_errors_caught_by_graphql(client): + response = client.get(url_string(query="syntaxerror")) + assert response.status_code == 400 + assert response_json(response) == { + "errors": [ + { + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + "locations": [{"column": 1, "line": 1}], + "path": None, + } + ] + } + + +def test_handles_errors_caused_by_a_lack_of_query(client): + response = client.get(url_string()) + + 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(client): + response = client.post(url_string(), 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(client): + response = client.post( + url_string(), 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(client): + response = client.post( + url_string(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(client): + response = client.get( + url_string( + 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(client): + response = client.put(url_string(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(client): + response = client.get(url_string(query="{request}", q="testing")) + + assert response.status_code == 200 + assert response_json(response) == {"data": {"request": "testing"}} + + +@pytest.mark.parametrize("settings", [dict(context="CUSTOM CONTEXT")]) +def test_supports_custom_context(client, settings): + response = client.get(url_string(query="{context}")) + + assert response.status_code == 200 + assert "data" in response_json(response) + assert ( + response_json(response)["data"]["context"] + == "GET /graphql?query=%7Bcontext%7D HTTP/1.0\r\nHost: localhost:80" + ) + + +def test_post_multipart_data(client): + query = "mutation TestMutation { writeTest { test } }" + data = ( + "------webobgraphql\r\n" + + 'Content-Disposition: form-data; name="query"\r\n' + + "\r\n" + + query + + "\r\n" + + "------webobgraphql--\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" + + "------webobgraphql--\r\n" + ) + + response = client.post( + url_string(), + data=data, + content_type="multipart/form-data; boundary=----webobgraphql", + ) + + assert response.status_code == 200 + assert response_json(response) == { + "data": {u"writeTest": {u"test": u"Hello World"}} + } + + +@pytest.mark.parametrize("settings", [dict(batch=True)]) +def test_batch_allows_post_with_json_encoding(client, settings): + response = client.post( + url_string(), + 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("settings", [dict(batch=True)]) +def test_batch_supports_post_json_query_with_json_variables(client, settings): + response = client.post( + url_string(), + 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("settings", [dict(batch=True)]) +def test_batch_allows_post_with_operation_name(client, settings): + response = client.post( + 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", + ), + content_type="application/json", + ) + + assert response.status_code == 200 + assert response_json(response) == [ + { + # 'id': 1, + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + ] From accfef41bfabbfc4f00c1a8b6b04522ded036461 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sat, 11 Jul 2020 09:12:06 -0500 Subject: [PATCH 08/48] refactor: graphiql template shared across servers (#49) * refactor: graphiql template shared across servers * chore: add typing-extensions to setup py * feat: add headers and should persist headers props * chore: mypy issues * fix: pass config to webob render graphiql * refactor: pass arguments instead of spread * chore: add pytest-asyncio and bump pytest dep --- graphql_server/aiohttp/graphqlview.py | 41 ++- graphql_server/aiohttp/render_graphiql.py | 208 -------------- graphql_server/flask/graphqlview.py | 44 ++- graphql_server/flask/render_graphiql.py | 148 ---------- graphql_server/render_graphiql.py | 330 ++++++++++++++++++++++ graphql_server/sanic/graphqlview.py | 43 ++- graphql_server/sanic/render_graphiql.py | 185 ------------ graphql_server/webob/graphqlview.py | 29 +- graphql_server/webob/render_graphiql.py | 172 ----------- setup.py | 4 +- tests/aiohttp/test_graphiqlview.py | 9 +- tox.ini | 2 +- 12 files changed, 448 insertions(+), 767 deletions(-) delete mode 100644 graphql_server/aiohttp/render_graphiql.py delete mode 100644 graphql_server/flask/render_graphiql.py create mode 100644 graphql_server/render_graphiql.py delete mode 100644 graphql_server/sanic/render_graphiql.py delete mode 100644 graphql_server/webob/render_graphiql.py diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index 9581e12..9d28f02 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -1,12 +1,14 @@ import copy from collections.abc import MutableMapping from functools import partial +from typing import List from aiohttp import web from graphql import GraphQLError from graphql.type.schema import GraphQLSchema from graphql_server import ( + GraphQLParams, HttpQueryError, encode_execution_results, format_error_default, @@ -14,8 +16,11 @@ load_json_body, run_http_query, ) - -from .render_graphiql import render_graphiql +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + render_graphiql_async, +) class GraphQLView: @@ -26,12 +31,14 @@ class GraphQLView: graphiql = False graphiql_version = None graphiql_template = None + graphiql_html_title = None middleware = None batch = False jinja_env = None max_age = 86400 enable_async = False subscriptions = None + headers = None accepted_methods = ["GET", "POST", "PUT", "DELETE"] @@ -88,16 +95,6 @@ async def parse_body(self, request): return {} - def render_graphiql(self, params, result): - return render_graphiql( - jinja_env=self.jinja_env, - params=params, - result=result, - graphiql_version=self.graphiql_version, - graphiql_template=self.graphiql_template, - subscriptions=self.subscriptions, - ) - # TODO: # use this method to replace flask and sanic # checks as this is equivalent to `should_display_graphiql` and @@ -135,6 +132,7 @@ async def __call__(self, request): if request_method == "options": return self.process_preflight(request) + all_params: List[GraphQLParams] execution_results, all_params = run_http_query( self.schema, request_method, @@ -162,7 +160,24 @@ async def __call__(self, request): ) if is_graphiql: - return await self.render_graphiql(params=all_params[0], result=result) + graphiql_data = GraphiQLData( + result=result, + query=getattr(all_params[0], "query"), + variables=getattr(all_params[0], "variables"), + operation_name=getattr(all_params[0], "operation_name"), + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=self.jinja_env, + ) + source = await render_graphiql_async( + data=graphiql_data, config=graphiql_config + ) + return web.Response(text=source, content_type="text/html") return web.Response( text=result, status=status_code, content_type="application/json", diff --git a/graphql_server/aiohttp/render_graphiql.py b/graphql_server/aiohttp/render_graphiql.py deleted file mode 100644 index 9da47d3..0000000 --- a/graphql_server/aiohttp/render_graphiql.py +++ /dev/null @@ -1,208 +0,0 @@ -import json -import re - -from aiohttp import web - -GRAPHIQL_VERSION = "0.17.5" - -TEMPLATE = """ - - - - - - - - - - - - - - - - -""" - - -def escape_js_value(value): - quotation = False - if value.startswith('"') and value.endswith('"'): - quotation = True - value = value[1:-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", "subscriptions"] - replace_jsonify = ["query", "result", "variables", "operation_name"] - - for rep in replace: - template = process_var(template, rep, values.get(rep, "")) - - for rep in replace_jsonify: - template = process_var(template, rep, values.get(rep, ""), True) - - return template - - -async def render_graphiql( - jinja_env=None, - graphiql_version=None, - graphiql_template=None, - params=None, - result=None, - subscriptions=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, - "subscriptions": subscriptions or "", - } - - 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 web.Response(text=source, content_type="text/html") diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 1a2f9af..9108a41 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -1,11 +1,13 @@ from functools import partial +from typing import List -from flask import Response, request +from flask import Response, render_template_string, request from flask.views import View from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema from graphql_server import ( + GraphQLParams, HttpQueryError, encode_execution_results, format_error_default, @@ -13,8 +15,11 @@ load_json_body, run_http_query, ) - -from .render_graphiql import render_graphiql +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + render_graphiql_sync, +) class GraphQLView(View): @@ -27,6 +32,8 @@ class GraphQLView(View): graphiql_html_title = None middleware = None batch = False + subscriptions = None + headers = None methods = ["GET", "POST", "PUT", "DELETE"] @@ -50,15 +57,6 @@ def get_context_value(self): def get_middleware(self): return self.middleware - 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) @@ -72,6 +70,7 @@ def dispatch_request(self): pretty = self.pretty or show_graphiql or request.args.get("pretty") + all_params: List[GraphQLParams] execution_results, all_params = run_http_query( self.schema, request_method, @@ -88,11 +87,28 @@ 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), # noqa ) if show_graphiql: - return self.render_graphiql(params=all_params[0], result=result) + graphiql_data = GraphiQLData( + result=result, + query=getattr(all_params[0], "query"), + variables=getattr(all_params[0], "variables"), + operation_name=getattr(all_params[0], "operation_name"), + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=None, + ) + source = render_graphiql_sync( + data=graphiql_data, config=graphiql_config + ) + return render_template_string(source) return Response(result, status=status_code, content_type="application/json") diff --git a/graphql_server/flask/render_graphiql.py b/graphql_server/flask/render_graphiql.py deleted file mode 100644 index d395d44..0000000 --- a/graphql_server/flask/render_graphiql.py +++ /dev/null @@ -1,148 +0,0 @@ -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/graphql_server/render_graphiql.py b/graphql_server/render_graphiql.py new file mode 100644 index 0000000..8ae4107 --- /dev/null +++ b/graphql_server/render_graphiql.py @@ -0,0 +1,330 @@ +"""Based on (express-graphql)[https://github.com/graphql/express-graphql/blob/master/src/renderGraphiQL.js] and +(subscriptions-transport-ws)[https://github.com/apollographql/subscriptions-transport-ws]""" +import json +import re +from typing import Any, Dict, Optional, Tuple + +from jinja2 import Environment +from typing_extensions import TypedDict + +GRAPHIQL_VERSION = "1.0.3" + +GRAPHIQL_TEMPLATE = """ + + + + + {{graphiql_html_title}} + + + + + + + + + + + + + + +
Loading...
+ + +""" + + +class GraphiQLData(TypedDict): + """GraphiQL ReactDom Data + + Has the following attributes: + + subscription_url + The GraphiQL socket endpoint for using subscriptions in graphql-ws. + headers + An optional GraphQL string to use as the initial displayed request headers, + if None is provided, the stored headers will be used. + """ + + query: Optional[str] + variables: Optional[str] + operation_name: Optional[str] + result: Optional[str] + subscription_url: Optional[str] + headers: Optional[str] + + +class GraphiQLConfig(TypedDict): + """GraphiQL Extra Config + + Has the following attributes: + + graphiql_version + The version of the provided GraphiQL package. + graphiql_template + Inject a Jinja template string to customize GraphiQL. + graphiql_html_title + Replace the default html title on the GraphiQL. + jinja_env + Sets jinja environment to be used to process GraphiQL template. + If Jinja’s async mode is enabled (by enable_async=True), + uses Template.render_async instead of Template.render. + If environment is not set, fallbacks to simple regex-based renderer. + """ + + graphiql_version: Optional[str] + graphiql_template: Optional[str] + graphiql_html_title: Optional[str] + jinja_env: Optional[Environment] + + +class GraphiQLOptions(TypedDict): + """GraphiQL options to display on the UI. + + Has the following attributes: + + default_query + An optional GraphQL string to use when no query is provided and no stored + query exists from a previous session. If undefined is provided, GraphiQL + will use its own default query. + header_editor_enabled + An optional boolean which enables the header editor when true. + Defaults to false. + should_persist_headers + An optional boolean which enables to persist headers to storage when true. + Defaults to false. + """ + + default_query: Optional[str] + header_editor_enabled: Optional[bool] + should_persist_headers: Optional[bool] + + +def escape_js_value(value: Any) -> Any: + 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: str, name: str, value: Any, jsonify=False) -> str: + 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: str, **values: Dict[str, Any]) -> str: + replace = [ + "graphiql_version", + "graphiql_html_title", + "subscription_url", + "header_editor_enabled", + "should_persist_headers", + ] + replace_jsonify = [ + "query", + "result", + "variables", + "operation_name", + "default_query", + "headers", + ] + + 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 + + +def _render_graphiql( + data: GraphiQLData, + config: GraphiQLConfig, + options: Optional[GraphiQLOptions] = None, +) -> Tuple[str, Dict[str, Any]]: + """When render_graphiql receives a request which does not Accept JSON, but does + Accept HTML, it may present GraphiQL, the in-browser GraphQL explorer IDE. + When shown, it will be pre-populated with the result of having executed + the requested query. + """ + graphiql_version = config.get("graphiql_version") or GRAPHIQL_VERSION + graphiql_template = config.get("graphiql_template") or GRAPHIQL_TEMPLATE + graphiql_html_title = config.get("graphiql_html_title") or "GraphiQL" + + template_vars: Dict[str, Any] = { + "graphiql_version": graphiql_version, + "graphiql_html_title": graphiql_html_title, + "query": data.get("query"), + "variables": data.get("variables"), + "operation_name": data.get("operation_name"), + "result": data.get("result"), + "subscription_url": data.get("subscription_url") or "", + "headers": data.get("headers") or "", + "default_query": options and options.get("default_query") or "", + "header_editor_enabled": options + and options.get("header_editor_enabled") + or "true", + "should_persist_headers": options + and options.get("should_persist_headers") + or "false", + } + + return graphiql_template, template_vars + + +async def render_graphiql_async( + data: GraphiQLData, + config: GraphiQLConfig, + options: Optional[GraphiQLOptions] = None, +) -> str: + graphiql_template, template_vars = _render_graphiql(data, config, options) + jinja_env: Optional[Environment] = config.get("jinja_env") + + if jinja_env: + # This method returns a Template. See https://jinja.palletsprojects.com/en/2.11.x/api/#jinja2.Template + template = jinja_env.from_string(graphiql_template) + if jinja_env.is_async: # type: ignore + source = await template.render_async(**template_vars) + else: + source = template.render(**template_vars) + else: + source = simple_renderer(graphiql_template, **template_vars) + return source + + +def render_graphiql_sync( + data: GraphiQLData, + config: GraphiQLConfig, + options: Optional[GraphiQLOptions] = None, +) -> str: + graphiql_template, template_vars = _render_graphiql(data, config, options) + + source = simple_renderer(graphiql_template, **template_vars) + return source diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index fd22af2..8e2c7b8 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -2,13 +2,15 @@ from cgi import parse_header from collections.abc import MutableMapping from functools import partial +from typing import List from graphql import GraphQLError from graphql.type.schema import GraphQLSchema -from sanic.response import HTTPResponse +from sanic.response import HTTPResponse, html from sanic.views import HTTPMethodView from graphql_server import ( + GraphQLParams, HttpQueryError, encode_execution_results, format_error_default, @@ -16,8 +18,11 @@ load_json_body, run_http_query, ) - -from .render_graphiql import render_graphiql +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + render_graphiql_async, +) class GraphQLView(HTTPMethodView): @@ -28,11 +33,14 @@ class GraphQLView(HTTPMethodView): graphiql = False graphiql_version = None graphiql_template = None + graphiql_html_title = None middleware = None batch = False jinja_env = None max_age = 86400 enable_async = False + subscriptions = None + headers = None methods = ["GET", "POST", "PUT", "DELETE"] @@ -62,15 +70,6 @@ def get_context(self, request): 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) @@ -87,6 +86,7 @@ async def dispatch_request(self, request, *args, **kwargs): pretty = self.pretty or show_graphiql or request.args.get("pretty") if request_method != "options": + all_params: List[GraphQLParams] execution_results, all_params = run_http_query( self.schema, request_method, @@ -113,9 +113,24 @@ async def dispatch_request(self, request, *args, **kwargs): ) if show_graphiql: - return await self.render_graphiql( - params=all_params[0], result=result + graphiql_data = GraphiQLData( + result=result, + query=getattr(all_params[0], "query"), + variables=getattr(all_params[0], "variables"), + operation_name=getattr(all_params[0], "operation_name"), + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=self.jinja_env, + ) + source = await render_graphiql_async( + data=graphiql_data, config=graphiql_config ) + return html(source) return HTTPResponse( result, status=status_code, content_type="application/json" diff --git a/graphql_server/sanic/render_graphiql.py b/graphql_server/sanic/render_graphiql.py deleted file mode 100644 index ca21ee3..0000000 --- a/graphql_server/sanic/render_graphiql.py +++ /dev/null @@ -1,185 +0,0 @@ -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/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py index a7cec7a..6a32c5b 100644 --- a/graphql_server/webob/graphqlview.py +++ b/graphql_server/webob/graphqlview.py @@ -1,12 +1,14 @@ import copy from collections.abc import MutableMapping from functools import partial +from typing import List from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema from webob import Response from graphql_server import ( + GraphQLParams, HttpQueryError, encode_execution_results, format_error_default, @@ -14,8 +16,11 @@ load_json_body, run_http_query, ) - -from .render_graphiql import render_graphiql +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + render_graphiql_sync, +) class GraphQLView: @@ -27,9 +32,12 @@ class GraphQLView: graphiql = False graphiql_version = None graphiql_template = None + graphiql_html_title = None middleware = None batch = False enable_async = False + subscriptions = None + headers = None charset = "UTF-8" def __init__(self, **kwargs): @@ -73,6 +81,7 @@ def dispatch_request(self, request): pretty = self.pretty or show_graphiql or request.params.get("pretty") + all_params: List[GraphQLParams] execution_results, all_params = run_http_query( self.schema, request_method, @@ -94,8 +103,22 @@ def dispatch_request(self, request): ) if show_graphiql: + graphiql_data = GraphiQLData( + result=result, + query=getattr(all_params[0], "query"), + variables=getattr(all_params[0], "variables"), + operation_name=getattr(all_params[0], "operation_name"), + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=None, + ) return Response( - render_graphiql(params=all_params[0], result=result), + render_graphiql_sync(data=graphiql_data, config=graphiql_config), charset=self.charset, content_type="text/html", ) diff --git a/graphql_server/webob/render_graphiql.py b/graphql_server/webob/render_graphiql.py deleted file mode 100644 index 5e9c735..0000000 --- a/graphql_server/webob/render_graphiql.py +++ /dev/null @@ -1,172 +0,0 @@ -import json -import re - -GRAPHIQL_VERSION = "0.17.5" - -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 - - -def render_graphiql( - 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, - } - - source = simple_renderer(template, **template_vars) - return source diff --git a/setup.py b/setup.py index 8977038..4c6aa58 100644 --- a/setup.py +++ b/setup.py @@ -2,10 +2,12 @@ install_requires = [ "graphql-core>=3.1.0,<4", + "typing-extensions>=3.7.4,<4" ] tests_requires = [ - "pytest>=5.3,<5.4", + "pytest>=5.4,<5.5", + "pytest-asyncio>=0.11.0", "pytest-cov>=2.8,<3", "aiohttp>=3.5.0,<4", "Jinja2>=2.10.1,<3", diff --git a/tests/aiohttp/test_graphiqlview.py b/tests/aiohttp/test_graphiqlview.py index dfe442a..a4a7a26 100644 --- a/tests/aiohttp/test_graphiqlview.py +++ b/tests/aiohttp/test_graphiqlview.py @@ -70,13 +70,6 @@ async def test_graphiql_jinja_renderer_async(self, app, client, pretty_response) assert response.status == 200 assert pretty_response in await response.text() - async def test_graphiql_jinja_renderer_sync(self, app, client, pretty_response): - response = client.get( - url_string(query="{test}"), headers={"Accept": "text/html"}, - ) - assert response.status == 200 - assert pretty_response in response.text() - @pytest.mark.asyncio async def test_graphiql_html_is_not_accepted(client): @@ -97,7 +90,7 @@ async def test_graphiql_get_mutation(app, client): @pytest.mark.asyncio @pytest.mark.parametrize("app", [create_app(graphiql=True)]) -async def test_graphiql_get_subscriptions(client): +async def test_graphiql_get_subscriptions(app, client): response = await client.get( url_string( query="subscription TestSubscriptions { subscriptionsTest { test } }" diff --git a/tox.ini b/tox.ini index 2453c8b..35edfc5 100644 --- a/tox.ini +++ b/tox.ini @@ -14,7 +14,7 @@ whitelist_externals = python commands = pip install -U setuptools - pytest --cov-report=term-missing --cov=graphql_server tests {posargs} + pytest tests --cov-report=term-missing --cov=graphql_server {posargs} [testenv:black] basepython=python3.7 From 070a23d7cb9298d1f1f02c41e227f6521683a600 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Sat, 11 Jul 2020 20:39:58 +0200 Subject: [PATCH 09/48] Run additional parse step only when necessary (#43) --- .gitignore | 1 + graphql_server/__init__.py | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 1789e38..642f015 100644 --- a/.gitignore +++ b/.gitignore @@ -132,6 +132,7 @@ pip-delete-this-directory.txt htmlcov/ .tox/ .nox/ +.venv/ .coverage .coverage.* .cache diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 4e5ad8f..369e62a 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -236,23 +236,23 @@ def get_response( 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) - except GraphQLError as e: - return ExecutionResult(data=None, errors=[e]) - except Exception as e: - e = GraphQLError(str(e), original_error=e) - return ExecutionResult(data=None, errors=[e]) - if allow_only_query: + # Parse document to check that only query operations are used + try: + document = parse(params.query) + except GraphQLError as e: + return ExecutionResult(data=None, errors=[e]) + except Exception as e: + e = GraphQLError(str(e), original_error=e) + return ExecutionResult(data=None, errors=[e]) operation_ast = get_operation_ast(document, params.operation_name) if operation_ast: operation = operation_ast.operation.value if operation != OperationType.QUERY.value: raise HttpQueryError( 405, - f"Can only perform a {operation} operation from a POST request.", # noqa + f"Can only perform a {operation} operation" + " from a POST request.", headers={"Allow": "POST"}, ) From c7490304dba5099c136dd4530ce15e9dd48445e0 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Sat, 11 Jul 2020 20:47:04 +0200 Subject: [PATCH 10/48] Run additional parse step only when necessary (#51) --- .gitignore | 1 + graphql_server/__init__.py | 20 ++++++++++---------- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 1789e38..642f015 100644 --- a/.gitignore +++ b/.gitignore @@ -132,6 +132,7 @@ pip-delete-this-directory.txt htmlcov/ .tox/ .nox/ +.venv/ .coverage .coverage.* .cache diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 4e5ad8f..369e62a 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -236,23 +236,23 @@ def get_response( 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) - except GraphQLError as e: - return ExecutionResult(data=None, errors=[e]) - except Exception as e: - e = GraphQLError(str(e), original_error=e) - return ExecutionResult(data=None, errors=[e]) - if allow_only_query: + # Parse document to check that only query operations are used + try: + document = parse(params.query) + except GraphQLError as e: + return ExecutionResult(data=None, errors=[e]) + except Exception as e: + e = GraphQLError(str(e), original_error=e) + return ExecutionResult(data=None, errors=[e]) operation_ast = get_operation_ast(document, params.operation_name) if operation_ast: operation = operation_ast.operation.value if operation != OperationType.QUERY.value: raise HttpQueryError( 405, - f"Can only perform a {operation} operation from a POST request.", # noqa + f"Can only perform a {operation} operation" + " from a POST request.", headers={"Allow": "POST"}, ) From 351ca7a1648bdc8fd56645371f879d26a1797bc3 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sat, 11 Jul 2020 14:39:24 -0500 Subject: [PATCH 11/48] Expose version number as __version__ (#50) --- graphql_server/__init__.py | 8 ++++ graphql_server/version.py | 44 +++++++++++++++++++++ setup.py | 11 +++++- tests/__init__.py | 2 +- tests/test_version.py | 78 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 140 insertions(+), 3 deletions(-) create mode 100644 graphql_server/version.py create mode 100644 tests/test_version.py diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 369e62a..99452b1 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -18,8 +18,16 @@ from graphql.pyutils import AwaitableOrValue from .error import HttpQueryError +from .version import version, version_info + +# The GraphQL-Server 3 version info. + +__version__ = version +__version_info__ = version_info __all__ = [ + "version", + "version_info", "run_http_query", "encode_execution_results", "load_json_body", diff --git a/graphql_server/version.py b/graphql_server/version.py new file mode 100644 index 0000000..f985b4d --- /dev/null +++ b/graphql_server/version.py @@ -0,0 +1,44 @@ +import re +from typing import NamedTuple + +__all__ = ["version", "version_info"] + + +version = "2.0.0" + +_re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(\D*)(\d*)") + + +class VersionInfo(NamedTuple): + major: int + minor: int + micro: int + releaselevel: str + serial: int + + @classmethod + def from_str(cls, v: str) -> "VersionInfo": + groups = _re_version.match(v).groups() # type: ignore + major, minor, micro = map(int, groups[:3]) + level = (groups[3] or "")[:1] + if level == "a": + level = "alpha" + elif level == "b": + level = "beta" + elif level in ("c", "r"): + level = "candidate" + else: + level = "final" + serial = groups[4] + serial = int(serial) if serial else 0 + return cls(major, minor, micro, level, serial) + + def __str__(self) -> str: + v = f"{self.major}.{self.minor}.{self.micro}" + level = self.releaselevel + if level and level != "final": + v = f"{v}{level[:1]}{self.serial}" + return v + + +version_info = VersionInfo.from_str(version) diff --git a/setup.py b/setup.py index 4c6aa58..72006bd 100644 --- a/setup.py +++ b/setup.py @@ -1,3 +1,4 @@ +from re import search from setuptools import setup, find_packages install_requires = [ @@ -44,11 +45,17 @@ install_webob_requires + \ install_aiohttp_requires +with open("graphql_server/version.py") as version_file: + version = search('version = "(.*)"', version_file.read()).group(1) + +with open("README.md", encoding="utf-8") as readme_file: + readme = readme_file.read() + setup( name="graphql-server-core", - version="2.0.0", + version=version, description="GraphQL Server tools for powering your server", - long_description=open("README.md", encoding="utf-8").read(), + long_description=readme, long_description_content_type="text/markdown", url="https://github.com/graphql-python/graphql-server-core", download_url="https://github.com/graphql-python/graphql-server-core/releases", diff --git a/tests/__init__.py b/tests/__init__.py index 2a8fe60..ad617d8 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +1 @@ -"""GraphQL-Server-Core Tests""" +"""GraphQL-Server Tests""" diff --git a/tests/test_version.py b/tests/test_version.py new file mode 100644 index 0000000..a69c95e --- /dev/null +++ b/tests/test_version.py @@ -0,0 +1,78 @@ +import re + +import graphql_server +from graphql_server.version import VersionInfo, version, version_info + +_re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(?:([abc])(\d+))?$") + + +def test_create_version_info_from_fields(): + v = VersionInfo(1, 2, 3, "alpha", 4) + assert v.major == 1 + assert v.minor == 2 + assert v.micro == 3 + assert v.releaselevel == "alpha" + assert v.serial == 4 + + +def test_create_version_info_from_str(): + v = VersionInfo.from_str("1.2.3") + assert v.major == 1 + assert v.minor == 2 + assert v.micro == 3 + assert v.releaselevel == "final" + assert v.serial == 0 + v = VersionInfo.from_str("1.2.3a4") + assert v.major == 1 + assert v.minor == 2 + assert v.micro == 3 + assert v.releaselevel == "alpha" + assert v.serial == 4 + v = VersionInfo.from_str("1.2.3beta4") + assert v.major == 1 + assert v.minor == 2 + assert v.micro == 3 + assert v.releaselevel == "beta" + assert v.serial == 4 + v = VersionInfo.from_str("12.34.56rc789") + assert v.major == 12 + assert v.minor == 34 + assert v.micro == 56 + assert v.releaselevel == "candidate" + assert v.serial == 789 + + +def test_serialize_as_str(): + v = VersionInfo(1, 2, 3, "final", 0) + assert str(v) == "1.2.3" + v = VersionInfo(1, 2, 3, "alpha", 4) + assert str(v) == "1.2.3a4" + + +def test_base_package_has_correct_version(): + assert graphql_server.__version__ == version + assert graphql_server.version == version + + +def test_base_package_has_correct_version_info(): + assert graphql_server.__version_info__ is version_info + assert graphql_server.version_info is version_info + + +def test_version_has_correct_format(): + assert isinstance(version, str) + assert _re_version.match(version) + + +def test_version_info_has_correct_fields(): + assert isinstance(version_info, tuple) + assert str(version_info) == version + groups = _re_version.match(version).groups() # type: ignore + assert version_info.major == int(groups[0]) + assert version_info.minor == int(groups[1]) + assert version_info.micro == int(groups[2]) + if groups[3] is None: # pragma: no cover + assert groups[4] is None + else: # pragma: no cover + assert version_info.releaselevel[:1] == groups[3] + assert version_info.serial == int(groups[4]) From 90cfb091c7a9339bdd120e6c3f75c42a1833661d Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Sat, 11 Jul 2020 22:51:28 +0200 Subject: [PATCH 12/48] Split parsing, validation and execution (#43) (#53) Instead of graphql()/graphql_sync() we now call execute() directly. This also allows adding custom validation rules and limiting the number of reported errors. --- graphql_server/__init__.py | 82 ++++++++++++++++++++++---------------- tests/test_query.py | 63 +++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+), 34 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 99452b1..2148389 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -9,13 +9,16 @@ import json from collections import namedtuple from collections.abc import MutableMapping -from typing import Any, Callable, Dict, List, Optional, Type, Union +from typing import Any, Callable, Collection, Dict, List, Optional, Type, Union -from graphql import ExecutionResult, GraphQLError, GraphQLSchema, OperationType -from graphql import format_error as format_error_default -from graphql import get_operation_ast, parse -from graphql.graphql import graphql, graphql_sync +from graphql.error import GraphQLError +from graphql.error import format_error as format_error_default +from graphql.execution import ExecutionResult, execute +from graphql.language import OperationType, parse from graphql.pyutils import AwaitableOrValue +from graphql.type import GraphQLSchema, validate_schema +from graphql.utilities import get_operation_ast +from graphql.validation import ASTValidationRule, validate from .error import HttpQueryError from .version import version, version_info @@ -223,36 +226,48 @@ def load_json_variables(variables: Optional[Union[str, Dict]]) -> Optional[Dict] return variables # type: ignore +def assume_not_awaitable(_value: Any) -> bool: + """Replacement for isawaitable if everything is assumed to be synchronous.""" + return False + + def get_response( schema: GraphQLSchema, params: GraphQLParams, catch_exc: Type[BaseException], allow_only_query: bool = False, run_sync: bool = True, + validation_rules: Optional[Collection[Type[ASTValidationRule]]] = None, + max_errors: Optional[int] = None, **kwargs, ) -> Optional[AwaitableOrValue[ExecutionResult]]: """Get an individual execution result as response, with option to catch errors. - This does the same as graphql_impl() except that you can either - throw an error on the ExecutionResult if allow_only_query is set to True - or catch errors that belong to an exception class that you need to pass - as a parameter. + This will validate the schema (if the schema is used for the first time), + parse the query, check if this is a query if allow_only_query is set to True, + validate the query (optionally with additional validation rules and limiting + the number of errors), execute the request (asynchronously if run_sync is not + set to True), and return the ExecutionResult. You can also catch all errors that + belong to an exception class specified by catch_exc. """ - # noinspection PyBroadException try: if not params.query: raise HttpQueryError(400, "Must provide query string.") + schema_validation_errors = validate_schema(schema) + if schema_validation_errors: + return ExecutionResult(data=None, errors=schema_validation_errors) + + try: + document = parse(params.query) + except GraphQLError as e: + return ExecutionResult(data=None, errors=[e]) + except Exception as e: + e = GraphQLError(str(e), original_error=e) + return ExecutionResult(data=None, errors=[e]) + if allow_only_query: - # Parse document to check that only query operations are used - try: - document = parse(params.query) - except GraphQLError as e: - return ExecutionResult(data=None, errors=[e]) - except Exception as e: - e = GraphQLError(str(e), original_error=e) - return ExecutionResult(data=None, errors=[e]) operation_ast = get_operation_ast(document, params.operation_name) if operation_ast: operation = operation_ast.operation.value @@ -264,22 +279,21 @@ def get_response( headers={"Allow": "POST"}, ) - if run_sync: - execution_result = graphql_sync( - schema=schema, - source=params.query, - variable_values=params.variables, - operation_name=params.operation_name, - **kwargs, - ) - else: - execution_result = graphql( # type: ignore - schema=schema, - source=params.query, - variable_values=params.variables, - operation_name=params.operation_name, - **kwargs, - ) + validation_errors = validate( + schema, document, rules=validation_rules, max_errors=max_errors + ) + if validation_errors: + return ExecutionResult(data=None, errors=validation_errors) + + execution_result = execute( + schema, + document, + variable_values=params.variables, + operation_name=params.operation_name, + is_awaitable=assume_not_awaitable if run_sync else None, + **kwargs, + ) + except catch_exc: return None diff --git a/tests/test_query.py b/tests/test_query.py index 7f5ab6f..70f49ac 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -2,6 +2,7 @@ from graphql.error import GraphQLError from graphql.execution import ExecutionResult +from graphql.validation import ValidationRule from pytest import raises from graphql_server import ( @@ -123,6 +124,68 @@ def test_reports_validation_errors(): assert response.status_code == 400 +def test_reports_custom_validation_errors(): + class CustomValidationRule(ValidationRule): + def enter_field(self, node, *_args): + self.report_error(GraphQLError("Custom validation error.", node)) + + results, params = run_http_query( + schema, + "get", + {}, + query_data=dict(query="{ test }"), + validation_rules=[CustomValidationRule], + ) + + assert as_dicts(results) == [ + { + "data": None, + "errors": [ + { + "message": "Custom validation error.", + "locations": [{"line": 1, "column": 3}], + "path": None, + } + ], + } + ] + + response = encode_execution_results(results) + assert response.status_code == 400 + + +def test_reports_max_num_of_validation_errors(): + results, params = run_http_query( + schema, + "get", + {}, + query_data=dict(query="{ test, unknownOne, unknownTwo }"), + max_errors=1, + ) + + assert as_dicts(results) == [ + { + "data": None, + "errors": [ + { + "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", + "locations": [{"line": 1, "column": 9}], + "path": None, + }, + { + "message": "Too many validation errors, error limit reached." + " Validation aborted.", + "locations": None, + "path": None, + }, + ], + } + ] + + response = encode_execution_results(results) + assert response.status_code == 400 + + def test_non_dict_params_in_non_batch_query(): with raises(HttpQueryError) as exc_info: # noinspection PyTypeChecker From 51dcc22c299c736cd3869b2e12c7a7d1cb6e08b6 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Wed, 22 Jul 2020 03:16:48 -0500 Subject: [PATCH 13/48] Docs about integration with each framework (#54) Co-authored-by: Jonathan Kim --- MANIFEST.in | 2 + README.md | 20 ++++--- docs/aiohttp.md | 73 ++++++++++++++++++++++++++ docs/flask.md | 81 +++++++++++++++++++++++++++++ docs/sanic.md | 74 ++++++++++++++++++++++++++ docs/webob.md | 61 ++++++++++++++++++++++ graphql_server/flask/graphqlview.py | 6 +-- graphql_server/sanic/graphqlview.py | 6 +-- graphql_server/webob/graphqlview.py | 6 +-- 9 files changed, 312 insertions(+), 17 deletions(-) create mode 100644 docs/aiohttp.md create mode 100644 docs/flask.md create mode 100644 docs/sanic.md create mode 100644 docs/webob.md diff --git a/MANIFEST.in b/MANIFEST.in index 12b4ad7..25673ee 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,6 +7,8 @@ include CONTRIBUTING.md include codecov.yml include tox.ini +recursive-include docs *.md + graft tests prune bin diff --git a/README.md b/README.md index 9e228f1..d4f717b 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,25 @@ -# GraphQL-Server-Core +# GraphQL-Server [![PyPI version](https://badge.fury.io/py/graphql-server-core.svg)](https://badge.fury.io/py/graphql-server-core) [![Build Status](https://travis-ci.org/graphql-python/graphql-server-core.svg?branch=master)](https://travis-ci.org/graphql-python/graphql-server-core) [![Coverage Status](https://codecov.io/gh/graphql-python/graphql-server-core/branch/master/graph/badge.svg)](https://codecov.io/gh/graphql-python/graphql-server-core) -GraphQL-Server-Core is a base library that serves as a helper +GraphQL-Server is a base library that serves as a helper for building GraphQL servers or integrations into existing web frameworks using [GraphQL-Core](https://github.com/graphql-python/graphql-core). -## Existing integrations built with GraphQL-Server-Core +## Integrations built with GraphQL-Server -| Server integration | Package | +| Server integration | Docs | |---|---| -| Flask | [flask-graphql](https://github.com/graphql-python/flask-graphql/) | -| Sanic |[sanic-graphql](https://github.com/graphql-python/sanic-graphql/) | -| AIOHTTP | [aiohttp-graphql](https://github.com/graphql-python/aiohttp-graphql) | -| WebOb (Pyramid, TurboGears) | [webob-graphql](https://github.com/graphql-python/webob-graphql/) | +| Flask | [flask](docs/flask.md) | +| Sanic |[sanic](docs/sanic.md) | +| AIOHTTP | [aiohttp](docs/aiohttp.md) | +| WebOb (Pyramid, TurboGears) | [webob](docs/webob.md) | + +## Other integrations built with GraphQL-Server + +| Server integration | Package | | WSGI | [wsgi-graphql](https://github.com/moritzmhmk/wsgi-graphql) | | Responder | [responder.ext.graphql](https://github.com/kennethreitz/responder/blob/master/responder/ext/graphql.py) | diff --git a/docs/aiohttp.md b/docs/aiohttp.md new file mode 100644 index 0000000..b99b78a --- /dev/null +++ b/docs/aiohttp.md @@ -0,0 +1,73 @@ +# aiohttp-Graphql + +Adds GraphQL support to your aiohttp application. + +## Installation + +To install the integration with aiohttp, run the below command on your terminal. + +`pip install graphql-server-core[aiohttp]` + +## Usage + +Use the `GraphQLView` view from `graphql_server.aiohttp` + +```python +from aiohttp import web +from graphql_server.aiohttp import GraphQLView + +from schema import schema + +app = web.Application() + +GraphQLView.attach(app, schema=schema, graphiql=True) + +# Optional, for adding batch query support (used in Apollo-Client) +GraphQLView.attach(app, schema=schema, batch=True, route_path="/graphql/batch") + +if __name__ == '__main__': + web.run_app(app) +``` + +This will add `/graphql` endpoint to your app (customizable by passing `route_path='/mypath'` to `GraphQLView.attach`) and enable the GraphiQL IDE. + +Note: `GraphQLView.attach` is just a convenience function, and the same functionality can be achieved with + +```python +gql_view = GraphQLView(schema=schema, graphiql=True) +app.router.add_route('*', '/graphql', gql_view, name='graphql') +``` + +It's worth noting that the the "view function" of `GraphQLView` is contained in `GraphQLView.__call__`. So, when you create an instance, that instance is callable with the request object as the sole positional argument. To illustrate: + +```python +gql_view = GraphQLView(schema=Schema, **kwargs) +gql_view(request) # <-- the instance is callable and expects a `aiohttp.web.Request` object. +``` + +### Supported options for GraphQLView + + * `schema`: The `GraphQLSchema` object that you want the view to execute when it gets a valid request. + * `context`: A value to pass as the `context_value` to graphql `execute` function. By default is set to `dict` with request object at key `request`. + * `root_value`: The `root_value` you want to provide to graphql `execute`. + * `pretty`: Whether or not you want the response to be pretty printed JSON. + * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). + * `graphiql_version`: The graphiql version to load. Defaults to **"1.0.3"**. + * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. + * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. + * `jinja_env`: Sets jinja environment to be used to process GraphiQL template. If Jinja’s async mode is enabled (by `enable_async=True`), uses +`Template.render_async` instead of `Template.render`. If environment is not set, fallbacks to simple regex-based renderer. + * `batch`: Set the GraphQL view as batch (for using in [Apollo-Client](http://dev.apollodata.com/core/network.html#query-batching) or [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer)) + * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `max_age`: Sets the response header Access-Control-Max-Age for preflight requests. + * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). + * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. + * `enable_async`: whether `async` mode will be enabled. + * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. + * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. + * `default_query`: An optional GraphQL string to use when no query is provided and no stored query exists from a previous session. If not provided, GraphiQL will use its own default query. +* `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. +* `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. + +## Contributing +See [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/docs/flask.md b/docs/flask.md new file mode 100644 index 0000000..bb66176 --- /dev/null +++ b/docs/flask.md @@ -0,0 +1,81 @@ +# Flask-GraphQL + +Adds GraphQL support to your Flask application. + +## Installation + +To install the integration with Flask, run the below command on your terminal. + +`pip install graphql-server-core[flask]` + +## Usage + +Use the `GraphQLView` view from `graphql_server.flask`. + +```python +from flask import Flask +from graphql_server.flask import GraphQLView + +from schema import schema + +app = Flask(__name__) + +app.add_url_rule('/graphql', view_func=GraphQLView.as_view( + 'graphql', + schema=schema, + graphiql=True, +)) + +# Optional, for adding batch query support (used in Apollo-Client) +app.add_url_rule('/graphql/batch', view_func=GraphQLView.as_view( + 'graphql', + schema=schema, + batch=True +)) + +if __name__ == '__main__': + app.run() +``` + +This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. + +### Special Note for Graphene v3 + +If you are using the `Schema` type of [Graphene](https://github.com/graphql-python/graphene) library, be sure to use the `graphql_schema` attribute to pass as schema on the `GraphQLView` view. Otherwise, the `GraphQLSchema` from `graphql-core` is the way to go. + +More info at [Graphene v3 release notes](https://github.com/graphql-python/graphene/wiki/v3-release-notes#graphene-schema-no-longer-subclasses-graphqlschema-type) and [GraphQL-core 3 usage](https://github.com/graphql-python/graphql-core#usage). + + +### Supported options for GraphQLView + + * `schema`: The `GraphQLSchema` object that you want the view to execute when it gets a valid request. + * `context`: A value to pass as the `context_value` to graphql `execute` function. By default is set to `dict` with request object at key `request`. + * `root_value`: The `root_value` you want to provide to graphql `execute`. + * `pretty`: Whether or not you want the response to be pretty printed JSON. + * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). + * `graphiql_version`: The graphiql version to load. Defaults to **"1.0.3"**. + * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. + * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. + * `batch`: Set the GraphQL view as batch (for using in [Apollo-Client](http://dev.apollodata.com/core/network.html#query-batching) or [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer)) + * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). + * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. + * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. + * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. + * `default_query`: An optional GraphQL string to use when no query is provided and no stored query exists from a previous session. If not provided, GraphiQL will use its own default query. +* `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. +* `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. + + +You can also subclass `GraphQLView` and overwrite `get_root_value(self, request)` to have a dynamic root value +per request. + +```python +class UserRootValue(GraphQLView): + def get_root_value(self, request): + return request.user + +``` + +## Contributing +See [CONTRIBUTING.md](../CONTRIBUTING.md) \ No newline at end of file diff --git a/docs/sanic.md b/docs/sanic.md new file mode 100644 index 0000000..f7fd278 --- /dev/null +++ b/docs/sanic.md @@ -0,0 +1,74 @@ +# Sanic-GraphQL + +Adds GraphQL support to your Sanic application. + +## Installation + +To install the integration with Sanic, run the below command on your terminal. + +`pip install graphql-server-core[sanic]` + +## Usage + +Use the `GraphQLView` view from `graphql_server.sanic` + +```python +from graphql_server.sanic import GraphQLView +from sanic import Sanic + +from schema import schema + +app = Sanic(name="Sanic Graphql App") + +app.add_route( + GraphQLView.as_view(schema=schema, graphiql=True), + '/graphql' +) + +# Optional, for adding batch query support (used in Apollo-Client) +app.add_route( + GraphQLView.as_view(schema=schema, batch=True), + '/graphql/batch' +) + +if __name__ == '__main__': + app.run(host='0.0.0.0', port=8000) +``` + +This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. + +### Supported options for GraphQLView + + * `schema`: The `GraphQLSchema` object that you want the view to execute when it gets a valid request. + * `context`: A value to pass as the `context_value` to graphql `execute` function. By default is set to `dict` with request object at key `request`. + * `root_value`: The `root_value` you want to provide to graphql `execute`. + * `pretty`: Whether or not you want the response to be pretty printed JSON. + * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). + * `graphiql_version`: The graphiql version to load. Defaults to **"1.0.3"**. + * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. + * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. + * `jinja_env`: Sets jinja environment to be used to process GraphiQL template. If Jinja’s async mode is enabled (by `enable_async=True`), uses +`Template.render_async` instead of `Template.render`. If environment is not set, fallbacks to simple regex-based renderer. + * `batch`: Set the GraphQL view as batch (for using in [Apollo-Client](http://dev.apollodata.com/core/network.html#query-batching) or [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer)) + * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `max_age`: Sets the response header Access-Control-Max-Age for preflight requests. + * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). + * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. + * `enable_async`: whether `async` mode will be enabled. + * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. + * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. + * `default_query`: An optional GraphQL string to use when no query is provided and no stored query exists from a previous session. If not provided, GraphiQL will use its own default query. +* `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. +* `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. + + +You can also subclass `GraphQLView` and overwrite `get_root_value(self, request)` to have a dynamic root value per request. + +```python +class UserRootValue(GraphQLView): + def get_root_value(self, request): + return request.user +``` + +## Contributing +See [CONTRIBUTING.md](../CONTRIBUTING.md) \ No newline at end of file diff --git a/docs/webob.md b/docs/webob.md new file mode 100644 index 0000000..afa7e8a --- /dev/null +++ b/docs/webob.md @@ -0,0 +1,61 @@ +# WebOb-GraphQL + +Adds GraphQL support to your WebOb (Pyramid, Pylons, ...) application. + +## Installation + +To install the integration with WebOb, run the below command on your terminal. + +`pip install graphql-server-core[webob]` + +## Usage + +Use the `GraphQLView` view from `graphql_server.webob` + +### Pyramid + +```python +from wsgiref.simple_server import make_server +from pyramid.config import Configurator + +from graphql_server.webob import GraphQLView + +from schema import schema + +def graphql_view(request): + return GraphQLView(request=request, schema=schema, graphiql=True).dispatch_request(request) + +if __name__ == '__main__': + with Configurator() as config: + config.add_route('graphql', '/graphql') + config.add_view(graphql_view, route_name='graphql') + app = config.make_wsgi_app() + server = make_server('0.0.0.0', 6543, app) + server.serve_forever() +``` + +This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. + +### Supported options for GraphQLView + + * `schema`: The `GraphQLSchema` object that you want the view to execute when it gets a valid request. + * `context`: A value to pass as the `context_value` to graphql `execute` function. By default is set to `dict` with request object at key `request`. + * `root_value`: The `root_value` you want to provide to graphql `execute`. + * `pretty`: Whether or not you want the response to be pretty printed JSON. + * `graphiql`: If `True`, may present [GraphiQL](https://github.com/graphql/graphiql) when loaded directly from a browser (a useful tool for debugging and exploration). + * `graphiql_version`: The graphiql version to load. Defaults to **"1.0.3"**. + * `graphiql_template`: Inject a Jinja template string to customize GraphiQL. + * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. + * `batch`: Set the GraphQL view as batch (for using in [Apollo-Client](http://dev.apollodata.com/core/network.html#query-batching) or [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer)) + * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). + * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. + * `enable_async`: whether `async` mode will be enabled. + * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. + * `headers`: An optional GraphQL string to use as the initial displayed request headers, if not provided, the stored headers will be used. + * `default_query`: An optional GraphQL string to use when no query is provided and no stored query exists from a previous session. If not provided, GraphiQL will use its own default query. +* `header_editor_enabled`: An optional boolean which enables the header editor when true. Defaults to **false**. +* `should_persist_headers`: An optional boolean which enables to persist headers to storage when true. Defaults to **false**. + +## Contributing +See [CONTRIBUTING.md](../CONTRIBUTING.md) \ No newline at end of file diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 9108a41..33467c9 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -37,6 +37,9 @@ class GraphQLView(View): methods = ["GET", "POST", "PUT", "DELETE"] + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + def __init__(self, **kwargs): super(GraphQLView, self).__init__() for key, value in kwargs.items(): @@ -57,9 +60,6 @@ def get_context_value(self): def get_middleware(self): return self.middleware - format_error = staticmethod(format_error_default) - encode = staticmethod(json_encode) - def dispatch_request(self): try: request_method = request.method.lower() diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index 8e2c7b8..d3fefaa 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -44,6 +44,9 @@ class GraphQLView(HTTPMethodView): methods = ["GET", "POST", "PUT", "DELETE"] + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + def __init__(self, **kwargs): super(GraphQLView, self).__init__() for key, value in kwargs.items(): @@ -70,9 +73,6 @@ def get_context(self, request): def get_middleware(self): return self.middleware - format_error = staticmethod(format_error_default) - encode = staticmethod(json_encode) - async def dispatch_request(self, request, *args, **kwargs): try: request_method = request.method.lower() diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py index 6a32c5b..3801fee 100644 --- a/graphql_server/webob/graphqlview.py +++ b/graphql_server/webob/graphqlview.py @@ -40,6 +40,9 @@ class GraphQLView: headers = None charset = "UTF-8" + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + def __init__(self, **kwargs): super(GraphQLView, self).__init__() for key, value in kwargs.items(): @@ -66,9 +69,6 @@ def get_context(self, request): def get_middleware(self): return self.middleware - format_error = staticmethod(format_error_default) - encode = staticmethod(json_encode) - def dispatch_request(self, request): try: request_method = request.method.lower() From 0c7b59afe1f3ca219f44e7dabc099a18fb295d9d Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Thu, 23 Jul 2020 08:02:15 -0500 Subject: [PATCH 14/48] docs: add graphql-server logo (#57) * docs: add graphql-server logo * docs: add logo to manifest --- MANIFEST.in | 2 +- README.md | 38 +++++++++++++++------------- docs/_static/graphql-server-logo.svg | 1 + 3 files changed, 22 insertions(+), 19 deletions(-) create mode 100644 docs/_static/graphql-server-logo.svg diff --git a/MANIFEST.in b/MANIFEST.in index 25673ee..a6c003d 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -7,7 +7,7 @@ include CONTRIBUTING.md include codecov.yml include tox.ini -recursive-include docs *.md +recursive-include docs *.md *.svg graft tests prune bin diff --git a/README.md b/README.md index d4f717b..b73e72f 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# GraphQL-Server + [![PyPI version](https://badge.fury.io/py/graphql-server-core.svg)](https://badge.fury.io/py/graphql-server-core) [![Build Status](https://travis-ci.org/graphql-python/graphql-server-core.svg?branch=master)](https://travis-ci.org/graphql-python/graphql-server-core) @@ -10,34 +10,35 @@ for building GraphQL servers or integrations into existing web frameworks using ## Integrations built with GraphQL-Server -| Server integration | Docs | -|---|---| -| Flask | [flask](docs/flask.md) | -| Sanic |[sanic](docs/sanic.md) | -| AIOHTTP | [aiohttp](docs/aiohttp.md) | -| WebOb (Pyramid, TurboGears) | [webob](docs/webob.md) | +| Server integration | Docs | +| --------------------------- | -------------------------- | +| Flask | [flask](docs/flask.md) | +| Sanic | [sanic](docs/sanic.md) | +| AIOHTTP | [aiohttp](docs/aiohttp.md) | +| WebOb (Pyramid, TurboGears) | [webob](docs/webob.md) | ## Other integrations built with GraphQL-Server -| Server integration | Package | -| WSGI | [wsgi-graphql](https://github.com/moritzmhmk/wsgi-graphql) | -| Responder | [responder.ext.graphql](https://github.com/kennethreitz/responder/blob/master/responder/ext/graphql.py) | +| Server integration | Package | +| ------------------ | ------------------------------------------------------------------------------------------------------- | +| WSGI | [wsgi-graphql](https://github.com/moritzmhmk/wsgi-graphql) | +| Responder | [responder.ext.graphql](https://github.com/kennethreitz/responder/blob/master/responder/ext/graphql.py) | ## Other integrations using GraphQL-Core or Graphene -| Server integration | Package | -|---|---| -| Django | [graphene-django](https://github.com/graphql-python/graphene-django/) | +| Server integration | Package | +| ------------------ | --------------------------------------------------------------------- | +| Django | [graphene-django](https://github.com/graphql-python/graphene-django/) | ## Documentation The `graphql_server` package provides these public helper functions: - * `run_http_query` - * `encode_execution_results` - * `load_json_body` - * `json_encode` - * `json_encode_pretty` +- `run_http_query` +- `encode_execution_results` +- `load_json_body` +- `json_encode` +- `json_encode_pretty` **NOTE:** the `json_encode_pretty` is kept as backward compatibility change as it uses `json_encode` with `pretty` parameter set to `True`. @@ -50,4 +51,5 @@ blueprint to build your own integration or GraphQL server implementations. Please let us know when you have built something new, so we can list it here. ## Contributing + See [CONTRIBUTING.md](CONTRIBUTING.md) diff --git a/docs/_static/graphql-server-logo.svg b/docs/_static/graphql-server-logo.svg new file mode 100644 index 0000000..7cf6592 --- /dev/null +++ b/docs/_static/graphql-server-logo.svg @@ -0,0 +1 @@ +graphql-server-logo \ No newline at end of file From e8f3a89a64d75e8668f5f4762b87d34a1840d926 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Thu, 23 Jul 2020 08:52:40 -0500 Subject: [PATCH 15/48] chore: add GitHub Actions (#58) * chore: add GitHub Actions * chore: add deploy workflow * chore: only run actions on pull request --- .github/workflows/deploy.yml | 26 ++++++++++++++++++++++++++ .github/workflows/lint.yml | 22 ++++++++++++++++++++++ .github/workflows/tests.yml | 26 ++++++++++++++++++++++++++ .travis.yml | 25 ------------------------- tox.ini | 17 ++++++++++++----- 5 files changed, 86 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/deploy.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/tests.yml delete mode 100644 .travis.yml diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 0000000..a580073 --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,26 @@ +name: 🚀 Deploy to PyPI + +on: + push: + tags: + - 'v*' + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Build wheel and source tarball + run: | + pip install wheel + python setup.py sdist + - name: Publish a Python distribution to PyPI + uses: pypa/gh-action-pypi-publish@v1.1.0 + with: + user: __token__ + password: ${{ secrets.pypi_password }} \ No newline at end of file diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..b36ef4c --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,22 @@ +name: Lint + +on: [pull_request] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox + - name: Run lint and static type checks + run: tox + env: + TOXENV: flake8,black,import-order,mypy,manifest \ No newline at end of file diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..03f92d6 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,26 @@ +name: Tests + +on: [pull_request] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + max-parallel: 4 + matrix: + python-version: ["3.6", "3.7", "3.8", "3.9-dev"] + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox + env: + TOXENV: ${{ matrix.toxenv }} \ No newline at end of file diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 29bac19..0000000 --- a/.travis.yml +++ /dev/null @@ -1,25 +0,0 @@ -language: python -sudo: false -python: - - 3.6 - - 3.7 - - 3.8 - - 3.9-dev -matrix: - include: - - python: 3.7 - env: TOXENV=flake8,black,import-order,mypy,manifest -cache: pip -install: pip install tox-travis codecov -script: tox -after_success: codecov -deploy: - provider: pypi - on: - branch: master - tags: true - python: 3.7 - skip_existing: true - user: __token__ - password: - secure: WcZf7AVMDzheXWUxNhZF/TUcyvyCdHZGyhHTakjBhUs8I8khSvlMPofaXTdN1Qn3WbHPK+IXeIPh/2NX0Le3Cdzp08Q/Tgrf9EZ4y02UrZxwSxtsUmjCVd8GaCsQnhR5t5cgrtw33OAf0O22rUnMXsFtw7xMIuCNTgFiYclNbHzYbvnJAEcY3qE8RBbP8zF5Brx+Bl49SjfVR3dJ7CBkjgC9scZjSBAo/yc64d506W59LOjfvXEiDtGUH2gxZNwNiteZtI3frMYqLRjS563SwEFlG36B8g0hBOj6FVpU+YXeImYXw3XFqC6dCvcwn1dAf/vUZ4IDiDIVf5KvFcyDx0ZwZlMSzqlkLVpSDGqPU+7Mx15NW00Yk2+Zs2ZWFMK+g5WtSehhrAWR6El3d0MRlDXKgt9QbCRyh8b2jPV/vQZN2FOBOg9V9a6IszOy/W1J81q39cLOroBhQF4mDFYTAQ5QpBVUyauAfB49QzXsmSWy2uOTsbgo+oAc+OGJ6q9vXCzNqHxhUvtDT9HIq4w5ixw9wqtpSf6n+l2F2RFl5SzHIR7Dt0m9Eg2Ig5NqSGlymz46ZcxpRjd4wVXALD4M8usqy35jGTeEXsqSTO98n3jwKTj/7Xi6GOZuBlwW+SGAjXQ0vzlWD3AEv0Jnh+4AH5UqWwBeD1skw8gtbjM4dos= diff --git a/tox.ini b/tox.ini index 35edfc5..813c610 100644 --- a/tox.ini +++ b/tox.ini @@ -4,6 +4,13 @@ envlist = py{36,37,38,39-dev} ; requires = tox-conda +[gh-actions] +python = + 3.6: py36 + 3.7: py37 + 3.8: py38 + 3.9: py39-dev + [testenv] passenv = * setenv = @@ -17,31 +24,31 @@ commands = pytest tests --cov-report=term-missing --cov=graphql_server {posargs} [testenv:black] -basepython=python3.7 +basepython = python3.8 deps = -e.[dev] commands = black --check graphql_server tests [testenv:flake8] -basepython=python3.7 +basepython = python3.8 deps = -e.[dev] commands = flake8 setup.py graphql_server tests [testenv:import-order] -basepython=python3.7 +basepython = python3.8 deps = -e.[dev] commands = isort -rc graphql_server/ tests/ [testenv:mypy] -basepython=python3.7 +basepython = python3.8 deps = -e.[dev] commands = mypy graphql_server tests --ignore-missing-imports [testenv:manifest] -basepython = python3.7 +basepython = python3.8 deps = -e.[dev] commands = check-manifest -v From cf6d1d41bff6cb9ef6b0cf4733fbc83b2c59e293 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Mon, 27 Jul 2020 18:14:37 -0500 Subject: [PATCH 16/48] Add graphiql options and missing flask context (#55) * Add graphiql options and missing flask context * Possible custom context test fix * Provide same context tests to other integration --- graphql_server/aiohttp/graphqlview.py | 11 +- graphql_server/flask/graphqlview.py | 28 ++++- graphql_server/render_graphiql.py | 2 +- graphql_server/sanic/graphqlview.py | 13 +- graphql_server/webob/graphqlview.py | 15 ++- tests/aiohttp/schema.py | 13 +- tests/aiohttp/test_graphqlview.py | 172 ++++++++++++++------------ tests/flask/schema.py | 14 ++- tests/flask/test_graphqlview.py | 26 +++- tests/sanic/schema.py | 13 +- tests/sanic/test_graphqlview.py | 29 ++++- tests/webob/schema.py | 13 +- tests/webob/test_graphqlview.py | 28 +++-- 13 files changed, 263 insertions(+), 114 deletions(-) diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index 9d28f02..84a5f11 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -19,6 +19,7 @@ from graphql_server.render_graphiql import ( GraphiQLConfig, GraphiQLData, + GraphiQLOptions, render_graphiql_async, ) @@ -39,6 +40,9 @@ class GraphQLView: enable_async = False subscriptions = None headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None accepted_methods = ["GET", "POST", "PUT", "DELETE"] @@ -174,8 +178,13 @@ async def __call__(self, request): graphiql_html_title=self.graphiql_html_title, jinja_env=self.jinja_env, ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) source = await render_graphiql_async( - data=graphiql_data, config=graphiql_config + data=graphiql_data, config=graphiql_config, options=graphiql_options ) return web.Response(text=source, content_type="text/html") diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 33467c9..1b33433 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -1,3 +1,5 @@ +import copy +from collections.abc import MutableMapping from functools import partial from typing import List @@ -18,6 +20,7 @@ from graphql_server.render_graphiql import ( GraphiQLConfig, GraphiQLData, + GraphiQLOptions, render_graphiql_sync, ) @@ -25,6 +28,7 @@ class GraphQLView(View): schema = None root_value = None + context = None pretty = False graphiql = False graphiql_version = None @@ -34,6 +38,9 @@ class GraphQLView(View): batch = False subscriptions = None headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None methods = ["GET", "POST", "PUT", "DELETE"] @@ -50,12 +57,18 @@ def __init__(self, **kwargs): 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_context(self): + 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 @@ -80,7 +93,7 @@ def dispatch_request(self): catch=catch, # Execute options root_value=self.get_root_value(), - context_value=self.get_context_value(), + context_value=self.get_context(), middleware=self.get_middleware(), ) result, status_code = encode_execution_results( @@ -105,8 +118,13 @@ def dispatch_request(self): graphiql_html_title=self.graphiql_html_title, jinja_env=None, ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) source = render_graphiql_sync( - data=graphiql_data, config=graphiql_config + data=graphiql_data, config=graphiql_config, options=graphiql_options ) return render_template_string(source) diff --git a/graphql_server/render_graphiql.py b/graphql_server/render_graphiql.py index 8ae4107..c942300 100644 --- a/graphql_server/render_graphiql.py +++ b/graphql_server/render_graphiql.py @@ -201,7 +201,7 @@ class GraphiQLOptions(TypedDict): default_query An optional GraphQL string to use when no query is provided and no stored - query exists from a previous session. If undefined is provided, GraphiQL + query exists from a previous session. If None is provided, GraphiQL will use its own default query. header_editor_enabled An optional boolean which enables the header editor when true. diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index d3fefaa..110ea2e 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -21,6 +21,7 @@ from graphql_server.render_graphiql import ( GraphiQLConfig, GraphiQLData, + GraphiQLOptions, render_graphiql_async, ) @@ -41,6 +42,9 @@ class GraphQLView(HTTPMethodView): enable_async = False subscriptions = None headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None methods = ["GET", "POST", "PUT", "DELETE"] @@ -127,8 +131,15 @@ async def dispatch_request(self, request, *args, **kwargs): graphiql_html_title=self.graphiql_html_title, jinja_env=self.jinja_env, ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) source = await render_graphiql_async( - data=graphiql_data, config=graphiql_config + data=graphiql_data, + config=graphiql_config, + options=graphiql_options, ) return html(source) diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py index 3801fee..4eff242 100644 --- a/graphql_server/webob/graphqlview.py +++ b/graphql_server/webob/graphqlview.py @@ -19,6 +19,7 @@ from graphql_server.render_graphiql import ( GraphiQLConfig, GraphiQLData, + GraphiQLOptions, render_graphiql_sync, ) @@ -38,6 +39,9 @@ class GraphQLView: enable_async = False subscriptions = None headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None charset = "UTF-8" format_error = staticmethod(format_error_default) @@ -117,8 +121,17 @@ def dispatch_request(self, request): graphiql_html_title=self.graphiql_html_title, jinja_env=None, ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) return Response( - render_graphiql_sync(data=graphiql_data, config=graphiql_config), + render_graphiql_sync( + data=graphiql_data, + config=graphiql_config, + options=graphiql_options, + ), charset=self.charset, content_type="text/html", ) diff --git a/tests/aiohttp/schema.py b/tests/aiohttp/schema.py index 9198b12..6e5495a 100644 --- a/tests/aiohttp/schema.py +++ b/tests/aiohttp/schema.py @@ -24,8 +24,17 @@ def resolve_raises(*_): resolve=lambda obj, info, *args: info.context["request"].query.get("q"), ), "context": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info, *args: info.context["request"], + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + }, + ), + resolve=lambda obj, info: info.context, ), "test": GraphQLField( type_=GraphQLString, diff --git a/tests/aiohttp/test_graphqlview.py b/tests/aiohttp/test_graphqlview.py index 0f6becb..0a940f9 100644 --- a/tests/aiohttp/test_graphqlview.py +++ b/tests/aiohttp/test_graphqlview.py @@ -521,8 +521,8 @@ async def test_handles_unsupported_http_methods(client): } -@pytest.mark.parametrize("app", [create_app()]) @pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app()]) async def test_passes_request_into_request_context(app, client): response = await client.get(url_string(query="{request}", q="testing")) @@ -532,27 +532,42 @@ async def test_passes_request_into_request_context(app, client): } -class TestCustomContext: - @pytest.mark.parametrize( - "app", [create_app(context="CUSTOM CONTEXT")], - ) - @pytest.mark.asyncio - async def test_context_remapped(self, app, client): - response = await client.get(url_string(query="{context}")) +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) +async def test_passes_custom_context_into_context(app, client): + response = await client.get(url_string(query="{context { session request }}")) + + _json = await response.json() + assert response.status == 200 + assert "data" in _json + assert "session" in _json["data"]["context"] + assert "request" in _json["data"]["context"] + assert "CUSTOM CONTEXT" in _json["data"]["context"]["session"] + assert "Request" in _json["data"]["context"]["request"] - _json = await response.json() - assert response.status == 200 - assert "Request" in _json["data"]["context"] - assert "CUSTOM CONTEXT" not in _json["data"]["context"] - @pytest.mark.parametrize("app", [create_app(context={"request": "test"})]) - @pytest.mark.asyncio - async def test_request_not_replaced(self, app, client): - response = await client.get(url_string(query="{context}")) +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) +async def test_context_remapped_if_not_mapping(app, client): + response = await client.get(url_string(query="{context { session request }}")) - _json = await response.json() - assert response.status == 200 - assert _json["data"]["context"] == "test" + _json = await response.json() + assert response.status == 200 + assert "data" in _json + assert "session" in _json["data"]["context"] + assert "request" in _json["data"]["context"] + assert "CUSTOM CONTEXT" not in _json["data"]["context"]["request"] + assert "Request" in _json["data"]["context"]["request"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context={"request": "test"})]) +async def test_request_not_replaced(app, client): + response = await client.get(url_string(query="{context { request }}")) + + _json = await response.json() + assert response.status == 200 + assert _json["data"]["context"]["request"] == "test" @pytest.mark.asyncio @@ -583,69 +598,68 @@ async def test_post_multipart_data(client): assert await response.json() == {"data": {u"writeTest": {u"test": u"Hello World"}}} -class TestBatchExecutor: - @pytest.mark.asyncio - @pytest.mark.parametrize("app", [create_app(batch=True)]) - async def test_batch_allows_post_with_json_encoding(self, app, client): - response = await client.post( - "/graphql", - data=json.dumps([dict(id=1, query="{test}")]), - headers={"content-type": "application/json"}, - ) +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_allows_post_with_json_encoding(app, client): + response = await client.post( + "/graphql", + data=json.dumps([dict(id=1, query="{test}")]), + headers={"content-type": "application/json"}, + ) - assert response.status == 200 - assert await response.json() == [{"data": {"test": "Hello World"}}] - - @pytest.mark.asyncio - @pytest.mark.parametrize("app", [create_app(batch=True)]) - async def test_batch_supports_post_json_query_with_json_variables( - self, app, client - ): - response = await client.post( - "/graphql", - data=json.dumps( - [ - dict( - id=1, - query="query helloWho($who: String){ test(who: $who) }", - variables={"who": "Dolly"}, - ) - ] - ), - headers={"content-type": "application/json"}, - ) + assert response.status == 200 + assert await response.json() == [{"data": {"test": "Hello World"}}] - assert response.status == 200 - assert await response.json() == [{"data": {"test": "Hello Dolly"}}] - - @pytest.mark.asyncio - @pytest.mark.parametrize("app", [create_app(batch=True)]) - async def test_batch_allows_post_with_operation_name(self, app, client): - response = await client.post( - "/graphql", - data=json.dumps( - [ - dict( - 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 await response.json() == [ - {"data": {"test": "Hello World", "shared": "Hello Everyone"}} - ] +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_supports_post_json_query_with_json_variables(app, client): + response = await client.post( + "/graphql", + data=json.dumps( + [ + dict( + id=1, + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ) + ] + ), + headers={"content-type": "application/json"}, + ) + + assert response.status == 200 + assert await response.json() == [{"data": {"test": "Hello Dolly"}}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_allows_post_with_operation_name(app, client): + response = await client.post( + "/graphql", + data=json.dumps( + [ + dict( + 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 await response.json() == [ + {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + ] @pytest.mark.asyncio diff --git a/tests/flask/schema.py b/tests/flask/schema.py index 5d4c52c..eb51e26 100644 --- a/tests/flask/schema.py +++ b/tests/flask/schema.py @@ -18,10 +18,20 @@ def resolve_raises(*_): "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises), "request": GraphQLField( GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context.args.get("q"), + resolve=lambda obj, info: info.context["request"].args.get("q"), ), "context": GraphQLField( - GraphQLNonNull(GraphQLString), resolve=lambda obj, info: info.context + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + }, + ), + resolve=lambda obj, info: info.context, ), "test": GraphQLField( type_=GraphQLString, diff --git a/tests/flask/test_graphqlview.py b/tests/flask/test_graphqlview.py index d2f478d..961a8e0 100644 --- a/tests/flask/test_graphqlview.py +++ b/tests/flask/test_graphqlview.py @@ -489,14 +489,30 @@ def test_passes_request_into_request_context(app, client): assert response_json(response) == {"data": {"request": "testing"}} -@pytest.mark.parametrize( - "app", [create_app(get_context_value=lambda: "CUSTOM CONTEXT")] -) +@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) def test_passes_custom_context_into_context(app, client): - response = client.get(url_string(app, query="{context}")) + response = client.get(url_string(app, query="{context { session request }}")) assert response.status_code == 200 - assert response_json(response) == {"data": {"context": "CUSTOM CONTEXT"}} + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] + assert "Request" in res["data"]["context"]["request"] + + +@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) +def test_context_remapped_if_not_mapping(app, client): + response = client.get(url_string(app, query="{context { session request }}")) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] + assert "Request" in res["data"]["context"]["request"] def test_post_multipart_data(app, client): diff --git a/tests/sanic/schema.py b/tests/sanic/schema.py index a129d92..f827c2b 100644 --- a/tests/sanic/schema.py +++ b/tests/sanic/schema.py @@ -24,8 +24,17 @@ def resolve_raises(*_): resolve=lambda obj, info: info.context["request"].args.get("q"), ), "context": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context["request"], + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + }, + ), + resolve=lambda obj, info: info.context, ), "test": GraphQLField( type_=GraphQLString, diff --git a/tests/sanic/test_graphqlview.py b/tests/sanic/test_graphqlview.py index 7325e6d..740697c 100644 --- a/tests/sanic/test_graphqlview.py +++ b/tests/sanic/test_graphqlview.py @@ -491,13 +491,30 @@ def test_passes_request_into_request_context(app): 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}")) +@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) +def test_passes_custom_context_into_context(app): + _, response = app.client.get(uri=url_string(query="{context { session request }}")) - assert response.status == 200 - assert "data" in response_json(response) - assert response_json(response)["data"]["context"] == "" + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] + assert "Request" in res["data"]["context"]["request"] + + +@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) +def test_context_remapped_if_not_mapping(app): + _, response = app.client.get(uri=url_string(query="{context { session request }}")) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] + assert "Request" in res["data"]["context"]["request"] @pytest.mark.parametrize("app", [create_app()]) diff --git a/tests/webob/schema.py b/tests/webob/schema.py index f00f14f..e6aa93f 100644 --- a/tests/webob/schema.py +++ b/tests/webob/schema.py @@ -22,8 +22,17 @@ def resolve_raises(*_): resolve=lambda obj, info: info.context["request"].params.get("q"), ), "context": GraphQLField( - GraphQLNonNull(GraphQLString), - resolve=lambda obj, info: info.context["request"], + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + }, + ), + resolve=lambda obj, info: info.context, ), "test": GraphQLField( type_=GraphQLString, diff --git a/tests/webob/test_graphqlview.py b/tests/webob/test_graphqlview.py index 6b5f37c..456b5f1 100644 --- a/tests/webob/test_graphqlview.py +++ b/tests/webob/test_graphqlview.py @@ -462,16 +462,30 @@ def test_passes_request_into_request_context(client): assert response_json(response) == {"data": {"request": "testing"}} +@pytest.mark.parametrize("settings", [dict(context={"session": "CUSTOM CONTEXT"})]) +def test_passes_custom_context_into_context(client, settings): + response = client.get(url_string(query="{context { session request }}")) + + assert response.status_code == 200 + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] + assert "request" in res["data"]["context"]["request"] + + @pytest.mark.parametrize("settings", [dict(context="CUSTOM CONTEXT")]) -def test_supports_custom_context(client, settings): - response = client.get(url_string(query="{context}")) +def test_context_remapped_if_not_mapping(client, settings): + response = client.get(url_string(query="{context { session request }}")) assert response.status_code == 200 - assert "data" in response_json(response) - assert ( - response_json(response)["data"]["context"] - == "GET /graphql?query=%7Bcontext%7D HTTP/1.0\r\nHost: localhost:80" - ) + res = response_json(response) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] + assert "request" in res["data"]["context"]["request"] def test_post_multipart_data(client): From f5e8302d1320b013b441844df059e90ae83d04a0 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Tue, 28 Jul 2020 13:57:34 -0500 Subject: [PATCH 17/48] chore: rename to graphql-server and bump version (#59) --- CONTRIBUTING.md | 8 ++++---- README.md | 5 ++--- docs/aiohttp.md | 2 +- docs/flask.md | 2 +- docs/sanic.md | 2 +- docs/webob.md | 2 +- graphql_server/__init__.py | 4 ++-- graphql_server/version.py | 2 +- setup.py | 6 +++--- 9 files changed, 16 insertions(+), 17 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c573f21..98f59f0 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing -Thanks for helping to make graphql-server-core awesome! +Thanks for helping to make graphql-server awesome! We welcome all kinds of contributions: @@ -12,7 +12,7 @@ We welcome all kinds of contributions: ## Getting started -If you have a specific contribution in mind, be sure to check the [issues](https://github.com/graphql-python/graphql-server-core/issues) and [pull requests](https://github.com/graphql-python/graphql-server-core/pulls) in progress - someone could already be working on something similar and you can help out. +If you have a specific contribution in mind, be sure to check the [issues](https://github.com/graphql-python/graphql-server/issues) and [pull requests](https://github.com/graphql-python/graphql-server/pulls) in progress - someone could already be working on something similar and you can help out. ## Project setup @@ -22,7 +22,7 @@ If you have a specific contribution in mind, be sure to check the [issues](https After cloning this repo, create a virtualenv: ```console -virtualenv graphql-server-core-dev +virtualenv graphql-server-dev ``` Activate the virtualenv and install dependencies by running: @@ -57,7 +57,7 @@ And you ready to start development! After developing, the full test suite can be evaluated by running: ```sh -pytest tests --cov=graphql-server-core -vv +pytest tests --cov=graphql-server -vv ``` If you are using Linux or MacOS, you can make use of Makefile command diff --git a/README.md b/README.md index b73e72f..cba2e4b 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,7 @@ -[![PyPI version](https://badge.fury.io/py/graphql-server-core.svg)](https://badge.fury.io/py/graphql-server-core) -[![Build Status](https://travis-ci.org/graphql-python/graphql-server-core.svg?branch=master)](https://travis-ci.org/graphql-python/graphql-server-core) -[![Coverage Status](https://codecov.io/gh/graphql-python/graphql-server-core/branch/master/graph/badge.svg)](https://codecov.io/gh/graphql-python/graphql-server-core) +[![PyPI version](https://badge.fury.io/py/graphql-server.svg)](https://badge.fury.io/py/graphql-server) +[![Coverage Status](https://codecov.io/gh/graphql-python/graphql-server/branch/master/graph/badge.svg)](https://codecov.io/gh/graphql-python/graphql-server) GraphQL-Server is a base library that serves as a helper for building GraphQL servers or integrations into existing web frameworks using diff --git a/docs/aiohttp.md b/docs/aiohttp.md index b99b78a..35f7fbf 100644 --- a/docs/aiohttp.md +++ b/docs/aiohttp.md @@ -6,7 +6,7 @@ Adds GraphQL support to your aiohttp application. To install the integration with aiohttp, run the below command on your terminal. -`pip install graphql-server-core[aiohttp]` +`pip install graphql-server[aiohttp]` ## Usage diff --git a/docs/flask.md b/docs/flask.md index bb66176..80bab4f 100644 --- a/docs/flask.md +++ b/docs/flask.md @@ -6,7 +6,7 @@ Adds GraphQL support to your Flask application. To install the integration with Flask, run the below command on your terminal. -`pip install graphql-server-core[flask]` +`pip install graphql-server[flask]` ## Usage diff --git a/docs/sanic.md b/docs/sanic.md index f7fd278..0b5ec35 100644 --- a/docs/sanic.md +++ b/docs/sanic.md @@ -6,7 +6,7 @@ Adds GraphQL support to your Sanic application. To install the integration with Sanic, run the below command on your terminal. -`pip install graphql-server-core[sanic]` +`pip install graphql-server[sanic]` ## Usage diff --git a/docs/webob.md b/docs/webob.md index afa7e8a..5203c2c 100644 --- a/docs/webob.md +++ b/docs/webob.md @@ -6,7 +6,7 @@ Adds GraphQL support to your WebOb (Pyramid, Pylons, ...) application. To install the integration with WebOb, run the below command on your terminal. -`pip install graphql-server-core[webob]` +`pip install graphql-server[webob]` ## Usage diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 2148389..8942332 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -1,8 +1,8 @@ """ -GraphQL-Server-Core +GraphQL-Server =================== -GraphQL-Server-Core is a base library that serves as a helper +GraphQL-Server is a base library that serves as a helper for building GraphQL servers or integrations into existing web frameworks using [GraphQL-Core](https://github.com/graphql-python/graphql-core). """ diff --git a/graphql_server/version.py b/graphql_server/version.py index f985b4d..1eb6190 100644 --- a/graphql_server/version.py +++ b/graphql_server/version.py @@ -4,7 +4,7 @@ __all__ = ["version", "version_info"] -version = "2.0.0" +version = "3.0.0b1" _re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(\D*)(\d*)") diff --git a/setup.py b/setup.py index 72006bd..ea5ea65 100644 --- a/setup.py +++ b/setup.py @@ -52,13 +52,13 @@ readme = readme_file.read() setup( - name="graphql-server-core", + name="graphql-server", version=version, description="GraphQL Server tools for powering your server", long_description=readme, long_description_content_type="text/markdown", - url="https://github.com/graphql-python/graphql-server-core", - download_url="https://github.com/graphql-python/graphql-server-core/releases", + url="https://github.com/graphql-python/graphql-server", + download_url="https://github.com/graphql-python/graphql-server/releases", author="Syrus Akbary", author_email="me@syrusakbary.com", license="MIT", From 482f21bd862838ef1cf779a577d00d10489e112c Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sun, 2 Aug 2020 14:38:54 -0500 Subject: [PATCH 18/48] docs: update links on readme (#60) --- README.md | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index cba2e4b..3e4588d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ - + [![PyPI version](https://badge.fury.io/py/graphql-server.svg)](https://badge.fury.io/py/graphql-server) [![Coverage Status](https://codecov.io/gh/graphql-python/graphql-server/branch/master/graph/badge.svg)](https://codecov.io/gh/graphql-python/graphql-server) @@ -9,12 +9,12 @@ for building GraphQL servers or integrations into existing web frameworks using ## Integrations built with GraphQL-Server -| Server integration | Docs | -| --------------------------- | -------------------------- | -| Flask | [flask](docs/flask.md) | -| Sanic | [sanic](docs/sanic.md) | -| AIOHTTP | [aiohttp](docs/aiohttp.md) | -| WebOb (Pyramid, TurboGears) | [webob](docs/webob.md) | +| Server integration | Docs | +| --------------------------- | --------------------------------------------------------------------------------------- | +| Flask | [flask](https://github.com/graphql-python/graphql-server/blob/master/docs/flask.md) | +| Sanic | [sanic](https://github.com/graphql-python/graphql-server/blob/master/docs/sanic.md) | +| AIOHTTP | [aiohttp](https://github.com/graphql-python/graphql-server/blob/master/docs/aiohttp.md) | +| WebOb (Pyramid, TurboGears) | [webob](https://github.com/graphql-python/graphql-server/blob/master/docs/webob.md) | ## Other integrations built with GraphQL-Server @@ -51,4 +51,4 @@ Please let us know when you have built something new, so we can list it here. ## Contributing -See [CONTRIBUTING.md](CONTRIBUTING.md) +See [CONTRIBUTING.md](https://github.com/graphql-python/graphql-server/blob/master/CONTRIBUTING.md) From 49f73c3aaa8d00054aef54524908d09828952b3b Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sat, 17 Oct 2020 13:19:10 -0500 Subject: [PATCH 19/48] chore: submit coverage to codecov (#63) * chore: submit coverage to codecov * chore: add correct package name on gh action * chore: add windows to os matrix for tests action workflow --- .github/workflows/lint.yml | 2 +- .github/workflows/tests.yml | 56 +++++++++++++++++++++++++++---------- 2 files changed, 42 insertions(+), 16 deletions(-) diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index b36ef4c..252a382 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -1,6 +1,6 @@ name: Lint -on: [pull_request] +on: [push, pull_request] jobs: build: diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 03f92d6..3373733 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,26 +1,52 @@ name: Tests -on: [pull_request] +on: [push, pull_request] jobs: build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: max-parallel: 4 matrix: python-version: ["3.6", "3.7", "3.8", "3.9-dev"] + os: [ubuntu-latest, windows-latest] + exclude: + - os: windows-latest + python-version: "3.6" + - os: windows-latest + python-version: "3.7" + - os: windows-latest + python-version: "3.9-dev" + + steps: + - uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install tox tox-gh-actions + - name: Test with tox + run: tox + env: + TOXENV: ${{ matrix.toxenv }} + + coverage: + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install tox tox-gh-actions - - name: Test with tox - run: tox - env: - TOXENV: ${{ matrix.toxenv }} \ No newline at end of file + - uses: actions/checkout@v2 + - name: Set up Python 3.8 + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install test dependencies + run: | + python -m pip install --upgrade pip + pip install .[test] + - name: Test with coverage + run: pytest --cov=graphql_server --cov-report=xml tests + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v1 From e39398adae2a3b01ba4a33e271338d3e71c58c0a Mon Sep 17 00:00:00 2001 From: Russell Owen Date: Sat, 17 Oct 2020 13:28:15 -0700 Subject: [PATCH 20/48] Fix enable_async for aiohttp and sanic if graphiql is enabled (#67) * Fix enable_async=True in aiohttp Apply the fix suggested by ketanbshah in https://github.com/graphql-python/graphql-server/issues/64 * Apply the same fix to sanic * tests: add tests for graphiql enabled plus async Co-authored-by: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Co-authored-by: KingDarBoja --- graphql_server/aiohttp/graphqlview.py | 7 ++-- graphql_server/sanic/graphqlview.py | 9 +++-- tests/aiohttp/schema.py | 18 ++++++++++ tests/aiohttp/test_graphiqlview.py | 48 ++++++++++++++++++++++++--- tests/sanic/schema.py | 18 ++++++++++ tests/sanic/test_graphiqlview.py | 31 +++++++++++++++-- 6 files changed, 120 insertions(+), 11 deletions(-) diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index 84a5f11..61d2a3d 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -4,7 +4,7 @@ from typing import List from aiohttp import web -from graphql import GraphQLError +from graphql import ExecutionResult, GraphQLError from graphql.type.schema import GraphQLSchema from graphql_server import ( @@ -152,7 +152,10 @@ async def __call__(self, request): ) exec_res = ( - [await ex for ex in execution_results] + [ + ex if ex is None or isinstance(ex, ExecutionResult) else await ex + for ex in execution_results + ] if self.enable_async else execution_results ) diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index 110ea2e..29548e9 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -4,7 +4,7 @@ from functools import partial from typing import List -from graphql import GraphQLError +from graphql import ExecutionResult, GraphQLError from graphql.type.schema import GraphQLSchema from sanic.response import HTTPResponse, html from sanic.views import HTTPMethodView @@ -105,7 +105,12 @@ async def dispatch_request(self, request, *args, **kwargs): middleware=self.get_middleware(), ) exec_res = ( - [await ex for ex in execution_results] + [ + ex + if ex is None or isinstance(ex, ExecutionResult) + else await ex + for ex in execution_results + ] if self.enable_async else execution_results ) diff --git a/tests/aiohttp/schema.py b/tests/aiohttp/schema.py index 6e5495a..7673180 100644 --- a/tests/aiohttp/schema.py +++ b/tests/aiohttp/schema.py @@ -91,4 +91,22 @@ def resolver_field_sync(_obj, info): ) +def resolver_field_sync_1(_obj, info): + return "synced_one" + + +def resolver_field_sync_2(_obj, info): + return "synced_two" + + +SyncQueryType = GraphQLObjectType( + "SyncQueryType", + { + "a": GraphQLField(GraphQLString, resolve=resolver_field_sync_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_sync_2), + }, +) + + AsyncSchema = GraphQLSchema(AsyncQueryType) +SyncSchema = GraphQLSchema(SyncQueryType) diff --git a/tests/aiohttp/test_graphiqlview.py b/tests/aiohttp/test_graphiqlview.py index a4a7a26..111b603 100644 --- a/tests/aiohttp/test_graphiqlview.py +++ b/tests/aiohttp/test_graphiqlview.py @@ -3,7 +3,7 @@ from jinja2 import Environment from tests.aiohttp.app import create_app, url_string -from tests.aiohttp.schema import AsyncSchema, Schema +from tests.aiohttp.schema import AsyncSchema, Schema, SyncSchema @pytest.fixture @@ -102,11 +102,51 @@ async def test_graphiql_get_subscriptions(app, client): @pytest.mark.asyncio -@pytest.mark.parametrize("app", [create_app(schema=AsyncSchema, enable_async=True)]) -async def test_graphiql_async_schema(app, client): +@pytest.mark.parametrize( + "app", [create_app(schema=AsyncSchema, enable_async=True, graphiql=True)] +) +async def test_graphiql_enabled_async_schema(app, client): response = await client.get( url_string(query="{a,b,c}"), 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 await response.text() + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", [create_app(schema=SyncSchema, enable_async=True, graphiql=True)] +) +async def test_graphiql_enabled_sync_schema(app, client): + response = await client.get( + url_string(query="{a,b}"), headers={"Accept": "text/html"}, + ) + + expected_response = ( + ( + "{\n" + ' "data": {\n' + ' "a": "synced_one",\n' + ' "b": "synced_two"\n' + " }\n" + "}" + ) + .replace('"', '\\"') + .replace("\n", "\\n") + ) assert response.status == 200 - assert await response.json() == {"data": {"a": "hey", "b": "hey2", "c": "hey3"}} + assert expected_response in await response.text() diff --git a/tests/sanic/schema.py b/tests/sanic/schema.py index f827c2b..3c3298f 100644 --- a/tests/sanic/schema.py +++ b/tests/sanic/schema.py @@ -78,4 +78,22 @@ def resolver_field_sync(_obj, info): }, ) + +def resolver_field_sync_1(_obj, info): + return "synced_one" + + +def resolver_field_sync_2(_obj, info): + return "synced_two" + + +SyncQueryType = GraphQLObjectType( + "SyncQueryType", + { + "a": GraphQLField(GraphQLString, resolve=resolver_field_sync_1), + "b": GraphQLField(GraphQLString, resolve=resolver_field_sync_2), + }, +) + AsyncSchema = GraphQLSchema(AsyncQueryType) +SyncSchema = GraphQLSchema(SyncQueryType) diff --git a/tests/sanic/test_graphiqlview.py b/tests/sanic/test_graphiqlview.py index 60ecc75..91711f0 100644 --- a/tests/sanic/test_graphiqlview.py +++ b/tests/sanic/test_graphiqlview.py @@ -2,7 +2,7 @@ from jinja2 import Environment from .app import create_app, url_string -from .schema import AsyncSchema +from .schema import AsyncSchema, SyncSchema @pytest.fixture @@ -62,9 +62,9 @@ def test_graphiql_html_is_not_accepted(app): @pytest.mark.parametrize( - "app", [create_app(graphiql=True, schema=AsyncSchema, enable_async=True)] + "app", [create_app(schema=AsyncSchema, enable_async=True, graphiql=True)] ) -def test_graphiql_asyncio_schema(app): +def test_graphiql_enabled_async_schema(app): query = "{a,b,c}" _, response = app.client.get( uri=url_string(query=query), headers={"Accept": "text/html"} @@ -86,3 +86,28 @@ def test_graphiql_asyncio_schema(app): assert response.status == 200 assert expected_response in response.body.decode("utf-8") + + +@pytest.mark.parametrize( + "app", [create_app(schema=SyncSchema, enable_async=True, graphiql=True)] +) +def test_graphiql_enabled_sync_schema(app): + query = "{a,b}" + _, response = app.client.get( + uri=url_string(query=query), headers={"Accept": "text/html"} + ) + + expected_response = ( + ( + "{\n" + ' "data": {\n' + ' "a": "synced_one",\n' + ' "b": "synced_two"\n' + " }\n" + "}" + ) + .replace('"', '\\"') + .replace("\n", "\\n") + ) + assert response.status == 200 + assert expected_response in response.body.decode("utf-8") From 60e9171446ce78d02337f4824d04c1aeb4c06e6c Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Tue, 27 Oct 2020 13:33:17 -0500 Subject: [PATCH 21/48] chore: stable Python 3.9 support and bump version (#71) --- .github/workflows/tests.yml | 4 ++-- graphql_server/version.py | 2 +- setup.py | 3 ++- tox.ini | 8 +++++--- 4 files changed, 10 insertions(+), 7 deletions(-) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 3373733..4110dae 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.6", "3.7", "3.8", "3.9-dev"] + python-version: ["3.6", "3.7", "3.8", "3.9"] os: [ubuntu-latest, windows-latest] exclude: - os: windows-latest @@ -16,7 +16,7 @@ jobs: - os: windows-latest python-version: "3.7" - os: windows-latest - python-version: "3.9-dev" + python-version: "3.9" steps: - uses: actions/checkout@v2 diff --git a/graphql_server/version.py b/graphql_server/version.py index 1eb6190..5536d02 100644 --- a/graphql_server/version.py +++ b/graphql_server/version.py @@ -4,7 +4,7 @@ __all__ = ["version", "version_info"] -version = "3.0.0b1" +version = "3.0.0b2" _re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(\D*)(\d*)") diff --git a/setup.py b/setup.py index ea5ea65..6295b99 100644 --- a/setup.py +++ b/setup.py @@ -63,12 +63,13 @@ author_email="me@syrusakbary.com", license="MIT", classifiers=[ - "Development Status :: 5 - Production/Stable", + "Development Status :: 3 - Alpha", "Intended Audience :: Developers", "Topic :: Software Development :: Libraries", "Programming Language :: Python :: 3.6", "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", "License :: OSI Approved :: MIT License", ], keywords="api graphql protocol rest", diff --git a/tox.ini b/tox.ini index 813c610..e374ee0 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] envlist = black,flake8,import-order,mypy,manifest, - py{36,37,38,39-dev} + py{36,37,38,39} ; requires = tox-conda [gh-actions] @@ -9,9 +9,10 @@ python = 3.6: py36 3.7: py37 3.8: py38 - 3.9: py39-dev + 3.9: py39 [testenv] +conda_channels = conda-forge passenv = * setenv = PYTHONPATH = {toxinidir} @@ -21,7 +22,8 @@ whitelist_externals = python commands = pip install -U setuptools - pytest tests --cov-report=term-missing --cov=graphql_server {posargs} + py{36,37,39}: pytest tests {posargs} + py{38}: pytest tests --cov-report=term-missing --cov=graphql_server {posargs} [testenv:black] basepython = python3.8 From f89d93caab2ed47eeac5c4435b1c28f3af564669 Mon Sep 17 00:00:00 2001 From: Shiny Brar Date: Sat, 31 Oct 2020 14:30:15 -0400 Subject: [PATCH 22/48] Update Sanic dependency to support 20.3.0 and above (#73) --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 6295b99..c590303 100644 --- a/setup.py +++ b/setup.py @@ -27,7 +27,7 @@ ] install_sanic_requires = [ - "sanic>=19.9.0,<20", + "sanic>=20.3.0", ] install_webob_requires = [ From 5b7f5de42efd3d8034b69c9dcc70f506a2247d55 Mon Sep 17 00:00:00 2001 From: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> Date: Sun, 1 Nov 2020 15:54:33 -0500 Subject: [PATCH 23/48] feat: Quart Server Integration (#70) * feat: Quart Server Integration * chore: change quart version constraint * tests: check py version for test_request_context * tests: refactor graphiqlview test suite * tests: properly match py36 quart API * fix: manually get accept mime types for py36 --- graphql_server/aiohttp/graphqlview.py | 4 +- graphql_server/flask/graphqlview.py | 7 +- graphql_server/quart/__init__.py | 3 + graphql_server/quart/graphqlview.py | 201 +++++++ setup.py | 8 +- tests/flask/app.py | 8 +- tests/flask/test_graphqlview.py | 30 +- tests/quart/__init__.py | 0 tests/quart/app.py | 18 + tests/quart/schema.py | 51 ++ tests/quart/test_graphiqlview.py | 87 +++ tests/quart/test_graphqlview.py | 732 ++++++++++++++++++++++++++ 12 files changed, 1115 insertions(+), 34 deletions(-) create mode 100644 graphql_server/quart/__init__.py create mode 100644 graphql_server/quart/graphqlview.py create mode 100644 tests/quart/__init__.py create mode 100644 tests/quart/app.py create mode 100644 tests/quart/schema.py create mode 100644 tests/quart/test_graphiqlview.py create mode 100644 tests/quart/test_graphqlview.py diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index 61d2a3d..a3db1d6 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -75,8 +75,8 @@ def get_context(self, request): def get_middleware(self): return self.middleware - # This method can be static - async def parse_body(self, request): + @staticmethod + async def parse_body(request): content_type = request.content_type # request.text() is the aiohttp equivalent to # request.body.decode("utf8") diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 1b33433..a417406 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -139,8 +139,8 @@ def dispatch_request(self): content_type="application/json", ) - # Flask - def parse_body(self): + @staticmethod + def parse_body(): # We use mimetype here since we don't need the other # information provided by content_type content_type = request.mimetype @@ -164,7 +164,8 @@ def should_display_graphiql(self): return self.request_wants_html() - def request_wants_html(self): + @staticmethod + def request_wants_html(): best = request.accept_mimetypes.best_match(["application/json", "text/html"]) return ( best == "text/html" diff --git a/graphql_server/quart/__init__.py b/graphql_server/quart/__init__.py new file mode 100644 index 0000000..8f5beaf --- /dev/null +++ b/graphql_server/quart/__init__.py @@ -0,0 +1,3 @@ +from .graphqlview import GraphQLView + +__all__ = ["GraphQLView"] diff --git a/graphql_server/quart/graphqlview.py b/graphql_server/quart/graphqlview.py new file mode 100644 index 0000000..9993998 --- /dev/null +++ b/graphql_server/quart/graphqlview.py @@ -0,0 +1,201 @@ +import copy +import sys +from collections.abc import MutableMapping +from functools import partial +from typing import List + +from graphql import ExecutionResult +from graphql.error import GraphQLError +from graphql.type.schema import GraphQLSchema +from quart import Response, render_template_string, request +from quart.views import View + +from graphql_server import ( + GraphQLParams, + HttpQueryError, + encode_execution_results, + format_error_default, + json_encode, + load_json_body, + run_http_query, +) +from graphql_server.render_graphiql import ( + GraphiQLConfig, + GraphiQLData, + GraphiQLOptions, + render_graphiql_sync, +) + + +class GraphQLView(View): + schema = None + root_value = None + context = None + pretty = False + graphiql = False + graphiql_version = None + graphiql_template = None + graphiql_html_title = None + middleware = None + batch = False + enable_async = False + subscriptions = None + headers = None + default_query = None + header_editor_enabled = None + should_persist_headers = None + + methods = ["GET", "POST", "PUT", "DELETE"] + + format_error = staticmethod(format_error_default) + encode = staticmethod(json_encode) + + 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): + 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 dispatch_request(self): + try: + request_method = request.method.lower() + data = await 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") + all_params: List[GraphQLParams] + 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(), + middleware=self.get_middleware(), + ) + exec_res = ( + [ + ex if ex is None or isinstance(ex, ExecutionResult) else 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 + ) + + if show_graphiql: + graphiql_data = GraphiQLData( + result=result, + query=getattr(all_params[0], "query"), + variables=getattr(all_params[0], "variables"), + operation_name=getattr(all_params[0], "operation_name"), + subscription_url=self.subscriptions, + headers=self.headers, + ) + graphiql_config = GraphiQLConfig( + graphiql_version=self.graphiql_version, + graphiql_template=self.graphiql_template, + graphiql_html_title=self.graphiql_html_title, + jinja_env=None, + ) + graphiql_options = GraphiQLOptions( + default_query=self.default_query, + header_editor_enabled=self.header_editor_enabled, + should_persist_headers=self.should_persist_headers, + ) + source = render_graphiql_sync( + data=graphiql_data, config=graphiql_config, options=graphiql_options + ) + return await render_template_string(source) + + 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", + ) + + @staticmethod + async def parse_body(): + # 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": + refined_data = await request.get_data(raw=False) + return {"query": refined_data} + + elif content_type == "application/json": + refined_data = await request.get_data(raw=False) + return load_json_body(refined_data) + + elif content_type == "application/x-www-form-urlencoded": + return await request.form + + # TODO: Fix this check + elif content_type == "multipart/form-data": + return await request.files + + return {} + + def should_display_graphiql(self): + if not self.graphiql or "raw" in request.args: + return False + + return self.request_wants_html() + + @staticmethod + def request_wants_html(): + best = request.accept_mimetypes.best_match(["application/json", "text/html"]) + + # Needed as this was introduced at Quart 0.8.0: https://gitlab.com/pgjones/quart/-/issues/189 + def _quality(accept, key: str) -> float: + for option in accept.options: + if accept._values_match(key, option.value): + return option.quality + return 0.0 + + if sys.version_info >= (3, 7): + return ( + best == "text/html" + and request.accept_mimetypes[best] + > request.accept_mimetypes["application/json"] + ) + else: + return best == "text/html" and _quality( + request.accept_mimetypes, best + ) > _quality(request.accept_mimetypes, "application/json") diff --git a/setup.py b/setup.py index c590303..e16e61b 100644 --- a/setup.py +++ b/setup.py @@ -38,12 +38,17 @@ "aiohttp>=3.5.0,<4", ] +install_quart_requires = [ + "quart>=0.6.15" +] + install_all_requires = \ install_requires + \ install_flask_requires + \ install_sanic_requires + \ install_webob_requires + \ - install_aiohttp_requires + install_aiohttp_requires + \ + install_quart_requires with open("graphql_server/version.py") as version_file: version = search('version = "(.*)"', version_file.read()).group(1) @@ -84,6 +89,7 @@ "sanic": install_sanic_requires, "webob": install_webob_requires, "aiohttp": install_aiohttp_requires, + "quart": install_quart_requires, }, include_package_data=True, zip_safe=False, diff --git a/tests/flask/app.py b/tests/flask/app.py index 01f6fa8..ec9e9d0 100644 --- a/tests/flask/app.py +++ b/tests/flask/app.py @@ -5,12 +5,12 @@ def create_app(path="/graphql", **kwargs): - app = Flask(__name__) - app.debug = True - app.add_url_rule( + server = Flask(__name__) + server.debug = True + server.add_url_rule( path, view_func=GraphQLView.as_view("graphql", schema=Schema, **kwargs) ) - return app + return server if __name__ == "__main__": diff --git a/tests/flask/test_graphqlview.py b/tests/flask/test_graphqlview.py index 961a8e0..d8d60b0 100644 --- a/tests/flask/test_graphqlview.py +++ b/tests/flask/test_graphqlview.py @@ -9,7 +9,7 @@ @pytest.fixture -def app(request): +def app(): # import app factory pattern app = create_app() @@ -269,7 +269,7 @@ def test_supports_post_url_encoded_query_with_string_variables(app, client): assert response_json(response) == {"data": {"test": "Hello Dolly"}} -def test_supports_post_json_quey_with_get_variable_values(app, client): +def test_supports_post_json_query_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) }",), @@ -533,20 +533,12 @@ def test_post_multipart_data(app, client): 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}" - ), + data=json_dump_kwarg_list(query="{test}"), content_type="application/json", ) assert response.status_code == 200 - assert response_json(response) == [ - { - # 'id': 1, - "data": {"test": "Hello World"} - } - ] + assert response_json(response) == [{"data": {"test": "Hello World"}}] @pytest.mark.parametrize("app", [create_app(batch=True)]) @@ -554,7 +546,6 @@ 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"}, ), @@ -562,12 +553,7 @@ def test_batch_supports_post_json_query_with_json_variables(app, client): ) assert response.status_code == 200 - assert response_json(response) == [ - { - # 'id': 1, - "data": {"test": "Hello Dolly"} - } - ] + assert response_json(response) == [{"data": {"test": "Hello Dolly"}}] @pytest.mark.parametrize("app", [create_app(batch=True)]) @@ -575,7 +561,6 @@ 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 } @@ -591,8 +576,5 @@ def test_batch_allows_post_with_operation_name(app, client): assert response.status_code == 200 assert response_json(response) == [ - { - # 'id': 1, - "data": {"test": "Hello World", "shared": "Hello Everyone"} - } + {"data": {"test": "Hello World", "shared": "Hello Everyone"}} ] diff --git a/tests/quart/__init__.py b/tests/quart/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/quart/app.py b/tests/quart/app.py new file mode 100644 index 0000000..2313f99 --- /dev/null +++ b/tests/quart/app.py @@ -0,0 +1,18 @@ +from quart import Quart + +from graphql_server.quart import GraphQLView +from tests.quart.schema import Schema + + +def create_app(path="/graphql", **kwargs): + server = Quart(__name__) + server.debug = True + server.add_url_rule( + path, view_func=GraphQLView.as_view("graphql", schema=Schema, **kwargs) + ) + return server + + +if __name__ == "__main__": + app = create_app(graphiql=True) + app.run() diff --git a/tests/quart/schema.py b/tests/quart/schema.py new file mode 100644 index 0000000..eb51e26 --- /dev/null +++ b/tests/quart/schema.py @@ -0,0 +1,51 @@ +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["request"].args.get("q"), + ), + "context": GraphQLField( + GraphQLObjectType( + name="context", + fields={ + "session": GraphQLField(GraphQLString), + "request": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=lambda obj, info: info.context["request"], + ), + }, + ), + 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/quart/test_graphiqlview.py b/tests/quart/test_graphiqlview.py new file mode 100644 index 0000000..12b001f --- /dev/null +++ b/tests/quart/test_graphiqlview.py @@ -0,0 +1,87 @@ +import sys + +import pytest +from quart import Quart, Response, url_for +from quart.testing import QuartClient +from werkzeug.datastructures import Headers + +from .app import create_app + + +@pytest.fixture +def app() -> Quart: + # import app factory pattern + app = create_app(graphiql=True) + + # pushes an application context manually + # ctx = app.app_context() + # await ctx.push() + return app + + +@pytest.fixture +def client(app: Quart) -> QuartClient: + return app.test_client() + + +@pytest.mark.asyncio +async def execute_client( + app: Quart, + client: QuartClient, + method: str = "GET", + headers: Headers = None, + **extra_params +) -> Response: + if sys.version_info >= (3, 7): + test_request_context = app.test_request_context("/", method=method) + else: + test_request_context = app.test_request_context(method, "/") + async with test_request_context: + string = url_for("graphql", **extra_params) + return await client.get(string, headers=headers) + + +@pytest.mark.asyncio +async def test_graphiql_is_enabled(app: Quart, client: QuartClient): + response = await execute_client( + app, client, headers=Headers({"Accept": "text/html"}), externals=False + ) + assert response.status_code == 200 + + +@pytest.mark.asyncio +async def test_graphiql_renders_pretty(app: Quart, client: QuartClient): + response = await execute_client( + app, client, headers=Headers({"Accept": "text/html"}), query="{test}" + ) + assert response.status_code == 200 + pretty_response = ( + "{\n" + ' "data": {\n' + ' "test": "Hello World"\n' + " }\n" + "}".replace('"', '\\"').replace("\n", "\\n") + ) + result = await response.get_data(raw=False) + assert pretty_response in result + + +@pytest.mark.asyncio +async def test_graphiql_default_title(app: Quart, client: QuartClient): + response = await execute_client( + app, client, headers=Headers({"Accept": "text/html"}) + ) + result = await response.get_data(raw=False) + assert "GraphiQL" in result + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "app", [create_app(graphiql=True, graphiql_html_title="Awesome")] +) +async def test_graphiql_custom_title(app: Quart, client: QuartClient): + response = await execute_client( + app, client, headers=Headers({"Accept": "text/html"}) + ) + result = await response.get_data(raw=False) + assert "Awesome" in result diff --git a/tests/quart/test_graphqlview.py b/tests/quart/test_graphqlview.py new file mode 100644 index 0000000..4a24ace --- /dev/null +++ b/tests/quart/test_graphqlview.py @@ -0,0 +1,732 @@ +import json +import sys + +# from io import StringIO +from urllib.parse import urlencode + +import pytest +from quart import Quart, Response, url_for +from quart.testing import QuartClient +from werkzeug.datastructures import Headers + +from .app import create_app + + +@pytest.fixture +def app() -> Quart: + # import app factory pattern + app = create_app(graphiql=True) + + # pushes an application context manually + # ctx = app.app_context() + # await ctx.push() + return app + + +@pytest.fixture +def client(app: Quart) -> QuartClient: + return app.test_client() + + +@pytest.mark.asyncio +async def execute_client( + app: Quart, + client: QuartClient, + method: str = "GET", + data: str = None, + headers: Headers = None, + **url_params +) -> Response: + if sys.version_info >= (3, 7): + test_request_context = app.test_request_context("/", method=method) + else: + test_request_context = app.test_request_context(method, "/") + async with test_request_context: + string = url_for("graphql") + + if url_params: + string += "?" + urlencode(url_params) + + if method == "POST": + return await client.post(string, data=data, headers=headers) + elif method == "PUT": + return await client.put(string, data=data, headers=headers) + else: + return await client.get(string) + + +def response_json(result): + return json.loads(result) + + +def json_dump_kwarg(**kwargs) -> str: + return json.dumps(kwargs) + + +def json_dump_kwarg_list(**kwargs): + return json.dumps([kwargs]) + + +@pytest.mark.asyncio +async def test_allows_get_with_query_param(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{test}") + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_get_with_variable_values(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_allows_get_with_operation_name(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + 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 + result = await response.get_data(raw=False) + assert response_json(result) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_reports_validation_errors(app: Quart, client: QuartClient): + response = await execute_client( + app, client, query="{ test, unknownOne, unknownTwo }" + ) + + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "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.asyncio +async def test_errors_when_missing_operation_name(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + ) + + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + { + "message": "Must provide operation name if query contains multiple operations.", # noqa: E501 + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_errors_when_sending_a_mutation_via_get(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + query=""" + mutation TestMutation { writeTest { test } } + """, + ) + assert response.status_code == 405 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_errors_when_selecting_a_mutation_within_a_get( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestMutation", + ) + + assert response.status_code == 405 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + { + "message": "Can only perform a mutation operation from a POST request.", + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_allows_mutation_to_exist_within_a_get(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + query=""" + query TestQuery { test } + mutation TestMutation { writeTest { test } } + """, + operationName="TestQuery", + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_post_with_json_encoding(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg(query="{test}"), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_allows_sending_a_mutation_via_post(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg(query="mutation TestMutation { writeTest { test } }"), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"writeTest": {"test": "Hello World"}}} + + +@pytest.mark.asyncio +async def test_allows_post_with_url_encoding(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data=urlencode(dict(query="{test}")), + headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello World"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_string_variables( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_json_variables( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_url_encoded_query_with_string_variables( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client, + method="POST", + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + variables=json.dumps({"who": "Dolly"}), + ) + ), + headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_json_query_with_get_variable_values( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), + headers=Headers({"Content-Type": "application/json"}), + variables=json.dumps({"who": "Dolly"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_post_url_encoded_query_with_get_variable_values( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client, + method="POST", + data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}), + variables=json.dumps({"who": "Dolly"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_supports_post_raw_text_query_with_get_variable_values( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client=client, + method="POST", + data="query helloWho($who: String){ test(who: $who) }", + headers=Headers({"Content-Type": "application/graphql"}), + variables=json.dumps({"who": "Dolly"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"test": "Hello Dolly"}} + + +@pytest.mark.asyncio +async def test_allows_post_with_operation_name(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + 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=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +async def test_allows_post_with_get_operation_name(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + 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=Headers({"Content-Type": "application/graphql"}), + operationName="helloWorld", + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == { + "data": {"test": "Hello World", "shared": "Hello Everyone"} + } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(pretty=True)]) +async def test_supports_pretty_printing(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{test}") + + result = await response.get_data(raw=False) + assert result == ("{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}") + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(pretty=False)]) +async def test_not_pretty_by_default(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{test}") + + result = await response.get_data(raw=False) + assert result == '{"data":{"test":"Hello World"}}' + + +@pytest.mark.asyncio +async def test_supports_pretty_printing_by_request(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{test}", pretty="1") + + result = await response.get_data(raw=False) + assert result == ("{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}") + + +@pytest.mark.asyncio +async def test_handles_field_errors_caught_by_graphql(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{thrower}") + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + { + "locations": [{"column": 2, "line": 1}], + "path": ["thrower"], + "message": "Throws!", + } + ], + "data": None, + } + + +@pytest.mark.asyncio +async def test_handles_syntax_errors_caught_by_graphql(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="syntaxerror") + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + { + "locations": [{"column": 1, "line": 1}], + "message": "Syntax Error: Unexpected Name 'syntaxerror'.", + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_handles_errors_caused_by_a_lack_of_query( + app: Quart, client: QuartClient +): + response = await execute_client(app, client) + + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + {"message": "Must provide query string.", "locations": None, "path": None} + ] + } + + +@pytest.mark.asyncio +async def test_handles_batch_correctly_if_is_disabled(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data="[]", + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + { + "message": "Batch GraphQL requests are not enabled.", + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_handles_incomplete_json_bodies(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data='{"query":', + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + {"message": "POST body sent invalid JSON.", "locations": None, "path": None} + ] + } + + +@pytest.mark.asyncio +async def test_handles_plain_post_text(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data="query helloWho($who: String){ test(who: $who) }", + headers=Headers({"Content-Type": "text/plain"}), + variables=json.dumps({"who": "Dolly"}), + ) + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + {"message": "Must provide query string.", "locations": None, "path": None} + ] + } + + +@pytest.mark.asyncio +async def test_handles_poorly_formed_variables(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + query="query helloWho($who: String){ test(who: $who) }", + variables="who:You", + ) + assert response.status_code == 400 + result = await response.get_data(raw=False) + assert response_json(result) == { + "errors": [ + {"message": "Variables are invalid JSON.", "locations": None, "path": None} + ] + } + + +@pytest.mark.asyncio +async def test_handles_unsupported_http_methods(app: Quart, client: QuartClient): + response = await execute_client(app, client, method="PUT", query="{test}") + assert response.status_code == 405 + result = await response.get_data(raw=False) + assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] + assert response_json(result) == { + "errors": [ + { + "message": "GraphQL only supports GET and POST requests.", + "locations": None, + "path": None, + } + ] + } + + +@pytest.mark.asyncio +async def test_passes_request_into_request_context(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{request}", q="testing") + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == {"data": {"request": "testing"}} + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context={"session": "CUSTOM CONTEXT"})]) +async def test_passes_custom_context_into_context(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{context { session request }}") + + assert response.status_code == 200 + result = await response.get_data(raw=False) + res = response_json(result) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" in res["data"]["context"]["session"] + assert "Request" in res["data"]["context"]["request"] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(context="CUSTOM CONTEXT")]) +async def test_context_remapped_if_not_mapping(app: Quart, client: QuartClient): + response = await execute_client(app, client, query="{context { session request }}") + + assert response.status_code == 200 + result = await response.get_data(raw=False) + res = response_json(result) + assert "data" in res + assert "session" in res["data"]["context"] + assert "request" in res["data"]["context"] + assert "CUSTOM CONTEXT" not in res["data"]["context"]["request"] + assert "Request" in res["data"]["context"]["request"] + + +# @pytest.mark.asyncio +# async def test_post_multipart_data(app: Quart, client: QuartClient): +# query = "mutation TestMutation { writeTest { test } }" +# response = await execute_client( +# app, +# client, +# method='POST', +# data={"query": query, "file": (StringIO(), "text1.txt")}, +# headers=Headers({"Content-Type": "multipart/form-data"}) +# ) +# +# assert response.status_code == 200 +# result = await response.get_data() +# assert response_json(result) == { +# "data": {u"writeTest": {u"test": u"Hello World"}} +# } + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_allows_post_with_json_encoding(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg_list(query="{test}"), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == [{"data": {"test": "Hello World"}}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_supports_post_json_query_with_json_variables( + app: Quart, client: QuartClient +): + response = await execute_client( + app, + client, + method="POST", + data=json_dump_kwarg_list( + query="query helloWho($who: String){ test(who: $who) }", + variables={"who": "Dolly"}, + ), + headers=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == [{"data": {"test": "Hello Dolly"}}] + + +@pytest.mark.asyncio +@pytest.mark.parametrize("app", [create_app(batch=True)]) +async def test_batch_allows_post_with_operation_name(app: Quart, client: QuartClient): + response = await execute_client( + app, + client, + method="POST", + 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=Headers({"Content-Type": "application/json"}), + ) + + assert response.status_code == 200 + result = await response.get_data(raw=False) + assert response_json(result) == [ + {"data": {"test": "Hello World", "shared": "Hello Everyone"}} + ] From 9815d26bb3b67afc93466befbc18398da3afd22e Mon Sep 17 00:00:00 2001 From: Rainer Koirikivi Date: Sat, 28 Nov 2020 19:12:54 +0200 Subject: [PATCH 24/48] Prevent including test directory when installing package (#75) * Prevent including test directory when installing package * chore: include only graphql_server package Co-authored-by: Manuel Bojato <30560560+KingDarBoja@users.noreply.github.com> --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index e16e61b..3758a20 100644 --- a/setup.py +++ b/setup.py @@ -78,7 +78,7 @@ "License :: OSI Approved :: MIT License", ], keywords="api graphql protocol rest", - packages=find_packages(exclude=["tests"]), + packages=find_packages(include=["graphql_server*"]), install_requires=install_requires, tests_require=install_all_requires + tests_requires, extras_require={ From c03e1a4177233b0a053948c96ce862314d52e7bf Mon Sep 17 00:00:00 2001 From: KingDarBoja Date: Sat, 28 Nov 2020 12:28:57 -0500 Subject: [PATCH 25/48] chore: bump version to v3.0.0b3 --- graphql_server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphql_server/version.py b/graphql_server/version.py index 5536d02..46cd5e1 100644 --- a/graphql_server/version.py +++ b/graphql_server/version.py @@ -4,7 +4,7 @@ __all__ = ["version", "version_info"] -version = "3.0.0b2" +version = "3.0.0b3" _re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(\D*)(\d*)") From b8705c2ca910d330120fc91d4b8a7a9a9adfefbd Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Mon, 9 Aug 2021 17:04:25 +0200 Subject: [PATCH 26/48] Fix tests (#84) --- graphql_server/__init__.py | 4 ++++ setup.py | 28 ++++++++++++---------------- tests/sanic/app.py | 3 +++ tests/test_query.py | 6 ++++-- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 8942332..239a1d4 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -255,6 +255,10 @@ def get_response( if not params.query: raise HttpQueryError(400, "Must provide query string.") + # Sanity check query + if not isinstance(params.query, str): + raise HttpQueryError(400, "Unexpected query type.") + schema_validation_errors = validate_schema(schema) if schema_validation_errors: return ExecutionResult(data=None, errors=schema_validation_errors) diff --git a/setup.py b/setup.py index 3758a20..e3f769e 100644 --- a/setup.py +++ b/setup.py @@ -1,10 +1,7 @@ from re import search from setuptools import setup, find_packages -install_requires = [ - "graphql-core>=3.1.0,<4", - "typing-extensions>=3.7.4,<4" -] +install_requires = ["graphql-core>=3.1.0,<4", "typing-extensions>=3.7.4,<4"] tests_requires = [ "pytest>=5.4,<5.5", @@ -23,11 +20,11 @@ ] + tests_requires install_flask_requires = [ - "flask>=0.7.0", + "flask>=0.7.0<1", ] install_sanic_requires = [ - "sanic>=20.3.0", + "sanic>=20.3.0,<21", ] install_webob_requires = [ @@ -38,17 +35,16 @@ "aiohttp>=3.5.0,<4", ] -install_quart_requires = [ - "quart>=0.6.15" -] +install_quart_requires = ["quart>=0.6.15,<1"] -install_all_requires = \ - install_requires + \ - install_flask_requires + \ - install_sanic_requires + \ - install_webob_requires + \ - install_aiohttp_requires + \ - install_quart_requires +install_all_requires = ( + install_requires + + install_flask_requires + + install_sanic_requires + + install_webob_requires + + install_aiohttp_requires + + install_quart_requires +) with open("graphql_server/version.py") as version_file: version = search('version = "(.*)"', version_file.read()).group(1) diff --git a/tests/sanic/app.py b/tests/sanic/app.py index f5a74cf..6966b1e 100644 --- a/tests/sanic/app.py +++ b/tests/sanic/app.py @@ -8,6 +8,9 @@ from .schema import Schema +Sanic.test_mode = True + + def create_app(path="/graphql", **kwargs): app = Sanic(__name__) app.debug = True diff --git a/tests/test_query.py b/tests/test_query.py index 70f49ac..c4f6a43 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -495,8 +495,10 @@ def test_handles_errors_caused_by_a_lack_of_query(): def test_handles_errors_caused_by_invalid_query_type(): - results, params = run_http_query(schema, "get", dict(query=42)) - assert results == [(None, [{"message": "Must provide Source. Received: 42."}])] + with raises(HttpQueryError) as exc_info: + results, params = run_http_query(schema, "get", dict(query=42)) + + assert exc_info.value == HttpQueryError(400, "Unexpected query type.") def test_handles_batch_correctly_if_is_disabled(): From 86b7926f16c547b1a0f2e50096eb92ce1dd327af Mon Sep 17 00:00:00 2001 From: Aryan Iyappan <69184573+codebyaryan@users.noreply.github.com> Date: Tue, 10 Aug 2021 17:42:32 +0530 Subject: [PATCH 27/48] add support for validation rules (#83) Co-authored-by: Aryan Iyappan <69184573+aryan340@users.noreply.github.com> Co-authored-by: Jonathan Kim --- .gitignore | 3 +++ docs/aiohttp.md | 1 + docs/flask.md | 1 + docs/sanic.md | 1 + docs/webob.md | 1 + graphql_server/aiohttp/graphqlview.py | 9 ++++++++- graphql_server/flask/graphqlview.py | 8 ++++++++ graphql_server/quart/graphqlview.py | 9 ++++++++- graphql_server/sanic/graphqlview.py | 9 ++++++++- graphql_server/webob/graphqlview.py | 8 ++++++++ 10 files changed, 47 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 642f015..bfac963 100644 --- a/.gitignore +++ b/.gitignore @@ -158,6 +158,9 @@ target/ # pyenv .python-version +# Pycharm venv +venv/ + # 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 diff --git a/docs/aiohttp.md b/docs/aiohttp.md index 35f7fbf..d65bcb8 100644 --- a/docs/aiohttp.md +++ b/docs/aiohttp.md @@ -59,6 +59,7 @@ gql_view(request) # <-- the instance is callable and expects a `aiohttp.web.Req `Template.render_async` instead of `Template.render`. If environment is not set, fallbacks to simple regex-based renderer. * `batch`: Set the GraphQL view as batch (for using in [Apollo-Client](http://dev.apollodata.com/core/network.html#query-batching) or [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer)) * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `validation_rules`: A list of graphql validation rules. * `max_age`: Sets the response header Access-Control-Max-Age for preflight requests. * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. diff --git a/docs/flask.md b/docs/flask.md index 80bab4f..f3a36e7 100644 --- a/docs/flask.md +++ b/docs/flask.md @@ -58,6 +58,7 @@ More info at [Graphene v3 release notes](https://github.com/graphql-python/graph * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. * `batch`: Set the GraphQL view as batch (for using in [Apollo-Client](http://dev.apollodata.com/core/network.html#query-batching) or [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer)) * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `validation_rules`: A list of graphql validation rules. * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. * `subscriptions`: The GraphiQL socket endpoint for using subscriptions in graphql-ws. diff --git a/docs/sanic.md b/docs/sanic.md index 0b5ec35..e922598 100644 --- a/docs/sanic.md +++ b/docs/sanic.md @@ -51,6 +51,7 @@ This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. `Template.render_async` instead of `Template.render`. If environment is not set, fallbacks to simple regex-based renderer. * `batch`: Set the GraphQL view as batch (for using in [Apollo-Client](http://dev.apollodata.com/core/network.html#query-batching) or [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer)) * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `validation_rules`: A list of graphql validation rules. * `max_age`: Sets the response header Access-Control-Max-Age for preflight requests. * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. diff --git a/docs/webob.md b/docs/webob.md index 5203c2c..41c0ad1 100644 --- a/docs/webob.md +++ b/docs/webob.md @@ -48,6 +48,7 @@ This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. * `graphiql_html_title`: The graphiql title to display. Defaults to **"GraphiQL"**. * `batch`: Set the GraphQL view as batch (for using in [Apollo-Client](http://dev.apollodata.com/core/network.html#query-batching) or [ReactRelayNetworkLayer](https://github.com/nodkz/react-relay-network-layer)) * `middleware`: A list of graphql [middlewares](http://docs.graphene-python.org/en/latest/execution/middleware/). + * `validation_rules`: A list of graphql validation rules. * `encode`: the encoder to use for responses (sensibly defaults to `graphql_server.json_encode`). * `format_error`: the error formatter to use for responses (sensibly defaults to `graphql_server.default_format_error`. * `enable_async`: whether `async` mode will be enabled. diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index a3db1d6..0081174 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -4,7 +4,7 @@ from typing import List from aiohttp import web -from graphql import ExecutionResult, GraphQLError +from graphql import ExecutionResult, GraphQLError, specified_rules from graphql.type.schema import GraphQLSchema from graphql_server import ( @@ -34,6 +34,7 @@ class GraphQLView: graphiql_template = None graphiql_html_title = None middleware = None + validation_rules = None batch = False jinja_env = None max_age = 86400 @@ -75,6 +76,11 @@ def get_context(self, request): def get_middleware(self): return self.middleware + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + @staticmethod async def parse_body(request): content_type = request.content_type @@ -149,6 +155,7 @@ async def __call__(self, request): root_value=self.get_root_value(), context_value=self.get_context(request), middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), ) exec_res = ( diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index a417406..59097d9 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -7,6 +7,7 @@ from flask.views import View from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema +from graphql import specified_rules from graphql_server import ( GraphQLParams, @@ -35,6 +36,7 @@ class GraphQLView(View): graphiql_template = None graphiql_html_title = None middleware = None + validation_rules = None batch = False subscriptions = None headers = None @@ -73,6 +75,11 @@ def get_context(self): def get_middleware(self): return self.middleware + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + def dispatch_request(self): try: request_method = request.method.lower() @@ -95,6 +102,7 @@ def dispatch_request(self): root_value=self.get_root_value(), context_value=self.get_context(), middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), ) result, status_code = encode_execution_results( execution_results, diff --git a/graphql_server/quart/graphqlview.py b/graphql_server/quart/graphqlview.py index 9993998..3f01edc 100644 --- a/graphql_server/quart/graphqlview.py +++ b/graphql_server/quart/graphqlview.py @@ -4,7 +4,7 @@ from functools import partial from typing import List -from graphql import ExecutionResult +from graphql import ExecutionResult, specified_rules from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema from quart import Response, render_template_string, request @@ -37,6 +37,7 @@ class GraphQLView(View): graphiql_template = None graphiql_html_title = None middleware = None + validation_rules = None batch = False enable_async = False subscriptions = None @@ -76,6 +77,11 @@ def get_context(self): def get_middleware(self): return self.middleware + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + async def dispatch_request(self): try: request_method = request.method.lower() @@ -98,6 +104,7 @@ async def dispatch_request(self): root_value=self.get_root_value(), context_value=self.get_context(), middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), ) exec_res = ( [ diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index 29548e9..e184143 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -4,7 +4,7 @@ from functools import partial from typing import List -from graphql import ExecutionResult, GraphQLError +from graphql import ExecutionResult, GraphQLError, specified_rules from graphql.type.schema import GraphQLSchema from sanic.response import HTTPResponse, html from sanic.views import HTTPMethodView @@ -36,6 +36,7 @@ class GraphQLView(HTTPMethodView): graphiql_template = None graphiql_html_title = None middleware = None + validation_rules = None batch = False jinja_env = None max_age = 86400 @@ -77,6 +78,11 @@ def get_context(self, request): def get_middleware(self): return self.middleware + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + async def dispatch_request(self, request, *args, **kwargs): try: request_method = request.method.lower() @@ -103,6 +109,7 @@ async def dispatch_request(self, request, *args, **kwargs): root_value=self.get_root_value(), context_value=self.get_context(request), middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), ) exec_res = ( [ diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py index 4eff242..ba54599 100644 --- a/graphql_server/webob/graphqlview.py +++ b/graphql_server/webob/graphqlview.py @@ -5,6 +5,7 @@ from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema +from graphql import specified_rules from webob import Response from graphql_server import ( @@ -35,6 +36,7 @@ class GraphQLView: graphiql_template = None graphiql_html_title = None middleware = None + validation_rules = None batch = False enable_async = False subscriptions = None @@ -73,6 +75,11 @@ def get_context(self, request): def get_middleware(self): return self.middleware + def get_validation_rules(self): + if self.validation_rules is None: + return specified_rules + return self.validation_rules + def dispatch_request(self, request): try: request_method = request.method.lower() @@ -98,6 +105,7 @@ def dispatch_request(self, request): root_value=self.get_root_value(), context_value=self.get_context(request), middleware=self.get_middleware(), + validation_rules=self.get_validation_rules(), ) result, status_code = encode_execution_results( execution_results, From 1ccebee8c6102f2855bcf64024d84091d8547f08 Mon Sep 17 00:00:00 2001 From: Jonathan Kim Date: Tue, 10 Aug 2021 14:13:35 +0200 Subject: [PATCH 28/48] v3.0.0b4 --- graphql_server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphql_server/version.py b/graphql_server/version.py index 46cd5e1..2ee7c44 100644 --- a/graphql_server/version.py +++ b/graphql_server/version.py @@ -4,7 +4,7 @@ __all__ = ["version", "version_info"] -version = "3.0.0b3" +version = "3.0.0b4" _re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(\D*)(\d*)") From 476edf370099df050289f9c0b8d70007e7dc8ecc Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Fri, 24 Dec 2021 14:28:48 +0100 Subject: [PATCH 29/48] Accept Graphene wrapped GraphQL schemas --- graphql_server/aiohttp/graphqlview.py | 8 +++++--- graphql_server/flask/graphqlview.py | 8 +++++--- graphql_server/quart/graphqlview.py | 8 +++++--- graphql_server/sanic/graphqlview.py | 8 +++++--- graphql_server/webob/graphqlview.py | 8 +++++--- 5 files changed, 25 insertions(+), 15 deletions(-) diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index 0081174..deb6522 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -56,9 +56,11 @@ 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." + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") def get_root_value(self): return self.root_value diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 59097d9..2a9e451 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -55,9 +55,11 @@ 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." + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") def get_root_value(self): return self.root_value diff --git a/graphql_server/quart/graphqlview.py b/graphql_server/quart/graphqlview.py index 3f01edc..ff737ec 100644 --- a/graphql_server/quart/graphqlview.py +++ b/graphql_server/quart/graphqlview.py @@ -57,9 +57,11 @@ 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." + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") def get_root_value(self): return self.root_value diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index e184143..c7a3b75 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -58,9 +58,11 @@ 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." + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") def get_root_value(self): return self.root_value diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py index ba54599..0aa08c6 100644 --- a/graphql_server/webob/graphqlview.py +++ b/graphql_server/webob/graphqlview.py @@ -55,9 +55,11 @@ 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." + if not isinstance(self.schema, GraphQLSchema): + # maybe the GraphQL schema is wrapped in a Graphene schema + self.schema = getattr(self.schema, "graphql_schema", None) + if not isinstance(self.schema, GraphQLSchema): + raise TypeError("A Schema is required to be provided to GraphQLView.") def get_root_value(self): return self.root_value From 384ae78d257f0bb8bd86c581b7f01eb395378d6f Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Mon, 17 Jan 2022 11:52:54 +0100 Subject: [PATCH 30/48] Update GraphQL-core from 3.1 to 3.2 (#85) --- graphql_server/__init__.py | 6 +++++- setup.py | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 239a1d4..5ae4acd 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -12,7 +12,6 @@ from typing import Any, Callable, Collection, Dict, List, Optional, Type, Union from graphql.error import GraphQLError -from graphql.error import format_error as format_error_default from graphql.execution import ExecutionResult, execute from graphql.language import OperationType, parse from graphql.pyutils import AwaitableOrValue @@ -55,6 +54,11 @@ # The public helper functions +def format_error_default(error: GraphQLError) -> Dict: + """The default function for converting GraphQLError to a dictionary.""" + return error.formatted + + def run_http_query( schema: GraphQLSchema, request_method: str, diff --git a/setup.py b/setup.py index e3f769e..91786c3 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from re import search from setuptools import setup, find_packages -install_requires = ["graphql-core>=3.1.0,<4", "typing-extensions>=3.7.4,<4"] +install_requires = ["graphql-core>=3.2,<3.3", "typing-extensions>=4,<5"] tests_requires = [ "pytest>=5.4,<5.5", From bc74eedab7e15b98aff4891dc1c74eb0528634f6 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Mon, 17 Jan 2022 13:12:26 +0100 Subject: [PATCH 31/48] Empty fields are not contained in formatted errors any more --- setup.py | 8 +-- tests/aiohttp/test_graphqlview.py | 86 ++++++++++++++----------------- tests/flask/test_graphqlview.py | 46 ++++++++--------- tests/quart/test_graphqlview.py | 52 ++++++------------- tests/sanic/test_graphqlview.py | 46 ++++++----------- tests/test_query.py | 13 +---- tests/webob/test_graphqlview.py | 52 ++++++------------- 7 files changed, 116 insertions(+), 187 deletions(-) diff --git a/setup.py b/setup.py index 91786c3..6bb761e 100644 --- a/setup.py +++ b/setup.py @@ -12,9 +12,9 @@ ] dev_requires = [ - "flake8>=3.7,<4", - "isort>=4,<5", - "black==19.10b0", + "flake8>=4,<5", + "isort>=5,<6", + "black>=19.10b0", "mypy>=0.761,<0.770", "check-manifest>=0.40,<1", ] + tests_requires @@ -28,7 +28,7 @@ ] install_webob_requires = [ - "webob>=1.8.6,<2", + "webob>=1.8.7,<2", ] install_aiohttp_requires = [ diff --git a/tests/aiohttp/test_graphqlview.py b/tests/aiohttp/test_graphqlview.py index 0a940f9..815d23d 100644 --- a/tests/aiohttp/test_graphqlview.py +++ b/tests/aiohttp/test_graphqlview.py @@ -76,12 +76,10 @@ async def test_reports_validation_errors(client): { "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, }, ], } @@ -107,8 +105,6 @@ async def test_errors_when_missing_operation_name(client): "Must provide operation name if query contains multiple " "operations." ), - "locations": None, - "path": None, }, ] } @@ -128,8 +124,6 @@ async def test_errors_when_sending_a_mutation_via_get(client): "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, }, ], } @@ -152,8 +146,6 @@ async def test_errors_when_selecting_a_mutation_within_a_get(client): "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, }, ], } @@ -174,10 +166,8 @@ async def test_errors_when_selecting_a_subscription_within_a_get(client): assert await response.json() == { "errors": [ { - "message": "Can only perform a subscription operation from a POST " - "request.", - "locations": None, - "path": None, + "message": "Can only perform a subscription operation" + " from a POST request.", }, ], } @@ -215,7 +205,11 @@ async def test_allows_post_with_json_encoding(client): async def test_allows_sending_a_mutation_via_post(client): response = await client.post( "/graphql", - data=json.dumps(dict(query="mutation TestMutation { writeTest { test } }",)), + data=json.dumps( + dict( + query="mutation TestMutation { writeTest { test } }", + ) + ), headers={"content-type": "application/json"}, ) @@ -292,7 +286,11 @@ async def test_supports_post_url_encoded_query_with_string_variables(client): async def test_supports_post_json_quey_with_get_variable_values(client): response = await client.post( url_string(variables=json.dumps({"who": "Dolly"})), - data=json.dumps(dict(query="query helloWho($who: String){ test(who: $who) }",)), + data=json.dumps( + dict( + query="query helloWho($who: String){ test(who: $who) }", + ) + ), headers={"content-type": "application/json"}, ) @@ -304,7 +302,11 @@ async def test_supports_post_json_quey_with_get_variable_values(client): async def test_post_url_encoded_query_with_get_variable_values(client): response = await client.post( url_string(variables=json.dumps({"who": "Dolly"})), - data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + ) + ), headers={"content-type": "application/x-www-form-urlencoded"}, ) @@ -421,7 +423,6 @@ async def test_handles_syntax_errors_caught_by_graphql(client): { "locations": [{"column": 1, "line": 1}], "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - "path": None, }, ], } @@ -433,16 +434,16 @@ async def test_handles_errors_caused_by_a_lack_of_query(client): assert response.status == 400 assert await response.json() == { - "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @pytest.mark.asyncio async def test_handles_batch_correctly_if_is_disabled(client): response = await client.post( - "/graphql", data="[]", headers={"content-type": "application/json"}, + "/graphql", + data="[]", + headers={"content-type": "application/json"}, ) assert response.status == 400 @@ -450,8 +451,6 @@ async def test_handles_batch_correctly_if_is_disabled(client): "errors": [ { "message": "Batch GraphQL requests are not enabled.", - "locations": None, - "path": None, } ] } @@ -460,7 +459,9 @@ async def test_handles_batch_correctly_if_is_disabled(client): @pytest.mark.asyncio async def test_handles_incomplete_json_bodies(client): response = await client.post( - "/graphql", data='{"query":', headers={"content-type": "application/json"}, + "/graphql", + data='{"query":', + headers={"content-type": "application/json"}, ) assert response.status == 400 @@ -468,8 +469,6 @@ async def test_handles_incomplete_json_bodies(client): "errors": [ { "message": "POST body sent invalid JSON.", - "locations": None, - "path": None, } ] } @@ -484,9 +483,7 @@ async def test_handles_plain_post_text(client): ) assert response.status == 400 assert await response.json() == { - "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @@ -499,9 +496,7 @@ async def test_handles_poorly_formed_variables(client): ) assert response.status == 400 assert await response.json() == { - "errors": [ - {"message": "Variables are invalid JSON.", "locations": None, "path": None} - ] + "errors": [{"message": "Variables are invalid JSON."}] } @@ -514,8 +509,6 @@ async def test_handles_unsupported_http_methods(client): "errors": [ { "message": "GraphQL only supports GET and POST requests.", - "locations": None, - "path": None, } ] } @@ -576,16 +569,15 @@ async def test_post_multipart_data(client): data = ( "------aiohttpgraphql\r\n" - + 'Content-Disposition: form-data; name="query"\r\n' - + "\r\n" - + query - + "\r\n" - + "------aiohttpgraphql--\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' # noqa: ignore - + "\r\n" - + "\r\n" - + "------aiohttpgraphql--\r\n" + 'Content-Disposition: form-data; name="query"\r\n' + "\r\n" + query + "\r\n" + "------aiohttpgraphql--\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" + "------aiohttpgraphql--\r\n" ) response = await client.post( @@ -595,7 +587,7 @@ async def test_post_multipart_data(client): ) assert response.status == 200 - assert await response.json() == {"data": {u"writeTest": {u"test": u"Hello World"}}} + assert await response.json() == {"data": {"writeTest": {"test": "Hello World"}}} @pytest.mark.asyncio @@ -674,7 +666,8 @@ async def test_async_schema(app, client): @pytest.mark.asyncio async def test_preflight_request(client): response = await client.options( - "/graphql", headers={"Access-Control-Request-Method": "POST"}, + "/graphql", + headers={"Access-Control-Request-Method": "POST"}, ) assert response.status == 200 @@ -683,7 +676,8 @@ async def test_preflight_request(client): @pytest.mark.asyncio async def test_preflight_incorrect_request(client): response = await client.options( - "/graphql", headers={"Access-Control-Request-Method": "OPTIONS"}, + "/graphql", + headers={"Access-Control-Request-Method": "OPTIONS"}, ) assert response.status == 400 diff --git a/tests/flask/test_graphqlview.py b/tests/flask/test_graphqlview.py index d8d60b0..9b388f9 100644 --- a/tests/flask/test_graphqlview.py +++ b/tests/flask/test_graphqlview.py @@ -97,12 +97,10 @@ def test_reports_validation_errors(app, client): { "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, }, ] } @@ -123,9 +121,8 @@ def test_errors_when_missing_operation_name(app, client): assert response_json(response) == { "errors": [ { - "message": "Must provide operation name if query contains multiple operations.", # noqa: E501 - "locations": None, - "path": None, + "message": "Must provide operation name" + " if query contains multiple operations.", } ] } @@ -145,8 +142,6 @@ def test_errors_when_sending_a_mutation_via_get(app, client): "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, } ] } @@ -169,8 +164,6 @@ def test_errors_when_selecting_a_mutation_within_a_get(app, client): "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, } ] } @@ -272,7 +265,9 @@ def test_supports_post_url_encoded_query_with_string_variables(app, client): def test_supports_post_json_query_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) }",), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + ), content_type="application/json", ) @@ -283,7 +278,11 @@ def test_supports_post_json_query_with_get_variable_values(app, client): 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) }",)), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + ) + ), content_type="application/x-www-form-urlencoded", ) @@ -392,7 +391,6 @@ def test_handles_syntax_errors_caught_by_graphql(app, client): { "locations": [{"column": 1, "line": 1}], "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - "path": None, } ] } @@ -404,7 +402,9 @@ def test_handles_errors_caused_by_a_lack_of_query(app, client): assert response.status_code == 400 assert response_json(response) == { "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} + { + "message": "Must provide query string.", + } ] } @@ -417,8 +417,6 @@ def test_handles_batch_correctly_if_is_disabled(app, client): "errors": [ { "message": "Batch GraphQL requests are not enabled.", - "locations": None, - "path": None, } ] } @@ -432,7 +430,9 @@ def test_handles_incomplete_json_bodies(app, client): assert response.status_code == 400 assert response_json(response) == { "errors": [ - {"message": "POST body sent invalid JSON.", "locations": None, "path": None} + { + "message": "POST body sent invalid JSON.", + } ] } @@ -446,7 +446,9 @@ def test_handles_plain_post_text(app, client): assert response.status_code == 400 assert response_json(response) == { "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} + { + "message": "Must provide query string.", + } ] } @@ -462,7 +464,9 @@ def test_handles_poorly_formed_variables(app, client): assert response.status_code == 400 assert response_json(response) == { "errors": [ - {"message": "Variables are invalid JSON.", "locations": None, "path": None} + { + "message": "Variables are invalid JSON.", + } ] } @@ -475,8 +479,6 @@ def test_handles_unsupported_http_methods(app, client): "errors": [ { "message": "GraphQL only supports GET and POST requests.", - "locations": None, - "path": None, } ] } @@ -524,9 +526,7 @@ def test_post_multipart_data(app, client): ) assert response.status_code == 200 - assert response_json(response) == { - "data": {u"writeTest": {u"test": u"Hello World"}} - } + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} @pytest.mark.parametrize("app", [create_app(batch=True)]) diff --git a/tests/quart/test_graphqlview.py b/tests/quart/test_graphqlview.py index 4a24ace..429b4ef 100644 --- a/tests/quart/test_graphqlview.py +++ b/tests/quart/test_graphqlview.py @@ -35,7 +35,7 @@ async def execute_client( method: str = "GET", data: str = None, headers: Headers = None, - **url_params + **url_params, ) -> Response: if sys.version_info >= (3, 7): test_request_context = app.test_request_context("/", method=method) @@ -126,12 +126,10 @@ async def test_reports_validation_errors(app: Quart, client: QuartClient): { "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, }, ] } @@ -153,9 +151,8 @@ async def test_errors_when_missing_operation_name(app: Quart, client: QuartClien assert response_json(result) == { "errors": [ { - "message": "Must provide operation name if query contains multiple operations.", # noqa: E501 - "locations": None, - "path": None, + "message": "Must provide operation name" + " if query contains multiple operations.", } ] } @@ -176,8 +173,6 @@ async def test_errors_when_sending_a_mutation_via_get(app: Quart, client: QuartC "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, } ] } @@ -203,8 +198,6 @@ async def test_errors_when_selecting_a_mutation_within_a_get( "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, } ] } @@ -342,7 +335,9 @@ async def test_supports_post_json_query_with_get_variable_values( app, client, method="POST", - data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + ), headers=Headers({"Content-Type": "application/json"}), variables=json.dumps({"who": "Dolly"}), ) @@ -360,7 +355,11 @@ async def test_post_url_encoded_query_with_get_variable_values( app, client, method="POST", - data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + ) + ), headers=Headers({"Content-Type": "application/x-www-form-urlencoded"}), variables=json.dumps({"who": "Dolly"}), ) @@ -463,7 +462,7 @@ async def test_supports_pretty_printing_by_request(app: Quart, client: QuartClie response = await execute_client(app, client, query="{test}", pretty="1") result = await response.get_data(raw=False) - assert result == ("{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}") + assert result == "{\n" ' "data": {\n' ' "test": "Hello World"\n' " }\n" "}" @pytest.mark.asyncio @@ -493,7 +492,6 @@ async def test_handles_syntax_errors_caught_by_graphql(app: Quart, client: Quart { "locations": [{"column": 1, "line": 1}], "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - "path": None, } ] } @@ -508,9 +506,7 @@ async def test_handles_errors_caused_by_a_lack_of_query( assert response.status_code == 400 result = await response.get_data(raw=False) assert response_json(result) == { - "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @@ -530,8 +526,6 @@ async def test_handles_batch_correctly_if_is_disabled(app: Quart, client: QuartC "errors": [ { "message": "Batch GraphQL requests are not enabled.", - "locations": None, - "path": None, } ] } @@ -550,9 +544,7 @@ async def test_handles_incomplete_json_bodies(app: Quart, client: QuartClient): assert response.status_code == 400 result = await response.get_data(raw=False) assert response_json(result) == { - "errors": [ - {"message": "POST body sent invalid JSON.", "locations": None, "path": None} - ] + "errors": [{"message": "POST body sent invalid JSON."}] } @@ -569,9 +561,7 @@ async def test_handles_plain_post_text(app: Quart, client: QuartClient): assert response.status_code == 400 result = await response.get_data(raw=False) assert response_json(result) == { - "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @@ -586,9 +576,7 @@ async def test_handles_poorly_formed_variables(app: Quart, client: QuartClient): assert response.status_code == 400 result = await response.get_data(raw=False) assert response_json(result) == { - "errors": [ - {"message": "Variables are invalid JSON.", "locations": None, "path": None} - ] + "errors": [{"message": "Variables are invalid JSON."}] } @@ -599,13 +587,7 @@ async def test_handles_unsupported_http_methods(app: Quart, client: QuartClient) result = await response.get_data(raw=False) assert response.headers["Allow"] in ["GET, POST", "HEAD, GET, POST, OPTIONS"] assert response_json(result) == { - "errors": [ - { - "message": "GraphQL only supports GET and POST requests.", - "locations": None, - "path": None, - } - ] + "errors": [{"message": "GraphQL only supports GET and POST requests."}] } diff --git a/tests/sanic/test_graphqlview.py b/tests/sanic/test_graphqlview.py index 740697c..7152150 100644 --- a/tests/sanic/test_graphqlview.py +++ b/tests/sanic/test_graphqlview.py @@ -74,12 +74,10 @@ def test_reports_validation_errors(app): { "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, }, ] } @@ -100,9 +98,8 @@ def test_errors_when_missing_operation_name(app): assert response_json(response) == { "errors": [ { - "locations": None, - "message": "Must provide operation name if query contains multiple operations.", - "path": None, + "message": "Must provide operation name" + " if query contains multiple operations.", } ] } @@ -121,9 +118,7 @@ def test_errors_when_sending_a_mutation_via_get(app): assert response_json(response) == { "errors": [ { - "locations": None, "message": "Can only perform a mutation operation from a POST request.", - "path": None, } ] } @@ -145,9 +140,7 @@ def test_errors_when_selecting_a_mutation_within_a_get(app): assert response_json(response) == { "errors": [ { - "locations": None, "message": "Can only perform a mutation operation from a POST request.", - "path": None, } ] } @@ -260,7 +253,9 @@ def test_supports_post_url_encoded_query_with_string_variables(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) }",), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + ), headers={"content-type": "application/json"}, ) @@ -272,7 +267,11 @@ def test_supports_post_json_query_with_get_variable_values(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) }",)), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + ) + ), headers={"content-type": "application/x-www-form-urlencoded"}, ) @@ -387,7 +386,6 @@ def test_handles_syntax_errors_caught_by_graphql(app): { "locations": [{"column": 1, "line": 1}], "message": "Syntax Error: Unexpected Name 'syntaxerror'.", - "path": None, } ] } @@ -399,9 +397,7 @@ def test_handles_errors_caused_by_a_lack_of_query(app): assert response.status == 400 assert response_json(response) == { - "errors": [ - {"locations": None, "message": "Must provide query string.", "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @@ -415,9 +411,7 @@ def test_handles_batch_correctly_if_is_disabled(app): assert response_json(response) == { "errors": [ { - "locations": None, "message": "Batch GraphQL requests are not enabled.", - "path": None, } ] } @@ -431,9 +425,7 @@ def test_handles_incomplete_json_bodies(app): assert response.status == 400 assert response_json(response) == { - "errors": [ - {"locations": None, "message": "POST body sent invalid JSON.", "path": None} - ] + "errors": [{"message": "POST body sent invalid JSON."}] } @@ -446,9 +438,7 @@ def test_handles_plain_post_text(app): ) assert response.status == 400 assert response_json(response) == { - "errors": [ - {"locations": None, "message": "Must provide query string.", "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @@ -461,9 +451,7 @@ def test_handles_poorly_formed_variables(app): ) assert response.status == 400 assert response_json(response) == { - "errors": [ - {"locations": None, "message": "Variables are invalid JSON.", "path": None} - ] + "errors": [{"message": "Variables are invalid JSON."}] } @@ -475,9 +463,7 @@ def test_handles_unsupported_http_methods(app): assert response_json(response) == { "errors": [ { - "locations": None, "message": "GraphQL only supports GET and POST requests.", - "path": None, } ] } @@ -542,9 +528,7 @@ def test_post_multipart_data(app): ) assert response.status == 200 - assert response_json(response) == { - "data": {u"writeTest": {u"test": u"Hello World"}} - } + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} @pytest.mark.parametrize("app", [create_app(batch=True)]) diff --git a/tests/test_query.py b/tests/test_query.py index c4f6a43..a1352cc 100644 --- a/tests/test_query.py +++ b/tests/test_query.py @@ -40,9 +40,7 @@ def test_validate_schema(): "data": None, "errors": [ { - "locations": None, "message": "Query root type must be provided.", - "path": None, } ], } @@ -109,12 +107,10 @@ def test_reports_validation_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, }, ], } @@ -144,7 +140,6 @@ def enter_field(self, node, *_args): { "message": "Custom validation error.", "locations": [{"line": 1, "column": 3}], - "path": None, } ], } @@ -170,13 +165,10 @@ def test_reports_max_num_of_validation_errors(): { "message": "Cannot query field 'unknownOne' on type 'QueryRoot'.", "locations": [{"line": 1, "column": 9}], - "path": None, }, { "message": "Too many validation errors, error limit reached." " Validation aborted.", - "locations": None, - "path": None, }, ], } @@ -223,12 +215,10 @@ def test_errors_when_missing_operation_name(): "data": None, "errors": [ { - "locations": None, "message": ( "Must provide operation name" " if query contains multiple operations." ), - "path": None, } ], } @@ -585,8 +575,7 @@ def test_encode_execution_results_batch(): results = [ExecutionResult(data, None), ExecutionResult(None, errors)] result = encode_execution_results(results, is_batch=True) assert result == ( - '[{"data":{"answer":42}},' - '{"errors":[{"message":"bad","locations":null,"path":null}]}]', + '[{"data":{"answer":42}},{"errors":[{"message":"bad"}]}]', 400, ) diff --git a/tests/webob/test_graphqlview.py b/tests/webob/test_graphqlview.py index 456b5f1..e1d783d 100644 --- a/tests/webob/test_graphqlview.py +++ b/tests/webob/test_graphqlview.py @@ -76,12 +76,10 @@ def test_reports_validation_errors(client): { "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, }, ] } @@ -101,9 +99,8 @@ def test_errors_when_missing_operation_name(client): assert response_json(response) == { "errors": [ { - "message": "Must provide operation name if query contains multiple operations.", - "locations": None, - "path": None, + "message": "Must provide operation name" + " if query contains multiple operations.", } ] } @@ -122,8 +119,6 @@ def test_errors_when_sending_a_mutation_via_get(client): "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, } ] } @@ -145,8 +140,6 @@ def test_errors_when_selecting_a_mutation_within_a_get(client): "errors": [ { "message": "Can only perform a mutation operation from a POST request.", - "locations": None, - "path": None, } ] } @@ -247,7 +240,9 @@ def test_supports_post_url_encoded_query_with_string_variables(client): def test_supports_post_json_quey_with_get_variable_values(client): response = client.post( url_string(variables=json.dumps({"who": "Dolly"})), - data=json_dump_kwarg(query="query helloWho($who: String){ test(who: $who) }",), + data=json_dump_kwarg( + query="query helloWho($who: String){ test(who: $who) }", + ), content_type="application/json", ) @@ -258,7 +253,11 @@ def test_supports_post_json_quey_with_get_variable_values(client): def test_post_url_encoded_query_with_get_variable_values(client): response = client.post( url_string(variables=json.dumps({"who": "Dolly"})), - data=urlencode(dict(query="query helloWho($who: String){ test(who: $who) }",)), + data=urlencode( + dict( + query="query helloWho($who: String){ test(who: $who) }", + ) + ), content_type="application/x-www-form-urlencoded", ) @@ -367,7 +366,6 @@ def test_handles_syntax_errors_caught_by_graphql(client): { "message": "Syntax Error: Unexpected Name 'syntaxerror'.", "locations": [{"column": 1, "line": 1}], - "path": None, } ] } @@ -378,9 +376,7 @@ def test_handles_errors_caused_by_a_lack_of_query(client): assert response.status_code == 400 assert response_json(response) == { - "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @@ -392,8 +388,6 @@ def test_handles_batch_correctly_if_is_disabled(client): "errors": [ { "message": "Batch GraphQL requests are not enabled.", - "locations": None, - "path": None, } ] } @@ -406,9 +400,7 @@ def test_handles_incomplete_json_bodies(client): assert response.status_code == 400 assert response_json(response) == { - "errors": [ - {"message": "POST body sent invalid JSON.", "locations": None, "path": None} - ] + "errors": [{"message": "POST body sent invalid JSON."}] } @@ -420,9 +412,7 @@ def test_handles_plain_post_text(client): ) assert response.status_code == 400 assert response_json(response) == { - "errors": [ - {"message": "Must provide query string.", "locations": None, "path": None} - ] + "errors": [{"message": "Must provide query string."}] } @@ -434,9 +424,7 @@ def test_handles_poorly_formed_variables(client): ) assert response.status_code == 400 assert response_json(response) == { - "errors": [ - {"message": "Variables are invalid JSON.", "locations": None, "path": None} - ] + "errors": [{"message": "Variables are invalid JSON."}] } @@ -445,13 +433,7 @@ def test_handles_unsupported_http_methods(client): 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, - } - ] + "errors": [{"message": "GraphQL only supports GET and POST requests."}] } @@ -511,9 +493,7 @@ def test_post_multipart_data(client): ) assert response.status_code == 200 - assert response_json(response) == { - "data": {u"writeTest": {u"test": u"Hello World"}} - } + assert response_json(response) == {"data": {"writeTest": {"test": "Hello World"}}} @pytest.mark.parametrize("settings", [dict(batch=True)]) From bda6a87bb987625908159a80a5563b1a1e7f05e5 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Mon, 17 Jan 2022 13:20:10 +0100 Subject: [PATCH 32/48] Update dependencies --- setup.py | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/setup.py b/setup.py index 6bb761e..18294bf 100644 --- a/setup.py +++ b/setup.py @@ -4,27 +4,27 @@ install_requires = ["graphql-core>=3.2,<3.3", "typing-extensions>=4,<5"] tests_requires = [ - "pytest>=5.4,<5.5", - "pytest-asyncio>=0.11.0", - "pytest-cov>=2.8,<3", - "aiohttp>=3.5.0,<4", - "Jinja2>=2.10.1,<3", + "pytest>=6.2,<6.3", + "pytest-asyncio>=0.17,<1", + "pytest-cov>=3,<4", + "aiohttp>=3.8,<4", + "Jinja2>=2.11,<3", ] dev_requires = [ "flake8>=4,<5", "isort>=5,<6", "black>=19.10b0", - "mypy>=0.761,<0.770", - "check-manifest>=0.40,<1", + "mypy>=0.931,<1", + "check-manifest>=0.47,<1", ] + tests_requires install_flask_requires = [ - "flask>=0.7.0<1", + "flask>=1,<2", ] install_sanic_requires = [ - "sanic>=20.3.0,<21", + "sanic>=21,<22", ] install_webob_requires = [ @@ -32,7 +32,7 @@ ] install_aiohttp_requires = [ - "aiohttp>=3.5.0,<4", + "aiohttp>=3.8,<4", ] install_quart_requires = ["quart>=0.6.15,<1"] From ec4ed15046c7b133907c9250a8101b01fe94eaaf Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Mon, 17 Jan 2022 14:13:52 +0100 Subject: [PATCH 33/48] Support Python 3.10 Also restrict web frameworks to supported versions --- .github/workflows/deploy.yml | 6 +++--- .github/workflows/lint.yml | 6 +++--- .github/workflows/tests.yml | 10 ++++++---- graphql_server/__init__.py | 14 ++++++++++++-- graphql_server/aiohttp/graphqlview.py | 4 +++- graphql_server/flask/graphqlview.py | 2 +- graphql_server/sanic/graphqlview.py | 4 ++-- graphql_server/webob/graphqlview.py | 2 +- setup.py | 5 +++-- tests/aiohttp/schema.py | 5 ++++- tests/aiohttp/test_graphiqlview.py | 17 ++++++++++++----- tests/quart/test_graphiqlview.py | 7 +------ tests/quart/test_graphqlview.py | 8 +------- tests/sanic/app.py | 1 - tox.ini | 17 +++++++++-------- 15 files changed, 61 insertions(+), 47 deletions(-) diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index a580073..6a34bba 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -11,10 +11,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Build wheel and source tarball run: | pip install wheel @@ -23,4 +23,4 @@ jobs: uses: pypa/gh-action-pypi-publish@v1.1.0 with: user: __token__ - password: ${{ secrets.pypi_password }} \ No newline at end of file + password: ${{ secrets.pypi_password }} diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 252a382..90ba2a1 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -8,10 +8,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install dependencies run: | python -m pip install --upgrade pip @@ -19,4 +19,4 @@ jobs: - name: Run lint and static type checks run: tox env: - TOXENV: flake8,black,import-order,mypy,manifest \ No newline at end of file + TOXENV: flake8,black,import-order,mypy,manifest diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4110dae..31616ec 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.6", "3.7", "3.8", "3.9"] + python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] os: [ubuntu-latest, windows-latest] exclude: - os: windows-latest @@ -16,7 +16,9 @@ jobs: - os: windows-latest python-version: "3.7" - os: windows-latest - python-version: "3.9" + python-version: "3.8" + - os: windows-latest + python-version: "3.10" steps: - uses: actions/checkout@v2 @@ -38,10 +40,10 @@ jobs: steps: - uses: actions/checkout@v2 - - name: Set up Python 3.8 + - name: Set up Python 3.9 uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install test dependencies run: | python -m pip install --upgrade pip diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index 5ae4acd..ee54cdb 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -9,7 +9,17 @@ import json from collections import namedtuple from collections.abc import MutableMapping -from typing import Any, Callable, Collection, Dict, List, Optional, Type, Union +from typing import ( + Any, + Callable, + Collection, + Dict, + List, + Optional, + Type, + Union, + cast, +) from graphql.error import GraphQLError from graphql.execution import ExecutionResult, execute @@ -56,7 +66,7 @@ def format_error_default(error: GraphQLError) -> Dict: """The default function for converting GraphQLError to a dictionary.""" - return error.formatted + return cast(Dict, error.formatted) def run_http_query( diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index deb6522..d98becd 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -201,7 +201,9 @@ async def __call__(self, request): return web.Response(text=source, content_type="text/html") return web.Response( - text=result, status=status_code, content_type="application/json", + text=result, + status=status_code, + content_type="application/json", ) except HttpQueryError as err: diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 2a9e451..063a67a 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -5,9 +5,9 @@ from flask import Response, render_template_string, request from flask.views import View +from graphql import specified_rules from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema -from graphql import specified_rules from graphql_server import ( GraphQLParams, diff --git a/graphql_server/sanic/graphqlview.py b/graphql_server/sanic/graphqlview.py index c7a3b75..569db53 100644 --- a/graphql_server/sanic/graphqlview.py +++ b/graphql_server/sanic/graphqlview.py @@ -212,8 +212,8 @@ def request_wants_html(request): 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 """ + """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() diff --git a/graphql_server/webob/graphqlview.py b/graphql_server/webob/graphqlview.py index 0aa08c6..36725f3 100644 --- a/graphql_server/webob/graphqlview.py +++ b/graphql_server/webob/graphqlview.py @@ -3,9 +3,9 @@ from functools import partial from typing import List +from graphql import specified_rules from graphql.error import GraphQLError from graphql.type.schema import GraphQLSchema -from graphql import specified_rules from webob import Response from graphql_server import ( diff --git a/setup.py b/setup.py index 18294bf..bb98728 100644 --- a/setup.py +++ b/setup.py @@ -24,7 +24,7 @@ ] install_sanic_requires = [ - "sanic>=21,<22", + "sanic>=20.3,<21", ] install_webob_requires = [ @@ -35,7 +35,7 @@ "aiohttp>=3.8,<4", ] -install_quart_requires = ["quart>=0.6.15,<1"] +install_quart_requires = ["quart>=0.6.15,<0.15"] install_all_requires = ( install_requires @@ -71,6 +71,7 @@ "Programming Language :: Python :: 3.7", "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", "License :: OSI Approved :: MIT License", ], keywords="api graphql protocol rest", diff --git a/tests/aiohttp/schema.py b/tests/aiohttp/schema.py index 7673180..54e0d10 100644 --- a/tests/aiohttp/schema.py +++ b/tests/aiohttp/schema.py @@ -18,7 +18,10 @@ def resolve_raises(*_): QueryRootType = GraphQLObjectType( name="QueryRoot", fields={ - "thrower": GraphQLField(GraphQLNonNull(GraphQLString), resolve=resolve_raises,), + "thrower": GraphQLField( + GraphQLNonNull(GraphQLString), + resolve=resolve_raises, + ), "request": GraphQLField( GraphQLNonNull(GraphQLString), resolve=lambda obj, info, *args: info.context["request"].query.get("q"), diff --git a/tests/aiohttp/test_graphiqlview.py b/tests/aiohttp/test_graphiqlview.py index 111b603..4e5bd32 100644 --- a/tests/aiohttp/test_graphiqlview.py +++ b/tests/aiohttp/test_graphiqlview.py @@ -52,7 +52,8 @@ async def test_graphiql_is_enabled(app, client): @pytest.mark.parametrize("app", [create_app(graphiql=True)]) async def test_graphiql_simple_renderer(app, client, pretty_response): response = await client.get( - url_string(query="{test}"), headers={"Accept": "text/html"}, + url_string(query="{test}"), + headers={"Accept": "text/html"}, ) assert response.status == 200 assert pretty_response in await response.text() @@ -65,7 +66,8 @@ class TestJinjaEnv: ) async def test_graphiql_jinja_renderer_async(self, app, client, pretty_response): response = await client.get( - url_string(query="{test}"), headers={"Accept": "text/html"}, + url_string(query="{test}"), + headers={"Accept": "text/html"}, ) assert response.status == 200 assert pretty_response in await response.text() @@ -73,7 +75,10 @@ async def test_graphiql_jinja_renderer_async(self, app, client, pretty_response) @pytest.mark.asyncio async def test_graphiql_html_is_not_accepted(client): - response = await client.get("/graphql", headers={"Accept": "application/json"},) + response = await client.get( + "/graphql", + headers={"Accept": "application/json"}, + ) assert response.status == 400 @@ -107,7 +112,8 @@ async def test_graphiql_get_subscriptions(app, client): ) async def test_graphiql_enabled_async_schema(app, client): response = await client.get( - url_string(query="{a,b,c}"), headers={"Accept": "text/html"}, + url_string(query="{a,b,c}"), + headers={"Accept": "text/html"}, ) expected_response = ( @@ -133,7 +139,8 @@ async def test_graphiql_enabled_async_schema(app, client): ) async def test_graphiql_enabled_sync_schema(app, client): response = await client.get( - url_string(query="{a,b}"), headers={"Accept": "text/html"}, + url_string(query="{a,b}"), + headers={"Accept": "text/html"}, ) expected_response = ( diff --git a/tests/quart/test_graphiqlview.py b/tests/quart/test_graphiqlview.py index 12b001f..1d8d7e3 100644 --- a/tests/quart/test_graphiqlview.py +++ b/tests/quart/test_graphiqlview.py @@ -1,5 +1,3 @@ -import sys - import pytest from quart import Quart, Response, url_for from quart.testing import QuartClient @@ -32,10 +30,7 @@ async def execute_client( headers: Headers = None, **extra_params ) -> Response: - if sys.version_info >= (3, 7): - test_request_context = app.test_request_context("/", method=method) - else: - test_request_context = app.test_request_context(method, "/") + test_request_context = app.test_request_context(path="/", method=method) async with test_request_context: string = url_for("graphql", **extra_params) return await client.get(string, headers=headers) diff --git a/tests/quart/test_graphqlview.py b/tests/quart/test_graphqlview.py index 429b4ef..79d1f73 100644 --- a/tests/quart/test_graphqlview.py +++ b/tests/quart/test_graphqlview.py @@ -1,7 +1,4 @@ import json -import sys - -# from io import StringIO from urllib.parse import urlencode import pytest @@ -37,10 +34,7 @@ async def execute_client( headers: Headers = None, **url_params, ) -> Response: - if sys.version_info >= (3, 7): - test_request_context = app.test_request_context("/", method=method) - else: - test_request_context = app.test_request_context(method, "/") + test_request_context = app.test_request_context(path="/", method=method) async with test_request_context: string = url_for("graphql") diff --git a/tests/sanic/app.py b/tests/sanic/app.py index 6966b1e..84269cc 100644 --- a/tests/sanic/app.py +++ b/tests/sanic/app.py @@ -7,7 +7,6 @@ from .schema import Schema - Sanic.test_mode = True diff --git a/tox.ini b/tox.ini index e374ee0..047d8a6 100644 --- a/tox.ini +++ b/tox.ini @@ -1,7 +1,7 @@ [tox] -envlist = +envlist = black,flake8,import-order,mypy,manifest, - py{36,37,38,39} + py{36,37,38,39,310} ; requires = tox-conda [gh-actions] @@ -10,6 +10,7 @@ python = 3.7: py37 3.8: py38 3.9: py39 + 3.10: py310 [testenv] conda_channels = conda-forge @@ -26,31 +27,31 @@ commands = py{38}: pytest tests --cov-report=term-missing --cov=graphql_server {posargs} [testenv:black] -basepython = python3.8 +basepython = python3.9 deps = -e.[dev] commands = black --check graphql_server tests [testenv:flake8] -basepython = python3.8 +basepython = python3.9 deps = -e.[dev] commands = flake8 setup.py graphql_server tests [testenv:import-order] -basepython = python3.8 +basepython = python3.9 deps = -e.[dev] commands = - isort -rc graphql_server/ tests/ + isort graphql_server/ tests/ [testenv:mypy] -basepython = python3.8 +basepython = python3.9 deps = -e.[dev] commands = mypy graphql_server tests --ignore-missing-imports [testenv:manifest] -basepython = python3.8 +basepython = python3.9 deps = -e.[dev] commands = check-manifest -v From eec3d3331413c0b3da4a4dca5e77dc2df7c74090 Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Mon, 17 Jan 2022 14:24:07 +0100 Subject: [PATCH 34/48] Make teste work with Python 3.6 again Note that pytest-asyncio 0.17 is not supported for Python 3.6. --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index bb98728..e2dfcaf 100644 --- a/setup.py +++ b/setup.py @@ -5,7 +5,7 @@ tests_requires = [ "pytest>=6.2,<6.3", - "pytest-asyncio>=0.17,<1", + "pytest-asyncio>=0.16,<1", "pytest-cov>=3,<4", "aiohttp>=3.8,<4", "Jinja2>=2.11,<3", From 8dec731311a653f6a3ebd5b91c51ad0ff9bb4bab Mon Sep 17 00:00:00 2001 From: Christoph Zwerschke Date: Mon, 17 Jan 2022 14:28:52 +0100 Subject: [PATCH 35/48] Release a new beta version --- graphql_server/version.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/graphql_server/version.py b/graphql_server/version.py index 2ee7c44..d159828 100644 --- a/graphql_server/version.py +++ b/graphql_server/version.py @@ -4,7 +4,7 @@ __all__ = ["version", "version_info"] -version = "3.0.0b4" +version = "3.0.0b5" _re_version = re.compile(r"(\d+)\.(\d+)\.(\d+)(\D*)(\d*)") From 184ba72578101ad7b11a2008e544d5432f627146 Mon Sep 17 00:00:00 2001 From: Kien Dang Date: Mon, 26 Dec 2022 02:59:20 +0800 Subject: [PATCH 36/48] chore: update dependencies (#99) * Update dependencies * Relax flask dependency to allow flask 2 * Fixes for quart >=0.15 Fix quart.request.get_data signature QuartClient -> TestClientProtocol * Lint * Fix aiohttp tests * Update sanic to v22.6 * Make sanic v22.9 work * Fix deprecation warnings DeprecationWarning: Use 'content=<...>' to upload raw bytes/text content. * Update graphiql to 1.4.7 for security reason "All versions of graphiql < 1.4.7 are vulnerable to an XSS attack." https://github.com/graphql/graphiql/blob/ab2b52f06213bd9bf90c905c1b460b6939f3d856/docs/security/2021-introspection-schema-xss.md * Fix webob graphiql check Was working by accident before * Fix quart PytestCollectionWarning cannot collect test class 'TestClientProtocol' because it has a __init__ constructor * Make Jinja2 optional * Add python 3.11 and remove 3.6 * Tweak quart for python 3.7 to 3.11 * Fix test for python 3.11 Co-authored-by: Giovanni Campagna Co-authored-by: Choongkyu Kim --- .github/workflows/deploy.yml | 8 +- .github/workflows/lint.yml | 8 +- .github/workflows/tests.yml | 20 +-- graphql_server/__init__.py | 12 +- graphql_server/quart/graphqlview.py | 27 ++-- graphql_server/render_graphiql.py | 17 ++- graphql_server/sanic/graphqlview.py | 4 +- graphql_server/webob/graphqlview.py | 8 +- setup.cfg | 1 + setup.py | 27 ++-- tests/aiohttp/test_graphiqlview.py | 3 +- tests/aiohttp/test_graphqlview.py | 3 +- tests/quart/conftest.py | 3 + tests/quart/test_graphiqlview.py | 24 ++-- tests/quart/test_graphqlview.py | 183 ++++++++++++++++------------ tests/sanic/app.py | 9 +- tests/sanic/test_graphiqlview.py | 14 +-- tests/sanic/test_graphqlview.py | 127 ++++++++++--------- tests/test_asyncio.py | 13 +- tox.ini | 18 +-- 20 files changed, 271 insertions(+), 258 deletions(-) create mode 100644 tests/quart/conftest.py diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6a34bba..29bb7d1 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,11 +10,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.9 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.10" - name: Build wheel and source tarball run: | pip install wheel diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 90ba2a1..454ab1b 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -7,11 +7,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.9 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.10" - name: Install dependencies run: | python -m pip install --upgrade pip diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 31616ec..7e58bb5 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,22 +8,22 @@ jobs: strategy: max-parallel: 4 matrix: - python-version: ["3.6", "3.7", "3.8", "3.9", "3.10"] + python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"] os: [ubuntu-latest, windows-latest] exclude: - - os: windows-latest - python-version: "3.6" - os: windows-latest python-version: "3.7" - os: windows-latest python-version: "3.8" - os: windows-latest - python-version: "3.10" + python-version: "3.9" + - os: windows-latest + python-version: "3.11" steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v3 - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + uses: actions/setup-python@v4 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -39,11 +39,11 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 - - name: Set up Python 3.9 - uses: actions/setup-python@v2 + - uses: actions/checkout@v3 + - name: Set up Python + uses: actions/setup-python@v4 with: - python-version: 3.9 + python-version: "3.10" - name: Install test dependencies run: | python -m pip install --upgrade pip diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index ee54cdb..9a58a9f 100644 --- a/graphql_server/__init__.py +++ b/graphql_server/__init__.py @@ -9,17 +9,7 @@ import json from collections import namedtuple from collections.abc import MutableMapping -from typing import ( - Any, - Callable, - Collection, - Dict, - List, - Optional, - Type, - Union, - cast, -) +from typing import Any, Callable, Collection, Dict, List, Optional, Type, Union, cast from graphql.error import GraphQLError from graphql.execution import ExecutionResult, execute diff --git a/graphql_server/quart/graphqlview.py b/graphql_server/quart/graphqlview.py index ff737ec..107cfdc 100644 --- a/graphql_server/quart/graphqlview.py +++ b/graphql_server/quart/graphqlview.py @@ -1,5 +1,4 @@ import copy -import sys from collections.abc import MutableMapping from functools import partial from typing import List @@ -165,11 +164,11 @@ async def parse_body(): # information provided by content_type content_type = request.mimetype if content_type == "application/graphql": - refined_data = await request.get_data(raw=False) + refined_data = await request.get_data(as_text=True) return {"query": refined_data} elif content_type == "application/json": - refined_data = await request.get_data(raw=False) + refined_data = await request.get_data(as_text=True) return load_json_body(refined_data) elif content_type == "application/x-www-form-urlencoded": @@ -191,20 +190,8 @@ def should_display_graphiql(self): def request_wants_html(): best = request.accept_mimetypes.best_match(["application/json", "text/html"]) - # Needed as this was introduced at Quart 0.8.0: https://gitlab.com/pgjones/quart/-/issues/189 - def _quality(accept, key: str) -> float: - for option in accept.options: - if accept._values_match(key, option.value): - return option.quality - return 0.0 - - if sys.version_info >= (3, 7): - return ( - best == "text/html" - and request.accept_mimetypes[best] - > request.accept_mimetypes["application/json"] - ) - else: - return best == "text/html" and _quality( - request.accept_mimetypes, best - ) > _quality(request.accept_mimetypes, "application/json") + return ( + best == "text/html" + and request.accept_mimetypes[best] + > request.accept_mimetypes["application/json"] + ) diff --git a/graphql_server/render_graphiql.py b/graphql_server/render_graphiql.py index c942300..498f53b 100644 --- a/graphql_server/render_graphiql.py +++ b/graphql_server/render_graphiql.py @@ -1,4 +1,4 @@ -"""Based on (express-graphql)[https://github.com/graphql/express-graphql/blob/master/src/renderGraphiQL.js] and +"""Based on (express-graphql)[https://github.com/graphql/express-graphql/blob/main/src/renderGraphiQL.ts] and (subscriptions-transport-ws)[https://github.com/apollographql/subscriptions-transport-ws]""" import json import re @@ -7,7 +7,7 @@ from jinja2 import Environment from typing_extensions import TypedDict -GRAPHIQL_VERSION = "1.0.3" +GRAPHIQL_VERSION = "1.4.7" GRAPHIQL_TEMPLATE = """