mirror of
https://github.com/fastapi/fastapi.git
synced 2026-06-06 06:34:19 -04:00
Compare commits
26 Commits
0.132.1
...
add-tests-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
90ebf74f68 | ||
|
|
7e96b8b8fa | ||
|
|
1a251c63c2 | ||
|
|
d10fa5df11 | ||
|
|
c49c90efc0 | ||
|
|
2b476737b8 | ||
|
|
1fa1065f9e | ||
|
|
daba0aa328 | ||
|
|
0c3581d5c4 | ||
|
|
c73bc94537 | ||
|
|
f49f65aa16 | ||
|
|
c733bab825 | ||
|
|
bfc09d9440 | ||
|
|
2a2aafa01e | ||
|
|
cf0d31bd69 | ||
|
|
7fed2671c4 | ||
|
|
d90bcc8569 | ||
|
|
27cc340880 | ||
|
|
9e85c19d3a | ||
|
|
3441e14197 | ||
|
|
e1adc4a739 | ||
|
|
e6475e960a | ||
|
|
2d43382626 | ||
|
|
0b5fea716b | ||
|
|
22d795d890 | ||
|
|
dada1d581f |
@@ -7,6 +7,16 @@ hide:
|
||||
|
||||
## Latest Changes
|
||||
|
||||
### Internal
|
||||
|
||||
* ✅ Fix all tests are skipped on Windows. PR [#14994](https://github.com/fastapi/fastapi/pull/14994) by [@YuriiMotov](https://github.com/YuriiMotov).
|
||||
|
||||
## 0.133.0
|
||||
|
||||
### Upgrades
|
||||
|
||||
* ⬆️ Add support for Starlette 1.0.0+. PR [#14987](https://github.com/fastapi/fastapi/pull/14987) by [@tiangolo](https://github.com/tiangolo).
|
||||
|
||||
## 0.132.1
|
||||
|
||||
### Refactors
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""FastAPI framework, high performance, easy to learn, fast to code, ready for production"""
|
||||
|
||||
__version__ = "0.132.1"
|
||||
__version__ = "0.133.0"
|
||||
|
||||
from starlette import status as status
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ classifiers = [
|
||||
"Topic :: Internet :: WWW/HTTP",
|
||||
]
|
||||
dependencies = [
|
||||
"starlette>=0.40.0,<1.0.0",
|
||||
"starlette>=0.40.0",
|
||||
"pydantic>=2.7.0",
|
||||
"typing-extensions>=4.8.0",
|
||||
"typing-inspection>=0.4.2",
|
||||
|
||||
@@ -10,9 +10,17 @@ skip_on_windows = pytest.mark.skipif(
|
||||
)
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(items: list[pytest.Item]) -> None:
|
||||
THIS_DIR = Path(__file__).parent.resolve()
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items: list[pytest.Item]) -> None:
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
|
||||
for item in items:
|
||||
item.add_marker(skip_on_windows)
|
||||
item_path = Path(item.fspath).resolve()
|
||||
if item_path.is_relative_to(THIS_DIR):
|
||||
item.add_marker(skip_on_windows)
|
||||
|
||||
|
||||
@pytest.fixture(name="runner")
|
||||
|
||||
1111
tests/test_request_params/test_body/test_nullable_and_defaults.py
Normal file
1111
tests/test_request_params/test_body/test_nullable_and_defaults.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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)),
|
||||
}
|
||||
@@ -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]}
|
||||
@@ -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)
|
||||
),
|
||||
}
|
||||
@@ -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)
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Not appllicable for Path parameters
|
||||
# Path parameters cannot have default values or be nullable
|
||||
@@ -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)
|
||||
),
|
||||
}
|
||||
62
uv.lock
generated
62
uv.lock
generated
@@ -192,7 +192,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "anthropic"
|
||||
version = "0.78.0"
|
||||
version = "0.83.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -204,9 +204,9 @@ dependencies = [
|
||||
{ name = "sniffio" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ec/51/32849a48f9b1cfe80a508fd269b20bd8f0b1357c70ba092890fde5a6a10b/anthropic-0.78.0.tar.gz", hash = "sha256:55fd978ab9b049c61857463f4c4e9e092b24f892519c6d8078cee1713d8af06e", size = 509136, upload-time = "2026-02-05T17:52:04.986Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/e5/02cd2919ec327b24234abb73082e6ab84c451182cc3cc60681af700f4c63/anthropic-0.83.0.tar.gz", hash = "sha256:a8732c68b41869266c3034541a31a29d8be0f8cd0a714f9edce3128b351eceb4", size = 534058, upload-time = "2026-02-19T19:26:38.904Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3b/03/2f50931a942e5e13f80e24d83406714672c57964be593fc046d81369335b/anthropic-0.78.0-py3-none-any.whl", hash = "sha256:2a9887d2e99d1b0f9fe08857a1e9fe5d2d4030455dbf9ac65aab052e2efaeac4", size = 405485, upload-time = "2026-02-05T17:52:03.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5f/75/b9d58e4e2a4b1fc3e75ffbab978f999baf8b7c4ba9f96e60edb918ba386b/anthropic-0.83.0-py3-none-any.whl", hash = "sha256:f069ef508c73b8f9152e8850830d92bd5ef185645dbacf234bb213344a274810", size = 456991, upload-time = "2026-02-19T19:26:40.114Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1242,7 +1242,7 @@ requires-dist = [
|
||||
{ name = "python-multipart", marker = "extra == 'standard'", specifier = ">=0.0.18" },
|
||||
{ name = "python-multipart", marker = "extra == 'standard-no-fastapi-cloud-cli'", specifier = ">=0.0.18" },
|
||||
{ name = "pyyaml", marker = "extra == 'all'", specifier = ">=5.3.1" },
|
||||
{ name = "starlette", specifier = ">=0.40.0,<1.0.0" },
|
||||
{ name = "starlette", specifier = ">=0.40.0" },
|
||||
{ name = "typing-extensions", specifier = ">=4.8.0" },
|
||||
{ name = "typing-inspection", specifier = ">=0.4.2" },
|
||||
{ name = "uvicorn", extras = ["standard"], marker = "extra == 'all'", specifier = ">=0.12.0" },
|
||||
@@ -1922,14 +1922,6 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/2b/98c7f93e6db9977aaee07eb1e51ca63bd5f779b900d362791d3252e60558/greenlet-3.3.1-cp314-cp314t-win_amd64.whl", hash = "sha256:301860987846c24cb8964bdec0e31a96ad4a2a801b41b4ef40963c1b44f33451", size = 233181, upload-time = "2026-01-23T15:33:00.29Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffelib"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffe-typingdoc"
|
||||
version = "0.3.1"
|
||||
@@ -1955,6 +1947,14 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/3c/c2a9eee79bf2c8002d2fa370534bee93fdca39e8b1fc82e83d552d5d2c07/griffe_warnings_deprecated-1.1.1-py3-none-any.whl", hash = "sha256:4b7d765e82ca9139ed44ffe7bdebed0d3a46ce014ad5a35a2c22e9a16288737a", size = 6565, upload-time = "2026-02-20T15:35:23.577Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffelib"
|
||||
version = "2.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/51/c936033e16d12b627ea334aaaaf42229c37620d0f15593456ab69ab48161/griffelib-2.0.0-py3-none-any.whl", hash = "sha256:01284878c966508b6d6f1dbff9b6fa607bc062d8261c5c7253cb285b06422a7f", size = 142004, upload-time = "2026-02-09T19:09:40.561Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "groq"
|
||||
version = "1.0.0"
|
||||
@@ -2162,26 +2162,23 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "huggingface-hub"
|
||||
version = "0.36.2"
|
||||
version = "1.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "filelock" },
|
||||
{ name = "fsspec" },
|
||||
{ name = "hf-xet", marker = "platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
|
||||
{ name = "hf-xet", marker = "platform_machine == 'AMD64' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'arm64' or platform_machine == 'x86_64'" },
|
||||
{ name = "httpx" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "shellingham" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "typer-slim" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7c/b7/8cb61d2eece5fb05a83271da168186721c450eb74e3c31f7ef3169fa475b/huggingface_hub-0.36.2.tar.gz", hash = "sha256:1934304d2fb224f8afa3b87007d58501acfda9215b334eed53072dd5e815ff7a", size = 649782, upload-time = "2026-02-06T09:24:13.098Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/fc/eb9bc06130e8bbda6a616e1b80a7aa127681c448d6b49806f61db2670b61/huggingface_hub-1.4.1.tar.gz", hash = "sha256:b41131ec35e631e7383ab26d6146b8d8972abc8b6309b963b306fbcca87f5ed5", size = 642156, upload-time = "2026-02-06T09:20:03.013Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/af/48ac8483240de756d2438c380746e7130d1c6f75802ef22f3c6d49982787/huggingface_hub-0.36.2-py3-none-any.whl", hash = "sha256:48f0c8eac16145dfce371e9d2d7772854a4f591bcb56c9cf548accf531d54270", size = 566395, upload-time = "2026-02-06T09:24:11.133Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
inference = [
|
||||
{ name = "aiohttp" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ae/2f6d96b4e6c5478d87d606a1934b5d436c4a2bce6bb7c6fdece891c128e3/huggingface_hub-1.4.1-py3-none-any.whl", hash = "sha256:9931d075fb7a79af5abc487106414ec5fba2c0ae86104c0c62fd6cae38873d18", size = 553326, upload-time = "2026-02-06T09:20:00.728Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3995,7 +3992,7 @@ groq = [
|
||||
{ name = "groq" },
|
||||
]
|
||||
huggingface = [
|
||||
{ name = "huggingface-hub", extra = ["inference"] },
|
||||
{ name = "huggingface-hub" },
|
||||
]
|
||||
logfire = [
|
||||
{ name = "logfire", extra = ["httpx"] },
|
||||
@@ -4147,7 +4144,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pydantic-evals"
|
||||
version = "1.56.0"
|
||||
version = "1.62.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
@@ -4157,9 +4154,9 @@ dependencies = [
|
||||
{ name = "pyyaml" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/98/f2/8c59284a2978af3fbda45ae3217218eaf8b071207a9290b54b7613983e5d/pydantic_evals-1.56.0.tar.gz", hash = "sha256:206635107127af6a3ee4b1fc8f77af6afb14683615a2d6b3609f79467c1c0d28", size = 47210, upload-time = "2026-02-06T01:13:25.714Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/23/90/080f6722412263395d1d6d066ee90fa8bc2722ce097844220c2d9c946877/pydantic_evals-1.62.0.tar.gz", hash = "sha256:198c4bee936718a4acf6f504056b113e60b34eb49021df8889a394e14c803693", size = 56434, upload-time = "2026-02-19T05:07:11.793Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/51/9875d19ff6d584aaeb574aba76b49d931b822546fc60b29c4fc0da98170d/pydantic_evals-1.56.0-py3-none-any.whl", hash = "sha256:d1efb410c97135aabd2a22453b10c981b2b9851985e9354713af67ae0973b7a9", size = 56407, upload-time = "2026-02-06T01:13:17.098Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/b9/dc8dba744ec02b16c6fd1abe3fd8ef1b00fd05c72feef5069851b811952f/pydantic_evals-1.62.0-py3-none-any.whl", hash = "sha256:0ca7e10037ed90393c54b6cff41370d6d4bac63f8c878715599c58863c303db1", size = 67341, upload-time = "2026-02-19T05:07:03.83Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5558,6 +5555,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/1d/d9257dd49ff2ca23ea5f132edf1281a0c4f9de8a762b9ae399b670a59235/typer-0.21.1-py3-none-any.whl", hash = "sha256:7985e89081c636b88d172c2ee0cfe33c253160994d47bdfdc302defd7d1f1d01", size = 47381, upload-time = "2026-01-06T11:21:09.824Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer-slim"
|
||||
version = "0.21.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "click" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a5/ca/0d9d822fd8a4c7e830cba36a2557b070d4b4a9558a0460377a61f8fb315d/typer_slim-0.21.2.tar.gz", hash = "sha256:78f20d793036a62aaf9c3798306142b08261d4b2a941c6e463081239f062a2f9", size = 120497, upload-time = "2026-02-10T19:33:45.836Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/54/03/e09325cfc40a33a82b31ba1a3f1d97e85246736856a45a43b19fcb48b1c2/typer_slim-0.21.2-py3-none-any.whl", hash = "sha256:4705082bb6c66c090f60e47c8be09a93158c139ce0aa98df7c6c47e723395e5f", size = 56790, upload-time = "2026-02-10T19:33:47.221Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "types-orjson"
|
||||
version = "3.6.2"
|
||||
|
||||
Reference in New Issue
Block a user