diff --git a/fastapi/openapi/utils.py b/fastapi/openapi/utils.py
index 1c7a17c4ca..71e9be2c60 100644
--- a/fastapi/openapi/utils.py
+++ b/fastapi/openapi/utils.py
@@ -337,76 +337,93 @@ def get_openapi_path(
)
callbacks[callback.name] = {callback.path: cb_path}
operation["callbacks"] = callbacks
- if route.status_code is not None:
- status_code = str(route.status_code)
+
+ if route.return_response_classes:
+ if route.return_response_models:
+ response_classes = (
+ current_response_class,
+ *route.return_response_classes,
+ )
+ else:
+ response_classes = tuple(route.return_response_classes)
else:
- # It would probably make more sense for all response classes to have an
- # explicit default status_code, and to extract it from them, instead of
- # doing this inspection tricks, that would probably be in the future
- # TODO: probably make status_code a default class attribute for all
- # responses in Starlette
- response_signature = inspect.signature(current_response_class.__init__)
- status_code_param = response_signature.parameters.get("status_code")
- if status_code_param is not None:
- if isinstance(status_code_param.default, int):
- status_code = str(status_code_param.default)
- operation.setdefault("responses", {}).setdefault(status_code, {})[
- "description"
- ] = route.response_description
- if is_body_allowed_for_status_code(route.status_code):
- # Check for JSONL streaming (generator endpoints)
- if route.is_json_stream:
- jsonl_content: dict[str, Any] = {}
- if route.stream_item_field:
- item_schema = get_schema_from_model_field(
- field=route.stream_item_field,
- model_name_map=model_name_map,
- field_mapping=field_mapping,
- separate_input_output_schemas=separate_input_output_schemas,
- )
- jsonl_content["itemSchema"] = item_schema
- else:
- jsonl_content["itemSchema"] = {}
- operation.setdefault("responses", {}).setdefault(
- status_code, {}
- ).setdefault("content", {})["application/jsonl"] = jsonl_content
- elif route.is_sse_stream:
- sse_content: dict[str, Any] = {}
- item_schema = copy.deepcopy(_SSE_EVENT_SCHEMA)
- if route.stream_item_field:
- content_schema = get_schema_from_model_field(
- field=route.stream_item_field,
- model_name_map=model_name_map,
- field_mapping=field_mapping,
- separate_input_output_schemas=separate_input_output_schemas,
- )
- item_schema["required"] = ["data"]
- item_schema["properties"]["data"] = {
- "type": "string",
- "contentMediaType": "application/json",
- "contentSchema": content_schema,
- }
- sse_content["itemSchema"] = item_schema
- operation.setdefault("responses", {}).setdefault(
- status_code, {}
- ).setdefault("content", {})["text/event-stream"] = sse_content
- elif route_response_media_type:
- response_schema = {"type": "string"}
- if lenient_issubclass(current_response_class, JSONResponse):
- if route.response_field:
- response_schema = get_schema_from_model_field(
- field=route.response_field,
+ response_classes = (current_response_class,)
+
+ for response_class in response_classes:
+ if route.status_code is not None:
+ status_code = str(route.status_code)
+ else:
+ # It would probably make more sense for all response classes to have an
+ # explicit default status_code, and to extract it from them, instead of
+ # doing this inspection tricks, that would probably be in the future
+ # TODO: probably make status_code a default class attribute for all
+ # responses in Starlette
+ response_signature = inspect.signature(response_class.__init__)
+ status_code_param = response_signature.parameters.get("status_code")
+ if status_code_param is not None:
+ if isinstance(status_code_param.default, int):
+ status_code = str(status_code_param.default)
+ operation.setdefault("responses", {}).setdefault(status_code, {})[
+ "description"
+ ] = route.response_description
+
+ if is_body_allowed_for_status_code(route.status_code):
+ return_response_media_type: str | None = response_class.media_type
+
+ # Check for JSONL streaming (generator endpoints)
+ if route.is_json_stream:
+ jsonl_content: dict[str, Any] = {}
+ if route.stream_item_field:
+ item_schema = get_schema_from_model_field(
+ field=route.stream_item_field,
model_name_map=model_name_map,
field_mapping=field_mapping,
separate_input_output_schemas=separate_input_output_schemas,
)
+ jsonl_content["itemSchema"] = item_schema
else:
- response_schema = {}
- operation.setdefault("responses", {}).setdefault(
- status_code, {}
- ).setdefault("content", {}).setdefault(
- route_response_media_type, {}
- )["schema"] = response_schema
+ jsonl_content["itemSchema"] = {}
+ operation.setdefault("responses", {}).setdefault(
+ status_code, {}
+ ).setdefault("content", {})["application/jsonl"] = jsonl_content
+ elif route.is_sse_stream:
+ sse_content: dict[str, Any] = {}
+ item_schema = copy.deepcopy(_SSE_EVENT_SCHEMA)
+ if route.stream_item_field:
+ content_schema = get_schema_from_model_field(
+ field=route.stream_item_field,
+ model_name_map=model_name_map,
+ field_mapping=field_mapping,
+ separate_input_output_schemas=separate_input_output_schemas,
+ )
+ item_schema["required"] = ["data"]
+ item_schema["properties"]["data"] = {
+ "type": "string",
+ "contentMediaType": "application/json",
+ "contentSchema": content_schema,
+ }
+ sse_content["itemSchema"] = item_schema
+ operation.setdefault("responses", {}).setdefault(
+ status_code, {}
+ ).setdefault("content", {})["text/event-stream"] = sse_content
+ elif return_response_media_type:
+ response_schema = {"type": "string"}
+ if lenient_issubclass(response_class, JSONResponse):
+ if route.response_field:
+ response_schema = get_schema_from_model_field(
+ field=route.response_field,
+ model_name_map=model_name_map,
+ field_mapping=field_mapping,
+ separate_input_output_schemas=separate_input_output_schemas,
+ )
+ else:
+ response_schema = {}
+ operation.setdefault("responses", {}).setdefault(
+ status_code, {}
+ ).setdefault("content", {}).setdefault(
+ return_response_media_type, {}
+ )["schema"] = response_schema
+
if route.responses:
operation_responses = operation.setdefault("responses", {})
for (
diff --git a/fastapi/routing.py b/fastapi/routing.py
index 21a1385a27..7ba25c6b10 100644
--- a/fastapi/routing.py
+++ b/fastapi/routing.py
@@ -3,6 +3,7 @@ import email.message
import functools
import inspect
import json
+import operator
import types
from collections.abc import (
AsyncIterator,
@@ -26,7 +27,10 @@ from typing import (
Annotated,
Any,
TypeVar,
+ Union,
cast,
+ get_args,
+ get_origin,
)
import anyio
@@ -65,7 +69,7 @@ from fastapi.sse import (
ServerSentEvent,
format_sse_event,
)
-from fastapi.types import DecoratedCallable, IncEx
+from fastapi.types import DecoratedCallable, IncEx, UnionType
from fastapi.utils import (
create_model_field,
generate_unique_id,
@@ -844,27 +848,54 @@ class APIRoute(routing.Route):
self.path = path
self.endpoint = endpoint
self.stream_item_type: Any | None = None
+ self.return_response_models = []
+ self.return_response_classes = []
+
if isinstance(response_model, DefaultPlaceholder):
return_annotation = get_typed_return_annotation(endpoint)
- if lenient_issubclass(return_annotation, Response):
+ stream_item = get_stream_item_type(return_annotation)
+
+ if stream_item is not None:
+ # Extract item type for JSONL or SSE streaming when
+ # response_class is DefaultPlaceholder (JSONL) or
+ # EventSourceResponse (SSE).
+ # ServerSentEvent is excluded: it's a transport
+ # wrapper, not a data model, so it shouldn't feed
+ # into validation or OpenAPI schema generation.
+ if (
+ isinstance(response_class, DefaultPlaceholder)
+ or lenient_issubclass(response_class, EventSourceResponse)
+ ) and not lenient_issubclass(stream_item, ServerSentEvent):
+ self.stream_item_type = stream_item
+
response_model = None
else:
- stream_item = get_stream_item_type(return_annotation)
- if stream_item is not None:
- # Extract item type for JSONL or SSE streaming when
- # response_class is DefaultPlaceholder (JSONL) or
- # EventSourceResponse (SSE).
- # ServerSentEvent is excluded: it's a transport
- # wrapper, not a data model, so it shouldn't feed
- # into validation or OpenAPI schema generation.
- if (
- isinstance(response_class, DefaultPlaceholder)
- or lenient_issubclass(response_class, EventSourceResponse)
- ) and not lenient_issubclass(stream_item, ServerSentEvent):
- self.stream_item_type = stream_item
- response_model = None
+ origin = get_origin(return_annotation)
+
+ if origin is Union or origin is UnionType:
+ for arg in get_args(return_annotation):
+ if arg is type(None):
+ continue
+
+ if lenient_issubclass(arg, Response):
+ self.return_response_classes.append(arg)
+ else:
+ self.return_response_models.append(arg)
+ elif lenient_issubclass(return_annotation, Response):
+ self.return_response_classes.append(return_annotation)
else:
- response_model = return_annotation
+ self.return_response_models.append(return_annotation)
+
+ if self.return_response_models:
+ if len(self.return_response_models) == 1:
+ response_model = self.return_response_models[0]
+ else:
+ response_model = functools.reduce(
+ operator.or_, self.return_response_models
+ )
+ else:
+ response_model = None
+
self.response_model = response_model
self.summary = summary
self.response_description = response_description
diff --git a/tests/test_response_class_as_return_annotation.py b/tests/test_response_class_as_return_annotation.py
new file mode 100644
index 0000000000..940b7ef293
--- /dev/null
+++ b/tests/test_response_class_as_return_annotation.py
@@ -0,0 +1,1053 @@
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+from inline_snapshot import snapshot
+from pydantic import BaseModel
+from starlette.responses import (
+ FileResponse,
+ HTMLResponse,
+ JSONResponse,
+ PlainTextResponse,
+ RedirectResponse,
+ Response,
+ StreamingResponse,
+)
+
+app = FastAPI()
+
+
+class Item(BaseModel):
+ name: str
+
+
+class User(BaseModel):
+ username: str
+ age: int
+
+
+@app.get("/none")
+def none() -> None:
+ return None
+
+
+@app.get("/response")
+def response() -> Response:
+ return Response(content="ok")
+
+
+@app.get("/response-or-none")
+def response_or_none() -> Response | None:
+ return Response(content="ok")
+
+
+@app.get("/plaintext-response")
+def plaintext_response() -> PlainTextResponse:
+ return PlainTextResponse("ok")
+
+
+@app.get("/html-response")
+def html_response() -> HTMLResponse:
+ return HTMLResponse("
ok
")
+
+
+@app.get("/none-or-html-response")
+def none_or_html_response() -> None | HTMLResponse:
+ return HTMLResponse("ok
")
+
+
+@app.get("/json-response")
+def json_response() -> JSONResponse:
+ return JSONResponse({"x": 1})
+
+
+@app.get("/json-response-or-none")
+def json_response_or_none() -> JSONResponse | None:
+ return JSONResponse({"x": 1})
+
+
+@app.get("/streaming-response")
+def streaming_response() -> StreamingResponse:
+ return StreamingResponse(iter((b"x",)))
+
+
+@app.get("/file-response")
+def file_response() -> FileResponse:
+ return FileResponse(__file__)
+
+
+@app.get("/redirect-response")
+def redirect_response() -> RedirectResponse:
+ return RedirectResponse("https://example.com")
+
+
+@app.get("/response-or-item-response")
+def response_or_item_response() -> Response | Item:
+ return JSONResponse({"name": "foo"})
+
+
+@app.get("/response-or-item-dict")
+def response_or_item_dict() -> Response | Item:
+ return {"name": "foo"}
+
+
+@app.get("/response-or-item-model")
+def response_or_item_model() -> Response | Item:
+ return Item(name="foo")
+
+
+@app.get("/item-or-html-response")
+def item_or_html_response() -> Item | HTMLResponse:
+ return HTMLResponse("ok
")
+
+
+@app.get("/item-or-html-response-or-none")
+def item_or_html_response_or_none() -> Item | HTMLResponse | None:
+ return HTMLResponse("ok
")
+
+
+@app.get("/plaintext-response-or-html-response")
+def plaintext_response_or_html_response() -> PlainTextResponse | HTMLResponse:
+ return PlainTextResponse("ok")
+
+
+@app.get("/html-response-or-plaintext-response")
+def html_response_or_plaintext_response() -> HTMLResponse | PlainTextResponse:
+ return HTMLResponse("ok
")
+
+
+@app.get("/html-response-or-plaintext-response-or-none")
+def html_response_or_plaintext_response_or_none() -> (
+ HTMLResponse | PlainTextResponse | None
+):
+ return HTMLResponse("ok
")
+
+
+@app.get("/three-responses")
+def three_responses() -> PlainTextResponse | HTMLResponse | JSONResponse:
+ return JSONResponse({"x": 1})
+
+
+@app.get("/three-responses-or-none")
+def three_responses_or_none() -> JSONResponse | None | HTMLResponse | PlainTextResponse:
+ return JSONResponse({"x": 1})
+
+
+@app.get("/two-models-or-response")
+def two_models_or_response() -> Item | Response | User:
+ return JSONResponse({"name": "foo"})
+
+
+@app.get("/two-models-or-html-response")
+def two_models_or_html_response() -> User | Item | HTMLResponse:
+ return HTMLResponse("ok
")
+
+
+@app.get("/two-models-or-two-responses")
+def two_models_or_two_responses() -> Item | PlainTextResponse | User | RedirectResponse:
+ return User(username="foo", age=42)
+
+
+@app.get("/two-models-or-two-responses-or-none")
+def two_models_or_two_responses_or_none() -> (
+ User | RedirectResponse | None | Item | PlainTextResponse
+):
+ return User(username="foo", age=42)
+
+
+@app.get(
+ "/decorator-response-matches-return-response",
+ response_class=HTMLResponse,
+)
+def decorator_response_matches_return_response() -> HTMLResponse:
+ return HTMLResponse("ok
")
+
+
+@app.get(
+ "/decorator-response-differs-from-return-response",
+ response_class=PlainTextResponse,
+)
+def decorator_response_differs_from_return_response() -> HTMLResponse:
+ return HTMLResponse("ok
")
+
+
+@app.get(
+ "/decorator-response-in-return-response-union",
+ response_class=HTMLResponse,
+)
+def decorator_response_in_return_response_union() -> HTMLResponse | PlainTextResponse:
+ return HTMLResponse("ok
")
+
+
+@app.get(
+ "/decorator-response-not-in-return-response-union",
+ response_class=RedirectResponse,
+)
+def decorator_response_not_in_return_response_union() -> (
+ PlainTextResponse | JSONResponse
+):
+ return JSONResponse({"x": 1})
+
+
+@app.get(
+ "/decorator-response-with-str-in-return-union-response",
+ response_class=PlainTextResponse,
+)
+def decorator_response_with_str_in_return_union_response() -> HTMLResponse | str:
+ return HTMLResponse("ok
")
+
+
+@app.get(
+ "/decorator-response-with-str-in-return-union-str",
+ response_class=PlainTextResponse,
+)
+def decorator_response_with_str_in_return_union_str() -> HTMLResponse | str:
+ return "ok"
+
+
+@app.get(
+ "/decorator-response-with-dict-in-return-union",
+ response_class=JSONResponse,
+)
+def decorator_response_with_dict_in_return_union() -> RedirectResponse | dict[str, str]:
+ return {"name": "foo"}
+
+
+@app.get(
+ "/decorator-response-with-model-in-return-union",
+ response_class=JSONResponse,
+)
+def decorator_response_with_model_in_return_union() -> RedirectResponse | User:
+ return User(username="foo", age=42)
+
+
+@app.get(
+ "/decorator-response-with-model-and-int-in-return-union",
+ response_class=JSONResponse,
+)
+def decorator_response_with_multiple_non_response_in_return_union() -> (
+ User | int | PlainTextResponse
+):
+ return 1
+
+
+@app.get(
+ "/decorator-response-with-model-and-str-in-return-union",
+ response_class=JSONResponse,
+)
+def decorator_response_with_model_and_str_in_return_union() -> (
+ User | PlainTextResponse | str
+):
+ return "ok"
+
+
+client = TestClient(app)
+
+
+def test_none():
+ response = client.get("/none")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() is None
+
+
+def test_response():
+ response = client.get("/response")
+ assert response.status_code == 200
+ assert "content-type" not in response.headers
+ assert response.text == "ok"
+
+
+def test_response_or_none():
+ response = client.get("/response-or-none")
+ assert response.status_code == 200
+ assert "content-type" not in response.headers
+ assert response.text == "ok"
+
+
+def test_plaintext_response():
+ response = client.get("/plaintext-response")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/plain")
+ assert response.text == "ok"
+
+
+def test_html_response():
+ response = client.get("/html-response")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ assert response.text == "ok
"
+
+
+def test_none_or_html_response():
+ response = client.get("/none-or-html-response")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ assert response.text == "ok
"
+
+
+def test_json_response():
+ response = client.get("/json-response")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() == {"x": 1}
+
+
+def test_json_response_or_none():
+ response = client.get("/json-response-or-none")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() == {"x": 1}
+
+
+def test_streaming_response():
+ response = client.get("/streaming-response")
+ assert response.status_code == 200
+ assert "content-type" not in response.headers
+
+
+def test_file_response():
+ response = client.get("/file-response")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/x-python")
+
+
+def test_redirect_response():
+ response = client.get("/redirect-response", follow_redirects=False)
+ assert response.status_code == 307
+ assert "content-type" not in response.headers
+
+
+def test_response_or_item_response():
+ response = client.get("/response-or-item-response")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() == {"name": "foo"}
+
+
+def test_response_or_item_dict():
+ response = client.get("/response-or-item-dict")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() == {"name": "foo"}
+
+
+def test_response_or_item_model():
+ response = client.get("/response-or-item-model")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() == {"name": "foo"}
+
+
+def test_item_or_html_response():
+ response = client.get("/item-or-html-response")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ assert response.text == "ok
"
+
+
+def test_item_or_html_response_or_none():
+ response = client.get("/item-or-html-response-or-none")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ assert response.text == "ok
"
+
+
+def test_plaintext_response_or_html_response():
+ response = client.get("/plaintext-response-or-html-response")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/plain")
+ assert response.text == "ok"
+
+
+def test_html_response_or_plaintext_response():
+ response = client.get("/html-response-or-plaintext-response")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ assert response.text == "ok
"
+
+
+def test_html_response_or_plaintext_response_or_none():
+ response = client.get("/html-response-or-plaintext-response-or-none")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ assert response.text == "ok
"
+
+
+def test_three_responses():
+ response = client.get("/three-responses")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() == {"x": 1}
+
+
+def test_three_responses_or_none():
+ response = client.get("/three-responses-or-none")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() == {"x": 1}
+
+
+def test_two_models_or_response():
+ response = client.get("/two-models-or-response")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() == {"name": "foo"}
+
+
+def test_two_models_or_html_response():
+ response = client.get("/two-models-or-html-response")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ assert response.text == "ok
"
+
+
+def test_two_models_or_two_responses():
+ response = client.get("/two-models-or-two-responses")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() == {"username": "foo", "age": 42}
+
+
+def test_two_models_or_two_responses_or_none():
+ response = client.get("/two-models-or-two-responses-or-none")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() == {"username": "foo", "age": 42}
+
+
+def test_decorator_response_matches_return_response():
+ response = client.get("/decorator-response-matches-return-response")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ assert response.text == "ok
"
+
+
+def test_decorator_response_differs_from_return_response():
+ response = client.get("/decorator-response-differs-from-return-response")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ assert response.text == "ok
"
+
+
+def test_decorator_response_in_return_response_union():
+ response = client.get("/decorator-response-in-return-response-union")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ assert response.text == "ok
"
+
+
+def test_decorator_response_not_in_return_response_union():
+ response = client.get("/decorator-response-not-in-return-response-union")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() == {"x": 1}
+
+
+def test_decorator_response_with_str_in_return_union_response():
+ response = client.get("/decorator-response-with-str-in-return-union-response")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/html")
+ assert response.text == "ok
"
+
+
+def test_decorator_response_with_str_in_return_union_str():
+ response = client.get("/decorator-response-with-str-in-return-union-str")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("text/plain")
+ assert response.text == "ok"
+
+
+def test_decorator_response_with_dict_in_return_union():
+ response = client.get("/decorator-response-with-dict-in-return-union")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() == {"name": "foo"}
+
+
+def test_decorator_response_with_model_in_return_union():
+ response = client.get("/decorator-response-with-model-in-return-union")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() == {"username": "foo", "age": 42}
+
+
+def test_decorator_response_with_model_and_int_in_return_union():
+ response = client.get("/decorator-response-with-model-and-int-in-return-union")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.json() == 1
+
+
+def test_decorator_response_with_model_and_str_in_return_union():
+ response = client.get("/decorator-response-with-model-and-str-in-return-union")
+ assert response.status_code == 200
+ assert response.headers["content-type"].startswith("application/json")
+ assert response.text == '"ok"'
+
+
+def test_openapi_schema():
+ response = client.get("/openapi.json")
+ assert response.status_code == 200, response.text
+ assert response.json() == snapshot(
+ {
+ "openapi": "3.1.0",
+ "info": {"title": "FastAPI", "version": "0.1.0"},
+ "paths": {
+ "/none": {
+ "get": {
+ "summary": "None",
+ "operationId": "none_none_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {"application/json": {"schema": {}}},
+ }
+ },
+ }
+ },
+ "/response": {
+ "get": {
+ "summary": "Response",
+ "operationId": "response_response_get",
+ "responses": {"200": {"description": "Successful Response"}},
+ }
+ },
+ "/response-or-none": {
+ "get": {
+ "summary": "Response Or None",
+ "operationId": "response_or_none_response_or_none_get",
+ "responses": {"200": {"description": "Successful Response"}},
+ }
+ },
+ "/plaintext-response": {
+ "get": {
+ "summary": "Plaintext Response",
+ "operationId": "plaintext_response_plaintext_response_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "text/plain": {"schema": {"type": "string"}}
+ },
+ }
+ },
+ }
+ },
+ "/html-response": {
+ "get": {
+ "summary": "Html Response",
+ "operationId": "html_response_html_response_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "text/html": {"schema": {"type": "string"}}
+ },
+ }
+ },
+ }
+ },
+ "/none-or-html-response": {
+ "get": {
+ "summary": "None Or Html Response",
+ "operationId": "none_or_html_response_none_or_html_response_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "text/html": {"schema": {"type": "string"}}
+ },
+ }
+ },
+ }
+ },
+ "/json-response": {
+ "get": {
+ "summary": "Json Response",
+ "operationId": "json_response_json_response_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {"application/json": {"schema": {}}},
+ }
+ },
+ }
+ },
+ "/json-response-or-none": {
+ "get": {
+ "summary": "Json Response Or None",
+ "operationId": "json_response_or_none_json_response_or_none_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {"application/json": {"schema": {}}},
+ }
+ },
+ }
+ },
+ "/streaming-response": {
+ "get": {
+ "summary": "Streaming Response",
+ "operationId": "streaming_response_streaming_response_get",
+ "responses": {"200": {"description": "Successful Response"}},
+ }
+ },
+ "/file-response": {
+ "get": {
+ "summary": "File Response",
+ "operationId": "file_response_file_response_get",
+ "responses": {"200": {"description": "Successful Response"}},
+ }
+ },
+ "/redirect-response": {
+ "get": {
+ "summary": "Redirect Response",
+ "operationId": "redirect_response_redirect_response_get",
+ "responses": {"307": {"description": "Successful Response"}},
+ }
+ },
+ "/response-or-item-response": {
+ "get": {
+ "summary": "Response Or Item Response",
+ "operationId": "response_or_item_response_response_or_item_response_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {"$ref": "#/components/schemas/Item"}
+ }
+ },
+ }
+ },
+ }
+ },
+ "/response-or-item-dict": {
+ "get": {
+ "summary": "Response Or Item Dict",
+ "operationId": "response_or_item_dict_response_or_item_dict_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {"$ref": "#/components/schemas/Item"}
+ }
+ },
+ }
+ },
+ }
+ },
+ "/response-or-item-model": {
+ "get": {
+ "summary": "Response Or Item Model",
+ "operationId": "response_or_item_model_response_or_item_model_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {"$ref": "#/components/schemas/Item"}
+ }
+ },
+ }
+ },
+ }
+ },
+ "/item-or-html-response": {
+ "get": {
+ "summary": "Item Or Html Response",
+ "operationId": "item_or_html_response_item_or_html_response_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {"$ref": "#/components/schemas/Item"}
+ },
+ "text/html": {"schema": {"type": "string"}},
+ },
+ }
+ },
+ }
+ },
+ "/item-or-html-response-or-none": {
+ "get": {
+ "summary": "Item Or Html Response Or None",
+ "operationId": "item_or_html_response_or_none_item_or_html_response_or_none_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {"$ref": "#/components/schemas/Item"}
+ },
+ "text/html": {"schema": {"type": "string"}},
+ },
+ }
+ },
+ }
+ },
+ "/plaintext-response-or-html-response": {
+ "get": {
+ "summary": "Plaintext Response Or Html Response",
+ "operationId": "plaintext_response_or_html_response_plaintext_response_or_html_response_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "text/plain": {"schema": {"type": "string"}},
+ "text/html": {"schema": {"type": "string"}},
+ },
+ }
+ },
+ }
+ },
+ "/html-response-or-plaintext-response": {
+ "get": {
+ "summary": "Html Response Or Plaintext Response",
+ "operationId": "html_response_or_plaintext_response_html_response_or_plaintext_response_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "text/html": {"schema": {"type": "string"}},
+ "text/plain": {"schema": {"type": "string"}},
+ },
+ }
+ },
+ }
+ },
+ "/html-response-or-plaintext-response-or-none": {
+ "get": {
+ "summary": "Html Response Or Plaintext Response Or None",
+ "operationId": "html_response_or_plaintext_response_or_none_html_response_or_plaintext_response_or_none_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "text/html": {"schema": {"type": "string"}},
+ "text/plain": {"schema": {"type": "string"}},
+ },
+ }
+ },
+ }
+ },
+ "/three-responses": {
+ "get": {
+ "summary": "Three Responses",
+ "operationId": "three_responses_three_responses_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "text/plain": {"schema": {"type": "string"}},
+ "text/html": {"schema": {"type": "string"}},
+ "application/json": {"schema": {}},
+ },
+ }
+ },
+ }
+ },
+ "/three-responses-or-none": {
+ "get": {
+ "summary": "Three Responses Or None",
+ "operationId": "three_responses_or_none_three_responses_or_none_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {"schema": {}},
+ "text/html": {"schema": {"type": "string"}},
+ "text/plain": {"schema": {"type": "string"}},
+ },
+ }
+ },
+ }
+ },
+ "/two-models-or-response": {
+ "get": {
+ "summary": "Two Models Or Response",
+ "operationId": "two_models_or_response_two_models_or_response_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {"$ref": "#/components/schemas/Item"},
+ {"$ref": "#/components/schemas/User"},
+ ],
+ "title": "Response Two Models Or Response Two Models Or Response Get",
+ }
+ }
+ },
+ }
+ },
+ }
+ },
+ "/two-models-or-html-response": {
+ "get": {
+ "summary": "Two Models Or Html Response",
+ "operationId": "two_models_or_html_response_two_models_or_html_response_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {"$ref": "#/components/schemas/User"},
+ {"$ref": "#/components/schemas/Item"},
+ ],
+ "title": "Response Two Models Or Html Response Two Models Or Html Response Get",
+ }
+ },
+ "text/html": {"schema": {"type": "string"}},
+ },
+ }
+ },
+ }
+ },
+ "/two-models-or-two-responses": {
+ "get": {
+ "summary": "Two Models Or Two Responses",
+ "operationId": "two_models_or_two_responses_two_models_or_two_responses_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {"$ref": "#/components/schemas/Item"},
+ {"$ref": "#/components/schemas/User"},
+ ],
+ "title": "Response Two Models Or Two Responses Two Models Or Two Responses Get",
+ }
+ },
+ "text/plain": {"schema": {"type": "string"}},
+ },
+ },
+ "307": {"description": "Successful Response"},
+ },
+ }
+ },
+ "/two-models-or-two-responses-or-none": {
+ "get": {
+ "summary": "Two Models Or Two Responses Or None",
+ "operationId": "two_models_or_two_responses_or_none_two_models_or_two_responses_or_none_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {"$ref": "#/components/schemas/User"},
+ {"$ref": "#/components/schemas/Item"},
+ ],
+ "title": "Response Two Models Or Two Responses Or None Two Models Or Two Responses Or None Get",
+ }
+ },
+ "text/plain": {"schema": {"type": "string"}},
+ },
+ },
+ "307": {"description": "Successful Response"},
+ },
+ }
+ },
+ "/decorator-response-matches-return-response": {
+ "get": {
+ "summary": "Decorator Response Matches Return Response",
+ "operationId": "decorator_response_matches_return_response_decorator_response_matches_return_response_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "text/html": {"schema": {"type": "string"}}
+ },
+ }
+ },
+ }
+ },
+ "/decorator-response-differs-from-return-response": {
+ "get": {
+ "summary": "Decorator Response Differs From Return Response",
+ "operationId": "decorator_response_differs_from_return_response_decorator_response_differs_from_return_response_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "text/html": {"schema": {"type": "string"}}
+ },
+ }
+ },
+ }
+ },
+ "/decorator-response-in-return-response-union": {
+ "get": {
+ "summary": "Decorator Response In Return Response Union",
+ "operationId": "decorator_response_in_return_response_union_decorator_response_in_return_response_union_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "text/html": {"schema": {"type": "string"}},
+ "text/plain": {"schema": {"type": "string"}},
+ },
+ }
+ },
+ }
+ },
+ "/decorator-response-not-in-return-response-union": {
+ "get": {
+ "summary": "Decorator Response Not In Return Response Union",
+ "operationId": "decorator_response_not_in_return_response_union_decorator_response_not_in_return_response_union_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "text/plain": {"schema": {"type": "string"}},
+ "application/json": {"schema": {}},
+ },
+ }
+ },
+ }
+ },
+ "/decorator-response-with-str-in-return-union-response": {
+ "get": {
+ "summary": "Decorator Response With Str In Return Union Response",
+ "operationId": "decorator_response_with_str_in_return_union_response_decorator_response_with_str_in_return_union_response_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "text/plain": {"schema": {"type": "string"}},
+ "text/html": {"schema": {"type": "string"}},
+ },
+ }
+ },
+ }
+ },
+ "/decorator-response-with-str-in-return-union-str": {
+ "get": {
+ "summary": "Decorator Response With Str In Return Union Str",
+ "operationId": "decorator_response_with_str_in_return_union_str_decorator_response_with_str_in_return_union_str_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "text/plain": {"schema": {"type": "string"}},
+ "text/html": {"schema": {"type": "string"}},
+ },
+ }
+ },
+ }
+ },
+ "/decorator-response-with-dict-in-return-union": {
+ "get": {
+ "summary": "Decorator Response With Dict In Return Union",
+ "operationId": "decorator_response_with_dict_in_return_union_decorator_response_with_dict_in_return_union_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "additionalProperties": {"type": "string"},
+ "type": "object",
+ "title": "Response Decorator Response With Dict In Return Union Decorator Response With Dict In Return Union Get",
+ }
+ }
+ },
+ },
+ "307": {"description": "Successful Response"},
+ },
+ }
+ },
+ "/decorator-response-with-model-in-return-union": {
+ "get": {
+ "summary": "Decorator Response With Model In Return Union",
+ "operationId": "decorator_response_with_model_in_return_union_decorator_response_with_model_in_return_union_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {"$ref": "#/components/schemas/User"}
+ }
+ },
+ },
+ "307": {"description": "Successful Response"},
+ },
+ }
+ },
+ "/decorator-response-with-model-and-int-in-return-union": {
+ "get": {
+ "summary": "Decorator Response With Multiple Non Response In Return Union",
+ "operationId": "decorator_response_with_multiple_non_response_in_return_union_decorator_response_with_model_and_int_in_return_union_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {"$ref": "#/components/schemas/User"},
+ {"type": "integer"},
+ ],
+ "title": "Response Decorator Response With Multiple Non Response In Return Union Decorator Response With Model And Int In Return Union Get",
+ }
+ },
+ "text/plain": {"schema": {"type": "string"}},
+ },
+ }
+ },
+ }
+ },
+ "/decorator-response-with-model-and-str-in-return-union": {
+ "get": {
+ "summary": "Decorator Response With Model And Str In Return Union",
+ "operationId": "decorator_response_with_model_and_str_in_return_union_decorator_response_with_model_and_str_in_return_union_get",
+ "responses": {
+ "200": {
+ "description": "Successful Response",
+ "content": {
+ "application/json": {
+ "schema": {
+ "anyOf": [
+ {"$ref": "#/components/schemas/User"},
+ {"type": "string"},
+ ],
+ "title": "Response Decorator Response With Model And Str In Return Union Decorator Response With Model And Str In Return Union Get",
+ }
+ },
+ "text/plain": {"schema": {"type": "string"}},
+ },
+ }
+ },
+ }
+ },
+ },
+ "components": {
+ "schemas": {
+ "Item": {
+ "properties": {"name": {"type": "string", "title": "Name"}},
+ "type": "object",
+ "required": ["name"],
+ "title": "Item",
+ },
+ "User": {
+ "properties": {
+ "username": {"type": "string", "title": "Username"},
+ "age": {"type": "integer", "title": "Age"},
+ },
+ "type": "object",
+ "required": ["username", "age"],
+ "title": "User",
+ },
+ }
+ },
+ }
+ )
diff --git a/tests/test_response_model_as_return_annotation.py b/tests/test_response_model_as_return_annotation.py
index 7be7902ada..c4d6455c73 100644
--- a/tests/test_response_model_as_return_annotation.py
+++ b/tests/test_response_model_as_return_annotation.py
@@ -1,6 +1,6 @@
import pytest
from fastapi import FastAPI
-from fastapi.exceptions import FastAPIError, ResponseValidationError
+from fastapi.exceptions import ResponseValidationError
from fastapi.responses import JSONResponse, Response
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
@@ -248,6 +248,11 @@ def no_response_model_annotation_json_response_class() -> JSONResponse:
return JSONResponse(content={"foo": "bar"})
+@app.get("/no_response_model-annotation_response_or_none")
+def no_response_model_annotation_response_or_none() -> Response | None:
+ return Response(content="Foo")
+
+
client = TestClient(app)
@@ -496,16 +501,10 @@ def test_no_response_model_annotation_json_response_class():
assert response.json() == {"foo": "bar"}
-def test_invalid_response_model_field():
- app = FastAPI()
- with pytest.raises(FastAPIError) as e:
-
- @app.get("/")
- def read_root() -> Response | None:
- return Response(content="Foo") # pragma: no cover
-
- assert "valid Pydantic field type" in e.value.args[0]
- assert "parameter response_model=None" in e.value.args[0]
+def test_no_response_model_annotation_response_or_none():
+ response = client.get("/no_response_model-annotation_response_or_none")
+ assert response.status_code == 200
+ assert response.text == "Foo"
def test_openapi_schema():
@@ -1077,7 +1076,6 @@ def test_openapi_schema():
"responses": {
"200": {
"description": "Successful Response",
- "content": {"application/json": {"schema": {}}},
}
},
}
@@ -1094,6 +1092,13 @@ def test_openapi_schema():
},
}
},
+ "/no_response_model-annotation_response_or_none": {
+ "get": {
+ "summary": "No Response Model Annotation Response Or None",
+ "operationId": "no_response_model_annotation_response_or_none_no_response_model_annotation_response_or_none_get",
+ "responses": {"200": {"description": "Successful Response"}},
+ }
+ },
},
"components": {
"schemas": {
diff --git a/tests/test_tutorial/test_response_model/test_tutorial003_02.py b/tests/test_tutorial/test_response_model/test_tutorial003_02.py
index a28c56be92..50176b6025 100644
--- a/tests/test_tutorial/test_response_model/test_tutorial003_02.py
+++ b/tests/test_tutorial/test_response_model/test_tutorial003_02.py
@@ -45,7 +45,6 @@ def test_openapi_schema():
"responses": {
"200": {
"description": "Successful Response",
- "content": {"application/json": {"schema": {}}},
},
"422": {
"description": "Validation Error",
diff --git a/tests/test_tutorial/test_response_model/test_tutorial003_03.py b/tests/test_tutorial/test_response_model/test_tutorial003_03.py
index 65e7470222..bcdf7b8a4a 100644
--- a/tests/test_tutorial/test_response_model/test_tutorial003_03.py
+++ b/tests/test_tutorial/test_response_model/test_tutorial003_03.py
@@ -24,12 +24,7 @@ def test_openapi_schema():
"get": {
"summary": "Get Teleport",
"operationId": "get_teleport_teleport_get",
- "responses": {
- "200": {
- "description": "Successful Response",
- "content": {"application/json": {"schema": {}}},
- }
- },
+ "responses": {"307": {"description": "Successful Response"}},
}
}
},
diff --git a/tests/test_tutorial/test_response_model/test_tutorial003_04.py b/tests/test_tutorial/test_response_model/test_tutorial003_04.py
index 44f2e504ea..70b42a7405 100644
--- a/tests/test_tutorial/test_response_model/test_tutorial003_04.py
+++ b/tests/test_tutorial/test_response_model/test_tutorial003_04.py
@@ -1,17 +1,20 @@
import importlib
import pytest
-from fastapi.exceptions import FastAPIError
from ...utils import needs_py310
+# Previously, unions including `Response` in the return annotation were
+# considered invalid and raised FastAPIError at import time.
+# They are now supported as part of the enhanced return annotation handling.
+# Importing the module should not raise FastAPIError anymore.
@pytest.mark.parametrize(
"module_name",
[
pytest.param("tutorial003_04_py310", marks=needs_py310),
],
)
-def test_invalid_response_model(module_name: str) -> None:
- with pytest.raises(FastAPIError):
- importlib.import_module(f"docs_src.response_model.{module_name}")
+def test_response_union_with_response_is_valid(module_name: str) -> None:
+ module = importlib.import_module(f"docs_src.response_model.{module_name}")
+ assert module is not None