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
14 changes: 10 additions & 4 deletions litestar/_openapi/responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,6 @@
Response as LitestarResponse,
)
from litestar.response.base import ASGIResponse
from litestar.types.builtin_types import NoneType
from litestar.typing import FieldDefinition
from litestar.utils import get_enum_string_value, get_name

Expand Down Expand Up @@ -120,12 +119,12 @@ def create_description(self) -> str:

def create_success_response(self) -> OpenAPIResponse:
"""Create the schema for a success response."""
if self.field_definition.is_subclass_of((NoneType, ASGIResponse)):
response = OpenAPIResponse(content=None, description=self.create_description())
elif self.field_definition.is_subclass_of(Redirect):
if self.field_definition.is_subclass_of(Redirect):
response = self.create_redirect_response()
elif self.field_definition.is_subclass_of(File):
response = self.create_file_response()
elif self.field_definition.is_subclass_of(ASGIResponse) or not self.route_handler.returns_content:
response = self.create_empty_response()
Comment on lines +122 to +127
Copy link
Copy Markdown
Author

@spietras spietras Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redirect, File are checked first, because they also might fall into returns_content being False, but they need their own special handling.

Checking ASGIResponse is still there, because I guess the assumption is that you can't infer any meaningful content schema for it, so you fall back to specifying there is no content. And it works like that regardless of whether there will be actual content in the response or not.

Checking NoneType is removed, because None doesn't necessarily mean there is no content. It also depends on the status code and method. GET with 200 returning None will include null in the actual response, and now it will also include content in OpenAPI specification.

else:
media_type = self.route_handler.media_type

Expand Down Expand Up @@ -163,6 +162,13 @@ def create_success_response(self) -> OpenAPIResponse:
self.set_success_response_headers(response)
return response

def create_empty_response(self) -> OpenAPIResponse:
"""Create the schema for a response with no content."""
return OpenAPIResponse(
content=None,
description=self.create_description(),
)

def create_redirect_response(self) -> OpenAPIResponse:
"""Create the schema for a redirect response."""
return OpenAPIResponse(
Expand Down
38 changes: 19 additions & 19 deletions litestar/handlers/http_handlers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,10 @@
Send,
TypeEncodersMap,
)
from litestar.types.builtin_types import NoneType
from litestar.utils import deprecated as litestar_deprecated
from litestar.utils import ensure_async_callable
from litestar.utils.empty import value_or_default
from litestar.utils.predicates import is_async_callable, is_class_and_subclass
from litestar.utils.predicates import is_async_callable
from litestar.utils.scope.state import ScopeState
from litestar.utils.warnings import warn_implicit_sync_to_thread, warn_sync_to_thread_with_async_callable

Expand Down Expand Up @@ -531,6 +530,15 @@ def resolve_request_max_body_size(self) -> int | None:
def request_max_body_size(self) -> int | None:
return value_or_default(self._request_max_body_size, None) # pyright: ignore

@property
def returns_content(self) -> bool:
"""Whether the route handler returns any content in the response body."""
return not (
self.status_code < 200
or self.status_code in {HTTP_204_NO_CONTENT, HTTP_304_NOT_MODIFIED}
or self.http_methods == {HttpMethod.HEAD}
)
Comment on lines +533 to +540
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know what the best place for this logic is. For now, I assumed that the handler is the owner of this information, because below it also validates the annotation based on the same logic.


def on_registration(self, route: BaseRoute, app: Litestar) -> None:
super().on_registration(route=route, app=app)

Expand Down Expand Up @@ -579,6 +587,15 @@ def _validate_handler_function(self) -> None:
f"If {self} should return a value, change the route handler status code to an appropriate value.",
)

if self.http_methods == {HttpMethod.HEAD} and not (
is_empty_response_annotation(return_type)
or return_type.is_subclass_of(File)
or return_type.is_subclass_of(ASGIFileResponse)
):
raise ImproperlyConfiguredException(
f"{self}: Handlers for 'HEAD' requests must not return a value. Either return 'None' or a response type without a body."
)

if not self.media_type:
if return_type.is_subclass_of((str, bytes)) or return_type.annotation is AnyStr:
self.media_type = MediaType.TEXT
Expand All @@ -591,23 +608,6 @@ def _validate_handler_function(self) -> None:
if "data" in self.parsed_fn_signature.parameters and "GET" in self.http_methods:
raise ImproperlyConfiguredException("'data' kwarg is unsupported for 'GET' request handlers")

if self.http_methods == {HttpMethod.HEAD} and not self.parsed_fn_signature.return_type.is_subclass_of(
(
NoneType,
File,
ASGIFileResponse,
)
):
field_definition = self.parsed_fn_signature.return_type
if not (
is_empty_response_annotation(field_definition)
or is_class_and_subclass(field_definition.annotation, File)
or is_class_and_subclass(field_definition.annotation, ASGIFileResponse)
):
raise ImproperlyConfiguredException(
f"{self}: Handlers for 'HEAD' requests must not return a value. Either return 'None' or a response type without a body."
)

