Compare commits

...

21 Commits

Author SHA1 Message Date
github-actions[bot]
90ebf74f68 🎨 Auto format 2026-02-25 17:41:37 +00:00
Yurii Motov
7e96b8b8fa Update snapshots to reflect latest changes in file param schema 2026-02-25 18:38:54 +01:00
Yurii Motov
1a251c63c2 Use inline snapshots in assertions 2026-02-25 18:36:46 +01:00
Yurii Motov
d10fa5df11 Update to Python 3.10 syntax 2026-02-25 17:27:52 +01:00
Yurii Motov
c49c90efc0 Merge remote-tracking branch 'upstream/master' into add-tests-for-parameter-defaults 2026-02-25 17:24:19 +01:00
Yurii Motov
f49f65aa16 Add tests with passing empty string value 2026-02-06 16:59:18 +01:00
Yurii Motov
c733bab825 Fix comment for different parameter types 2026-02-06 16:35:39 +01:00
Yurii Motov
bfc09d9440 Update xfail reason msg 2026-02-06 16:33:45 +01:00
Yurii Motov
2a2aafa01e Update tests for passing empty str to Form 2026-02-06 16:27:25 +01:00
Yurii Motov
cf0d31bd69 Fix test name 2026-02-06 14:47:26 +01:00
Yurii Motov
7fed2671c4 Add pragma: no cover to make coverage pass 2026-02-05 15:38:23 +01:00
Yurii Motov
d90bcc8569 Remove xfail for defaul_factory in openapi 2026-02-05 15:15:00 +01:00
Yurii Motov
27cc340880 Add tests for nullable File parameter with\without default 2026-02-05 11:35:48 +01:00
Yurii Motov
9e85c19d3a Add BeforeValidator to Body tests 2026-02-05 11:35:48 +01:00
Yurii Motov
3441e14197 Check call args for empty string with Form 2026-02-05 11:35:48 +01:00
Yurii Motov
e1adc4a739 Add tests for nullable Header parameter with\without default 2026-02-05 11:35:48 +01:00
Yurii Motov
e6475e960a Add tests for nullable Cookie parameters with\without default 2026-02-05 11:35:48 +01:00
Yurii Motov
2d43382626 Add tests for nullable Body parameter with\without default 2026-02-05 11:35:48 +01:00
Yurii Motov
0b5fea716b Add notes about nullability and default for Path parameters 2026-02-05 11:35:48 +01:00
Yurii Motov
22d795d890 Add tests for nullable Form parameter with\without default 2026-02-05 11:35:48 +01:00
Yurii Motov
dada1d581f Add tests for nullable query parameter with\without default 2026-02-05 11:35:48 +01:00
7 changed files with 3956 additions and 0 deletions

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,431 @@
from typing import Annotated, Any
from unittest.mock import Mock, patch
import pytest
from dirty_equals import IsList, IsOneOf
from fastapi import Cookie, FastAPI
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
from pydantic import BaseModel, BeforeValidator, field_validator
app = FastAPI()
def convert(v: Any) -> Any:
return v
# =====================================================================================
# Nullable required
@app.get("/nullable-required")
async def read_nullable_required(
int_val: Annotated[
int | None,
Cookie(),
BeforeValidator(lambda v: convert(v)),
],
str_val: Annotated[
str | None,
Cookie(),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"int_val": int_val,
"str_val": str_val,
"fields_set": None,
}
class ModelNullableRequired(BaseModel):
int_val: int | None
str_val: str | None
@field_validator("*", mode="before")
@classmethod
def convert_fields(cls, v):
return convert(v)
@app.get("/model-nullable-required")
async def read_model_nullable_required(
params: Annotated[ModelNullableRequired, Cookie()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_schema(path: str):
assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot(
[
{
"required": True,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
},
"name": "int_val",
"in": "cookie",
},
{
"required": True,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
},
"name": "str_val",
"in": "cookie",
},
]
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 422
assert response.json() == snapshot(
{
"detail": [
{
"type": "missing",
"loc": ["cookie", "int_val"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
{
"type": "missing",
"loc": ["cookie", "str_val"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
]
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
@pytest.mark.parametrize(
"values",
[
{"int_val": "1", "str_val": "test"},
{"int_val": "0", "str_val": ""},
],
)
def test_nullable_required_pass_value(path: str, values: dict[str, str]):
client = TestClient(app)
client.cookies.set("int_val", values["int_val"])
client.cookies.set("str_val", values["str_val"])
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 2, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": int(values["int_val"]),
"str_val": values["str_val"],
"fields_set": IsOneOf(None, IsList("int_val", "str_val", check_order=False)),
}
# =====================================================================================
# Nullable with default=None
@app.get("/nullable-non-required")
async def read_nullable_non_required(
int_val: Annotated[
int | None,
Cookie(),
BeforeValidator(lambda v: convert(v)),
] = None,
str_val: Annotated[
str | None,
Cookie(),
BeforeValidator(lambda v: convert(v)),
] = None,
):
return {
"int_val": int_val,
"str_val": str_val,
"fields_set": None,
}
class ModelNullableNonRequired(BaseModel):
int_val: int | None = None
str_val: str | None = None
@field_validator("*", mode="before")
@classmethod
def convert_fields(cls, v):
return convert(v)
@app.get("/model-nullable-non-required")
async def read_model_nullable_non_required(
params: Annotated[ModelNullableNonRequired, Cookie()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_schema(path: str):
assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot(
[
{
"required": False,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"name": "int_val",
"in": "cookie",
},
{
"required": False,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"name": "str_val",
"in": "cookie",
},
]
)
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200
assert response.json() == {
"int_val": None,
"str_val": None,
"fields_set": IsOneOf(None, []),
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
@pytest.mark.parametrize(
"values",
[
{"int_val": "1", "str_val": "test"},
{"int_val": "0", "str_val": ""},
],
)
def test_nullable_non_required_pass_value(path: str, values: dict[str, str]):
client = TestClient(app)
client.cookies.set("int_val", values["int_val"])
client.cookies.set("str_val", values["str_val"])
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 2, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": int(values["int_val"]),
"str_val": values["str_val"],
"fields_set": IsOneOf(None, IsList("int_val", "str_val", check_order=False)),
}
# =====================================================================================
# Nullable with not-None default
@app.get("/nullable-with-non-null-default")
async def read_nullable_with_non_null_default(
*,
int_val: Annotated[
int | None,
Cookie(),
BeforeValidator(lambda v: convert(v)),
] = -1,
str_val: Annotated[
str | None,
Cookie(),
BeforeValidator(lambda v: convert(v)),
] = "default",
):
return {
"int_val": int_val,
"str_val": str_val,
"fields_set": None,
}
class ModelNullableWithNonNullDefault(BaseModel):
int_val: int | None = -1
str_val: str | None = "default"
@field_validator("*", mode="before")
@classmethod
def convert_fields(cls, v):
return convert(v)
@app.get("/model-nullable-with-non-null-default")
async def read_model_nullable_with_non_null_default(
params: Annotated[ModelNullableWithNonNullDefault, Cookie()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
def test_nullable_with_non_null_default_schema(path: str):
assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot(
[
{
"required": False,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
"default": -1,
},
"name": "int_val",
"in": "cookie",
},
{
"required": False,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": "default",
},
"name": "str_val",
"in": "cookie",
},
]
)
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
@pytest.mark.xfail(
reason="Missing parameters are pre-populated with default values before validation"
)
def test_nullable_with_non_null_default_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200 # pragma: no cover
assert response.json() == { # pragma: no cover
"int_val": -1,
"str_val": "default",
"fields_set": IsOneOf(None, []),
}
# TODO: Remove 'no cover' when the issue is fixed
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
@pytest.mark.parametrize(
"values",
[
{"int_val": "1", "str_val": "test"},
{"int_val": "0", "str_val": ""},
],
)
def test_nullable_with_non_null_default_pass_value(path: str, values: dict[str, str]):
client = TestClient(app)
client.cookies.set("int_val", values["int_val"])
client.cookies.set("str_val", values["str_val"])
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 2, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": int(values["int_val"]),
"str_val": values["str_val"],
"fields_set": IsOneOf(None, IsList("int_val", "str_val", check_order=False)),
}

View File

@@ -0,0 +1,525 @@
from typing import Annotated, Any
from unittest.mock import Mock, patch
import pytest
from dirty_equals import IsOneOf
from fastapi import FastAPI, File, UploadFile
from fastapi.testclient import TestClient
from inline_snapshot import Is, snapshot
from pydantic import BeforeValidator
from starlette.datastructures import UploadFile as StarletteUploadFile
from .utils import get_body_model_name
app = FastAPI()
def convert(v: Any) -> Any:
return v
# =====================================================================================
# Nullable required
@app.post("/nullable-required-bytes")
async def read_nullable_required_bytes(
file: Annotated[
bytes | None,
File(),
BeforeValidator(lambda v: convert(v)),
],
files: Annotated[
list[bytes] | None,
File(),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"file": len(file) if file is not None else None,
"files": [len(f) for f in files] if files is not None else None,
}
@app.post("/nullable-required-uploadfile")
async def read_nullable_required_uploadfile(
file: Annotated[
UploadFile | None,
File(),
BeforeValidator(lambda v: convert(v)),
],
files: Annotated[
list[UploadFile] | None,
File(),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"file": file.size if file is not None else None,
"files": [f.size for f in files] if files is not None else None,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-required-bytes",
"/nullable-required-uploadfile",
],
)
def test_nullable_required_schema(path: str):
openapi = app.openapi()
body_model_name = get_body_model_name(openapi, path)
assert openapi["components"]["schemas"][body_model_name] == snapshot(
{
"properties": {
"file": {
"title": "File",
"anyOf": [
{
"type": "string",
"contentMediaType": "application/octet-stream",
},
{"type": "null"},
],
},
"files": {
"title": "Files",
"anyOf": [
{
"type": "array",
"items": {
"type": "string",
"contentMediaType": "application/octet-stream",
},
},
{"type": "null"},
],
},
},
"required": ["file", "files"],
"title": Is(body_model_name),
"type": "object",
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required-bytes",
"/nullable-required-uploadfile",
],
)
def test_nullable_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 422
assert response.json() == snapshot(
{
"detail": [
{
"type": "missing",
"loc": ["body", "file"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
{
"type": "missing",
"loc": ["body", "files"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
]
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required-bytes",
"/nullable-required-uploadfile",
],
)
def test_nullable_required_pass_empty_file(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
files=[("file", b""), ("files", b""), ("files", b"")],
)
assert mock_convert.call_count == 2, "Validator should be called for each field"
call_args = [call_args_item.args for call_args_item in mock_convert.call_args_list]
file_call_arg_1 = call_args[0][0]
files_call_arg_1 = call_args[1][0]
assert (
(file_call_arg_1 == b"") # file as bytes
or isinstance(file_call_arg_1, StarletteUploadFile) # file as UploadFile
)
assert (
(files_call_arg_1 == [b"", b""]) # files as bytes
or all( # files as UploadFile
isinstance(f, StarletteUploadFile) for f in files_call_arg_1
)
)
assert response.status_code == 200, response.text
assert response.json() == {
"file": 0,
"files": [0, 0],
}
@pytest.mark.parametrize(
"path",
[
"/nullable-required-bytes",
"/nullable-required-uploadfile",
],
)
def test_nullable_required_pass_file(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
files=[
("file", b"test 1"),
("files", b"test 2"),
("files", b"test 3"),
],
)
assert mock_convert.call_count == 2, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {"file": 6, "files": [6, 6]}
# =====================================================================================
# Nullable with default=None
@app.post("/nullable-non-required-bytes")
async def read_nullable_non_required_bytes(
file: Annotated[
bytes | None,
File(),
BeforeValidator(lambda v: convert(v)),
] = None,
files: Annotated[
list[bytes] | None,
File(),
BeforeValidator(lambda v: convert(v)),
] = None,
):
return {
"file": len(file) if file is not None else None,
"files": [len(f) for f in files] if files is not None else None,
}
@app.post("/nullable-non-required-uploadfile")
async def read_nullable_non_required_uploadfile(
file: Annotated[
UploadFile | None,
File(),
BeforeValidator(lambda v: convert(v)),
] = None,
files: Annotated[
list[UploadFile] | None,
File(),
BeforeValidator(lambda v: convert(v)),
] = None,
):
return {
"file": file.size if file is not None else None,
"files": [f.size for f in files] if files is not None else None,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required-bytes",
"/nullable-non-required-uploadfile",
],
)
def test_nullable_non_required_schema(path: str):
openapi = app.openapi()
body_model_name = get_body_model_name(openapi, path)
assert openapi["components"]["schemas"][body_model_name] == snapshot(
{
"properties": {
"file": {
"title": "File",
"anyOf": [
{
"type": "string",
"contentMediaType": "application/octet-stream",
},
{"type": "null"},
],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"files": {
"title": "Files",
"anyOf": [
{
"type": "array",
"items": {
"type": "string",
"contentMediaType": "application/octet-stream",
},
},
{"type": "null"},
],
# "default": None, # `None` values are omitted in OpenAPI schema
},
},
"title": Is(body_model_name),
"type": "object",
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required-bytes",
"/nullable-non-required-uploadfile",
],
)
def test_nullable_non_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200
assert response.json() == {
"file": None,
"files": None,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required-bytes",
"/nullable-non-required-uploadfile",
],
)
def test_nullable_non_required_pass_empty_file(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
files=[("file", b""), ("files", b""), ("files", b"")],
)
assert mock_convert.call_count == 2, "Validator should be called for each field"
call_args = [call_args_item.args for call_args_item in mock_convert.call_args_list]
file_call_arg_1 = call_args[0][0]
files_call_arg_1 = call_args[1][0]
assert (
(file_call_arg_1 == b"") # file as bytes
or isinstance(file_call_arg_1, StarletteUploadFile) # file as UploadFile
)
assert (
(files_call_arg_1 == [b"", b""]) # files as bytes
or all( # files as UploadFile
isinstance(f, StarletteUploadFile) for f in files_call_arg_1
)
)
assert response.status_code == 200, response.text
assert response.json() == {"file": 0, "files": [0, 0]}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required-bytes",
"/nullable-non-required-uploadfile",
],
)
def test_nullable_non_required_pass_file(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
files=[("file", b"test 1"), ("files", b"test 2"), ("files", b"test 3")],
)
assert mock_convert.call_count == 2, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {"file": 6, "files": [6, 6]}
# =====================================================================================
# Nullable with not-None default
@app.post("/nullable-with-non-null-default-bytes")
async def read_nullable_with_non_null_default_bytes(
*,
file: Annotated[
bytes | None,
File(),
BeforeValidator(lambda v: convert(v)),
] = b"default",
files: Annotated[
list[bytes] | None,
File(default_factory=lambda: [b"default"]),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"file": len(file) if file is not None else None,
"files": [len(f) for f in files] if files is not None else None,
}
# Note: It seems to be not possible to create endpoint with UploadFile and non-None default
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default-bytes",
],
)
def test_nullable_with_non_null_default_schema(path: str):
openapi = app.openapi()
body_model_name = get_body_model_name(openapi, path)
assert openapi["components"]["schemas"][body_model_name] == snapshot(
{
"properties": {
"file": {
"title": "File",
"anyOf": [
{
"type": "string",
"contentMediaType": "application/octet-stream",
},
{"type": "null"},
],
"default": "default", # <= Default value here looks strange to me
},
"files": {
"title": "Files",
"anyOf": [
{
"type": "array",
"items": {
"type": "string",
"contentMediaType": "application/octet-stream",
},
},
{"type": "null"},
],
},
},
"title": Is(body_model_name),
"type": "object",
}
)
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-with-non-null-default-bytes",
marks=pytest.mark.xfail(
reason="AttributeError: 'bytes' object has no attribute 'read'",
),
),
],
)
def test_nullable_with_non_null_default_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(path)
assert mock_convert.call_count == 0, ( # pragma: no cover
"Validator should not be called if the value is missing"
)
assert response.status_code == 200 # pragma: no cover
assert response.json() == {"file": None, "files": None} # pragma: no cover
# TODO: Remove 'no cover' when the issue is fixed
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default-bytes",
],
)
def test_nullable_with_non_null_default_pass_empty_file(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
files=[("file", b""), ("files", b""), ("files", b"")],
)
assert mock_convert.call_count == 2, "Validator should be called for each field"
call_args = [call_args_item.args for call_args_item in mock_convert.call_args_list]
file_call_arg_1 = call_args[0][0]
files_call_arg_1 = call_args[1][0]
assert (
(file_call_arg_1 == b"") # file as bytes
or isinstance(file_call_arg_1, StarletteUploadFile) # file as UploadFile
)
assert (
(files_call_arg_1 == [b"", b""]) # files as bytes
or all( # files as UploadFile
isinstance(f, StarletteUploadFile) for f in files_call_arg_1
)
)
assert response.status_code == 200, response.text
assert response.json() == {"file": 0, "files": [0, 0]}
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default-bytes",
],
)
def test_nullable_with_non_null_default_pass_file(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
files=[("file", b"test 1"), ("files", b"test 2"), ("files", b"test 3")],
)
assert mock_convert.call_count == 2, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {"file": 6, "files": [6, 6]}

View File

@@ -0,0 +1,746 @@
from typing import Annotated, Any
from unittest.mock import Mock, call, patch
import pytest
from dirty_equals import IsList, IsOneOf, IsPartialDict
from fastapi import FastAPI, Form
from fastapi.testclient import TestClient
from inline_snapshot import Is, snapshot
from pydantic import BaseModel, BeforeValidator, field_validator
from .utils import get_body_model_name
app = FastAPI()
def convert(v: Any) -> Any:
return v
# =====================================================================================
# Nullable required
@app.post("/nullable-required")
async def read_nullable_required(
int_val: Annotated[
int | None,
Form(),
BeforeValidator(lambda v: convert(v)),
],
str_val: Annotated[
str | None,
Form(),
BeforeValidator(lambda v: convert(v)),
],
list_val: Annotated[
list[int] | None,
Form(),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableRequired(BaseModel):
int_val: int | None
str_val: str | None
list_val: list[int] | None
@field_validator("*", mode="before")
def convert_fields(cls, v):
return convert(v)
@app.post("/model-nullable-required")
async def read_model_nullable_required(
params: Annotated[ModelNullableRequired, Form()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@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 openapi["components"]["schemas"][body_model_name] == snapshot(
{
"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": Is(body_model_name),
"type": "object",
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 422
assert response.json() == snapshot(
{
"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="Empty str is replaced with None even for required parameters"
),
),
"/model-nullable-required",
],
)
def test_nullable_required_pass_empty_str_to_str_val(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
data={
"int_val": "0", # Empty string would cause validation error (see below)
"str_val": "",
"list_val": "0", # Empty string would cause validation error (see below)
},
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert mock_convert.call_args_list == [
call("0"), # int_val
call(""), # str_val
call(["0"]), # list_val
]
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": 0,
"str_val": "",
"list_val": [0],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-required",
marks=pytest.mark.xfail(
reason="Empty str is replaced with None even for required parameters"
),
),
"/model-nullable-required",
],
)
def test_nullable_required_pass_empty_str_to_int_val_and_list_val(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
data={
"int_val": "",
"str_val": "",
"list_val": "",
},
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert mock_convert.call_args_list == [
call(""), # int_val
call(""), # str_val
call([""]), # list_val
]
assert response.status_code == 422, response.text
assert response.json() == snapshot(
{
"detail": [
{
"input": "",
"loc": ["body", "int_val"],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"type": "int_parsing",
},
{
"input": "",
"loc": ["body", "list_val", 0],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"type": "int_parsing",
},
]
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_pass_value(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path, data={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]}
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
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)
),
}
# =====================================================================================
# Nullable with default=None
@app.post("/nullable-non-required")
async def read_nullable_non_required(
int_val: Annotated[
int | None,
Form(),
BeforeValidator(lambda v: convert(v)),
] = None,
str_val: Annotated[
str | None,
Form(),
BeforeValidator(lambda v: convert(v)),
] = None,
list_val: Annotated[
list[int] | None,
Form(),
BeforeValidator(lambda v: convert(v)),
] = None,
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableNonRequired(BaseModel):
int_val: int | None = None
str_val: str | None = None
list_val: list[int] | None = None
@field_validator("*", mode="before")
def convert_fields(cls, v):
return convert(v)
@app.post("/model-nullable-non-required")
async def read_model_nullable_non_required(
params: Annotated[ModelNullableNonRequired, Form()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@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 openapi["components"]["schemas"][body_model_name] == snapshot(
{
"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": Is(body_model_name),
"type": "object",
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
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",
[
"/nullable-non-required",
pytest.param(
"/model-nullable-non-required",
marks=pytest.mark.xfail(
reason="Empty strings are not replaced with None for parameters declared as model"
),
),
],
)
def test_nullable_non_required_pass_empty_str_to_str_val_and_int_val(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
data={
"int_val": "",
"str_val": "",
"list_val": "0", # Empty string would cause validation error (see below)
},
)
assert mock_convert.call_count == 1, "Validator should be called for list_val only"
assert mock_convert.call_args_list == [
call(["0"]), # list_val
]
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": None,
"str_val": None,
"list_val": [0],
"fields_set": IsOneOf(None, IsList("list_val", check_order=False)),
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
pytest.param(
"/model-nullable-non-required",
marks=pytest.mark.xfail(
reason="Empty strings are not replaced with None for parameters declared as model"
),
),
],
)
def test_nullable_non_required_pass_empty_str_to_all(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
data={
"int_val": "",
"str_val": "",
"list_val": "",
},
)
assert mock_convert.call_count == 1, "Validator should be called for list_val only"
assert mock_convert.call_args_list == [
call([""]), # list_val
]
assert response.status_code == 422, response.text
assert response.json() == snapshot(
{
"detail": [
{
"input": "",
"loc": ["body", "list_val", 0],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"type": "int_parsing",
},
]
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_pass_value(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path, data={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]}
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
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)
),
}
# =====================================================================================
# Nullable with not-None default
@app.post("/nullable-with-non-null-default")
async def read_nullable_with_non_null_default(
*,
int_val: Annotated[
int | None,
Form(),
BeforeValidator(lambda v: convert(v)),
] = -1,
str_val: Annotated[
str | None,
Form(),
BeforeValidator(lambda v: convert(v)),
] = "default",
list_val: Annotated[
list[int] | None,
Form(default_factory=lambda: [0]),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableWithNonNullDefault(BaseModel):
int_val: int | None = -1
str_val: str | None = "default"
list_val: list[int] | None = [0]
@field_validator("*", mode="before")
def convert_fields(cls, v):
return convert(v)
@app.post("/model-nullable-with-non-null-default")
async def read_model_nullable_with_non_null_default(
params: Annotated[ModelNullableWithNonNullDefault, Form()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/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)
body_model = openapi["components"]["schemas"][body_model_name]
assert body_model == snapshot(
{
"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": IsPartialDict(
{
"title": "List Val",
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
}
),
},
"title": Is(body_model_name),
"type": "object",
}
)
if path == "/model-nullable-with-non-null-default":
# Check default value for list_val param for model-based parameters only.
# default_factory is not reflected in OpenAPI schema
assert body_model["properties"]["list_val"]["default"] == [0]
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
@pytest.mark.xfail(
reason="Missing parameters are pre-populated with default values before validation"
)
def test_nullable_with_non_null_default_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200 # pragma: no cover
assert response.json() == { # pragma: no cover
"int_val": -1,
"str_val": "default",
"list_val": [0],
"fields_set": IsOneOf(None, []),
}
# TODO: Remove 'no cover' when the issue is fixed
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-with-non-null-default",
marks=pytest.mark.xfail(
reason="Empty strings are replaced with default values before validation"
),
),
pytest.param(
"/model-nullable-with-non-null-default",
marks=pytest.mark.xfail(
reason="Empty strings are not replaced with None for parameters declared as model"
),
),
],
)
def test_nullable_with_non_null_default_pass_empty_str_to_str_val_and_int_val(
path: str,
):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
data={
"int_val": "",
"str_val": "",
"list_val": "0", # Empty string would cause validation error (see below)
},
)
assert mock_convert.call_count == 1, "Validator should be called for list_val only"
assert mock_convert.call_args_list == [ # pragma: no cover
call(["0"]), # list_val
]
assert response.status_code == 200, response.text # pragma: no cover
assert response.json() == { # pragma: no cover
"int_val": -1,
"str_val": "default",
"list_val": [0],
"fields_set": IsOneOf(None, IsList("list_val", check_order=False)),
}
# TODO: Remove 'no cover' when the issue is fixed
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-with-non-null-default",
marks=pytest.mark.xfail(
reason="Empty strings are replaced with default values before validation"
),
),
pytest.param(
"/model-nullable-with-non-null-default",
marks=pytest.mark.xfail(
reason="Empty strings are not replaced with None for parameters declared as model"
),
),
],
)
def test_nullable_with_non_null_default_pass_empty_str_to_all(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path,
data={
"int_val": "",
"str_val": "",
"list_val": "",
},
)
assert mock_convert.call_count == 1, "Validator should be called for list_val only"
assert mock_convert.call_args_list == [ # pragma: no cover
call([""]), # list_val
]
assert response.status_code == 422, response.text # pragma: no cover
assert response.json() == snapshot( # pragma: no cover
{
"detail": [
{
"input": "",
"loc": ["body", "list_val", 0],
"msg": "Input should be a valid integer, unable to parse string as an integer",
"type": "int_parsing",
},
]
}
)
# TODO: Remove 'no cover' when the issue is fixed
@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)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.post(
path, data={"int_val": "1", "str_val": "test", "list_val": ["1", "2"]}
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
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)
),
}

