Skip to content

Commit 3388fc1

Browse files
Fix GraphiQL IDE rendering (#137)
- Fix Flask views using Jinja2's render_template_string which HTML-escaped JSON values, breaking the GraphiQL JS config. Both sync and async views now use to_template_string() with the framework-agnostic simple_renderer. - Fix operationName not passed to the template due to a variable naming mismatch (operationName vs operation_name) in the sync Flask view. - Restore the locationQuery JS function accidentally removed in 578453f, which caused a ReferenceError when editing queries in the GraphiQL IDE. - Escape < and > as \u003c and \u003e in tojson() to prevent queries containing </script> from breaking the page by prematurely closing the script tag. - Add CodeMirror 5 fold gutter CSS to fix broken fold markers in the GraphiQL editor.
1 parent 2d5ac95 commit 3388fc1

File tree

6 files changed

+129
-19
lines changed

6 files changed

+129
-19
lines changed

RELEASE.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
Release type: patch
2+
3+
Fix GraphiQL IDE rendering issues introduced in v3.0.0:
4+
5+
- Fix Flask views using Jinja2's `render_template_string` which
6+
HTML-escaped JSON values (e.g., `&#34;` instead of `"`), breaking
7+
the GraphiQL JavaScript configuration. Both sync and async Flask
8+
views now use `to_template_string()` with the framework-agnostic
9+
`simple_renderer`.
10+
- Fix `operationName` not being passed to the GraphiQL template due
11+
to a variable naming mismatch (`operationName` vs `operation_name`)
12+
in the sync Flask view.
13+
- Restore the `locationQuery` JavaScript function that was
14+
accidentally removed, which caused a `ReferenceError` when editing
15+
queries, variables, or headers in the GraphiQL IDE.
16+
- Escape `<` and `>` as `\u003c` and `\u003e` in `tojson()` to
17+
prevent queries containing `</script>` from breaking the GraphiQL
18+
page by prematurely closing the script tag.
19+
- Add CodeMirror 5 fold gutter CSS to fix missing/broken fold
20+
markers in the GraphiQL editor.

src/graphql_server/flask/views.py

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
)
1212
from typing_extensions import TypeGuard
1313

