Skip to content

Commit 35ed87d

Browse files
authored
Merge sanic-graphql (#38)
* refactor: add sanic-graphql as optional feature * refactor: sanic tests and remove executor parameter * styles: apply black and flake8 formatting
1 parent 6c13ef6 commit 35ed87d

File tree

10 files changed

+1188
-1
lines changed

10 files changed

+1188
-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

+190
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
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+
root_value = None
26+
context = None
27+
pretty = False
28+
graphiql = False
29+
graphiql_version = None
30+
graphiql_template = None
31+
middleware = None
32+
batch = False
33+
jinja_env = None
34+
max_age = 86400
35+
enable_async = False
36+
37+
methods = ["GET", "POST", "PUT", "DELETE"]
38+
39+
def __init__(self, **kwargs):
40+
super(GraphQLView, self).__init__()
41+
for key, value in kwargs.items():
42+
if hasattr(self, key):
43+
setattr(self, key, value)
44+
45+
assert isinstance(
46+
self.schema, GraphQLSchema
47+
), "A Schema is required to be provided to GraphQLView."
48+
49+
def get_root_value(self):
50+
return self.root_value
51+
52+
def get_context(self, request):
53+
context = (
54+
copy.copy(self.context)
55+
if self.context and isinstance(self.context, MutableMapping)
56+
else {}
57+
)
58+
if isinstance(context, MutableMapping) and "request" not in context:
59+
context.update({"request": request})
60+
return context
61+
62+
def get_middleware(self):
63+
return self.middleware
64+
65+
async def render_graphiql(self, params, result):
66+
return await render_graphiql(
67+
jinja_env=self.jinja_env,
68+
params=params,
69+
result=result,
70+
graphiql_version=self.graphiql_version,
71+
graphiql_template=self.graphiql_template,
72+
)
73+
74+
format_error = staticmethod(format_error_default)
75+
encode = staticmethod(json_encode)
76+
77+
async def dispatch_request(self, request, *args, **kwargs):
78+
try:
79+
request_method = request.method.lower()
80+
data = self.parse_body(request)
81+
82+
show_graphiql = request_method == "get" and self.should_display_graphiql(
83+
request
84+
)
85+
catch = show_graphiql
86+
87+
pretty = self.pretty or show_graphiql or request.args.get("pretty")
88+
89+
if request_method != "options":
90+
execution_results, all_params = run_http_query(
91+
self.schema,
92+
request_method,
93+
data,
94+
query_data=request.args,
95+
batch_enabled=self.batch,
96+
catch=catch,
97+
# Execute options
98+
run_sync=not self.enable_async,
99+
root_value=self.get_root_value(),
100+
context_value=self.get_context(request),
101+
middleware=self.get_middleware(),
102+
)
103+
exec_res = (
104+
[await ex for ex in execution_results]
105+
if self.enable_async
106+
else execution_results
107+
)
108+
result, status_code = encode_execution_results(
109+
exec_res,
110+
is_batch=isinstance(data, list),
111+
format_error=self.format_error,
112+
encode=partial(self.encode, pretty=pretty), # noqa: ignore
113+
)
114+
115+
if show_graphiql:
116+
return await self.render_graphiql(
117+
params=all_params[0], result=result
118+
)
119+
120+
return HTTPResponse(
121+
result, status=status_code, content_type="application/json"
122+
)
123+
124+
else:
125+
return self.process_preflight(request)
126+
127+
except HttpQueryError as e:
128+
parsed_error = GraphQLError(e.message)
129+
return HTTPResponse(
130+
self.encode(dict(errors=[self.format_error(parsed_error)])),
131+
status=e.status_code,
132+
headers=e.headers,
133+
content_type="application/json",
134+
)
135+
136+
# noinspection PyBroadException
137+
def parse_body(self, request):
138+
content_type = self.get_mime_type(request)
139+
if content_type == "application/graphql":
140+
return {"query": request.body.decode("utf8")}
141+
142+
elif content_type == "application/json":
143+
return load_json_body(request.body.decode("utf8"))
144+
145+
elif content_type in (
146+
"application/x-www-form-urlencoded",
147+
"multipart/form-data",
148+
):
149+
return request.form
150+
151+
return {}
152+
153+
@staticmethod
154+
def get_mime_type(request):
155+
# We use mime type here since we don't need the other
156+
# information provided by content_type
157+
if "content-type" not in request.headers:
158+
return None
159+
160+
mime_type, _ = parse_header(request.headers["content-type"])
161+
return mime_type
162+
163+
def should_display_graphiql(self, request):
164+
if not self.graphiql or "raw" in request.args:
165+
return False
166+
167+
return self.request_wants_html(request)
168+
169+
@staticmethod
170+
def request_wants_html(request):
171+
accept = request.headers.get("accept", {})
172+
return "text/html" in accept or "*/*" in accept
173+
174+
def process_preflight(self, request):
175+
""" Preflight request support for apollo-client
176+
https://www.w3.org/TR/cors/#resource-preflight-requests """
177+
origin = request.headers.get("Origin", "")
178+
method = request.headers.get("Access-Control-Request-Method", "").upper()
179+
180+
if method and method in self.methods:
181+
return HTTPResponse(
182+
status=200,
183+
headers={
184+
"Access-Control-Allow-Origin": origin,
185+
"Access-Control-Allow-Methods": ", ".join(self.methods),
186+
"Access-Control-Max-Age": str(self.max_age),
187+
},
188+
)
189+
else:
190+
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)