View File

@@ -0,0 +1,634 @@
from typing import Annotated, Any
from unittest.mock import Mock, patch
import pytest
from dirty_equals import AnyThing, IsList, IsOneOf, IsPartialDict
from fastapi import FastAPI, Header
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
from pydantic import BaseModel, BeforeValidator, field_validator
app = FastAPI()
def convert(v: Any) -> Any:
return v
# =====================================================================================
# Nullable required
@app.get("/nullable-required")
async def read_nullable_required(
int_val: Annotated[
int | None,
Header(),
BeforeValidator(lambda v: convert(v)),
],
str_val: Annotated[
str | None,
Header(),
BeforeValidator(lambda v: convert(v)),
],
list_val: Annotated[
list[int] | None,
Header(),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableRequired(BaseModel):
int_val: int | None
str_val: str | None
list_val: list[int] | None
@field_validator("*", mode="before")
@classmethod
def convert_fields(cls, v):
return convert(v)
@app.get("/model-nullable-required")
async def read_model_nullable_required(
params: Annotated[ModelNullableRequired, Header()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-required",
marks=pytest.mark.xfail(
reason="Title contains hyphens for single Header parameters"
),
),
"/model-nullable-required",
],
)
def test_nullable_required_schema(path: str):
assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot(
[
{
"required": True,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
},
"name": "int-val",
"in": "header",
},
{
"required": True,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
},
"name": "str-val",
"in": "header",
},
{
"required": True,
"schema": {
"title": "List Val",
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
},
"name": "list-val",
"in": "header",
},
]
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
pytest.param(
"/model-nullable-required",
marks=pytest.mark.xfail(
reason=(
"For parameters declared as model, underscores are not replaced "
"with hyphens in error loc"
)
),
),
],
)
def test_nullable_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 422
assert response.json() == snapshot(
{
"detail": [
{
"type": "missing",
"loc": ["header", "int-val"],
"msg": "Field required",
"input": AnyThing(),
},
{
"type": "missing",
"loc": ["header", "str-val"],
"msg": "Field required",
"input": AnyThing(),
},
{
"type": "missing",
"loc": ["header", "list-val"],
"msg": "Field required",
"input": AnyThing(),
},
]
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_pass_value(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(
path,
headers=[
("int-val", "1"),
("str-val", "test"),
("list-val", "1"),
("list-val", "2"),
],
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
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",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_pass_empty_str_to_str_val(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(
path,
headers=[
("int-val", "1"),
("str-val", ""),
("list-val", "1"),
],
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": 1,
"str_val": "",
"list_val": [1],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
# =====================================================================================
# Nullable with default=None
@app.get("/nullable-non-required")
async def read_nullable_non_required(
int_val: Annotated[
int | None,
Header(),
BeforeValidator(lambda v: convert(v)),
] = None,
str_val: Annotated[
str | None,
Header(),
BeforeValidator(lambda v: convert(v)),
] = None,
list_val: Annotated[
list[int] | None,
Header(),
BeforeValidator(lambda v: convert(v)),
] = None,
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableNonRequired(BaseModel):
int_val: int | None = None
str_val: str | None = None
list_val: list[int] | None = None
@field_validator("*", mode="before")
@classmethod
def convert_fields(cls, v):
return convert(v)
@app.get("/model-nullable-non-required")
async def read_model_nullable_non_required(
params: Annotated[ModelNullableNonRequired, Header()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-non-required",
marks=pytest.mark.xfail(
reason="Title contains hyphens for single Header parameters"
),
),
"/model-nullable-non-required",
],
)
def test_nullable_non_required_schema(path: str):
assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot(
[
{
"required": False,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"name": "int-val",
"in": "header",
},
{
"required": False,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"name": "str-val",
"in": "header",
},
{
"required": False,
"schema": {
"title": "List Val",
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"name": "list-val",
"in": "header",
},
]
)
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
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",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_pass_value(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(
path,
headers=[
("int-val", "1"),
("str-val", "test"),
("list-val", "1"),
("list-val", "2"),
],
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
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",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_pass_empty_str_to_str_val(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(
path,
headers=[
("int-val", "1"),
("str-val", ""),
("list-val", "1"),
],
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": 1,
"str_val": "",
"list_val": [1],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
# =====================================================================================
# Nullable with not-None default
@app.get("/nullable-with-non-null-default")
async def read_nullable_with_non_null_default(
*,
int_val: Annotated[
int | None,
Header(),
BeforeValidator(lambda v: convert(v)),
] = -1,
str_val: Annotated[
str | None,
Header(),
BeforeValidator(lambda v: convert(v)),
] = "default",
list_val: Annotated[
list[int] | None,
Header(default_factory=lambda: [0]),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableWithNonNullDefault(BaseModel):
int_val: int | None = -1
str_val: str | None = "default"
list_val: list[int] | None = [0]
@field_validator("*", mode="before")
@classmethod
def convert_fields(cls, v):
return convert(v)
@app.get("/model-nullable-with-non-null-default")
async def read_model_nullable_with_non_null_default(
params: Annotated[ModelNullableWithNonNullDefault, Header()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
pytest.param(
"/nullable-with-non-null-default",
marks=pytest.mark.xfail(
reason="Title contains hyphens for single Header parameters"
),
),
"/model-nullable-with-non-null-default",
],
)
def test_nullable_with_non_null_default_schema(path: str):
parameters = app.openapi()["paths"][path]["get"]["parameters"]
assert parameters == snapshot(
[
{
"required": False,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
"default": -1,
},
"name": "int-val",
"in": "header",
},
{
"required": False,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": "default",
},
"name": "str-val",
"in": "header",
},
{
"required": False,
"schema": IsPartialDict(
{
"title": "List Val",
"anyOf": [
{"type": "array", "items": {"type": "integer"}},
{"type": "null"},
],
}
),
"name": "list-val",
"in": "header",
},
]
)
if path == "/model-nullable-with-non-null-default":
# Check default value for list_val param for model-based parameters only.
# default_factory is not reflected in OpenAPI schema
assert parameters[2]["schema"]["default"] == [0]
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
@pytest.mark.xfail(
reason="Missing parameters are pre-populated with default values before validation"
)
def test_nullable_with_non_null_default_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200 # pragma: no cover
assert response.json() == { # pragma: no cover
"int_val": -1,
"str_val": "default",
"list_val": [0],
"fields_set": IsOneOf(None, []),
}
# TODO: Remove 'no cover' when the issue is fixed
@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)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(
path,
headers=[
("int-val", "1"),
("str-val", "test"),
("list-val", "1"),
("list-val", "2"),
],
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
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",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
def test_nullable_with_non_null_default_pass_empty_str_to_str_val(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(
path,
headers=[
("int-val", "1"),
("str-val", ""),
("list-val", "1"),
],
)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": 1,
"str_val": "",
"list_val": [1],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}

View File

@@ -0,0 +1,2 @@
# Not appllicable for Path parameters
# Path parameters cannot have default values or be nullable

View File

@@ -0,0 +1,507 @@
from typing import Annotated, Any
from unittest.mock import Mock, patch
import pytest
from dirty_equals import IsList, IsOneOf, IsPartialDict
from fastapi import FastAPI, Query
from fastapi.testclient import TestClient
from inline_snapshot import snapshot
from pydantic import BaseModel, BeforeValidator, field_validator
app = FastAPI()
def convert(v: Any) -> Any:
return v
# =====================================================================================
# Nullable required
@app.get("/nullable-required")
async def read_nullable_required(
int_val: Annotated[
int | None,
BeforeValidator(lambda v: convert(v)),
],
str_val: Annotated[
str | None,
BeforeValidator(lambda v: convert(v)),
],
list_val: Annotated[
list[int] | None,
Query(),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableRequired(BaseModel):
int_val: int | None
str_val: str | None
list_val: list[int] | None
@field_validator("*", mode="before")
@classmethod
def convert_all(cls, v: Any) -> Any:
return convert(v)
@app.get("/model-nullable-required")
async def read_model_nullable_required(
params: Annotated[ModelNullableRequired, Query()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_schema(path: str):
assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot(
[
{
"required": True,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
},
"name": "int_val",
"in": "query",
},
{
"required": True,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
},
"name": "str_val",
"in": "query",
},
{
"in": "query",
"name": "list_val",
"required": True,
"schema": {
"anyOf": [
{"items": {"type": "integer"}, "type": "array"},
{"type": "null"},
],
"title": "List Val",
},
},
]
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
def test_nullable_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 422
assert response.json() == snapshot(
{
"detail": [
{
"type": "missing",
"loc": ["query", "int_val"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
{
"type": "missing",
"loc": ["query", "str_val"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
{
"type": "missing",
"loc": ["query", "list_val"],
"msg": "Field required",
"input": IsOneOf(None, {}),
},
]
}
)
@pytest.mark.parametrize(
"path",
[
"/nullable-required",
"/model-nullable-required",
],
)
@pytest.mark.parametrize(
"values",
[
{"int_val": "1", "str_val": "test", "list_val": ["1", "2"]},
{"int_val": "0", "str_val": "", "list_val": ["0"]},
],
)
def test_nullable_required_pass_value(path: str, values: dict[str, Any]):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path, params=values)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": int(values["int_val"]),
"str_val": values["str_val"],
"list_val": [int(v) for v in values["list_val"]],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
# =====================================================================================
# Nullable with default=None
@app.get("/nullable-non-required")
async def read_nullable_non_required(
int_val: Annotated[
int | None,
BeforeValidator(lambda v: convert(v)),
] = None,
str_val: Annotated[
str | None,
BeforeValidator(lambda v: convert(v)),
] = None,
list_val: Annotated[
list[int] | None,
Query(),
BeforeValidator(lambda v: convert(v)),
] = None,
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableNonRequired(BaseModel):
int_val: int | None = None
str_val: str | None = None
list_val: list[int] | None = None
@field_validator("*", mode="before")
@classmethod
def convert_all(cls, v: Any) -> Any:
return convert(v)
@app.get("/model-nullable-non-required")
async def read_model_nullable_non_required(
params: Annotated[ModelNullableNonRequired, Query()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_schema(path: str):
assert app.openapi()["paths"][path]["get"]["parameters"] == snapshot(
[
{
"required": False,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"name": "int_val",
"in": "query",
},
{
"required": False,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
# "default": None, # `None` values are omitted in OpenAPI schema
},
"name": "str_val",
"in": "query",
},
{
"in": "query",
"name": "list_val",
"required": False,
"schema": {
"anyOf": [
{"items": {"type": "integer"}, "type": "array"},
{"type": "null"},
],
"title": "List Val",
# "default": None, # `None` values are omitted in OpenAPI schema
},
},
]
)
@pytest.mark.parametrize(
"path",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
def test_nullable_non_required_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
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",
[
"/nullable-non-required",
"/model-nullable-non-required",
],
)
@pytest.mark.parametrize(
"values",
[
{"int_val": "1", "str_val": "test", "list_val": ["1", "2"]},
{"int_val": "0", "str_val": "", "list_val": ["0"]},
],
)
def test_nullable_non_required_pass_value(path: str, values: dict[str, Any]):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path, params=values)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": int(values["int_val"]),
"str_val": values["str_val"],
"list_val": [int(v) for v in values["list_val"]],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}
# =====================================================================================
# Nullable with not-None default
@app.get("/nullable-with-non-null-default")
async def read_nullable_with_non_null_default(
*,
int_val: Annotated[
int | None,
BeforeValidator(lambda v: convert(v)),
] = -1,
str_val: Annotated[
str | None,
BeforeValidator(lambda v: convert(v)),
] = "default",
list_val: Annotated[
list[int] | None,
Query(default_factory=lambda: [0]),
BeforeValidator(lambda v: convert(v)),
],
):
return {
"int_val": int_val,
"str_val": str_val,
"list_val": list_val,
"fields_set": None,
}
class ModelNullableWithNonNullDefault(BaseModel):
int_val: int | None = -1
str_val: str | None = "default"
list_val: list[int] | None = [0]
@field_validator("*", mode="before")
@classmethod
def convert_all(cls, v: Any) -> Any:
return convert(v)
@app.get("/model-nullable-with-non-null-default")
async def read_model_nullable_with_non_null_default(
params: Annotated[ModelNullableWithNonNullDefault, Query()],
):
return {
"int_val": params.int_val,
"str_val": params.str_val,
"list_val": params.list_val,
"fields_set": params.model_fields_set,
}
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
def test_nullable_with_non_null_default_schema(path: str):
parameters = app.openapi()["paths"][path]["get"]["parameters"]
assert parameters == snapshot(
[
{
"required": False,
"schema": {
"title": "Int Val",
"anyOf": [{"type": "integer"}, {"type": "null"}],
"default": -1,
},
"name": "int_val",
"in": "query",
},
{
"required": False,
"schema": {
"title": "Str Val",
"anyOf": [{"type": "string"}, {"type": "null"}],
"default": "default",
},
"name": "str_val",
"in": "query",
},
{
"in": "query",
"name": "list_val",
"required": False,
"schema": IsPartialDict(
{
"anyOf": [
{"items": {"type": "integer"}, "type": "array"},
{"type": "null"},
],
"title": "List Val",
}
),
},
]
)
if path == "/model-nullable-with-non-null-default":
# Check default value for list_val param for model-based parameters only.
# default_factory is not reflected in OpenAPI schema
assert parameters[2]["schema"]["default"] == [0]
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
@pytest.mark.xfail(
reason="Missing parameters are pre-populated with default values before validation"
)
def test_nullable_with_non_null_default_missing(path: str):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path)
assert mock_convert.call_count == 0, (
"Validator should not be called if the value is missing"
)
assert response.status_code == 200 # pragma: no cover
assert response.json() == { # pragma: no cover
"int_val": -1,
"str_val": "default",
"list_val": [0],
"fields_set": IsOneOf(None, []),
}
# TODO: Remove 'no cover' when the issue is fixed
@pytest.mark.parametrize(
"path",
[
"/nullable-with-non-null-default",
"/model-nullable-with-non-null-default",
],
)
@pytest.mark.parametrize(
"values",
[
{"int_val": "1", "str_val": "test", "list_val": ["1", "2"]},
{"int_val": "0", "str_val": "", "list_val": ["0"]},
],
)
def test_nullable_with_non_null_default_pass_value(path: str, values: dict[str, Any]):
client = TestClient(app)
with patch(f"{__name__}.convert", Mock(wraps=convert)) as mock_convert:
response = client.get(path, params=values)
assert mock_convert.call_count == 3, "Validator should be called for each field"
assert response.status_code == 200, response.text
assert response.json() == {
"int_val": int(values["int_val"]),
"str_val": values["str_val"],
"list_val": [int(v) for v in values["list_val"]],
"fields_set": IsOneOf(
None, IsList("int_val", "str_val", "list_val", check_order=False)
),
}