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
165 changes: 41 additions & 124 deletions cylc/uiserver/graphql/tornado.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@

from asyncio import iscoroutinefunction
import json
import re
import sys
import traceback
from typing import (
Expand All @@ -41,7 +40,6 @@
from tornado import web
from tornado.escape import json_encode
from tornado.escape import to_unicode
from tornado.httpclient import HTTPClientError
from tornado.log import app_log
from tornado.web import HTTPError

Expand All @@ -60,11 +58,7 @@
from graphql.pyutils import is_awaitable
from graphql.validation import validate

from cylc.flow.network.graphql import (
NULL_VALUE,
instantiate_middleware,
strip_null
)
from cylc.flow.network.graphql import instantiate_middleware

if TYPE_CHECKING:
from graphene import Schema
Expand All @@ -74,43 +68,10 @@
MAX_VALIDATION_ERRORS = None


def data_search_action(data, action):
if isinstance(data, dict):
return {
key: data_search_action(val, action)
for key, val in data.items()
}
if isinstance(data, list):
return [
data_search_action(val, action)
for val in data
]
return action(data)


def get_content_type(request: 'HTTPServerRequest') -> str:
return request.headers.get("Content-Type", "").split(";", 1)[0].lower()


def get_accepted_content_types(request: 'HTTPServerRequest') -> list:
def qualify(x):
parts = x.split(";", 1)
if len(parts) == 2:
match = re.match(
r"(^|;)q=(0(\.\d{,3})?|1(\.0{,3})?)(;|$)", parts[1])
if match:
return parts[0].strip(), float(match.group(2))
return parts[0].strip(), 1

raw_content_types = request.headers.get("Accept", "*/*").split(",")
qualified_content_types = map(qualify, raw_content_types)
return [
x[0]
for x in sorted(
qualified_content_types, key=lambda x: x[1], reverse=True)
]


Comment on lines -95 to -113
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note to reviewers]

Unused function.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same as above

class ExecutionError(Exception):
def __init__(self, status_code, errors):
super().__init__(status_code, errors)
Expand Down Expand Up @@ -184,42 +145,35 @@ async def post(self) -> None:
self.handle_error(ex)

async def run(self, *args, **kwargs):
try:
data = self.parse_body()

if self.batch:
responses = [
await self.get_response(entry)
for entry in data
]
result = "[{}]".format(
",".join([response[0] for response in responses])
)
status_code = (
responses
and max(responses, key=lambda response: response[1])[1]
or 200
)
else:
result, status_code = await self.get_response(data)
data = self.parse_body()

if self.batch:
responses = [
await self.get_response(entry)
for entry in data
]
result = "[{}]".format(
",".join([response[0] for response in responses])
)
status_code = (
responses
and max(responses, key=lambda response: response[1])[1]
or 200
)
else:
result, status_code = await self.get_response(data)

if status_code == 400:
self.set_status(status_code, reason=result)
else:
self.set_status(status_code)
self.set_header("Content-Type", "application/json")
self.write(result)
await self.finish()

except HTTPClientError as e:
response = e.response
response["Content-Type"] = "application/json"
response.content = self.json_encode(
self.request, {"errors": [self.format_error(e)]}
)
return response

self.set_header("Content-Type", "application/json")
self.write(result)
await self.finish()

async def get_response(self, data):
query, variables, operation_name, _id = self.get_graphql_params(
self.request, data
)
query, variables, operation_name, _id = self.get_graphql_params(data)

execution_result = await self.execute_graphql_request(
data, query, variables, operation_name
Expand Down Expand Up @@ -252,38 +206,19 @@ async def get_response(self, data):
if self.batch:
response["id"] = _id
response["status"] = status_code
try:
result = self.json_encode(response)
except TypeError:
# Catch exceptions in response
errors = []

def exc_to_errors(data):
if isinstance(data, Exception):
errors.append({
'message': (
f'{data.value}'
if hasattr(data, 'value') else f'{data}'
)
})
return NULL_VALUE
return data

response = data_search_action(
response,
exc_to_errors
)
response.setdefault("errors", []).extend(errors)
response = strip_null(response)

result = self.json_encode(response)
result = self.json_encode(response)
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No need to wrap json.dumps/encode in this try/except.. see:
https://github.com/graphql-python/graphene-django/blob/f02ea337a23df06d166d463d6f92cb7505fbb435/graphene_django/views.py#L215

response should always be JSON serializable..

else:
result = None

return result, status_code

def json_encode(self, d, pretty=False):
if (self.pretty or pretty) or self.get_query_argument("pretty", False):
def json_encode(self, d):
if (
self.pretty
or self.get_query_argument("pretty", False)
or self.get_body_argument("pretty", False)
):
return json.dumps(
d, sort_keys=True, indent=2, separators=(",", ": "))

Expand All @@ -300,17 +235,17 @@ def parse_body(self):
try:
body = self.request.body
except Exception as e:
raise ExecutionError(400, e)
raise ExecutionError(400, [e])

try:
request_json = json.loads(body)
if self.batch:
if not isinstance(request_json, list):
raise AssertionError(
raise AssertionError((
"Batch requests should receive a list"
", but received {}."
).format(repr(request_json))
if len(request_json <= 0):
).format(repr(request_json)))
if len(request_json) <= 0:
raise AssertionError(
"Received an empty list in the batch request."
)
Expand Down Expand Up @@ -402,37 +337,19 @@ async def execute_graphql_request(

return result
except Exception as e:
return ExecutionResult(errors=[e])
return ExecutionResult(data=None, errors=[e])

async def execute(self, *args, **kwargs):
return execute(*args, **kwargs)

def request_wants_html(self):
accepted = get_accepted_content_types(self.request)
accepted_length = len(accepted)
# the list will be ordered in preferred first - so we have to make
# sure the most preferred gets the highest number
html_priority = (
accepted_length - accepted.index("text/html")
if "text/html" in accepted
else 0
)
json_priority = (
accepted_length - accepted.index("application/json")
if "application/json" in accepted
else 0
)

return html_priority > json_priority

Comment on lines -410 to -427
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[note to reviewers]

This is presumably something we inherited via graphene-tornado that's no longer required - https://github.com/graphql-python/graphene-tornado/blob/e4fa7d7b4c2256fa37f5ad89cbfd4d4bf6fdb606/graphene_tornado/tornado_graphql_handler.py#L379

Can't find any trace of it in Tornado.

Copy link
Member Author

@dwsutherland dwsutherland Jan 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, I used Django as the starting point, and graphene-tornado as the reference (because graphene-django is more up to date)..
Some of the things removed were related to the inbuilt GraphiQL, where we use the UI/Vue version instead.

def get_graphql_params(self, request, data):
def get_graphql_params(self, data):
if self.graphql_params:
return self.graphql_params

single_args = {}
for key in request.arguments.keys():
for key in self.request.arguments.keys():
single_args[key] = self.decode_argument(
request.arguments.get(key)[0])
self.request.arguments.get(key)[0])

query = single_args.get("query") or data.get("query")
variables = single_args.get("variables") or data.get("variables")
Expand Down
Loading