Comment on lines 590 to -610
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know why this check was so complicated before. I simplified it, and I think it does the same as before? Correct me if I'm missing something.

I also moved it be closer to the other check with status codes, as both of them are about empty annotations.

if (body_param := self.parsed_fn_signature.parameters.get("body")) and not body_param.is_subclass_of(bytes):
raise ImproperlyConfiguredException(
f"Invalid type annotation for 'body' parameter in route handler {self}. 'body' will always receive the "
Expand Down
27 changes: 9 additions & 18 deletions tests/unit/test_openapi/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,24 +55,15 @@ def handler_2() -> None:
openapi_config=OpenAPIConfig(title="my title", version="1.0.0", operation_id_creator=operation_id_creator),
)

assert app.openapi_schema.to_schema()["paths"] == {
"/1": {
"get": {
"deprecated": False,
"operationId": "id_x",
"responses": {"200": {"description": "Request fulfilled, document follows", "headers": {}}},
"summary": "Handler1",
}
},
"/2": {
"get": {
"deprecated": False,
"operationId": "id_y",
"responses": {"200": {"description": "Request fulfilled, document follows", "headers": {}}},
"summary": "Handler2",
}
},
}
Comment on lines -58 to -75
Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These handlers are annotated with None, but they actually return null. So now the OpenAPI specification correctly includes a schema for the response with null. So I would need to add this here.

But I think it's pointless to compare the whole thing anyway. This test only cares about customising operation IDs, so I restricted it to check only that.

And the test whether handlers like this include null content schema in response specifications is added in another place.

assert app.openapi_schema.paths is not None

assert "/1" in app.openapi_schema.paths
assert app.openapi_schema.paths["/1"].get is not None
assert app.openapi_schema.paths["/1"].get.operation_id == "id_x"

assert "/2" in app.openapi_schema.paths
assert app.openapi_schema.paths["/2"].get is not None
assert app.openapi_schema.paths["/2"].get.operation_id == "id_y"


def test_raises_exception_when_no_config_in_place() -> None:
Expand Down
72 changes: 71 additions & 1 deletion tests/unit/test_openapi/test_responses.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import pytest
from typing_extensions import TypeAlias

from litestar import Controller, Litestar, MediaType, Response, delete, get, post
from litestar import Controller, Litestar, MediaType, Response, delete, get, head, post
from litestar._openapi.datastructures import OpenAPIContext
from litestar._openapi.responses import (
ResponseFactory,
Expand All @@ -30,6 +30,7 @@
from litestar.openapi.spec import Example, OpenAPIHeader, OpenAPIMediaType, OpenAPIResponse, Reference, Schema
from litestar.openapi.spec.enums import OpenAPIType
from litestar.response import File, Redirect, Stream, Template
from litestar.response.base import ASGIResponse
from litestar.routes import HTTPRoute
from litestar.status_codes import (
HTTP_200_OK,
Expand Down Expand Up @@ -290,6 +291,75 @@ def redirect_handler() -> Redirect:
assert location.description


def test_create_success_response_asgi_response(create_factory: CreateFactoryFixture) -> None:
@get(path="/test", name="test")
def handler() -> ASGIResponse:
return ASGIResponse()

handler = get_registered_route_handler(handler, "test")
response = create_factory(handler, True).create_success_response()

assert response.content is None


def test_create_success_response_none(create_factory: CreateFactoryFixture) -> None:
@get(path="/test", name="test")
def handler() -> None:
return None

handler = get_registered_route_handler(handler, "test")
response = create_factory(handler, True).create_success_response()

assert response.content
schema = response.content[handler.media_type].schema
assert isinstance(schema, Schema)
assert schema.type == OpenAPIType.NULL


def test_create_success_response_none_no_content(create_factory: CreateFactoryFixture) -> None:
@get(path="/test", status_code=HTTP_204_NO_CONTENT, name="test")
def handler() -> None:
return None

handler = get_registered_route_handler(handler, "test")
response = create_factory(handler, True).create_success_response()

assert response.content is None


def test_create_success_response_none_head(create_factory: CreateFactoryFixture) -> None:
@head(path="/test", name="test")
def handler() -> None:
return None

handler = get_registered_route_handler(handler, "test")
response = create_factory(handler, True).create_success_response()

assert response.content is None


def test_create_success_response_response_none_no_content(create_factory: CreateFactoryFixture) -> None:
@get(path="/test", status_code=HTTP_204_NO_CONTENT, name="test")
def handler() -> Response[None]:
return Response(None)

handler = get_registered_route_handler(handler, "test")
response = create_factory(handler, True).create_success_response()

assert response.content is None


def test_create_success_response_response_none_head(create_factory: CreateFactoryFixture) -> None:
@head(path="/test", name="test")
def handler() -> Response[None]:
return Response(None)

handler = get_registered_route_handler(handler, "test")
response = create_factory(handler, True).create_success_response()

assert response.content is None


def test_create_success_response_no_content_explicit_responsespec(
create_factory: CreateFactoryFixture,
) -> None:
Expand Down
Loading