diff --git a/tornado/curl_httpclient.py b/tornado/curl_httpclient.py index 06c75fe30..41c8b8d93 100644 --- a/tornado/curl_httpclient.py +++ b/tornado/curl_httpclient.py @@ -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 diff --git a/tornado/http1connection.py b/tornado/http1connection.py index 17ada11f6..8c1fa6070 100644 --- a/tornado/http1connection.py +++ b/tornado/http1connection.py @@ -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: diff --git a/tornado/simple_httpclient.py b/tornado/simple_httpclient.py index b8e4d8c93..dc9f1f71a 100644 --- a/tornado/simple_httpclient.py +++ b/tornado/simple_httpclient.py @@ -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, @@ -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 @@ -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 diff --git a/tornado/test/httpclient_test.py b/tornado/test/httpclient_test.py index 64e5cc5ff..8b6b1aff8 100644 --- a/tornado/test/httpclient_test.py +++ b/tornado/test/httpclient_test.py @@ -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): @@ -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) diff --git a/tornado/test/web_test.py b/tornado/test/web_test.py index 36049e26b..4ce29f02f 100644 --- a/tornado/test/web_test.py +++ b/tornado/test/web_test.py @@ -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): diff --git a/tornado/web.py b/tornado/web.py index b572beac0..1e4c0ffa9 100644 --- a/tornado/web.py +++ b/tornado/web.py @@ -180,6 +180,7 @@ class RequestHandler: "PATCH", "PUT", "OPTIONS", + "QUERY", ) _template_loaders: dict[str, template.BaseLoader] = {} @@ -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.