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 = """

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy