From dd7807ed0ceac55db8a36cd47f0f851055fa79cb Mon Sep 17 00:00:00 2001 From: Reinaldo Loyo Date: Fri, 23 Jan 2026 13:26:04 -0300 Subject: [PATCH 1/2] Fix Optional Literal Form field handling when value is None --- fastapi/dependencies/utils.py | 22 ++++- .../test_form/test_optional_literal_form.py | 84 +++++++++++++++++++ 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 tests/test_request_params/test_form/test_optional_literal_form.py diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index 45e1ff3ed1..ce2c07fad3 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -347,6 +347,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 @@ -412,7 +427,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..ce0ea0c12f --- /dev/null +++ b/tests/test_request_params/test_form/test_optional_literal_form.py @@ -0,0 +1,84 @@ +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} From 5c4937cca6908cd27513b3ee57771a1cc1d758ac Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Fri, 23 Jan 2026 16:35:52 +0000 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=8E=A8=20Auto=20format?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- fastapi/dependencies/utils.py | 2 +- .../test_form/test_optional_literal_form.py | 17 +++++------------ 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py index ce2c07fad3..d6e1c1cce3 100644 --- a/fastapi/dependencies/utils.py +++ b/fastapi/dependencies/utils.py @@ -350,7 +350,7 @@ def add_non_field_param_to_dependency( 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 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 index ce0ea0c12f..11f9916ab6 100644 --- a/tests/test_request_params/test_form/test_optional_literal_form.py +++ b/tests/test_request_params/test_form/test_optional_literal_form.py @@ -1,4 +1,5 @@ from typing import Annotated, Literal, Optional + from fastapi import FastAPI, Form from fastapi.testclient import TestClient @@ -8,9 +9,7 @@ def test_optional_literal_form_none(): app = FastAPI() @app.post("/") - async def read_main( - attribute: Annotated[Optional[Literal["abc", "def"]], Form()] - ): + async def read_main(attribute: Annotated[Optional[Literal["abc", "def"]], Form()]): return {"attribute": attribute} client = TestClient(app) @@ -26,9 +25,7 @@ def test_optional_literal_form_valid_values(): app = FastAPI() @app.post("/") - async def read_main( - attribute: Annotated[Optional[Literal["abc", "def"]], Form()] - ): + async def read_main(attribute: Annotated[Optional[Literal["abc", "def"]], Form()]): return {"attribute": attribute} client = TestClient(app) @@ -49,9 +46,7 @@ def test_optional_literal_form_invalid_value(): app = FastAPI() @app.post("/") - async def read_main( - attribute: Annotated[Optional[Literal["abc", "def"]], Form()] - ): + async def read_main(attribute: Annotated[Optional[Literal["abc", "def"]], Form()]): return {"attribute": attribute} client = TestClient(app) @@ -70,9 +65,7 @@ def test_optional_literal_form_empty_string(): app = FastAPI() @app.post("/") - async def read_main( - attribute: Annotated[Optional[Literal["abc", "def"]], Form()] - ): + async def read_main(attribute: Annotated[Optional[Literal["abc", "def"]], Form()]): return {"attribute": attribute} client = TestClient(app)