Skip to content

feat: add support for rendering GraphiQL with jinja #103

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
Feb 15, 2023
2 changes: 1 addition & 1 deletion docs/aiohttp.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ gql_view(request) # <-- the instance is callable and expects a `aiohttp.web.Req
* `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.4.7"**.
* `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.
Expand Down
3 changes: 2 additions & 1 deletion docs/flask.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,10 @@ This will add `/graphql` endpoint to your app and enable the GraphiQL IDE.
* `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.4.7"**.
* `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.
Expand Down
2 changes: 1 addition & 1 deletion docs/sanic.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ This will add `/graphql` endpoint to your app and enable the GraphiQL IDE.
* `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.4.7"**.
* `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.
Expand Down
3 changes: 2 additions & 1 deletion docs/webob.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,10 @@ This will add `/graphql` endpoint to your app and enable the GraphiQL IDE.
* `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.4.7"**.
* `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.
Expand Down
14 changes: 14 additions & 0 deletions graphql_server/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -335,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.")
4 changes: 4 additions & 0 deletions graphql_server/aiohttp/graphqlview.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from graphql_server import (
GraphQLParams,
HttpQueryError,
_check_jinja,
encode_execution_results,
format_error_default,
json_encode,
Expand Down Expand Up @@ -66,6 +67,9 @@ 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

Expand Down
7 changes: 6 additions & 1 deletion graphql_server/flask/graphqlview.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
from graphql_server import (
GraphQLParams,
HttpQueryError,
_check_jinja,
encode_execution_results,
format_error_default,
json_encode,
Expand Down Expand Up @@ -39,6 +40,7 @@ class GraphQLView(View):
validation_rules = None
execution_context_class = None
batch = False
jinja_env = None
subscriptions = None
headers = None
default_query = None
Expand All @@ -62,6 +64,9 @@ 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

Expand Down Expand Up @@ -131,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,
Expand Down
7 changes: 6 additions & 1 deletion graphql_server/quart/graphqlview.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from graphql_server import (
GraphQLParams,
HttpQueryError,
_check_jinja,
encode_execution_results,
format_error_default,
json_encode,
Expand Down Expand Up @@ -42,6 +43,7 @@ class GraphQLView(View):
validation_rules = None
execution_context_class = None
batch = False
jinja_env = None
enable_async = False
subscriptions = None
headers = None
Expand All @@ -66,6 +68,9 @@ 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

Expand Down Expand Up @@ -147,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,
Expand Down
92 changes: 37 additions & 55 deletions graphql_server/render_graphiql.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
"""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]"""
(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.4.7"
GRAPHIQL_VERSION = "2.2.0"

GRAPHIQL_TEMPLATE = """<!--
The request to this GraphQL server provided the header "Accept: text/html"
Expand All @@ -34,13 +40,12 @@
}
</style>
<link href="//cdn.jsdelivr.net/npm/graphiql@{{graphiql_version}}/graphiql.css" rel="stylesheet" />
<script src="//cdn.jsdelivr.net/npm/promise-polyfill@8.2.0/dist/polyfill.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/unfetch@4.2.0/dist/unfetch.umd.js"></script>
<script src="//cdn.jsdelivr.net/npm/react@16.14.0/umd/react.production.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/react-dom@16.14.0/umd/react-dom.production.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/promise-polyfill@8.2.3/dist/polyfill.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/unfetch@5.0.0/dist/unfetch.umd.js"></script>
<script src="//cdn.jsdelivr.net/npm/react@18.2.0/umd/react.production.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/react-dom@18.2.0/umd/react-dom.production.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/graphiql@{{graphiql_version}}/graphiql.min.js"></script>
<script src="//cdn.jsdelivr.net/npm/subscriptions-transport-ws@0.9.18/browser/client.js"></script>
<script src="//cdn.jsdelivr.net/npm/graphiql-subscriptions-fetcher@0.0.2/browser/client.js"></script>
<script src="//cdn.jsdelivr.net/npm/graphql-ws@5.11.2/umd/graphql-ws.min.js"></script>
</head>
<body>
<div id="graphiql">Loading...</div>
Expand Down Expand Up @@ -75,35 +80,16 @@
otherParams[k] = parameters[k];
}
}
// Configure the subscription client
let subscriptionsFetcher = null;
if ('{{subscription_url}}') {
let subscriptionsClient = new SubscriptionsTransportWs.SubscriptionClient(
'{{ subscription_url }}',
{ reconnect: true }
);
subscriptionsFetcher = GraphiQLSubscriptionsFetcher.graphQLFetcher(
subscriptionsClient,
graphQLFetcher
);
}
var fetchURL = locationQuery(otherParams);
// Defines a GraphQL fetcher using the fetch API.
function graphQLFetcher(graphQLParams, opts) {
return fetch(fetchURL, {
method: 'post',
headers: Object.assign(
{
'Accept': 'application/json',
'Content-Type': 'application/json'
},
opts && opts.headers,
),
body: JSON.stringify(graphQLParams),
credentials: 'include',
}).then(function (response) {
return response.json();
// Defines a GraphQL fetcher.
var graphQLFetcher;
if ('{{subscription_url}}') {
graphQLFetcher = GraphiQL.createFetcher({
url: fetchURL,
subscription_url: '{{subscription_url}}'
});
} else {
graphQLFetcher = GraphiQL.createFetcher({ url: fetchURL });
}
// When the query and variables string is edited, update the URL bar so
// that it can be easily shared.
Expand All @@ -129,7 +115,7 @@
// Render <GraphiQL /> into the body.
ReactDOM.render(
React.createElement(GraphiQL, {
fetcher: subscriptionsFetcher || graphQLFetcher,
fetcher: graphQLFetcher,
onEditQuery: onEditQuery,
onEditVariables: onEditVariables,
onEditHeaders: onEditHeaders,
Expand All @@ -140,7 +126,7 @@
headers: {{headers|tojson}},
operationName: {{operation_name|tojson}},
defaultQuery: {{default_query|tojson}},
headerEditorEnabled: {{header_editor_enabled|tojson}},
isHeadersEditorEnabled: {{header_editor_enabled|tojson}},
shouldPersistHeaders: {{should_persist_headers|tojson}}
}),
document.getElementById('graphiql')
Expand Down Expand Up @@ -216,24 +202,12 @@ class GraphiQLOptions(TypedDict):
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*}}"
pattern = r"{{\s*" + name.replace("\\", r"\\") + r"(\s*|[^}]+)*\s*}}"
if jsonify and value not in ["null", "undefined"]:
value = json.dumps(value)
value = escape_js_value(value)

value = value.replace("\\", r"\\")

return re.sub(pattern, value, template)

Expand Down Expand Up @@ -296,6 +270,9 @@ def _render_graphiql(
or "false",
}

if template_vars["result"] in ("null"):
template_vars["result"] = None

return graphiql_template, template_vars


Expand All @@ -305,7 +282,7 @@ async def render_graphiql_async(
options: Optional[GraphiQLOptions] = None,
) -> str:
graphiql_template, template_vars = _render_graphiql(data, config, options)
jinja_env: Optional[Environment] = config.get("jinja_env")
jinja_env = config.get("jinja_env")

if jinja_env:
template = jinja_env.from_string(graphiql_template)
Expand All @@ -324,6 +301,11 @@ def render_graphiql_sync(
options: Optional[GraphiQLOptions] = None,
) -> str:
graphiql_template, template_vars = _render_graphiql(data, config, options)
jinja_env = config.get("jinja_env")

source = simple_renderer(graphiql_template, **template_vars)
if jinja_env:
template = jinja_env.from_string(graphiql_template)
source = template.render(**template_vars)
else:
source = simple_renderer(graphiql_template, **template_vars)
return source
4 changes: 4 additions & 0 deletions graphql_server/sanic/graphqlview.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from graphql_server import (
GraphQLParams,
HttpQueryError,
_check_jinja,
encode_execution_results,
format_error_default,
json_encode,
Expand Down Expand Up @@ -68,6 +69,9 @@ 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

Expand Down
2 changes: 1 addition & 1 deletion graphql_server/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

if sys.version_info >= (3, 10):
from typing import ParamSpec
else:
else: # pragma: no cover
from typing_extensions import ParamSpec


Expand Down
7 changes: 6 additions & 1 deletion graphql_server/webob/graphqlview.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from graphql_server import (
GraphQLParams,
HttpQueryError,
_check_jinja,
encode_execution_results,
format_error_default,
json_encode,
Expand Down Expand Up @@ -38,6 +39,7 @@ class GraphQLView:
validation_rules = None
execution_context_class = None
batch = False
jinja_env = None
enable_async = False
subscriptions = None
headers = None
Expand All @@ -61,6 +63,9 @@ 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

Expand Down Expand Up @@ -133,7 +138,7 @@ def dispatch_request(self, request):
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,
Expand Down
12 changes: 4 additions & 8 deletions tests/aiohttp/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
from aiohttp import web

from graphql_server.aiohttp import GraphQLView
from tests.aiohttp.schema import Schema

from .schema import Schema


def create_app(schema=Schema, **kwargs):
Expand All @@ -13,10 +14,5 @@ def create_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
def url_string(url="/graphql", **url_params):
return f"{url}?{urlencode(url_params)}" if url_params else url
Loading