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
2 changes: 1 addition & 1 deletion tornado/curl_httpclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ def write_function(b: bytes | bytearray) -> int:
else:
raise KeyError("unknown method " + request.method)

body_expected = request.method in ("POST", "PATCH", "PUT")
body_expected = request.method in ("POST", "PATCH", "PUT", "QUERY")
body_present = request.body is not None
if not request.allow_nonstandard_methods:
# Some HTTP methods nearly always have bodies while others
Expand Down
2 changes: 1 addition & 1 deletion tornado/http1connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -396,7 +396,7 @@ def write_headers(
# Content-Length or a Transfer-Encoding. If Content-Length is not
# present we'll add our Transfer-Encoding below.
self._chunking_output = (
start_line.method in ("POST", "PUT", "PATCH")
start_line.method in ("POST", "PUT", "PATCH", "QUERY")
and "Content-Length" not in headers
)
else:
Expand Down
21 changes: 18 additions & 3 deletions tornado/simple_httpclient.py
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,16 @@ def _on_timeout(self, key: object, info: str | None = None) -> None:


class _HTTPConnection(httputil.HTTPMessageDelegate):
_SUPPORTED_METHODS = {"GET", "HEAD", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"}
_SUPPORTED_METHODS = {
"GET",
"HEAD",
"POST",
"PUT",
"DELETE",
"PATCH",
"OPTIONS",
"QUERY",
}

def __init__(
self,
Expand Down Expand Up @@ -399,7 +408,12 @@ async def run(self) -> None:
# Some HTTP methods nearly always have bodies while others
# almost never do. Fail in this case unless the user has
# opted out of sanity checks with allow_nonstandard_methods.
body_expected = self.request.method in ("POST", "PATCH", "PUT")
body_expected = self.request.method in (
"POST",
"PATCH",
"PUT",
"QUERY",
)
body_present = (
self.request.body is not None
or self.request.body_producer is not None
Expand Down Expand Up @@ -673,7 +687,8 @@ def finish(self) -> None:
# for *all* methods, but libcurl < 7.70 only does this
# for POST, while libcurl >= 7.70 does it for other methods.
if (self.code == 303 and self.request.method != "HEAD") or (
self.code in (301, 302) and self.request.method == "POST"
self.code in (301, 302)
and self.request.method in ("POST", "QUERY")
):
new_request.method = "GET"
new_request.body = None # type: ignore
Expand Down
20 changes: 18 additions & 2 deletions tornado/test/httpclient_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,13 +119,13 @@ def patch(self):


class AllMethodsHandler(RequestHandler):
SUPPORTED_METHODS = RequestHandler.SUPPORTED_METHODS + ("OTHER",) # type: ignore
SUPPORTED_METHODS = RequestHandler.SUPPORTED_METHODS + ("OTHER", "QUERY") # type: ignore

def method(self):
assert self.request.method is not None
self.write(self.request.method)

get = head = post = put = delete = options = patch = other = method # type: ignore
get = head = post = put = delete = options = patch = other = query = method # type: ignore


class SetHeaderHandler(RequestHandler):
Expand Down Expand Up @@ -385,6 +385,22 @@ def test_method_after_redirect(self):
self.assertEqual(200, resp.code)
self.assertEqual(b"", resp.body)

def test_query_after_redirect(self):
# QUERY is a safe, idempotent, cacheable method (RFC 10008) so
# a 301/302/303 redirect from a QUERY request should turn the
# followed request into a GET, matching the behaviour of
# browsers and what Tornado already does for POST.
for status in [301, 302, 303]:
url = "/redirect?url=/all_methods&status=%d" % status
resp = self.fetch(url, method="QUERY", body=b"id=42")
self.assertEqual(b"GET", resp.body)

# Newer redirects preserve the original QUERY method.
for status in [307, 308]:
url = "/redirect?url=/all_methods&status=%d" % status
resp = self.fetch(url, method="QUERY", body=b"id=42")
self.assertEqual(b"QUERY", resp.body)

def test_credentials_in_url(self):
url = self.get_url("/auth").replace("http://", "http://me:secret@")
response = self.fetch(url)
Expand Down
29 changes: 29 additions & 0 deletions tornado/test/web_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2337,6 +2337,35 @@ def test_other(self):
self.assertEqual(response.body, b"other")


class QueryMethodTest(SimpleHandlerTestCase):
"""The QUERY method was standardised by RFC 10008. It is safe, idempotent
and cacheable, and carries its query in a request body rather than in
the URL. Tornado's RequestHandler should treat it like any other
standard method (responds with 405 when not implemented, dispatches to
``query()`` when implemented).
"""

class Handler(RequestHandler):
# Intentionally no ``query`` method. The default stub raises 405.
pass

def test_unimplemented_query(self):
# QUERY is now in SUPPORTED_METHODS by default, so the
# unimplemented-method stub should produce a 405.
response = self.fetch("/", method="QUERY", body=b"")
self.assertEqual(response.code, 405)


class QueryMethodDispatchTest(SimpleHandlerTestCase):
class Handler(RequestHandler):
def query(self):
self.write("query")

def test_query(self):
response = self.fetch("/", method="QUERY", body=b"")
self.assertEqual(response.body, b"query")


class FinishInPrepareTest(SimpleHandlerTestCase):
class Handler(RequestHandler):
def prepare(self):
Expand Down
2 changes: 2 additions & 0 deletions tornado/web.py
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ class RequestHandler:
"PATCH",
"PUT",
"OPTIONS",
"QUERY",
)

_template_loaders: dict[str, template.BaseLoader] = {}
Expand Down Expand Up @@ -263,6 +264,7 @@ def _unimplemented_method(self, *args: str, **kwargs: str) -> None:
patch: Callable[..., Awaitable[None] | None] = _unimplemented_method
put: Callable[..., Awaitable[None] | None] = _unimplemented_method
options: Callable[..., Awaitable[None] | None] = _unimplemented_method
query: Callable[..., Awaitable[None] | None] = _unimplemented_method

def prepare(self) -> Awaitable[None] | None:
"""Called at the beginning of a request before `get`/`post`/etc.
Expand Down