14-
from flask import Request, Response, render_template_string, request
14+
from flask import Request, Response, request
1515
from flask.views import View
1616
from graphql_server.http import GraphQLRequestData
1717
from graphql_server.http.async_base_view import (
@@ -138,12 +138,8 @@ def dispatch_request(self) -> ResponseReturnValue:
138138
def render_graphql_ide(
139139
self, request: Request, request_data: GraphQLRequestData
140140
) -> Response:
141-
return render_template_string(
142-
self.graphql_ide_html,
143-
query=request_data.query,
144-
variables=request_data.variables,
145-
operationName=request_data.operation_name,
146-
) # type: ignore
141+
content = request_data.to_template_string(self.graphql_ide_html)
142+
return Response(content, status=200, content_type="text/html")
147143

148144

149145
class AsyncFlaskHTTPRequestAdapter(AsyncHTTPRequestAdapter):
@@ -208,9 +204,7 @@ async def dispatch_request(self) -> ResponseReturnValue: # type: ignore
208204
async def render_graphql_ide(
209205
self, request: Request, request_data: GraphQLRequestData
210206
) -> Response:
211-
content = render_template_string(
212-
self.graphql_ide_html, **request_data.to_template_context()
213-
)
207+
content = request_data.to_template_string(self.graphql_ide_html)
214208
return Response(content, status=200, content_type="text/html")
215209

216210
def is_websocket_request(self, request: Request) -> TypeGuard[Request]:

src/graphql_server/http/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,11 @@ def process_result(
3737
def tojson(value):
3838
if value not in ["true", "false", "null", "undefined"]:
3939
value = json.dumps(value)
40-
# value = escape_js_value(value)
40+
# Escape characters that are significant to the HTML parser when
41+
# embedded inside <script> tags. Using JS Unicode escapes (\u003c)
42+
# rather than HTML entities (&#60;) so JavaScript correctly decodes
43+
# them at runtime while the HTML parser never sees raw < or >.
44+
value = value.replace("<", "\\u003c").replace(">", "\\u003e")
4145
return value
4246

4347

src/graphql_server/static/graphiql.html

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,13 @@
7878
href="https://unpkg.com/@graphiql/plugin-explorer@1.0.2/dist/style.css"
7979
integrity="sha384-5DFJlDPW2tSATRbM8kzoP1j194jexLswuNmClWoRr2Q0x7R68JIQzPHZ02Faktwi"
8080
/>
81+
82+
<link
83+
crossorigin
84+
rel="stylesheet"
85+
href="https://unpkg.com/codemirror@5.65.16/addon/fold/foldgutter.css"
86+
integrity="sha384-gW0T7WIPsj+5+b/qOsKxiwxdUCfZsjfGtzACaGGLdwEHq/pZ4aS5daCrQznA4Y8H"
87+
/>
8188
</head>
8289

8390
<body>
@@ -183,6 +190,15 @@
183190
parameters.operationName = newOperationName;
184191
updateURL();
185192
}
193+
// Produce a Location query string from a parameter object.
194+
function locationQuery(params) {
195+
return '?' + Object.keys(params).filter(function (key) {
196+
return Boolean(params[key]);
197+
}).map(function (key) {
198+
return encodeURIComponent(key) + '=' +
199+
encodeURIComponent(params[key]);
200+
}).join('&');
201+
}
186202
function updateURL() {
187203
history.replaceState(null, null, locationQuery(parameters));
188204
}

src/tests/http/test_graphql_ide.py

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,11 @@
2424
async def test_renders_graphql_ide(
2525
header_value: str,
2626
http_client_class: type[HttpClient],
27-
graphql_ide_and_title: tuple[Literal["graphiql"], Literal["GraphiQL"]]
28-
| tuple[Literal["apollo-sandbox"], Literal["Apollo Sandbox"]]
29-
| tuple[Literal["pathfinder"], Literal["GraphQL Pathfinder"]],
27+
graphql_ide_and_title: (
28+
tuple[Literal["graphiql"], Literal["GraphiQL"]]
29+
| tuple[Literal["apollo-sandbox"], Literal["Apollo Sandbox"]]
30+
| tuple[Literal["pathfinder"], Literal["GraphQL Pathfinder"]]
31+
),
3032
):
3133
graphql_ide, title = graphql_ide_and_title
3234
http_client = http_client_class(graphql_ide=graphql_ide)
@@ -126,9 +128,11 @@ async def test_renders_graphiql_disabled_deprecated(
126128
async def test_renders_graphql_ide_with_variables(
127129
header_value: str,
128130
http_client_class: type[HttpClient],
129-
graphql_ide_and_title: tuple[Literal["graphiql"], Literal["GraphiQL"]]
130-
| tuple[Literal["apollo-sandbox"], Literal["Apollo Sandbox"]]
131-
| tuple[Literal["pathfinder"], Literal["GraphQL Pathfinder"]],
131+
graphql_ide_and_title: (
132+
tuple[Literal["graphiql"], Literal["GraphiQL"]]
133+
| tuple[Literal["apollo-sandbox"], Literal["Apollo Sandbox"]]
134+
| tuple[Literal["pathfinder"], Literal["GraphQL Pathfinder"]]
135+
),
132136
):
133137
graphql_ide, title = graphql_ide_and_title
134138
http_client = http_client_class(graphql_ide=graphql_ide)
@@ -155,3 +159,75 @@ async def test_renders_graphql_ide_with_variables(
155159

156160
if graphql_ide == "graphiql":
157161
assert "unpkg.com/graphiql" in response.text
162+
163+
164+
async def test_renders_graphql_ide_with_operation_name(
165+
http_client_class: type[HttpClient],
166+
):
167+
http_client = http_client_class(graphql_ide="graphiql")
168+
169+
query = "query TestOp { __typename }"
170+
query_encoded = quote(query)
171+
operation_name = "TestOp"
172+
operation_name_encoded = quote(operation_name)
173+
response = await http_client.get(
174+
f"/graphql?query={query_encoded}&operationName={operation_name_encoded}",
175+
headers={"Accept": "text/html"},
176+
)
177+
178+
assert response.status_code == 200
179+
assert 'operationName: "TestOp"' in response.text
180+
181+
182+
async def test_renders_graphql_ide_without_html_escaping(
183+
http_client_class: type[HttpClient],
184+
):
185+
http_client = http_client_class(graphql_ide="graphiql")
186+
187+
# Use a query with a quoted string arg to ensure " characters are present
188+
# in the raw value
189+
query = '{ field(arg: "value") }'
190+
query_encoded = quote(query)
191+
response = await http_client.get(
192+
f"/graphql?query={query_encoded}",
193+
headers={"Accept": "text/html"},
194+
)
195+
196+
assert response.status_code == 200
197+
# Verify JSON values are not escaped (e.g. &#34; instead of ")
198+
assert "&#" not in response.text
199+
200+
201+
async def test_renders_graphql_ide_default_query(
202+
http_client_class: type[HttpClient],
203+
):
204+
http_client = http_client_class(graphql_ide="graphiql")
205+
206+
# When no query parameter is provided, the template should render
207+
# query: undefined so GraphiQL falls back to its defaultQuery.
208+
# Using null would put GraphiQL in controlled mode with an empty editor.
209+
response = await http_client.get(
210+
"/graphql",
211+
headers={"Accept": "text/html"},
212+
)
213+
214+
assert response.status_code == 200
215+
assert "query: undefined" in response.text
216+
217+
218+
async def test_renders_graphql_ide_with_script_tag_in_query(
219+
http_client_class: type[HttpClient],
220+
):
221+
http_client = http_client_class(graphql_ide="graphiql")
222+
223+
query = "{ field } </script>"
224+
query_encoded = quote(query)
225+
response = await http_client.get(
226+
f"/graphql?query={query_encoded}",
227+
headers={"Accept": "text/html"},
228+
)
229+
230+
assert response.status_code == 200
231+
# The < and > in the query must be escaped as \u003c and \u003e so the
232+
# HTML parser doesn't see a literal </script> and close the tag early.
233+
assert "\\u003c/script\\u003e" in response.text

uv.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)