diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 6a34bba..271642c 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -10,17 +10,17 @@ 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 python setup.py sdist - name: Publish a Python distribution to PyPI - uses: pypa/gh-action-pypi-publish@v1.1.0 + uses: pypa/gh-action-pypi-publish@release/v1 with: user: __token__ password: ${{ secrets.pypi_password }} 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/docs/aiohttp.md b/docs/aiohttp.md index d65bcb8..b301855 100644 --- a/docs/aiohttp.md +++ b/docs/aiohttp.md @@ -47,19 +47,19 @@ gql_view(request) # <-- the instance is callable and expects a `aiohttp.web.Req ### Supported options for GraphQLView - * `schema`: The `GraphQLSchema` object that you want the view to execute when it gets a valid request. + * `schema`: The GraphQL schema object that you want the view to execute when it gets a valid request. Accepts either an object of type `GraphQLSchema` from `graphql-core` or `Schema` from `graphene`. For Graphene v3, passing either `schema: graphene.Schema` or `schema.graphql_schema` is allowed. * `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_version`: The graphiql version to load. Defaults to **"2.2.0"**. * `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. + * `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/). * `validation_rules`: A list of graphql validation rules. + * `execution_context_class`: Specifies a custom execution context class. * `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 f3a36e7..dfe0aa7 100644 --- a/docs/flask.md +++ b/docs/flask.md @@ -39,26 +39,21 @@ if __name__ == '__main__': 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. + * `schema`: The GraphQL schema object that you want the view to execute when it gets a valid request. Accepts either an object of type `GraphQLSchema` from `graphql-core` or `Schema` from `graphene`. For Graphene v3, passing either `schema: graphene.Schema` or `schema.graphql_schema` is allowed. * `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_version`: The graphiql version to load. Defaults to **"2.2.0"**. * `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 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. + * `validation_rules`: A list of graphql validation rules. + * `execution_context_class`: Specifies a custom execution context class. * `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. @@ -79,4 +74,4 @@ class UserRootValue(GraphQLView): ``` ## Contributing -See [CONTRIBUTING.md](../CONTRIBUTING.md) \ No newline at end of file +See [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/docs/sanic.md b/docs/sanic.md index e922598..102e38d 100644 --- a/docs/sanic.md +++ b/docs/sanic.md @@ -39,19 +39,19 @@ 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. + * `schema`: The GraphQL schema object that you want the view to execute when it gets a valid request. Accepts either an object of type `GraphQLSchema` from `graphql-core` or `Schema` from `graphene`. For Graphene v3, passing either `schema: graphene.Schema` or `schema.graphql_schema` is allowed. * `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_version`: The graphiql version to load. Defaults to **"2.2.0"**. * `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. + * `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/). - * `validation_rules`: A list of graphql validation rules. + * `validation_rules`: A list of graphql validation rules. + * `execution_context_class`: Specifies a custom execution context class. * `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`. @@ -72,4 +72,4 @@ class UserRootValue(GraphQLView): ``` ## Contributing -See [CONTRIBUTING.md](../CONTRIBUTING.md) \ No newline at end of file +See [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/docs/webob.md b/docs/webob.md index 41c0ad1..2f88a31 100644 --- a/docs/webob.md +++ b/docs/webob.md @@ -38,17 +38,19 @@ 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. + * `schema`: The GraphQL schema object that you want the view to execute when it gets a valid request. Accepts either an object of type `GraphQLSchema` from `graphql-core` or `Schema` from `graphene`. For Graphene v3, passing either `schema: graphene.Schema` or `schema.graphql_schema` is allowed. * `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_version`: The graphiql version to load. Defaults to **"2.2.0"**. * `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 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. + * `validation_rules`: A list of graphql validation rules. + * `execution_context_class`: Specifies a custom execution context class. * `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. @@ -59,4 +61,4 @@ This will add `/graphql` endpoint to your app and enable the GraphiQL IDE. * `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 +See [CONTRIBUTING.md](../CONTRIBUTING.md) diff --git a/graphql_server/__init__.py b/graphql_server/__init__.py index ee54cdb..f8456de 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 @@ -345,3 +335,17 @@ def format_execution_result( response = {"data": execution_result.data} return FormattedResult(response, status_code) + + +def _check_jinja(jinja_env: Any) -> None: + try: + from jinja2 import Environment + except ImportError: # pragma: no cover + raise RuntimeError( + "Attempt to set 'jinja_env' to a value other than None while Jinja2 is not installed.\n" + "Please install Jinja2 to render GraphiQL with Jinja2.\n" + "Otherwise set 'jinja_env' to None to use the simple regex renderer." + ) + + if not isinstance(jinja_env, Environment): # pragma: no cover + raise TypeError("'jinja_env' has to be of type jinja2.Environment.") diff --git a/graphql_server/aiohttp/graphqlview.py b/graphql_server/aiohttp/graphqlview.py index d98becd..4087946 100644 --- a/graphql_server/aiohttp/graphqlview.py +++ b/graphql_server/aiohttp/graphqlview.py @@ -1,15 +1,18 @@ +import asyncio import copy from collections.abc import MutableMapping from functools import partial from typing import List from aiohttp import web -from graphql import ExecutionResult, GraphQLError, specified_rules +from graphql import GraphQLError, specified_rules +from graphql.pyutils import is_awaitable from graphql.type.schema import GraphQLSchema from graphql_server import ( GraphQLParams, HttpQueryError, + _check_jinja, encode_execution_results, format_error_default, json_encode, @@ -22,6 +25,7 @@ GraphiQLOptions, render_graphiql_async, ) +from graphql_server.utils import wrap_in_async class GraphQLView: @@ -35,6 +39,7 @@ class GraphQLView: graphiql_html_title = None middleware = None validation_rules = None + execution_context_class = None batch = False jinja_env = None max_age = 86400 @@ -62,13 +67,16 @@ def __init__(self, **kwargs): if not isinstance(self.schema, GraphQLSchema): raise TypeError("A Schema is required to be provided to GraphQLView.") + if self.jinja_env is not None: + _check_jinja(self.jinja_env) + 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) + if self.context is not None and isinstance(self.context, MutableMapping) else {} ) if isinstance(context, MutableMapping) and "request" not in context: @@ -83,6 +91,9 @@ def get_validation_rules(self): return specified_rules return self.validation_rules + def get_execution_context_class(self): + return self.execution_context_class + @staticmethod async def parse_body(request): content_type = request.content_type @@ -158,13 +169,18 @@ async def __call__(self, request): context_value=self.get_context(request), middleware=self.get_middleware(), validation_rules=self.get_validation_rules(), + execution_context_class=self.get_execution_context_class(), ) exec_res = ( - [ - ex if ex is None or isinstance(ex, ExecutionResult) else await ex - for ex in execution_results - ] + await asyncio.gather( + *( + ex + if ex is not None and is_awaitable(ex) + else wrap_in_async(lambda: ex)() + for ex in execution_results + ) + ) if self.enable_async else execution_results ) diff --git a/graphql_server/flask/graphqlview.py b/graphql_server/flask/graphqlview.py index 063a67a..7440f82 100644 --- a/graphql_server/flask/graphqlview.py +++ b/graphql_server/flask/graphqlview.py @@ -12,6 +12,7 @@ from graphql_server import ( GraphQLParams, HttpQueryError, + _check_jinja, encode_execution_results, format_error_default, json_encode, @@ -37,7 +38,9 @@ class GraphQLView(View): graphiql_html_title = None middleware = None validation_rules = None + execution_context_class = None batch = False + jinja_env = None subscriptions = None headers = None default_query = None @@ -61,13 +64,16 @@ def __init__(self, **kwargs): if not isinstance(self.schema, GraphQLSchema): raise TypeError("A Schema is required to be provided to GraphQLView.") + if self.jinja_env is not None: + _check_jinja(self.jinja_env) + 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) + if self.context is not None and isinstance(self.context, MutableMapping) else {} ) if isinstance(context, MutableMapping) and "request" not in context: @@ -82,6 +88,9 @@ def get_validation_rules(self): return specified_rules return self.validation_rules + def get_execution_context_class(self): + return self.execution_context_class + def dispatch_request(self): try: request_method = request.method.lower() @@ -105,6 +114,7 @@ def dispatch_request(self): context_value=self.get_context(), middleware=self.get_middleware(), validation_rules=self.get_validation_rules(), + execution_context_class=self.get_execution_context_class(), ) result, status_code = encode_execution_results( execution_results, @@ -126,7 +136,7 @@ def dispatch_request(self): graphiql_version=self.graphiql_version, graphiql_template=self.graphiql_template, graphiql_html_title=self.graphiql_html_title, - jinja_env=None, + jinja_env=self.jinja_env, ) graphiql_options = GraphiQLOptions( default_query=self.default_query, diff --git a/graphql_server/quart/graphqlview.py b/graphql_server/quart/graphqlview.py index ff737ec..7dd479f 100644 --- a/graphql_server/quart/graphqlview.py +++ b/graphql_server/quart/graphqlview.py @@ -1,11 +1,12 @@ +import asyncio import copy -import sys from collections.abc import MutableMapping from functools import partial from typing import List -from graphql import ExecutionResult, specified_rules +from graphql import specified_rules from graphql.error import GraphQLError +from graphql.pyutils import is_awaitable from graphql.type.schema import GraphQLSchema from quart import Response, render_template_string, request from quart.views import View @@ -13,6 +14,7 @@ from graphql_server import ( GraphQLParams, HttpQueryError, + _check_jinja, encode_execution_results, format_error_default, json_encode, @@ -25,6 +27,7 @@ GraphiQLOptions, render_graphiql_sync, ) +from graphql_server.utils import wrap_in_async class GraphQLView(View): @@ -38,7 +41,9 @@ class GraphQLView(View): graphiql_html_title = None middleware = None validation_rules = None + execution_context_class = None batch = False + jinja_env = None enable_async = False subscriptions = None headers = None @@ -63,13 +68,16 @@ def __init__(self, **kwargs): if not isinstance(self.schema, GraphQLSchema): raise TypeError("A Schema is required to be provided to GraphQLView.") + if self.jinja_env is not None: + _check_jinja(self.jinja_env) + 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) + if self.context is not None and isinstance(self.context, MutableMapping) else {} ) if isinstance(context, MutableMapping) and "request" not in context: @@ -84,6 +92,9 @@ def get_validation_rules(self): return specified_rules return self.validation_rules + def get_execution_context_class(self): + return self.execution_context_class + async def dispatch_request(self): try: request_method = request.method.lower() @@ -107,12 +118,17 @@ async def dispatch_request(self): context_value=self.get_context(), middleware=self.get_middleware(), validation_rules=self.get_validation_rules(), + execution_context_class=self.get_execution_context_class(), ) exec_res = ( - [ - ex if ex is None or isinstance(ex, ExecutionResult) else await ex - for ex in execution_results - ] + await asyncio.gather( + *( + ex + if ex is not None and is_awaitable(ex) + else wrap_in_async(lambda: ex)() + for ex in execution_results + ) + ) if self.enable_async else execution_results ) @@ -136,7 +152,7 @@ async def dispatch_request(self): graphiql_version=self.graphiql_version, graphiql_template=self.graphiql_template, graphiql_html_title=self.graphiql_html_title, - jinja_env=None, + jinja_env=self.jinja_env, ) graphiql_options = GraphiQLOptions( default_query=self.default_query, @@ -165,11 +181,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 +207,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..0da06b9 100644 --- a/graphql_server/render_graphiql.py +++ b/graphql_server/render_graphiql.py @@ -1,13 +1,19 @@ -"""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]""" +"""Based on (express-graphql)[https://github.com/graphql/express-graphql/blob/main/src/renderGraphiQL.ts] and +(graphql-ws)[https://github.com/enisdenjo/graphql-ws]""" import json import re from typing import Any, Dict, Optional, Tuple -from jinja2 import Environment +# This Environment import is only for type checking purpose, +# and only relevant if rendering GraphiQL with Jinja +try: + from jinja2 import Environment +except ImportError: # pragma: no cover + pass + from typing_extensions import TypedDict -GRAPHIQL_VERSION = "1.0.3" +GRAPHIQL_VERSION = "2.2.0" GRAPHIQL_TEMPLATE = """