Skip to content

Commit

Permalink
Merge webob-graphql (#45)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
KingDarBoja authored Jul 5, 2020
1 parent 8e2f147 commit eaf75e6
Show file tree
Hide file tree
Showing 10 changed files with 1,041 additions and 2 deletions.
3 changes: 3 additions & 0 deletions graphql_server/webob/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from .graphqlview import GraphQLView

__all__ = ["GraphQLView"]
148 changes: 148 additions & 0 deletions graphql_server/webob/graphqlview.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import copy
from collections.abc import MutableMapping
from functools import partial

from graphql.error import GraphQLError
from graphql.type.schema import GraphQLSchema
from webob import Response

from graphql_server import (
HttpQueryError,
encode_execution_results,
format_error_default,
json_encode,
load_json_body,
run_http_query,
)

from .render_graphiql import render_graphiql


class GraphQLView:
schema = None
request = None
root_value = None
context = None
pretty = False
graphiql = False
graphiql_version = None
graphiql_template = None
middleware = None
batch = False
enable_async = False
charset = "UTF-8"

def __init__(self, **kwargs):
super(GraphQLView, self).__init__()
for key, value in kwargs.items():
if hasattr(self, key):
setattr(self, key, value)

assert isinstance(
self.schema, GraphQLSchema
), "A Schema is required to be provided to GraphQLView."

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)
else {}
)
if isinstance(context, MutableMapping) and "request" not in context:
context.update({"request": request})
return context

def get_middleware(self):
return self.middleware

format_error = staticmethod(format_error_default)
encode = staticmethod(json_encode)

def dispatch_request(self, request):
try:
request_method = request.method.lower()
data = self.parse_body(request)

show_graphiql = request_method == "get" and self.should_display_graphiql(
request
)
catch = show_graphiql

pretty = self.pretty or show_graphiql or request.params.get("pretty")

execution_results, all_params = run_http_query(
self.schema,
request_method,
data,
query_data=request.params,
batch_enabled=self.batch,
catch=catch,
# Execute options
run_sync=not self.enable_async,
root_value=self.get_root_value(),
context_value=self.get_context(request),
middleware=self.get_middleware(),
)
result, status_code = encode_execution_results(
execution_results,
is_batch=isinstance(data, list),
format_error=self.format_error,
encode=partial(self.encode, pretty=pretty), # noqa
)

if show_graphiql:
return Response(
render_graphiql(params=all_params[0], result=result),
charset=self.charset,
content_type="text/html",
)

return Response(
result,
status=status_code,
charset=self.charset,
content_type="application/json",
)

except HttpQueryError as e:
parsed_error = GraphQLError(e.message)
return Response(
self.encode(dict(errors=[self.format_error(parsed_error)])),
status=e.status_code,
charset=self.charset,
headers=e.headers or {},
content_type="application/json",
)

# WebOb
@staticmethod
def parse_body(request):
# We use mimetype here since we don't need the other
# information provided by content_type
content_type = request.content_type
if content_type == "application/graphql":
return {"query": request.body.decode("utf8")}

elif content_type == "application/json":
return load_json_body(request.body.decode("utf8"))

elif content_type in (
"application/x-www-form-urlencoded",
"multipart/form-data",
):
return request.params

return {}

def should_display_graphiql(self, request):
if not self.graphiql or "raw" in request.params:
return False

return self.request_wants_html()

def request_wants_html(self):
best = self.request.accept.best_match(["application/json", "text/html"])
return best == "text/html"
172 changes: 172 additions & 0 deletions graphql_server/webob/render_graphiql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import json
import re

GRAPHIQL_VERSION = "0.17.5"

