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
6 changes: 6 additions & 0 deletions docs/endpoints.md
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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
Expand Down
7 changes: 7 additions & 0 deletions docs/routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion starlette/endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
]

Expand Down
4 changes: 2 additions & 2 deletions starlette/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""
Expand Down Expand Up @@ -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)
Expand Down
11 changes: 10 additions & 1 deletion tests/test_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)])

Expand All @@ -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:
Expand Down
16 changes: 16 additions & 0 deletions tests/test_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down Expand Up @@ -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"),
Expand Down Expand Up @@ -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()
Expand Down
26 changes: 26 additions & 0 deletions tests/test_schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
"""
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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:
Expand Down