diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index dd42371ecc..872e08f119 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -352,6 +352,21 @@ def add_non_field_param_to_dependency( return None +def _is_optional_type(annotation: Any) -> bool: + """ + Check if a type annotation is Optional (Union with None). + + Optional[X] is equivalent to Union[X, None], so we check if: + 1. The origin is Union + 2. NoneType is one of the union args + """ + origin = get_origin(annotation) + if origin is Union: + args = get_args(annotation) + return type(None) in args + return False + + @dataclass class ParamDetails: type_annotation: Any @@ -420,7 +435,12 @@ def analyze_param( assert not is_path_param, "Path parameters cannot have default values" field_info.default = value else: - field_info.default = RequiredParam + # Check if the type annotation is Optional (Union with None) + # If so, use None as default instead of RequiredParam + if _is_optional_type(type_annotation): + field_info.default = None + else: + field_info.default = RequiredParam # Get Annotated Depends elif isinstance(fastapi_annotation, params.Depends): depends = fastapi_annotation diff --git a/tests/test_request_params/test_form/test_optional_literal_form.py b/tests/test_request_params/test_form/test_optional_literal_form.py new file mode 100644 index 0000000000..11f9916ab6 --- /dev/null +++ b/tests/test_request_params/test_form/test_optional_literal_form.py @@ -0,0 +1,77 @@ +from typing import Annotated, Literal, Optional + +from fastapi import FastAPI, Form +from fastapi.testclient import TestClient + + +def test_optional_literal_form_none(): + """Test that omitting an optional form field returns None""" + app = FastAPI() + + @app.post("/") + async def read_main(attribute: Annotated[Optional[Literal["abc", "def"]], Form()]): + return {"attribute": attribute} + + client = TestClient(app) + + # FIXED: Use empty data instead of {"attribute": None} + response = client.post("/", data={}) + assert response.status_code == 200 + assert response.json() == {"attribute": None} + + +def test_optional_literal_form_valid_values(): + """Test that valid literal values work correctly""" + app = FastAPI() + + @app.post("/") + async def read_main(attribute: Annotated[Optional[Literal["abc", "def"]], Form()]): + return {"attribute": attribute} + + client = TestClient(app) + + # Test with "abc" + response = client.post("/", data={"attribute": "abc"}) + assert response.status_code == 200 + assert response.json() == {"attribute": "abc"} + + # Test with "def" + response = client.post("/", data={"attribute": "def"}) + assert response.status_code == 200 + assert response.json() == {"attribute": "def"} + + +def test_optional_literal_form_invalid_value(): + """Test that invalid values return 422""" + app = FastAPI() + + @app.post("/") + async def read_main(attribute: Annotated[Optional[Literal["abc", "def"]], Form()]): + return {"attribute": attribute} + + client = TestClient(app) + + # Invalid value should fail + response = client.post("/", data={"attribute": "xyz"}) + assert response.status_code == 422 + + # String "None" is also invalid + response = client.post("/", data={"attribute": "None"}) + assert response.status_code == 422 + + +def test_optional_literal_form_empty_string(): + """Test that empty string is treated as None for optional Form fields""" + app = FastAPI() + + @app.post("/") + async def read_main(attribute: Annotated[Optional[Literal["abc", "def"]], Form()]): + return {"attribute": attribute} + + client = TestClient(app) + + # Empty string is treated as "not provided" for optional Form fields + # This is consistent with FastAPI's behavior for Form data + response = client.post("/", data={"attribute": ""}) + assert response.status_code == 200 + assert response.json() == {"attribute": None}