diff --git a/litestar/__init__.py b/litestar/__init__.py index 1303d932aa..2a50c15d3e 100644 --- a/litestar/__init__.py +++ b/litestar/__init__.py @@ -10,6 +10,7 @@ patch, post, put, + query, route, websocket, websocket_listener, @@ -39,6 +40,7 @@ "patch", "post", "put", + "query", "route", "websocket", "websocket_listener", diff --git a/litestar/_openapi/typescript_converter/converter.py b/litestar/_openapi/typescript_converter/converter.py index 5d18dfb5e8..f99224996b 100644 --- a/litestar/_openapi/typescript_converter/converter.py +++ b/litestar/_openapi/typescript_converter/converter.py @@ -18,17 +18,19 @@ TypeScriptType, TypeScriptUnion, ) -from litestar.enums import HttpMethod, ParamType +from litestar.enums import ParamType from litestar.openapi.spec import ( Components, OpenAPI, Operation, Parameter, + PathItem, Reference, RequestBody, Responses, Schema, ) +from litestar.openapi.spec.base import BaseSchemaObject __all__ = ( "convert_openapi_to_typescript", @@ -40,7 +42,8 @@ "resolve_ref", ) -from litestar.openapi.spec.base import BaseSchemaObject +# OpenAPI 3.x path-item method fields — derived from the PathItem spec's Operation-typed fields. +_PATH_ITEM_METHODS = tuple(f.name for f in fields(PathItem) if "Operation" in str(f.type) and "None" in str(f.type)) T = TypeVar("T") @@ -275,10 +278,8 @@ def convert_openapi_to_typescript(openapi_schema: OpenAPI, namespace: str = "API shared_params = [ get_openapi_type(p, components=openapi_schema.components) for p in (path_item.parameters or []) ] - for method in HttpMethod: - if ( - operation := cast("Operation | None", getattr(path_item, method.lower(), "None")) - ) and operation.operation_id: + for method in _PATH_ITEM_METHODS: + if (operation := cast("Operation | None", getattr(path_item, method, None))) and operation.operation_id: params = parse_params( [ *( diff --git a/litestar/config/csrf.py b/litestar/config/csrf.py index 225dc23daa..6827b067f0 100644 --- a/litestar/config/csrf.py +++ b/litestar/config/csrf.py @@ -34,7 +34,7 @@ class CSRFConfig: """The value to set in the ``SameSite`` attribute of the cookie.""" cookie_domain: str | None = field(default=None) """Specifies which hosts can receive the cookie.""" - safe_methods: set[Method] = field(default_factory=lambda: {"GET", "HEAD", "OPTIONS"}) + safe_methods: set[Method] = field(default_factory=lambda: {"GET", "HEAD", "OPTIONS", "QUERY"}) """A set of "safe methods" that can set the cookie.""" exclude: str | list[str] | None = field(default=None) """A pattern or list of patterns to skip in the CSRF middleware.""" diff --git a/litestar/enums.py b/litestar/enums.py index 62d2db3789..861303ee95 100644 --- a/litestar/enums.py +++ b/litestar/enums.py @@ -21,6 +21,7 @@ class HttpMethod(StrEnum): PATCH = "PATCH" POST = "POST" PUT = "PUT" + QUERY = "QUERY" TRACE = "TRACE" diff --git a/litestar/handlers/__init__.py b/litestar/handlers/__init__.py index 8cb1731f57..9420f5058e 100644 --- a/litestar/handlers/__init__.py +++ b/litestar/handlers/__init__.py @@ -1,6 +1,6 @@ from .asgi_handlers import ASGIRouteHandler, asgi from .base import BaseRouteHandler -from .http_handlers import HTTPRouteHandler, delete, get, head, patch, post, put, route +from .http_handlers import HTTPRouteHandler, delete, get, head, patch, post, put, query, route from .websocket_handlers import ( WebsocketListener, WebsocketListenerRouteHandler, @@ -25,6 +25,7 @@ "patch", "post", "put", + "query", "route", "send_websocket_stream", "websocket", diff --git a/litestar/handlers/http_handlers/__init__.py b/litestar/handlers/http_handlers/__init__.py index 3009fda95b..915509c030 100644 --- a/litestar/handlers/http_handlers/__init__.py +++ b/litestar/handlers/http_handlers/__init__.py @@ -1,7 +1,7 @@ from __future__ import annotations from .base import HTTPRouteHandler -from .decorators import delete, get, head, patch, post, put, route +from .decorators import delete, get, head, patch, post, put, query, route __all__ = ( "HTTPRouteHandler", @@ -11,5 +11,6 @@ "patch", "post", "put", + "query", "route", ) diff --git a/litestar/handlers/http_handlers/decorators.py b/litestar/handlers/http_handlers/decorators.py index de7cb2e664..798419eb3c 100644 --- a/litestar/handlers/http_handlers/decorators.py +++ b/litestar/handlers/http_handlers/decorators.py @@ -56,7 +56,7 @@ ) from litestar.types.callable_types import AnyCallable, OperationIDCreator -__all__ = ("delete", "get", "head", "patch", "post", "put", "route") +__all__ = ("delete", "get", "head", "patch", "post", "put", "query", "route") def route( @@ -1086,6 +1086,177 @@ def decorator(fn: AnyCallable) -> HTTPRouteHandler: return decorator +def query( + path: str | None | Sequence[str] = None, + *, + after_request: AfterRequestHookHandler | None = None, + after_response: AfterResponseHookHandler | None = None, + background: BackgroundTask | BackgroundTasks | None = None, + before_request: BeforeRequestHookHandler | None = None, + cache: bool | int | type[CACHE_FOREVER] = False, + cache_control: CacheControlHeader | None = None, + cache_key_builder: CacheKeyBuilder | None = None, + dependencies: Dependencies | None = None, + dto: type[AbstractDTO] | None | EmptyType = Empty, + etag: ETag | None = None, + exception_handlers: ExceptionHandlersMap | None = None, + guards: Sequence[Guard] | None = None, + media_type: MediaType | str | None = None, + middleware: Sequence[Middleware] | None = None, + name: str | None = None, + opt: Mapping[str, Any] | None = None, + request_class: type[Request] | None = None, + request_max_body_size: int | None | EmptyType = Empty, + response_class: type[Response] | None = None, + response_cookies: ResponseCookies | None = None, + response_headers: ResponseHeaders | None = None, + return_dto: type[AbstractDTO] | None | EmptyType = Empty, + signature_namespace: Mapping[str, Any] | None = None, + status_code: int | None = None, + sync_to_thread: bool | None = None, + # OpenAPI related attributes + content_encoding: str | None = None, + content_media_type: str | None = None, + deprecated: bool = False, + description: str | None = None, + include_in_schema: bool | EmptyType = False, + operation_class: type[Operation] = Operation, + operation_id: str | OperationIDCreator | None = None, + raises: Sequence[type[HTTPException]] | None = None, + response_description: str | None = None, + responses: Mapping[int, ResponseSpec] | None = None, + security: Sequence[SecurityRequirement] | None = None, + summary: str | None = None, + tags: Sequence[str] | None = None, + type_decoders: TypeDecodersSequence | None = None, + type_encoders: TypeEncodersMap | None = None, + handler_class: type[HTTPRouteHandler] = HTTPRouteHandler, + **kwargs: Any, +) -> Callable[[AnyCallable], HTTPRouteHandler]: + """Create an :class:`HTTPRouteHandler` with a ``QUERY`` method. + + Args: + path: A path fragment for the route handler function or a sequence of path fragments. + If not given defaults to ``/`` + after_request: A sync or async function executed before a :class:`Request <.connection.Request>` is passed + to any route handler. If this function returns a value, the request will not reach the route handler, + and instead this value will be used. + after_response: A sync or async function called after the response has been awaited. It receives the + :class:`Request <.connection.Request>` object and should not return any values. + background: A :class:`BackgroundTask <.background_tasks.BackgroundTask>` instance or + :class:`BackgroundTasks <.background_tasks.BackgroundTasks>` to execute after the response is finished. + Defaults to ``None``. + before_request: A sync or async function called immediately before calling the route handler. Receives + the :class:`.connection.Request` instance and any non-``None`` return value is used for the response, + bypassing the route handler. + cache: Enables response caching if configured on the application level. Valid values are ``True`` or a number + of seconds (e.g. ``120``) to cache the response. + cache_control: A ``cache-control`` header of type + :class:`CacheControlHeader <.datastructures.CacheControlHeader>` that will be added to the response. + cache_key_builder: A :class:`cache-key builder function <.types.CacheKeyBuilder>`. Allows for customization + of the cache key if caching is configured on the application level. + dependencies: A string keyed mapping of dependency :class:`Provider <.di.Provide>` instances. + dto: :class:`AbstractDTO <.dto.base_dto.AbstractDTO>` to use for (de)serializing and + validation of request data. + etag: An ``etag`` header of type :class:`ETag <.datastructures.ETag>` that will be added to the response. + exception_handlers: A mapping of status codes and/or exception types to handler functions. + guards: A sequence of :class:`Guard <.types.Guard>` callables. + media_type: A member of the :class:`MediaType <.enums.MediaType>` enum or a string with a + valid IANA Media-Type. + middleware: A sequence of :class:`Middleware <.types.Middleware>`. + name: A string identifying the route handler. + opt: A string keyed mapping of arbitrary values that can be accessed in :class:`Guards <.types.Guard>` or + wherever you have access to :class:`Request <.connection.Request>` or :class:`ASGI Scope <.types.Scope>`. + request_class: A custom subclass of :class:`Request <.connection.Request>` to be used as route handler's + default request. + request_max_body_size: Maximum allowed size of the request body in bytes. If this size is exceeded, + a '413 - Request Entity Too Large' error response is returned. + response_class: A custom subclass of :class:`Response <.response.Response>` to be used as route handler's + default response. + response_cookies: A sequence of :class:`Cookie <.datastructures.Cookie>` instances. + response_headers: A string keyed mapping of :class:`ResponseHeader <.datastructures.ResponseHeader>` + instances. + responses: A mapping of additional status codes and a description of their expected content. + This information will be included in the OpenAPI schema + return_dto: :class:`AbstractDTO <.dto.base_dto.AbstractDTO>` to use for serializing + outbound response data. + signature_namespace: A mapping of names to types for use in forward reference resolution during signature modelling. + status_code: An http status code for the response. Defaults to ``200`` for mixed method or ``GET``, ``PUT`` and + ``PATCH``, ``201`` for ``POST`` and ``204`` for ``DELETE``. + sync_to_thread: A boolean dictating whether the handler function will be executed in a worker thread or the + main event loop. This has an effect only for sync handler functions. See using sync handler functions. + content_encoding: A string describing the encoding of the content, e.g. ``base64``. + content_media_type: A string designating the media-type of the content, e.g. ``image/png``. + deprecated: A boolean dictating whether this route should be marked as deprecated in the OpenAPI schema. + description: Text used for the route's schema description section. + include_in_schema: A boolean flag dictating whether the route handler should be documented in the OpenAPI schema. + operation_class: :class:`Operation <.openapi.spec.operation.Operation>` to be used with the route's OpenAPI schema. + operation_id: Either a string or a callable returning a string. An identifier used for the route's schema operationId. + raises: A list of exception classes extending from litestar.HttpException that is used for the OpenAPI documentation. + This list should describe all exceptions raised within the route handler's function/method. The Litestar + ValidationException will be added automatically for the schema if any validation is involved. + response_description: Text used for the route's response schema description section. + security: A sequence of dictionaries that contain information about which security scheme can be used on the endpoint. + summary: Text used for the route's schema summary section. + tags: A sequence of string tags that will be appended to the OpenAPI schema. + type_decoders: A sequence of tuples, each composed of a predicate testing for type identity and a msgspec + hook for deserialization. + type_encoders: A mapping of types to callables that transform them into types supported for serialization. + handler_class: Route handler class instantiated by the decorator + **kwargs: Any additional kwarg - will be set in the opt dictionary. + """ + + def decorator(fn: AnyCallable) -> HTTPRouteHandler: + return handler_class( + fn=fn, + after_request=after_request, + after_response=after_response, + background=background, + before_request=before_request, + cache=cache, + cache_control=cache_control, + cache_key_builder=cache_key_builder, + content_encoding=content_encoding, + content_media_type=content_media_type, + dependencies=dependencies, + deprecated=deprecated, + description=description, + dto=dto, + etag=etag, + exception_handlers=exception_handlers, + guards=guards, + http_method=HttpMethod.QUERY, + include_in_schema=include_in_schema, + media_type=media_type, + middleware=middleware, + name=name, + operation_class=operation_class, + operation_id=operation_id, + opt=opt, + path=path, + raises=raises, + request_class=request_class, + request_max_body_size=request_max_body_size, + response_class=response_class, + response_cookies=response_cookies, + response_description=response_description, + response_headers=response_headers, + responses=responses, + return_dto=return_dto, + security=security, + signature_namespace=signature_namespace, + status_code=status_code, + summary=summary, + sync_to_thread=sync_to_thread, + tags=tags, + type_decoders=type_decoders, + type_encoders=type_encoders, + **kwargs, + ) + + return decorator + + def delete( path: str | None | Sequence[str] = None, *, diff --git a/litestar/testing/request_factory.py b/litestar/testing/request_factory.py index cda4870c43..f4147f72eb 100644 --- a/litestar/testing/request_factory.py +++ b/litestar/testing/request_factory.py @@ -10,7 +10,7 @@ from httpx._content import encode_json as httpx_encode_json from httpx._content import encode_multipart_data, encode_urlencoded_data -from litestar import delete, patch, post, put +from litestar import delete, patch, post, put, query from litestar.app import Litestar from litestar.connection import Request from litestar.enums import HttpMethod, ParamType, RequestEncodingType, ScopeType @@ -33,6 +33,7 @@ HttpMethod.DELETE: delete, HttpMethod.PATCH: patch, HttpMethod.PUT: put, + HttpMethod.QUERY: query, } @@ -520,6 +521,60 @@ def patch( route_handler=route_handler, ) + def query( + self, + path: str = "/", + headers: dict[str, str] | None = None, + cookies: list[Cookie] | str | None = None, + session: dict[str, Any] | None = None, + user: Any = None, + auth: Any = None, + request_media_type: RequestEncodingType = RequestEncodingType.JSON, + data: dict[str, Any] | DataContainerType | None = None, + query_params: dict[str, str | list[str]] | None = None, + state: dict[str, Any] | None = None, + path_params: dict[str, str] | None = None, + http_version: str | None = "1.1", + route_handler: RouteHandlerType | None = None, + ) -> Request[Any, Any, Any]: + """Create a QUERY :class:`Request ` instance. + + Args: + path: The request's path. + headers: A dictionary of headers. + cookies: A string representing the cookie header or a list of "Cookie" instances. + This value can include multiple cookies. + session: A dictionary of session data. + user: A value for `request.scope["user"]`. + auth: A value for `request.scope["auth"]`. + request_media_type: The 'Content-Type' header of the request. + data: A value for the request's body. Can be any supported serializable type. + query_params: A dictionary of values from which the request's query will be generated. + state: Arbitrary request state. + path_params: A string keyed dictionary of path parameter values. + http_version: HTTP version. Defaults to "1.1". + route_handler: A route handler instance or method. If not provided a default handler is set. + + Returns: + A :class:`Request ` instance + """ + return self._create_request_with_data( + auth=auth, + cookies=cookies, + data=data, + headers=headers, + http_method=HttpMethod.QUERY, + path=path, + query_params=query_params, + request_media_type=request_media_type, + session=session, + user=user, + state=state, + path_params=path_params, + http_version=http_version, + route_handler=route_handler, + ) + def delete( self, path: str = "/", diff --git a/litestar/types/asgi_types.py b/litestar/types/asgi_types.py index e77f25e3d0..a43ac69c17 100644 --- a/litestar/types/asgi_types.py +++ b/litestar/types/asgi_types.py @@ -100,7 +100,7 @@ from .internal_types import RouteHandlerType from .serialization import DataContainerType -HttpMethodName: TypeAlias = Literal["GET", "POST", "DELETE", "PATCH", "PUT", "HEAD", "TRACE", "OPTIONS"] +HttpMethodName: TypeAlias = Literal["GET", "POST", "DELETE", "PATCH", "PUT", "QUERY", "HEAD", "TRACE", "OPTIONS"] Method: TypeAlias = Union[HttpMethodName, HttpMethod] ScopeSession: TypeAlias = "EmptyType | dict[str, Any] | DataContainerType | None" diff --git a/tests/e2e/test_query_method.py b/tests/e2e/test_query_method.py new file mode 100644 index 0000000000..5db044fcbc --- /dev/null +++ b/tests/e2e/test_query_method.py @@ -0,0 +1,68 @@ +from typing import Any, get_args + +from litestar import HttpMethod, query +from litestar.handlers import query as handlers_query +from litestar.handlers.http_handlers import query as http_handlers_query +from litestar.status_codes import HTTP_413_REQUEST_ENTITY_TOO_LARGE +from litestar.testing import create_test_client +from litestar.types.asgi_types import HttpMethodName + + +def test_query_public_api_exports() -> None: + assert HttpMethod.QUERY == "QUERY" + assert "QUERY" in get_args(HttpMethodName) + assert handlers_query is query + assert http_handlers_query is query + + +def test_query_decorator_configures_route_handler() -> None: + @query("/search", sync_to_thread=False) + def handler() -> dict[str, str]: + return {"method": "QUERY"} + + assert handler.http_methods == {"QUERY"} + assert handler.include_in_schema is False + + +def test_query_route_handler() -> None: + @query("/search", sync_to_thread=False) + def handler() -> dict[str, str]: + return {"method": "QUERY"} + + with create_test_client(route_handlers=[handler], openapi_config=None) as client: + response = client.request("QUERY", "/search") + + assert response.status_code == 200 + assert response.json() == {"method": "QUERY"} + + +def test_query_route_handler_receives_json_body() -> None: + @query("/search", sync_to_thread=False) + def handler(data: dict[str, Any]) -> dict[str, Any]: + return data + + with create_test_client(route_handlers=[handler], openapi_config=None) as client: + response = client.request("QUERY", "/search", json={"term": "litestar"}) + + assert response.status_code == 200 + assert response.json() == {"term": "litestar"} + + +def test_query_route_handler_respects_request_max_body_size() -> None: + @query("/search", request_max_body_size=1, sync_to_thread=False) + def handler(data: dict[str, Any]) -> dict[str, Any]: + return data + + with create_test_client(route_handlers=[handler], openapi_config=None) as client: + response = client.request("QUERY", "/search", json={"term": "litestar"}) + + assert response.status_code == HTTP_413_REQUEST_ENTITY_TOO_LARGE + + +def test_query_route_handler_is_excluded_from_openapi_schema_by_default() -> None: + @query("/search", sync_to_thread=False) + def handler() -> dict[str, str]: + return {"method": "QUERY"} + + with create_test_client(route_handlers=[handler]) as client: + assert "/search" not in client.app.openapi_schema.to_schema()["paths"] diff --git a/tests/unit/test_data_extractors.py b/tests/unit/test_data_extractors.py index 99f24ba413..88477bcc64 100644 --- a/tests/unit/test_data_extractors.py +++ b/tests/unit/test_data_extractors.py @@ -58,6 +58,12 @@ async def test_parse_json_data() -> None: assert await ConnectionDataExtractor()(request).get("body") == await request.body() # type: ignore[misc] +async def test_parse_query_method_json_data() -> None: + request = factory.query(path="/a/b/c", data={"hello": "world"}) + assert request.method == "QUERY" + assert await ConnectionDataExtractor(parse_body=True)(request).get("body") == await request.json() # type: ignore[misc] + + async def test_parse_form_data() -> None: request = factory.post(path="/a/b/c", data={"file": b"123"}, request_media_type=RequestEncodingType.MULTI_PART) assert await ConnectionDataExtractor(parse_body=True)(request).get("body") == dict(await request.form()) # type: ignore[misc] diff --git a/tests/unit/test_middleware/test_csrf_middleware.py b/tests/unit/test_middleware/test_csrf_middleware.py index d8e34ba15b..fbf41506c3 100644 --- a/tests/unit/test_middleware/test_csrf_middleware.py +++ b/tests/unit/test_middleware/test_csrf_middleware.py @@ -6,7 +6,7 @@ import pytest from bs4 import BeautifulSoup -from litestar import MediaType, WebSocket, delete, get, patch, post, put, websocket +from litestar import MediaType, WebSocket, delete, get, patch, post, put, query, websocket from litestar.config.csrf import CSRFConfig from litestar.handlers import HTTPRouteHandler from litestar.params import URLEncodedBody @@ -47,6 +47,11 @@ def patch_handler() -> HTTPRouteHandler: return patch()(handler_fn) +@pytest.fixture +def query_handler() -> HTTPRouteHandler: + return query()(handler_fn) + + def test_csrf_successful_flow(get_handler: HTTPRouteHandler, post_handler: HTTPRouteHandler) -> None: with create_test_client( route_handlers=[get_handler, post_handler], csrf_config=CSRFConfig(secret="secret") @@ -96,6 +101,19 @@ def test_unsafe_method_fails_without_csrf_header( assert response.json() == {"detail": "CSRF token verification failed", "status_code": 403} +def test_query_method_is_safe_by_default(get_handler: HTTPRouteHandler, query_handler: HTTPRouteHandler) -> None: + with create_test_client( + route_handlers=[get_handler, query_handler], + csrf_config=CSRFConfig(secret="secret"), + openapi_config=None, + ) as client: + response = client.get("/") + assert response.status_code == HTTP_200_OK + + response = client.request("QUERY", "/") + assert response.status_code == HTTP_200_OK + + def test_invalid_csrf_token(get_handler: HTTPRouteHandler, post_handler: HTTPRouteHandler) -> None: with create_test_client( route_handlers=[get_handler, post_handler], csrf_config=CSRFConfig(secret="secret") diff --git a/tests/unit/test_openapi/test_path_item.py b/tests/unit/test_openapi/test_path_item.py index f99b49fffd..fd3509e305 100644 --- a/tests/unit/test_openapi/test_path_item.py +++ b/tests/unit/test_openapi/test_path_item.py @@ -224,7 +224,7 @@ def handler_2() -> None: ... assert schema.delete is None -@pytest.mark.parametrize("method", HttpMethod) +@pytest.mark.parametrize("method", [method for method in HttpMethod if method is not HttpMethod.QUERY]) def test_merge_path_item_operations_operation_set_on_both_raises(method: HttpMethod) -> None: with pytest.raises(ValueError, match="Cannot merge operation"): merge_path_item_operations( @@ -243,6 +243,7 @@ def test_merge_path_item_operations_operation_set_on_both_raises(method: HttpMet not in [ *HttpMethod, "TRACE", # remove once https://github.com/litestar-org/litestar/pull/3294 is merged + "QUERY", # remove when OpenAPI 3.2 path items are supported ] ], )