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