Skip to content

Commit eaf75e6

Browse files
authored
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
1 parent 8e2f147 commit eaf75e6

File tree

10 files changed

+1041
-2
lines changed

10 files changed

+1041
-2
lines changed

graphql_server/webob/__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/webob/graphqlview.py

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

setup.py

+6
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@
2727
"sanic>=19.9.0,<20",
2828
]
2929

30+
install_webob_requires = [
31+
"webob>=1.8.6,<2",
32+
]
33+
3034
install_aiohttp_requires = [
3135
"aiohttp>=3.5.0,<4",
3236
]
@@ -35,6 +39,7 @@
3539
install_requires + \
3640
install_flask_requires + \
3741
install_sanic_requires + \
42+
install_webob_requires + \
3843
install_aiohttp_requires
3944

4045
setup(
@@ -67,6 +72,7 @@
6772
"dev": install_all_requires + dev_requires,
6873
"flask": install_flask_requires,
6974
"sanic": install_sanic_requires,
75+
"webob": install_webob_requires,
7076
"aiohttp": install_aiohttp_requires,
7177
},
7278
include_package_data=True,

tests/aiohttp/test_graphiqlview.py

+9-2
Original file line numberDiff line numberDiff line change
@@ -61,15 +61,22 @@ async def test_graphiql_simple_renderer(app, client, pretty_response):
6161
class TestJinjaEnv:
6262
@pytest.mark.asyncio
6363
@pytest.mark.parametrize(
64-
"app", [create_app(graphiql=True, jinja_env=Environment())]
64+
"app", [create_app(graphiql=True, jinja_env=Environment(enable_async=True))]
6565
)
66-
async def test_graphiql_jinja_renderer(self, app, client, pretty_response):
66+
async def test_graphiql_jinja_renderer_async(self, app, client, pretty_response):
6767
response = await client.get(
6868
url_string(query="{test}"), headers={"Accept": "text/html"},
6969
)
7070
assert response.status == 200
7171
assert pretty_response in await response.text()
7272

73+
async def test_graphiql_jinja_renderer_sync(self, app, client, pretty_response):
74+
response = client.get(
75+
url_string(query="{test}"), headers={"Accept": "text/html"},
76+
)
77+
assert response.status == 200
78+
assert pretty_response in response.text()
79+
7380

7481
@pytest.mark.asyncio
7582
async def test_graphiql_html_is_not_accepted(client):

tests/webob/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)