From 2d4338262647c9f647dec37b9197e018c9993893 Mon Sep 17 00:00:00 2001 From: Yurii Motov Date: Wed, 4 Feb 2026 17:12:24 +0100 Subject: [PATCH] Add tests for nullable Body parameter with\without default --- .../test_body/test_nullable_and_defaults.py | 927 ++++++++++++++++++ 1 file changed, 927 insertions(+) create mode 100644 tests/test_request_params/test_body/test_nullable_and_defaults.py diff --git a/tests/test_request_params/test_body/test_nullable_and_defaults.py b/tests/test_request_params/test_body/test_nullable_and_defaults.py new file mode 100644 index 0000000000..0e032e8419 --- /dev/null +++ b/tests/test_request_params/test_body/test_nullable_and_defaults.py @@ -0,0 +1,927 @@ +from typing import Annotated, Any, Union + +import pytest +from dirty_equals import IsList, IsOneOf +from fastapi import Body, FastAPI +from fastapi.testclient import TestClient +from pydantic import BaseModel + +from .utils import get_body_model_name + +app = FastAPI() + + +# ===================================================================================== +# Nullable required + + +@app.post("/nullable-required") +async def read_nullable_required( + int_val: Annotated[Union[int, None], Body()], + str_val: Annotated[Union[str, None], Body()], + list_val: Union[list[int], None], +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableRequired(BaseModel): + int_val: Union[int, None] + str_val: Union[str, None] + list_val: Union[list[int], None] + + +@app.post("/model-nullable-required") +async def read_model_nullable_required(params: ModelNullableRequired): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@app.post("/nullable-required-str") +async def read_nullable_required_no_embed_str( + str_val: Annotated[Union[str, None], Body()], +): + return {"val": str_val} + + +@app.post("/nullable-required-int") +async def read_nullable_required_no_embed_int( + int_val: Annotated[Union[int, None], Body()], +): + return {"val": int_val} + + +@app.post("/nullable-required-list") +async def read_nullable_required_no_embed_list( + list_val: Annotated[Union[list[int], None], Body()], +): + return {"val": list_val} + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + }, + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + }, + "list_val": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + }, + }, + "required": ["int_val", "str_val", "list_val"], + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + ("path", "schema"), + [ + ( + "/nullable-required-str", + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Str Val", + }, + ), + ( + "/nullable-required-int", + { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "title": "Int Val", + }, + ), + ( + "/nullable-required-list", + { + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + "title": "List Val", + }, + ), + ], +) +def test_nullable_required_no_embed_schema(path: str, schema: dict): + openapi = app.openapi() + path_operation = openapi["paths"][path]["post"] + assert ( + path_operation["requestBody"]["content"]["application/json"]["schema"] == schema + ) + assert path_operation["requestBody"]["required"] is True + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_missing(path: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body", "int_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["body", "str_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + { + "type": "missing", + "loc": ["body", "list_val"], + "msg": "Field required", + "input": IsOneOf(None, {}), + }, + ] + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-required", + marks=pytest.mark.xfail( + reason="For non-model Body parameters, gives errors for each parameter separately" + ), + ), + "/model-nullable-required", + ], +) +def test_nullable_required_no_body(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body"], + "msg": "Field required", + "input": None, + }, + ] + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required-str", + "/nullable-required-int", + "/nullable-required-list", + ], +) +def test_nullable_required_no_embed_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "input": None, + "loc": ["body"], + "msg": "Field required", + "type": "missing", + } + ] + } + + +@pytest.mark.parametrize( + ("path", "msg", "error_type"), + [ + ( + "/nullable-required-str", + "Input should be a valid string", + "string_type", + ), + ( + "/nullable-required-int", + "Input should be a valid integer", + "int_type", + ), + ( + "/nullable-required-list", + "Input should be a valid list", + "list_type", + ), + ], +) +def test_nullable_required_pass_empty_dict(path: str, msg: str, error_type: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "input": {}, + "loc": ["body"], + "msg": msg, + "type": error_type, + } + ] + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-required", + marks=pytest.mark.xfail( + reason="Null values are treated as missing for non-model Body parameters" + ), + ), + pytest.param( + "/model-nullable-required", + ), + ], +) +def test_nullable_required_pass_null(path: str): + client = TestClient(app) + response = client.post( + path, + json={ + "int_val": None, + "str_val": None, + "list_val": None, + }, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": None, + "str_val": None, + "list_val": None, + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required-str", + "/nullable-required-int", + "/nullable-required-list", + ], +) +@pytest.mark.xfail(reason="Explicit null-body is treated as missing") +def test_nullable_required_no_embed_pass_null(path: str): + client = TestClient(app) + response = client.post(path, content="null") + assert response.status_code == 200, response.text + assert response.json() == {"val": None} + # TODO: add test with BeforeValidator to ensure that it recieves `None` value + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-required", + "/model-nullable-required", + ], +) +def test_nullable_required_pass_value(path: str): + client = TestClient(app) + response = client.post( + path, json={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} + ) + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + ("path", "value"), + [ + ("/nullable-required-str", "test"), + ("/nullable-required-int", 1), + ("/nullable-required-list", [1, 2]), + ], +) +def test_nullable_required_no_embed_pass_value(path: str, value: Any): + client = TestClient(app) + response = client.post( + path, + json=value, + ) + assert response.status_code == 200, response.text + assert response.json() == {"val": value} + + +# ===================================================================================== +# Nullable with default=None + + +@app.post("/nullable-non-required") +async def read_nullable_non_required( + int_val: Annotated[Union[int, None], Body()] = None, + str_val: Annotated[Union[str, None], Body()] = None, + list_val: Union[list[int], None] = None, +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableNonRequired(BaseModel): + int_val: Union[int, None] = None + str_val: Union[str, None] = None + list_val: Union[list[int], None] = None + + +@app.post("/model-nullable-non-required") +async def read_model_nullable_non_required( + params: ModelNullableNonRequired, +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@app.post("/nullable-non-required-str") +async def read_nullable_non_required_no_embed_str( + str_val: Annotated[Union[str, None], Body()] = None, +): + return {"val": str_val} + + +@app.post("/nullable-non-required-int") +async def read_nullable_non_required_no_embed_int( + int_val: Annotated[Union[int, None], Body()] = None, +): + return {"val": int_val} + + +@app.post("/nullable-non-required-list") +async def read_nullable_non_required_no_embed_list( + list_val: Annotated[Union[list[int], None], Body()] = None, +): + return {"val": list_val} + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + "list_val": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + # "default": None, # `None` values are omitted in OpenAPI schema + }, + }, + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + ("path", "schema"), + [ + ( + "/nullable-non-required-str", + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Str Val", + # "default": None, # `None` values are omitted in OpenAPI schema + }, + ), + ( + "/nullable-non-required-int", + { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "title": "Int Val", + # "default": None, # `None` values are omitted in OpenAPI schema + }, + ), + ( + "/nullable-non-required-list", + { + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + "title": "List Val", + # "default": None, # `None` values are omitted in OpenAPI schema + }, + ), + ], +) +def test_nullable_non_required_no_embed_schema(path: str, schema: dict): + openapi = app.openapi() + path_operation = openapi["paths"][path]["post"] + assert ( + path_operation["requestBody"]["content"]["application/json"]["schema"] == schema + ) + assert "required" not in path_operation["requestBody"] + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_missing(path: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 200 + assert response.json() == { + "int_val": None, + "str_val": None, + "list_val": None, + "fields_set": IsOneOf(None, []), + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-non-required", + marks=pytest.mark.xfail( + reason="For non-model Body parameters, validates each parameter separately" + ), + ), + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_no_body(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body"], + "msg": "Field required", + "input": None, + }, + ] + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required-str", + "/nullable-non-required-int", + "/nullable-non-required-list", + ], +) +def test_nullable_non_required_no_embed_missing(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200 + assert response.json() == {"val": None} + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_pass_null(path: str): + client = TestClient(app) + response = client.post( + path, + json={ + "int_val": None, + "str_val": None, + "list_val": None, + }, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": None, + "str_val": None, + "list_val": None, + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required-str", + "/nullable-non-required-int", + "/nullable-non-required-list", + ], +) +def test_nullable_non_required_no_embed_pass_null(path: str): + client = TestClient(app) + response = client.post(path, content="null") + assert response.status_code == 200, response.text + assert response.json() == {"val": None} + # TODO: add test with BeforeValidator to ensure that it recieves `None` value + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-non-required", + "/model-nullable-non-required", + ], +) +def test_nullable_non_required_pass_value(path: str): + client = TestClient(app) + response = client.post( + path, json={"int_val": 1, "str_val": "test", "list_val": [1, 2]} + ) + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + ("path", "value"), + [ + ("/nullable-non-required-str", "test"), + ("/nullable-non-required-int", 1), + ("/nullable-non-required-list", [1, 2]), + ], +) +def test_nullable_non_required_no_embed_pass_value(path: str, value: Any): + client = TestClient(app) + response = client.post(path, json=value) + assert response.status_code == 200, response.text + assert response.json() == {"val": value} + + +# ===================================================================================== +# Nullable with not-None default + + +@app.post("/nullable-with-non-null-default") +async def read_nullable_with_non_null_default( + *, + int_val: Annotated[Union[int, None], Body()] = -1, + str_val: Annotated[Union[str, None], Body()] = "default", + list_val: Annotated[Union[list[int], None], Body(default_factory=lambda: [0])], +): + return { + "int_val": int_val, + "str_val": str_val, + "list_val": list_val, + "fields_set": None, + } + + +class ModelNullableWithNonNullDefault(BaseModel): + int_val: Union[int, None] = -1 + str_val: Union[str, None] = "default" + list_val: Union[list[int], None] = [0] + + +@app.post("/model-nullable-with-non-null-default") +async def read_model_nullable_with_non_null_default( + params: ModelNullableWithNonNullDefault, +): + return { + "int_val": params.int_val, + "str_val": params.str_val, + "list_val": params.list_val, + "fields_set": params.model_fields_set, + } + + +@app.post("/nullable-with-non-null-default-str") +async def read_nullable_with_non_null_default_no_embed_str( + str_val: Annotated[Union[str, None], Body()] = "default", +): + return {"val": str_val} + + +@app.post("/nullable-with-non-null-default-int") +async def read_nullable_with_non_null_default_no_embed_int( + int_val: Annotated[Union[int, None], Body()] = -1, +): + return {"val": int_val} + + +@app.post("/nullable-with-non-null-default-list") +async def read_nullable_with_non_null_default_no_embed_list( + list_val: Annotated[Union[list[int], None], Body(default_factory=lambda: [0])], +): + return {"val": list_val} + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-with-non-null-default", + marks=pytest.mark.xfail( + reason="`default_factory` is not reflected in OpenAPI schema" + ), + ), + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_schema(path: str): + openapi = app.openapi() + body_model_name = get_body_model_name(openapi, path) + + assert app.openapi()["components"]["schemas"][body_model_name] == { + "properties": { + "int_val": { + "title": "Int Val", + "anyOf": [{"type": "integer"}, {"type": "null"}], + "default": -1, + }, + "str_val": { + "title": "Str Val", + "anyOf": [{"type": "string"}, {"type": "null"}], + "default": "default", + }, + "list_val": { + "title": "List Val", + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + "default": [0], # default_factory is not reflected in OpenAPI schema + }, + }, + "title": body_model_name, + "type": "object", + } + + +@pytest.mark.parametrize( + ("path", "schema"), + [ + ( + "/nullable-with-non-null-default-str", + { + "anyOf": [{"type": "string"}, {"type": "null"}], + "title": "Str Val", + "default": "default", + }, + ), + ( + "/nullable-with-non-null-default-int", + { + "anyOf": [{"type": "integer"}, {"type": "null"}], + "title": "Int Val", + "default": -1, + }, + ), + pytest.param( + "/nullable-with-non-null-default-list", + { + "anyOf": [ + {"type": "array", "items": {"type": "integer"}}, + {"type": "null"}, + ], + "title": "List Val", + "default": [0], # default_factory is not reflected in OpenAPI schema + }, + marks=pytest.mark.xfail( + reason="`default_factory` is not reflected in OpenAPI schema" + ), + ), + ], +) +def test_nullable_with_non_null_default_no_embed_schema(path: str, schema: dict): + openapi = app.openapi() + path_operation = openapi["paths"][path]["post"] + assert ( + path_operation["requestBody"]["content"]["application/json"]["schema"] == schema + ) + assert "required" not in path_operation["requestBody"] + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default", + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_missing(path: str): + client = TestClient(app) + response = client.post(path, json={}) + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": -1, + "str_val": "default", + "list_val": [0], + "fields_set": IsOneOf(None, []), + } + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-with-non-null-default", + marks=pytest.mark.xfail( + reason="For non-model Body parameters, validates each parameter separately" + ), + ), + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_no_body(path: str): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["body"], + "msg": "Field required", + "input": None, + }, + ] + } + + +@pytest.mark.parametrize( + ("path", "expected"), + [ + ("/nullable-with-non-null-default-str", "default"), + ("/nullable-with-non-null-default-int", -1), + ("/nullable-with-non-null-default-list", [0]), + ], +) +def test_nullable_with_non_null_default_no_embed_missing(path: str, expected: Any): + client = TestClient(app) + response = client.post(path) + assert response.status_code == 200, response.text + assert response.json() == {"val": expected} + + +@pytest.mark.parametrize( + "path", + [ + pytest.param( + "/nullable-with-non-null-default", + marks=pytest.mark.xfail( + reason="Null values are treated as missing for non-model Body parameters" + ), + ), + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_pass_null(path: str): + client = TestClient(app) + response = client.post( + path, + json={ + "int_val": None, + "str_val": None, + "list_val": None, + }, + ) + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": None, + "str_val": None, + "list_val": None, + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default-str", + "/nullable-with-non-null-default-int", + "/nullable-with-non-null-default-list", + ], +) +@pytest.mark.xfail(reason="Explicit null-body is treated as missing") +def test_nullable_with_non_null_default_no_embed_pass_null(path: str): + client = TestClient(app) + response = client.post(path, content="null") + assert response.status_code == 200, response.text + assert response.json() == {"val": None} + + +@pytest.mark.parametrize( + "path", + [ + "/nullable-with-non-null-default", + "/model-nullable-with-non-null-default", + ], +) +def test_nullable_with_non_null_default_pass_value(path: str): + client = TestClient(app) + response = client.post( + path, json={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]} + ) + assert response.status_code == 200, response.text + assert response.json() == { + "int_val": 1, + "str_val": "test", + "list_val": [1, 2], + "fields_set": IsOneOf( + None, IsList("int_val", "str_val", "list_val", check_order=False) + ), + } + + +@pytest.mark.parametrize( + ("path", "value"), + [ + ("/nullable-with-non-null-default-str", "test"), + ("/nullable-with-non-null-default-int", 1), + ("/nullable-with-non-null-default-list", [1, 2]), + ], +) +def test_nullable_with_non_null_default_no_embed_pass_value(path: str, value: Any): + client = TestClient(app) + response = client.post(path, json=value) + assert response.status_code == 200, response.text + assert response.json() == {"val": value}