Skip to content

Commit 5c79d33

Browse files
committed
refactor: add sanic-graphql as optional feature
1 parent 6c13ef6 commit 5c79d33

File tree

10 files changed

+1259
-1
lines changed

10 files changed

+1259
-1
lines changed

graphql_server/sanic/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from .graphqlview import GraphQLView
2+
3+
__all__ = ["GraphQLView"]

graphql_server/sanic/graphqlview.py

+198
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
import copy
2+
from cgi import parse_header
3+
from collections.abc import MutableMapping
4+
from functools import partial
5+
6+
from graphql import GraphQLError
7+
from graphql.type.schema import GraphQLSchema
8+
from sanic.response import HTTPResponse
9+
from sanic.views import HTTPMethodView
10+
11+
from graphql_server import (
12+
HttpQueryError,
13+
encode_execution_results,
14+
format_error_default,
15+
json_encode,
16+
load_json_body,
17+
run_http_query,
18+
)
19+
20+
from .render_graphiql import render_graphiql
21+
22+
23+
class GraphQLView(HTTPMethodView):
24+
schema = None
25+
executor = None
26+
root_value = None
27+
context = None
28+
pretty = False
29+
graphiql = False
30+
graphiql_version = None
31+
graphiql_template = None
32+
middleware = None
33+
batch = False
34+
jinja_env = None
35+
max_age = 86400
36+
enable_async = True
37+
38+
methods = ["GET", "POST", "PUT", "DELETE"]
39+
40+
def __init__(self, **kwargs):
41+
super(GraphQLView, self).__init__()
42+
for key, value in kwargs.items():
43+
if hasattr(self, key):
44+
setattr(self, key, value)
45+
46+
assert isinstance(
47+
self.schema, GraphQLSchema
48+
), "A Schema is required to be provided to GraphQLView."
49+
50+
def get_root_value(self):
51+
return self.root_value
52+
53+
def get_context(self, request):
54+
context = (
55+
copy.copy(self.context)
56+
if self.context and isinstance(self.context, MutableMapping)
57+
else {}
58+
)
59+
if isinstance(context, MutableMapping) and "request" not in context:
60+
context.update({"request": request})
61+
return context
62+
63+
def get_middleware(self):
64+
return self.middleware
65+
66+
def get_executor(self):
67+
return self.executor
68+
69+
def render_graphiql(self, params, result):
70+
return render_graphiql(
71+
jinja_env=self.jinja_env,
72+
params=params,
73+
result=result,
74+
graphiql_version=self.graphiql_version,
75+
graphiql_template=self.graphiql_template,
76+
)
77+
78+
format_error = staticmethod(format_error_default)
79+
encode = staticmethod(json_encode)
80+
81+
async def dispatch_request(self, request, *args, **kwargs):
82+
try:
83+
request_method = request.method.lower()
84+
data = self.parse_body(request)
85+
86+
show_graphiql = request_method == "get" and self.should_display_graphiql(
87+
request
88+
)
89+
catch = show_graphiql
90+
91+
pretty = self.pretty or show_graphiql or request.args.get("pretty")
92+
93+
extra_options = {}
94+
executor = self.get_executor()
95+
if executor:
96+
# We only include it optionally since
97+
# executor is not a valid argument in all backends
98+
extra_options["executor"] = executor
99+
100+
if request_method != "options":
101+
execution_results, all_params = run_http_query(
102+
self.schema,
103+
request_method,
104+
data,
105+
query_data=request.args,
106+
batch_enabled=self.batch,
107+
catch=catch,
108+
# Execute options
109+
# This check was previously using an isinstance of AsyncioExecutor
110+
check_sync=not (self.enable_async and self.executor is not None),
111+
root_value=self.get_root_value(),
112+
context_value=self.get_context(request),
113+
middleware=self.get_middleware(),
114+
**extra_options
115+
)
116+
result, status_code = encode_execution_results(
117+
execution_results,
118+
is_batch=isinstance(data, list),
119+
format_error=self.format_error,
120+
encode=partial(self.encode, pretty=pretty), # noqa: ignore
121+
)
122+
123+
if show_graphiql:
124+
return await self.render_graphiql(
125+
params=all_params[0], result=result
126+
)
127+
128+
return HTTPResponse(
129+
result, status=status_code, content_type="application/json"
130+
)
131+
132+
else:
133+
return self.process_preflight(request)
134+
135+
except HttpQueryError as e:
136+
parsed_error = GraphQLError(e.message)
137+
return HTTPResponse(
138+
self.encode(dict(errors=[self.format_error(parsed_error)])),
139+
status=e.status_code,
140+
headers=e.headers,
141+
content_type="application/json",
142+
)
143+
144+
# noinspection PyBroadException
145+
def parse_body(self, request):
146+
content_type = self.get_mime_type(request)
147+
if content_type == "application/graphql":
148+
return {"query": request.body.decode("utf8")}
149+
150+
elif content_type == "application/json":
151+
return load_json_body(request.body.decode("utf8"))
152+
153+
elif content_type in (
154+
"application/x-www-form-urlencoded",
155+
"multipart/form-data",
156+
):
157+
return request.form
158+
159+
return {}
160+
161+
@staticmethod
162+
def get_mime_type(request):
163+
# We use mime type here since we don't need the other
164+
# information provided by content_type
165+
if "content-type" not in request.headers:
166+
return None
167+
168+
mime_type, _ = parse_header(request.headers["content-type"])
169+
return mime_type
170+
171+
def should_display_graphiql(self, request):
172+
if not self.graphiql or "raw" in request.args:
173+
return False
174+
175+
return self.request_wants_html(request)
176+
177+
@staticmethod
178+
def request_wants_html(request):
179+
accept = request.headers.get("accept", {})
180+
return "text/html" in accept or "*/*" in accept
181+
182+
def process_preflight(self, request):
183+
""" Preflight request support for apollo-client
184+
https://www.w3.org/TR/cors/#resource-preflight-requests """
185+
origin = request.headers.get("Origin", "")
186+
method = request.headers.get("Access-Control-Request-Method", "").upper()
187+
188+
if method and method in self.methods:
189+
return HTTPResponse(
190+
status=200,
191+
headers={
192+
"Access-Control-Allow-Origin": origin,
193+
"Access-Control-Allow-Methods": ", ".join(self.methods),
194+
"Access-Control-Max-Age": str(self.max_age),
195+
},
196+
)
197+
else:
198+
return HTTPResponse(status=400)
+185
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import json
2+
import re
3+
4+
from sanic.response import html
5+
6+
GRAPHIQL_VERSION = "0.7.1"
7+
8+
TEMPLATE = """<!--
9+
The request to this GraphQL server provided the header "Accept: text/html"
10+
and as a result has been presented GraphiQL - an in-browser IDE for
11+
exploring GraphQL.
12+
If you wish to receive JSON, provide the header "Accept: application/json" or
13+
add "&raw" to the end of the URL within a browser.
14+
-->
15+
<!DOCTYPE html>
16+
<html>
17+
<head>
18+
<style>
19+
html, body {
20+
height: 100%;
21+
margin: 0;
22+
overflow: hidden;
23+
width: 100%;
24+
}
25+
</style>
26+
<meta name="referrer" content="no-referrer">
27+
<link
28+
href="//cdn.jsdelivr.net/graphiql/{{graphiql_version}}/graphiql.css"
29+
rel="stylesheet" />
30+
<script src="//cdn.jsdelivr.net/fetch/0.9.0/fetch.min.js"></script>
31+
<script src="//cdn.jsdelivr.net/react/15.0.0/react.min.js"></script>
32+
<script src="//cdn.jsdelivr.net/react/15.0.0/react-dom.min.js"></script>
33+
<script
34+
src="//cdn.jsdelivr.net/graphiql/{{graphiql_version}}/graphiql.min.js">
35+
</script>
36+
</head>
37+
<body>
38+
<script>
39+
// Collect the URL parameters
40+
var parameters = {};
41+
window.location.search.substr(1).split('&').forEach(function (entry) {
42+
var eq = entry.indexOf('=');
43+
if (eq >= 0) {
44+
parameters[decodeURIComponent(entry.slice(0, eq))] =
45+
decodeURIComponent(entry.slice(eq + 1));
46+
}
47+
});
48+
// Produce a Location query string from a parameter object.
49+
function locationQuery(params) {
50+
return '?' + Object.keys(params).map(function (key) {
51+
return encodeURIComponent(key) + '=' +
52+
encodeURIComponent(params[key]);
53+
}).join('&');
54+
}
55+
// Derive a fetch URL from the current URL, sans the GraphQL parameters.
56+
var graphqlParamNames = {
57+
query: true,
58+
variables: true,
59+
operationName: true
60+
};
61+
var otherParams = {};
62+
for (var k in parameters) {
63+
if (parameters.hasOwnProperty(k) && graphqlParamNames[k] !== true) {
64+
otherParams[k] = parameters[k];
65+
}
66+
}
67+
var fetchURL = locationQuery(otherParams);
68+
// Defines a GraphQL fetcher using the fetch API.
69+
function graphQLFetcher(graphQLParams) {
70+
return fetch(fetchURL, {
71+
method: 'post',
72+
headers: {
73+
'Accept': 'application/json',
74+
'Content-Type': 'application/json'
75+
},
76+
body: JSON.stringify(graphQLParams),
77+
credentials: 'include',
78+
}).then(function (response) {
79+
return response.text();
80+
}).then(function (responseBody) {
81+
try {
82+
return JSON.parse(responseBody);
83+
} catch (error) {
84+
return responseBody;
85+
}
86+
});
87+
}
88+
// When the query and variables string is edited, update the URL bar so
89+
// that it can be easily shared.
90+
function onEditQuery(newQuery) {
91+
parameters.query = newQuery;
92+
updateURL();
93+
}
94+
function onEditVariables(newVariables) {
95+
parameters.variables = newVariables;
96+
updateURL();
97+
}
98+
function onEditOperationName(newOperationName) {
99+
parameters.operationName = newOperationName;
100+
updateURL();
101+
}
102+
function updateURL() {
103+
history.replaceState(null, null, locationQuery(parameters));
104+
}
105+
// Render <GraphiQL /> into the body.
106+
ReactDOM.render(
107+
React.createElement(GraphiQL, {
108+
fetcher: graphQLFetcher,
109+
onEditQuery: onEditQuery,
110+
onEditVariables: onEditVariables,
111+
onEditOperationName: onEditOperationName,
112+
query: {{query|tojson}},
113+
response: {{result|tojson}},
114+
variables: {{variables|tojson}},
115+
operationName: {{operation_name|tojson}},
116+
}),
117+
document.body
118+
);
119+
</script>
120+
</body>
121+
</html>"""
122+
123+
124+
def escape_js_value(value):
125+
quotation = False
126+
if value.startswith('"') and value.endswith('"'):
127+
quotation = True
128+
value = value[1 : len(value) - 1]
129+
130+
value = value.replace("\\\\n", "\\\\\\n").replace("\\n", "\\\\n")
131+
if quotation:
132+
value = '"' + value.replace('\\\\"', '"').replace('"', '\\"') + '"'
133+
134+
return value
135+
136+
137+
def process_var(template, name, value, jsonify=False):
138+
pattern = r"{{\s*" + name + r"(\s*|[^}]+)*\s*}}"
139+
if jsonify and value not in ["null", "undefined"]:
140+
value = json.dumps(value)
141+
value = escape_js_value(value)
142+
143+
return re.sub(pattern, value, template)
144+
145+
146+
def simple_renderer(template, **values):
147+
replace = ["graphiql_version"]
148+
replace_jsonify = ["query", "result", "variables", "operation_name"]
149+
150+
for r in replace:
151+
template = process_var(template, r, values.get(r, ""))
152+
153+
for r in replace_jsonify:
154+
template = process_var(template, r, values.get(r, ""), True)
155+
156+
return template
157+
158+
159+
async def render_graphiql(
160+
jinja_env=None,
161+
graphiql_version=None,
162+
graphiql_template=None,
163+
params=None,
164+
result=None,
165+
):
166+
graphiql_version = graphiql_version or GRAPHIQL_VERSION
167+
template = graphiql_template or TEMPLATE
168+
template_vars = {
169+
"graphiql_version": graphiql_version,
170+
"query": params and params.query,
171+
"variables": params and params.variables,
172+
"operation_name": params and params.operation_name,
173+
"result": result,
174+
}
175+
176+
if jinja_env:
177+
template = jinja_env.from_string(template)
178+
if jinja_env.is_async:
179+
source = await template.render_async(**template_vars)
180+
else:
181+
source = template.render(**template_vars)
182+
else:
183+
source = simple_renderer(template, **template_vars)
184+
185+
return html(source)

setup.cfg

+1
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
[flake8]
22
exclude = docs
33
max-line-length = 88
4+
ignore = E203, E501, W503
45

56
[isort]
67
known_first_party=graphql_server

0 commit comments

Comments
 (0)