diff --git a/docs/endpoints.md b/docs/endpoints.md index 1362f5e80..86e6a6b5b 100644 --- a/docs/endpoints.md +++ b/docs/endpoints.md @@ -31,6 +31,8 @@ class Homepage(HTTPEndpoint): async def get(self, request): return PlainTextResponse(f"Hello, world!") + async def query(self, request): + return PlainTextResponse(f"Hello, query!") class User(HTTPEndpoint): async def get(self, request): @@ -48,6 +50,10 @@ app = Starlette(routes=routes) HTTP endpoint classes will respond with "405 Method not allowed" responses for any request methods which do not map to a corresponding handler. +You can define a handler for any supported HTTP method by adding a matching +lowercase method name, for example `async def query(self, request):` to handle +`QUERY` requests. + ### WebSocketEndpoint The `WebSocketEndpoint` class is an ASGI application that presents a wrapper around diff --git a/docs/routing.md b/docs/routing.md index 9a426402d..be2cecf25 100644 --- a/docs/routing.md +++ b/docs/routing.md @@ -101,6 +101,13 @@ Route('/users/{user_id:int}', user, methods=["GET", "POST"]) By default function endpoints will only accept `GET` requests, unless specified. +Any HTTP method name can be used here, including newer or draft methods such as +`QUERY`: + +```python +Route('/users/search', search_users, methods=["QUERY"]) +``` + ## Submounting routes In large applications you might find that you want to break out parts of the diff --git a/starlette/endpoints.py b/starlette/endpoints.py index bdd51dce9..21481f6ff 100644 --- a/starlette/endpoints.py +++ b/starlette/endpoints.py @@ -22,7 +22,7 @@ def __init__(self, scope: Scope, receive: Receive, send: Send) -> None: self.send = send self._allowed_methods = [ method - for method in ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + for method in ("GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "QUERY") if getattr(self, method.lower(), None) is not None ] diff --git a/starlette/schemas.py b/starlette/schemas.py index cf7d439e3..23391f3f9 100644 --- a/starlette/schemas.py +++ b/starlette/schemas.py @@ -44,7 +44,7 @@ def get_endpoints(self, routes: list[BaseRoute]) -> list[EndpointInfo]: - path eg: /users/ - http_method - one of 'get', 'post', 'put', 'patch', 'delete', 'options' + one of 'get', 'post', 'put', 'patch', 'delete', 'options', 'query' - func method ready to extract the docstring """ @@ -78,7 +78,7 @@ def get_endpoints(self, routes: list[BaseRoute]) -> list[EndpointInfo]: endpoints_info.append(EndpointInfo(path, method.lower(), route.endpoint)) else: path = self._remove_converter(route.path) - for method in ["get", "post", "put", "patch", "delete", "options"]: + for method in ["get", "post", "put", "patch", "delete", "options", "query"]: if not hasattr(route.endpoint, method): continue func = getattr(route.endpoint, method) diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index dfc201969..33746fe19 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -18,6 +18,9 @@ async def get(self, request: Request) -> PlainTextResponse: return PlainTextResponse("Hello, world!") return PlainTextResponse(f"Hello, {username}!") + async def query(self, request: Request) -> PlainTextResponse: + return PlainTextResponse("Hello, QUERY!") + app = Router(routes=[Route("/", endpoint=Homepage), Route("/{username}", endpoint=Homepage)]) @@ -44,7 +47,13 @@ def test_http_endpoint_route_method(client: TestClient) -> None: response = client.post("/") assert response.status_code == 405 assert response.text == "Method Not Allowed" - assert response.headers["allow"] == "GET" + assert response.headers["allow"] == "GET, QUERY" + + +def test_http_endpoint_route_query_method(client: TestClient) -> None: + response = client.request("QUERY", "/") + assert response.status_code == 200 + assert response.text == "Hello, QUERY!" def test_http_endpoint_does_not_dispatch_non_verb_method(test_client_factory: TestClientFactory) -> None: diff --git a/tests/test_routing.py b/tests/test_routing.py index bca758c2a..875caee54 100644 --- a/tests/test_routing.py +++ b/tests/test_routing.py @@ -80,6 +80,10 @@ def contact(request: Request) -> Response: return Response("Hello, POST!", media_type="text/plain") +def search(request: Request) -> Response: + return Response("Hello, QUERY!", media_type="text/plain") + + def int_convertor(request: Request) -> JSONResponse: number = request.path_params["param"] return JSONResponse({"int": number}) @@ -148,6 +152,7 @@ async def websocket_params(session: WebSocket) -> None: Mount("/static", app=Response("xxxxx", media_type="image/png")), Route("/func", endpoint=func_homepage, methods=["GET"]), Route("/func", endpoint=contact, methods=["POST"]), + Route("/search", endpoint=search, methods=["QUERY"]), Route("/int/{param:int}", endpoint=int_convertor, name="int-convertor"), Route("/float/{param:float}", endpoint=float_convertor, name="float-convertor"), Route("/path/{param:path}", endpoint=path_convertor, name="path-convertor"), @@ -304,6 +309,17 @@ def test_router_duplicate_path(client: TestClient) -> None: assert response.text == "Hello, POST!" +def test_router_query_method(client: TestClient) -> None: + response = client.request("QUERY", "/search") + assert response.status_code == 200 + assert response.text == "Hello, QUERY!" + + response = client.get("/search") + assert response.status_code == 405 + assert response.text == "Method Not Allowed" + assert response.headers["allow"] == "QUERY" + + def test_router_add_websocket_route(client: TestClient) -> None: with client.websocket_connect("/ws") as session: text = session.receive_text() diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 3b321ca0b..885b81c54 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -48,6 +48,17 @@ def create_user(request: Request) -> None: pass # pragma: no cover +def query_users(request: Request) -> None: + """ + responses: + 200: + description: Users matching a query. + examples: + [{"username": "tom"}] + """ + pass # pragma: no cover + + class OrganisationsEndpoint(HTTPEndpoint): def get(self, request: Request) -> None: """ @@ -119,6 +130,7 @@ def schema(request: Request) -> Response: Route("/users/{id:int}", endpoint=get_user, methods=["GET"]), Route("/users", endpoint=list_users, methods=["GET", "HEAD"]), Route("/users", endpoint=create_user, methods=["POST"]), + Route("/users", endpoint=query_users, methods=["QUERY"]), Route("/orgs", endpoint=OrganisationsEndpoint), Route("/regular-docstring-and-schema", endpoint=regular_docstring_and_schema), Route("/regular-docstring", endpoint=regular_docstring), @@ -173,6 +185,14 @@ def test_schema_generation() -> None: } }, "post": {"responses": {200: {"description": "A user.", "examples": {"username": "tom"}}}}, + "query": { + "responses": { + 200: { + "description": "Users matching a query.", + "examples": [{"username": "tom"}], + } + } + }, }, "/users/{id}": { "get": { @@ -237,6 +257,12 @@ def test_schema_generation() -> None: description: A user. examples: username: tom + query: + responses: + 200: + description: Users matching a query. + examples: + - username: tom /users/{id}: get: responses: