Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions RELEASE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
Release type: patch

Fix GraphiQL IDE rendering issues introduced in v3.0.0:

- Fix Flask views using Jinja2's `render_template_string` which
HTML-escaped JSON values (e.g., `"` instead of `"`), breaking
the GraphiQL JavaScript configuration. Both sync and async Flask
views now use `to_template_string()` with the framework-agnostic
`simple_renderer`.
- Fix `operationName` not being passed to the GraphiQL template due
to a variable naming mismatch (`operationName` vs `operation_name`)
in the sync Flask view.
- Restore the `locationQuery` JavaScript function that was
accidentally removed, which caused a `ReferenceError` when editing
queries, variables, or headers in the GraphiQL IDE.
- Escape `<` and `>` as `\u003c` and `\u003e` in `tojson()` to
prevent queries containing `</script>` from breaking the GraphiQL
page by prematurely closing the script tag.
- Add CodeMirror 5 fold gutter CSS to fix missing/broken fold
markers in the GraphiQL editor.
14 changes: 4 additions & 10 deletions src/graphql_server/flask/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
)
from typing_extensions import TypeGuard

from flask import Request, Response, render_template_string, request
from flask import Request, Response, request
from flask.views import View
from graphql_server.http import GraphQLRequestData
from graphql_server.http.async_base_view import (
Expand Down Expand Up @@ -138,12 +138,8 @@ def dispatch_request(self) -> ResponseReturnValue:
def render_graphql_ide(
self, request: Request, request_data: GraphQLRequestData
) -> Response:
return render_template_string(
self.graphql_ide_html,
query=request_data.query,
variables=request_data.variables,
operationName=request_data.operation_name,
) # type: ignore
content = request_data.to_template_string(self.graphql_ide_html)
return Response(content, status=200, content_type="text/html")


class AsyncFlaskHTTPRequestAdapter(AsyncHTTPRequestAdapter):
Expand Down Expand Up @@ -208,9 +204,7 @@ async def dispatch_request(self) -> ResponseReturnValue: # type: ignore
async def render_graphql_ide(
self, request: Request, request_data: GraphQLRequestData
) -> Response:
content = render_template_string(
self.graphql_ide_html, **request_data.to_template_context()
)
content = request_data.to_template_string(self.graphql_ide_html)
return Response(content, status=200, content_type="text/html")

def is_websocket_request(self, request: Request) -> TypeGuard[Request]:
Expand Down
6 changes: 5 additions & 1 deletion src/graphql_server/http/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,11 @@ def process_result(
def tojson(value):
if value not in ["true", "false", "null", "undefined"]:
value = json.dumps(value)
# value = escape_js_value(value)
# Escape characters that are significant to the HTML parser when
# embedded inside <script> tags. Using JS Unicode escapes (\u003c)
# rather than HTML entities (&#60;) so JavaScript correctly decodes
# them at runtime while the HTML parser never sees raw < or >.
value = value.replace("<", "\\u003c").replace(">", "\\u003e")
return value


Expand Down
16 changes: 16 additions & 0 deletions src/graphql_server/static/graphiql.html
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@
href="https://unpkg.com/@graphiql/plugin-explorer@1.0.2/dist/style.css"
integrity="sha384-5DFJlDPW2tSATRbM8kzoP1j194jexLswuNmClWoRr2Q0x7R68JIQzPHZ02Faktwi"
/>

<link
crossorigin
rel="stylesheet"
href="https://unpkg.com/codemirror@5.65.16/addon/fold/foldgutter.css"
integrity="sha384-gW0T7WIPsj+5+b/qOsKxiwxdUCfZsjfGtzACaGGLdwEHq/pZ4aS5daCrQznA4Y8H"
/>
</head>

<body>
Expand Down Expand Up @@ -183,6 +190,15 @@
parameters.operationName = newOperationName;
updateURL();
}
// Produce a Location query string from a parameter object.
function locationQuery(params) {
return '?' + Object.keys(params).filter(function (key) {
return Boolean(params[key]);
}).map(function (key) {
return encodeURIComponent(key) + '=' +
encodeURIComponent(params[key]);
}).join('&');
}
function updateURL() {
history.replaceState(null, null, locationQuery(parameters));
}
Expand Down
71 changes: 65 additions & 6 deletions src/tests/http/test_graphql_ide.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,11 @@
async def test_renders_graphql_ide(
header_value: str,
http_client_class: type[HttpClient],
graphql_ide_and_title: tuple[Literal["graphiql"], Literal["GraphiQL"]]
| tuple[Literal["apollo-sandbox"], Literal["Apollo Sandbox"]]
| tuple[Literal["pathfinder"], Literal["GraphQL Pathfinder"]],
graphql_ide_and_title: (
tuple[Literal["graphiql"], Literal["GraphiQL"]]
| tuple[Literal["apollo-sandbox"], Literal["Apollo Sandbox"]]
| tuple[Literal["pathfinder"], Literal["GraphQL Pathfinder"]]
),
):
graphql_ide, title = graphql_ide_and_title
http_client = http_client_class(graphql_ide=graphql_ide)
Expand Down Expand Up @@ -126,9 +128,11 @@ async def test_renders_graphiql_disabled_deprecated(
async def test_renders_graphql_ide_with_variables(
header_value: str,
http_client_class: type[HttpClient],
graphql_ide_and_title: tuple[Literal["graphiql"], Literal["GraphiQL"]]
| tuple[Literal["apollo-sandbox"], Literal["Apollo Sandbox"]]
| tuple[Literal["pathfinder"], Literal["GraphQL Pathfinder"]],
graphql_ide_and_title: (
tuple[Literal["graphiql"], Literal["GraphiQL"]]
| tuple[Literal["apollo-sandbox"], Literal["Apollo Sandbox"]]
| tuple[Literal["pathfinder"], Literal["GraphQL Pathfinder"]]
),
):
graphql_ide, title = graphql_ide_and_title
http_client = http_client_class(graphql_ide=graphql_ide)
Expand All @@ -155,3 +159,58 @@ async def test_renders_graphql_ide_with_variables(

if graphql_ide == "graphiql":
assert "unpkg.com/graphiql" in response.text


async def test_renders_graphql_ide_with_operation_name(
http_client_class: type[HttpClient],
):
http_client = http_client_class(graphql_ide="graphiql")

query = "query TestOp { __typename }"
query_encoded = quote(query)
operation_name = "TestOp"
operation_name_encoded = quote(operation_name)
response = await http_client.get(
f"/graphql?query={query_encoded}&operationName={operation_name_encoded}",
headers={"Accept": "text/html"},
)

assert response.status_code == 200
assert 'operationName: "TestOp"' in response.text


async def test_renders_graphql_ide_without_html_escaping(
http_client_class: type[HttpClient],
):
http_client = http_client_class(graphql_ide="graphiql")

# Use a query with a quoted string arg to ensure " characters are present
# in the raw value
query = '{ field(arg: "value") }'
query_encoded = quote(query)
response = await http_client.get(
f"/graphql?query={query_encoded}",
headers={"Accept": "text/html"},
)

assert response.status_code == 200
# Verify JSON values are not escaped (e.g. &#34; instead of ")
assert "&#" not in response.text


async def test_renders_graphql_ide_with_script_tag_in_query(
http_client_class: type[HttpClient],
):
http_client = http_client_class(graphql_ide="graphiql")

query = "{ field } </script>"
query_encoded = quote(query)
response = await http_client.get(
f"/graphql?query={query_encoded}",
headers={"Accept": "text/html"},
)

assert response.status_code == 200
# The < and > in the query must be escaped as \u003c and \u003e so the
# HTML parser doesn't see a literal </script> and close the tag early.
assert "\\u003c/script\\u003e" in response.text
4 changes: 2 additions & 2 deletions uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.