from typing import Annotated from unittest.mock import Mock import pytest from fastapi import Body, Cookie, FastAPI, Form, Header, Query from fastapi._compat import PYDANTIC_VERSION_MINOR_TUPLE from fastapi.testclient import TestClient from pydantic import BaseModel, Field @pytest.fixture(params=[Query, Header, Cookie, Form, Body]) def parameter(request): return request.param @pytest.mark.parametrize("path", ["/parameter", "/model"]) def test_nullable_required_schema_and_supplied_value(parameter, path): app = FastAPI() field = Body(embed=True) if parameter is Body else parameter() class Model(BaseModel): value: int | None @app.post("/parameter") def read_required(value: Annotated[int | None, field]): return {"value": value, "fields_set": None} @app.post("/model") def read_model(params: Annotated[Model, parameter()]): return {"value": params.value, "fields_set": sorted(params.model_fields_set)} client = TestClient(app) if parameter is Query: supplied = client.post(path, params={"value": "0"}) elif parameter is Header: supplied = client.post(path, headers={"value": "0"}) elif parameter is Cookie: supplied = client.post(path, headers={"cookie": "value=0"}) elif parameter is Form: supplied = client.post(path, data={"value": "0"}) else: supplied = client.post(path, json={"value": 0}) assert supplied.status_code == 200 assert supplied.json()["value"] == 0 response = client.get("/openapi.json") assert response.status_code == 200, response.text openapi = response.json() operation = openapi["paths"][path]["post"] if parameter in (Query, Header, Cookie): field = operation["parameters"][0] schema = field["schema"] required = field["required"] else: media_type = ( "application/json" if parameter is Body else "application/x-www-form-urlencoded" ) reference = operation["requestBody"]["content"][media_type]["schema"]["$ref"] model = openapi["components"]["schemas"][reference.rsplit("/", 1)[1]] schema = model["properties"]["value"] required = "value" in model.get("required", []) assert schema["anyOf"] == [{"type": "integer"}, {"type": "null"}] assert required is True assert "default" not in schema @pytest.mark.parametrize("path", ["/parameter", "/model"]) def test_nullable_none_default_schema_and_supplied_value(parameter, path): app = FastAPI() field = Body(embed=True) if parameter is Body else parameter() class Model(BaseModel): value: int | None = None @app.post("/parameter") def read_defaulted(value: Annotated[int | None, field] = None): return {"value": value, "fields_set": None} @app.post("/model") def read_model(params: Annotated[Model, parameter()]): return {"value": params.value, "fields_set": sorted(params.model_fields_set)} client = TestClient(app) if parameter is Query: supplied = client.post(path, params={"value": "0"}) elif parameter is Header: supplied = client.post(path, headers={"value": "0"}) elif parameter is Cookie: supplied = client.post(path, headers={"cookie": "value=0"}) elif parameter is Form: supplied = client.post(path, data={"value": "0"}) else: supplied = client.post(path, json={"value": 0}) assert supplied.status_code == 200 assert supplied.json()["value"] == 0 response = client.get("/openapi.json") assert response.status_code == 200, response.text openapi = response.json() operation = openapi["paths"][path]["post"] if parameter in (Query, Header, Cookie): field = operation["parameters"][0] schema = field["schema"] required = field["required"] else: media_type = ( "application/json" if parameter is Body else "application/x-www-form-urlencoded" ) reference = operation["requestBody"]["content"][media_type]["schema"]["$ref"] model = openapi["components"]["schemas"][reference.rsplit("/", 1)[1]] schema = model["properties"]["value"] required = "value" in model.get("required", []) assert schema["anyOf"] == [{"type": "integer"}, {"type": "null"}] assert required is False assert "default" not in schema @pytest.mark.parametrize("path", ["/parameter", "/model"]) def test_nullable_non_null_default_schema_and_supplied_value(parameter, path): app = FastAPI() field = Body(embed=True) if parameter is Body else parameter() class Model(BaseModel): value: int | None = 10 @app.post("/parameter") def read_defaulted(value: Annotated[int | None, field] = 10): return {"value": value, "fields_set": None} @app.post("/model") def read_model(params: Annotated[Model, parameter()]): return {"value": params.value, "fields_set": sorted(params.model_fields_set)} client = TestClient(app) if parameter is Query: supplied = client.post(path, params={"value": "0"}) elif parameter is Header: supplied = client.post(path, headers={"value": "0"}) elif parameter is Cookie: supplied = client.post(path, headers={"cookie": "value=0"}) elif parameter is Form: supplied = client.post(path, data={"value": "0"}) else: supplied = client.post(path, json={"value": 0}) assert supplied.status_code == 200 assert supplied.json()["value"] == 0 response = client.get("/openapi.json") assert response.status_code == 200, response.text openapi = response.json() operation = openapi["paths"][path]["post"] if parameter in (Query, Header, Cookie): field = operation["parameters"][0] schema = field["schema"] required = field["required"] else: media_type = ( "application/json" if parameter is Body else "application/x-www-form-urlencoded" ) reference = operation["requestBody"]["content"][media_type]["schema"]["$ref"] model = openapi["components"]["schemas"][reference.rsplit("/", 1)[1]] schema = model["properties"]["value"] required = "value" in model.get("required", []) assert schema["anyOf"] == [{"type": "integer"}, {"type": "null"}] assert required is False assert schema["default"] == 10 @pytest.mark.parametrize("path", ["/parameter", "/model"]) def test_nullable_scalar_is_required(parameter, path): app = FastAPI() class Model(BaseModel): value: int | None field = Body(embed=True) if parameter is Body else parameter() @app.post("/parameter") def read_parameter(value: Annotated[int | None, field]): return {"value": value, "fields_set": None} @app.post("/model") def read_model(params: Annotated[Model, parameter()]): return {"value": params.value, "fields_set": sorted(params.model_fields_set)} client = TestClient(app) if parameter is Query: supplied = client.post(path, params={"value": "0"}) elif parameter is Header: supplied = client.post(path, headers={"value": "0"}) elif parameter is Cookie: supplied = client.post(path, headers={"cookie": "value=0"}) elif parameter is Form: supplied = client.post(path, data={"value": "0"}) else: supplied = client.post(path, json={"value": 0}) assert supplied.status_code == 200 assert supplied.json()["value"] == 0 if parameter is Body: response = client.post(path, json={}) else: response = client.post(path) assert response.status_code == 422 assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [ ( "missing", ["body" if parameter in (Body, Form) else parameter().in_.value, "value"], ) ] @pytest.mark.parametrize("path", ["/parameter", "/model"]) @pytest.mark.parametrize("parameter", [Query, Header, Form, Body]) def test_nullable_list_is_required(parameter, path): app = FastAPI() class Model(BaseModel): value: list[int] | None field = Body(embed=True) if parameter is Body else parameter() @app.post("/parameter") def read_parameter(value: Annotated[list[int] | None, field]): return {"value": value, "fields_set": None} @app.post("/model") def read_model(params: Annotated[Model, parameter()]): return {"value": params.value, "fields_set": sorted(params.model_fields_set)} client = TestClient(app) if parameter is Query: supplied = client.post(path, params={"value": "0"}) elif parameter is Header: supplied = client.post(path, headers={"value": "0"}) elif parameter is Form: supplied = client.post(path, data={"value": "0"}) else: supplied = client.post(path, json={"value": [0]}) assert supplied.status_code == 200 assert supplied.json()["value"] == [0] if parameter is Body: response = client.post(path, json={}) else: response = client.post(path) assert response.status_code == 422 assert [(error["type"], error["loc"]) for error in response.json()["detail"]] == [ ( "missing", ["body" if parameter in (Body, Form) else parameter().in_.value, "value"], ) ] response = client.get("/openapi.json") assert response.status_code == 200, response.text openapi = response.json() operation = openapi["paths"][path]["post"] if parameter in (Query, Header): field = operation["parameters"][0] schema = field["schema"] required = field["required"] else: media_type = ( "application/json" if parameter is Body else "application/x-www-form-urlencoded" ) reference = operation["requestBody"]["content"][media_type]["schema"]["$ref"] model = openapi["components"]["schemas"][reference.rsplit("/", 1)[1]] schema = model["properties"]["value"] required = "value" in model.get("required", []) assert required is True assert schema["anyOf"] == [ {"type": "array", "items": {"type": "integer"}}, {"type": "null"}, ] assert "default" not in schema @pytest.mark.parametrize("path", ["/parameter", "/model"]) @pytest.mark.parametrize("default", [None, 10]) def test_missing_value_uses_default(parameter, path, default): app = FastAPI() class Model(BaseModel): value: int | None = default field = Body(embed=True) if parameter is Body else parameter() @app.post("/parameter") def read_parameter(value: Annotated[int | None, field] = default): return {"value": value, "fields_set": None} @app.post("/model") def read_model(params: Annotated[Model, parameter()]): return {"value": params.value, "fields_set": sorted(params.model_fields_set)} client = TestClient(app) if parameter is Body: response = client.post(path, json={}) else: response = client.post(path) assert response.status_code == 200 assert response.json()["value"] == default @pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body]) def test_missing_none_default_is_unset(parameter): app = FastAPI() class Model(BaseModel): value: int | None = None @app.post("/model") def read_model(params: Annotated[Model, parameter()]): return {"value": params.value, "fields_set": sorted(params.model_fields_set)} client = TestClient(app) if parameter is Body: response = client.post("/model", json={}) else: response = client.post("/model") assert response.status_code == 200 assert response.json() == {"value": None, "fields_set": []} @pytest.mark.parametrize( "parameter,annotation,default", [ (Query, int | None, 10), (Header, int | None, 10), (Cookie, int | None, 10), (Form, int | None, 10), (Body, int | None, 10), (Query, list[int] | None, [10]), (Header, list[int] | None, [10]), (Form, list[int] | None, [10]), (Body, list[int] | None, [10]), ], ) def test_missing_non_null_default_is_unset(parameter, annotation, default): app = FastAPI() class Model(BaseModel): value: annotation = default @app.post("/") def read_model(params: Annotated[Model, parameter()]): return {"value": params.value, "fields_set": sorted(params.model_fields_set)} client = TestClient(app) if parameter is Body: response = client.post("/", json={}) else: response = client.post("/") assert response.status_code == 200 assert response.json() == {"value": default, "fields_set": []} @pytest.mark.parametrize( "parameter,annotation,default", [ (Query, int | None, 10), (Header, int | None, 10), (Cookie, int | None, 10), (Form, int | None, 10), (Body, int | None, 10), (Query, list[int] | None, [10]), (Header, list[int] | None, [10]), (Form, list[int] | None, [10]), (Body, list[int] | None, [10]), ], ) def test_missing_factory_default_is_unset(parameter, annotation, default): app = FastAPI() class Model(BaseModel): value: Annotated[annotation, Field(default_factory=lambda: default)] @app.post("/") def read_model(params: Annotated[Model, parameter()]): return {"value": params.value, "fields_set": sorted(params.model_fields_set)} client = TestClient(app) if parameter is Body: response = client.post("/", json={}) else: response = client.post("/") assert response.status_code == 200 assert response.json() == {"value": default, "fields_set": []} @pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body]) @pytest.mark.parametrize("path", ["/parameter", "/model"]) @pytest.mark.parametrize("default", [None, 10]) def test_missing_value_calls_default_factory_once(parameter, path, default): app = FastAPI() default_factory = Mock(return_value=default) field = ( Body(embed=True, default_factory=default_factory) if parameter is Body else parameter(default_factory=default_factory) ) @app.post("/parameter") def read_parameter(value: Annotated[int | None, field]): return value class Model(BaseModel): value: Annotated[int | None, Field(default_factory=default_factory)] @app.post("/model") def read_model(params: Annotated[Model, parameter()]): return params.value default_factory.assert_not_called() client = TestClient(app) if parameter is Body: response = client.post(path, json={}) else: response = client.post(path) assert response.status_code == 200 assert response.json() == default default_factory.assert_called_once_with() @pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body]) @pytest.mark.skipif( PYDANTIC_VERSION_MINOR_TUPLE < (2, 10), reason="Default factories receiving validated data require Pydantic 2.10", ) def test_model_default_factory_receives_validated_data(parameter): app = FastAPI() class Model(BaseModel): value: int doubled: Annotated[int, Field(default_factory=lambda data: data["value"] * 2)] @app.post("/") def read_model(params: Annotated[Model, parameter()]): return { "values": params.model_dump(), "supplied": params.model_dump(exclude_unset=True), } client = TestClient(app) if parameter is Query: response = client.post("/", params={"value": "5"}) elif parameter is Header: response = client.post("/", headers={"value": "5"}) elif parameter is Cookie: response = client.post("/", headers={"cookie": "value=5"}) elif parameter is Form: response = client.post("/", data={"value": "5"}) else: response = client.post("/", json={"value": 5}) assert response.status_code == 200 assert response.json() == { "values": {"value": 5, "doubled": 10}, "supplied": {"value": 5}, } @pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body]) @pytest.mark.parametrize("path", ["/parameter", "/model"]) def test_supplied_value_does_not_call_default_factory(parameter, path): app = FastAPI() default_factory = Mock(return_value=10) field = ( Body(embed=True, default_factory=default_factory) if parameter is Body else parameter(default_factory=default_factory) ) @app.post("/parameter") def read_parameter(value: Annotated[int | None, field]): return value class Model(BaseModel): value: Annotated[int | None, Field(default_factory=default_factory)] @app.post("/model") def read_model(params: Annotated[Model, parameter()]): return params.value client = TestClient(app) if parameter is Query: response = client.post(path, params={"value": "0"}) elif parameter is Header: response = client.post(path, headers={"value": "0"}) elif parameter is Cookie: response = client.post(path, headers={"cookie": "value=0"}) elif parameter is Form: response = client.post(path, data={"value": "0"}) else: response = client.post(path, json={"value": 0}) assert response.status_code == 200 assert response.json() == 0 default_factory.assert_not_called() @pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body]) def test_value_equal_to_default_is_explicitly_set(parameter): app = FastAPI() class Model(BaseModel): value: int | None = 0 @app.post("/model") def read_model(params: Annotated[Model, parameter()]): return {"value": params.value, "fields_set": sorted(params.model_fields_set)} client = TestClient(app) if parameter is Query: response = client.post("/model", params={"value": "0"}) elif parameter is Header: response = client.post("/model", headers={"value": "0"}) elif parameter is Cookie: response = client.post("/model", headers={"cookie": "value=0"}) elif parameter is Form: response = client.post("/model", data={"value": "0"}) else: response = client.post("/model", json={"value": 0}) assert response.status_code == 200 assert response.json() == {"value": 0, "fields_set": ["value"]} @pytest.mark.parametrize("parameter", [Query, Header, Cookie, Form, Body]) def test_partial_model_submission(parameter): app = FastAPI() class Model(BaseModel): value: int | None = None omitted: int | None = 10 @app.post("/") def read_model(params: Annotated[Model, parameter()]): return params.model_dump(exclude_unset=True) client = TestClient(app) if parameter is Query: response = client.post("/", params={"value": "0"}) elif parameter is Header: response = client.post("/", headers={"value": "0"}) elif parameter is Cookie: response = client.post("/", headers={"cookie": "value=0"}) elif parameter is Form: response = client.post("/", data={"value": "0"}) else: response = client.post("/", json={"value": 0}) assert response.status_code == 200 assert response.json() == {"value": 0} @pytest.mark.parametrize("parameter", [Query, Header, Form, Body]) @pytest.mark.parametrize("path", ["/parameter", "/model"]) @pytest.mark.parametrize( "default,expected_schema", [ pytest.param( None, { "title": "Value", "anyOf": [ {"type": "array", "items": {"type": "integer"}}, {"type": "null"}, ], }, id="none-default", ), pytest.param( [10], { "title": "Value", "anyOf": [ {"type": "array", "items": {"type": "integer"}}, {"type": "null"}, ], "default": [10], }, id="non-null-default", ), ], ) def test_nullable_list_default_and_repeated_values( parameter, path, default, expected_schema ): app = FastAPI() class Model(BaseModel): value: list[int] | None = default field = Body(embed=True) if parameter is Body else parameter() @app.post("/parameter") def read_parameter(value: Annotated[list[int] | None, field] = default): return {"value": value, "fields_set": None} @app.post("/model") def read_model(params: Annotated[Model, parameter()]): return {"value": params.value, "fields_set": sorted(params.model_fields_set)} client = TestClient(app) if parameter is Body: missing = client.post(path, json={}) else: missing = client.post(path) assert missing.status_code == 200 assert missing.json()["value"] == default if parameter is Query: supplied = client.post(path, params={"value": ["0", "1"]}) elif parameter is Header: supplied = client.post(path, headers=[("value", "0"), ("value", "1")]) elif parameter is Form: supplied = client.post(path, data={"value": ["0", "1"]}) else: supplied = client.post(path, json={"value": [0, 1]}) assert supplied.status_code == 200 assert supplied.json() == { "value": [0, 1], "fields_set": ["value"] if path == "/model" else None, } response = client.get("/openapi.json") assert response.status_code == 200, response.text openapi = response.json() operation = openapi["paths"][path]["post"] if parameter in (Query, Header): field = operation["parameters"][0] schema = field["schema"] required = field["required"] else: media_type = ( "application/json" if parameter is Body else "application/x-www-form-urlencoded" ) reference = operation["requestBody"]["content"][media_type]["schema"]["$ref"] model = openapi["components"]["schemas"][reference.rsplit("/", 1)[1]] schema = model["properties"]["value"] required = "value" in model.get("required", []) assert required is False assert schema == expected_schema @pytest.mark.parametrize("parameter", [Query, Header, Cookie]) @pytest.mark.parametrize("path", ["/parameter", "/model"]) @pytest.mark.parametrize( "default,expected_schema", [ ( None, {"title": "Value", "anyOf": [{"type": "string"}, {"type": "null"}]}, ), ( "default", { "title": "Value", "anyOf": [{"type": "string"}, {"type": "null"}], "default": "default", }, ), ], ) def test_empty_non_form_string_is_supplied(parameter, path, default, expected_schema): app = FastAPI() class Model(BaseModel): value: str | None = default @app.post("/parameter") def read_parameter(value: Annotated[str | None, parameter()] = default): return {"value": value, "fields_set": None} @app.post("/model") def read_model(params: Annotated[Model, parameter()]): return {"value": params.value, "fields_set": sorted(params.model_fields_set)} client = TestClient(app) if parameter is Query: response = client.post(path, params={"value": ""}) elif parameter is Header: response = client.post(path, headers={"value": ""}) else: response = client.post(path, headers={"cookie": "value="}) assert response.status_code == 200 assert response.json() == { "value": "", "fields_set": ["value"] if path == "/model" else None, } response = client.get("/openapi.json") assert response.status_code == 200, response.text field = response.json()["paths"][path]["post"]["parameters"][0] assert field["required"] is False assert field["schema"] == expected_schema @pytest.mark.parametrize("parameter", [Query, Header, Cookie]) @pytest.mark.parametrize("path", ["/parameter", "/model"]) def test_empty_required_non_form_string_is_supplied(parameter, path): app = FastAPI() class Model(BaseModel): value: str | None @app.post("/parameter") def read_parameter(value: Annotated[str | None, parameter()]): return value @app.post("/model") def read_model(params: Annotated[Model, parameter()]): return params.value client = TestClient(app) if parameter is Query: response = client.post(path, params={"value": ""}) elif parameter is Header: response = client.post(path, headers={"value": ""}) else: response = client.post(path, headers={"cookie": "value="}) assert response.status_code == 200 assert response.json() == "" missing = client.post(path) assert missing.status_code == 422 assert [(error["type"], error["loc"]) for error in missing.json()["detail"]] == [ ("missing", [parameter().in_.value, "value"]), ] response = client.get("/openapi.json") assert response.status_code == 200, response.text field = response.json()["paths"][path]["post"]["parameters"][0] assert field["required"] is True assert field["schema"] == { "title": "Value", "anyOf": [{"type": "string"}, {"type": "null"}], } @pytest.mark.parametrize("parameter", [Query, Header, Form, Body]) def test_nullable_list_parameter_default_factory(parameter): app = FastAPI() field = ( Body(embed=True, default_factory=lambda: [10]) if parameter is Body else parameter(default_factory=lambda: [10]) ) @app.post("/") def read_parameter(value: Annotated[list[int] | None, field]): return value client = TestClient(app) missing = client.post("/") assert missing.status_code == 200 assert missing.json() == [10] if parameter is Query: supplied = client.post("/", params={"value": ["0", "1"]}) elif parameter is Header: supplied = client.post("/", headers=[("value", "0"), ("value", "1")]) elif parameter is Form: supplied = client.post("/", data={"value": ["0", "1"]}) else: supplied = client.post("/", json={"value": [0, 1]}) assert supplied.status_code == 200 assert supplied.json() == [0, 1] response = client.get("/openapi.json") assert response.status_code == 200, response.text openapi = response.json() operation = openapi["paths"]["/"]["post"] if parameter in (Query, Header): field = operation["parameters"][0] schema = field["schema"] required = field["required"] else: media_type = ( "application/json" if parameter is Body else "application/x-www-form-urlencoded" ) reference = operation["requestBody"]["content"][media_type]["schema"]["$ref"] model = openapi["components"]["schemas"][reference.rsplit("/", 1)[1]] schema = model["properties"]["value"] required = "value" in model.get("required", []) assert required is False assert schema["anyOf"] == [ {"type": "array", "items": {"type": "integer"}}, {"type": "null"}, ] assert "default" not in schema