TEMPLATE = """<!--
The request to this GraphQL server provided the header "Accept: text/html"
and as a result has been presented GraphiQL - an in-browser IDE for
exploring GraphQL.
If you wish to receive JSON, provide the header "Accept: application/json" or
add "&raw" to the end of the URL within a browser.
-->
<!DOCTYPE html>
<html>
<head>
<style>
html, body {
height: 100%;
margin: 0;
overflow: hidden;
width: 100%;
}
</style>
<meta name="referrer" content="no-referrer">
<link
href="//cdn.jsdelivr.net/graphiql/{{graphiql_version}}/graphiql.css"
rel="stylesheet" />
<script src="//cdn.jsdelivr.net/fetch/0.9.0/fetch.min.js"></script>
<script src="//cdn.jsdelivr.net/react/15.0.0/react.min.js"></script>
<script src="//cdn.jsdelivr.net/react/15.0.0/react-dom.min.js"></script>
<script
src="//cdn.jsdelivr.net/graphiql/{{graphiql_version}}/graphiql.min.js">
</script>
</head>
<body>
<script>
// Collect the URL parameters
var parameters = {};
window.location.search.substr(1).split('&').forEach(function (entry) {
var eq = entry.indexOf('=');
if (eq >= 0) {
parameters[decodeURIComponent(entry.slice(0, eq))] =
decodeURIComponent(entry.slice(eq + 1));
}
});
// Produce a Location query string from a parameter object.
function locationQuery(params) {
return '?' + Object.keys(params).map(function (key) {
return encodeURIComponent(key) + '=' +
encodeURIComponent(params[key]);
}).join('&');
}
// Derive a fetch URL from the current URL, sans the GraphQL parameters.
var graphqlParamNames = {
query: true,
variables: true,
operationName: true
};
var otherParams = {};
for (var k in parameters) {
if (parameters.hasOwnProperty(k) && graphqlParamNames[k] !== true) {
otherParams[k] = parameters[k];
}
}
var fetchURL = locationQuery(otherParams);
// Defines a GraphQL fetcher using the fetch API.
function graphQLFetcher(graphQLParams) {
return fetch(fetchURL, {
method: 'post',
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify(graphQLParams),
credentials: 'include',
}).then(function (response) {
return response.text();
}).then(function (responseBody) {
try {
return JSON.parse(responseBody);
} catch (error) {
return responseBody;
}
});
}
// When the query and variables string is edited, update the URL bar so
// that it can be easily shared.
function onEditQuery(newQuery) {
parameters.query = newQuery;
updateURL();
}
function onEditVariables(newVariables) {
parameters.variables = newVariables;
updateURL();
}
function onEditOperationName(newOperationName) {
parameters.operationName = newOperationName;
updateURL();
}
function updateURL() {
history.replaceState(null, null, locationQuery(parameters));
}
// Render <GraphiQL /> into the body.
ReactDOM.render(
React.createElement(GraphiQL, {
fetcher: graphQLFetcher,
onEditQuery: onEditQuery,
onEditVariables: onEditVariables,
onEditOperationName: onEditOperationName,
query: {{query|tojson}},
response: {{result|tojson}},
variables: {{variables|tojson}},
operationName: {{operation_name|tojson}},
}),
document.body
);
</script>
</body>
</html>"""


def escape_js_value(value):
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, name, value, jsonify=False):
pattern = r"{{\s*" + name + r"(\s*|[^}]+)*\s*}}"
if jsonify and value not in ["null", "undefined"]:
value = json.dumps(value)
value = escape_js_value(value)

return re.sub(pattern, value, template)


def simple_renderer(template, **values):
replace = ["graphiql_version"]
replace_jsonify = ["query", "result", "variables", "operation_name"]

for r in replace:
template = process_var(template, r, values.get(r, ""))

for r in replace_jsonify:
template = process_var(template, r, values.get(r, ""), True)

return template


def render_graphiql(
graphiql_version=None, graphiql_template=None, params=None, result=None,
):
graphiql_version = graphiql_version or GRAPHIQL_VERSION
template = graphiql_template or TEMPLATE

template_vars = {
"graphiql_version": graphiql_version,
"query": params and params.query,
"variables": params and params.variables,
"operation_name": params and params.operation_name,
"result": result,
}

source = simple_renderer(template, **template_vars)
return source
6 changes: 6 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@
"sanic>=19.9.0,<20",
]

install_webob_requires = [
"webob>=1.8.6,<2",
]

install_aiohttp_requires = [
"aiohttp>=3.5.0,<4",
]
Expand All @@ -35,6 +39,7 @@
install_requires + \
install_flask_requires + \
install_sanic_requires + \
install_webob_requires + \
install_aiohttp_requires

setup(
Expand Down Expand Up @@ -67,6 +72,7 @@
"dev": install_all_requires + dev_requires,
"flask": install_flask_requires,
"sanic": install_sanic_requires,
"webob": install_webob_requires,
"aiohttp": install_aiohttp_requires,
},
include_package_data=True,
Expand Down
11 changes: 9 additions & 2 deletions tests/aiohttp/test_graphiqlview.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,22 @@ async def test_graphiql_simple_renderer(app, client, pretty_response):
class TestJinjaEnv:
@pytest.mark.asyncio
@pytest.mark.parametrize(
"app", [create_app(graphiql=True, jinja_env=Environment())]
"app", [create_app(graphiql=True, jinja_env=Environment(enable_async=True))]
)
async def test_graphiql_jinja_renderer(self, app, client, pretty_response):
async def test_graphiql_jinja_renderer_async(self, app, client, pretty_response):
response = await client.get(
url_string(query="{test}"), headers={"Accept": "text/html"},
)
assert response.status == 200
assert pretty_response in await response.text()

async def test_graphiql_jinja_renderer_sync(self, app, client, pretty_response):
response = client.get(
url_string(query="{test}"), headers={"Accept": "text/html"},
)
assert response.status == 200
assert pretty_response in response.text()


@pytest.mark.asyncio
async def test_graphiql_html_is_not_accepted(client):
Expand Down
Empty file added tests/webob/__init__.py
Empty file.
Loading

0 comments on commit eaf75e6

Please sign in to comment.