diff --git a/docs/em/docs/tutorial/query-params-str-validations.md b/docs/em/docs/tutorial/query-params-str-validations.md index fd077bf8f..5cfce1a7c 100644 --- a/docs/em/docs/tutorial/query-params-str-validations.md +++ b/docs/em/docs/tutorial/query-params-str-validations.md @@ -154,7 +154,7 @@ q: Union[str, None] = Query(default=None, min_length=3) 👈, 👆 💪 📣 👈 `None` ☑ 🆎 ✋️ ⚙️ `default=...`: -{* ../../docs_src/query_params_str_validations/tutorial006c.py hl[9] *} +{* ../../docs_src/query_params_str_validations/tutorial006c_an_py310.py hl[9] *} /// tip diff --git a/docs/en/docs/tutorial/query-params-str-validations.md b/docs/en/docs/tutorial/query-params-str-validations.md index adf08a924..81633e4b6 100644 --- a/docs/en/docs/tutorial/query-params-str-validations.md +++ b/docs/en/docs/tutorial/query-params-str-validations.md @@ -260,11 +260,29 @@ So, when you need to declare a value as required while using `Query`, you can si ### Required, can be `None` { #required-can-be-none } -You can declare that a parameter can accept `None`, but that it's still required. This would force clients to send a value, even if the value is `None`. +You might want to declare that a parameter can accept `None`, but is still required. -To do that, you can declare that `None` is a valid type but simply do not declare a default value: +However, because of how **HTTP query parameters** work, clients cannot actually send a real `None` (or `null`) value — query parameters are always sent as **strings**. +That means you cannot truly have a *required* parameter that also allows a real `None` value. -{* ../../docs_src/query_params_str_validations/tutorial006c_an_py310.py hl[9] *} +For example, you might try: + +```Python +q: Annotated[str | None, Query(min_length=3)] = ... +``` + +But this will still expect a **string** value, and if the client omits `q` or tries to send `q=None`, FastAPI will raise a **request validation error**. +In other words, `None` is not an acceptable runtime value for query parameters — only strings are. + +If you want to accept special values (like `"None"` or an empty string) and interpret them as `None` in your application, you can handle them manually in your function: + +{* ../../docs_src/query_params_str_validations/tutorial006c_an_py310.py hl[9:12,17] *} + +/// note + +This example uses `BeforeValidator`, which is only available in **Pydantic v2**. + +/// ## Query parameter list / multiple values { #query-parameter-list-multiple-values } diff --git a/docs/zh/docs/tutorial/query-params-str-validations.md b/docs/zh/docs/tutorial/query-params-str-validations.md index c2f9a7e9f..50a51f184 100644 --- a/docs/zh/docs/tutorial/query-params-str-validations.md +++ b/docs/zh/docs/tutorial/query-params-str-validations.md @@ -114,7 +114,7 @@ q: Union[str, None] = Query(default=None, min_length=3) 为此,你可以声明`None`是一个有效的类型,并仍然使用`default=...`: -{* ../../docs_src/query_params_str_validations/tutorial006c.py hl[9] *} +{* ../../docs_src/query_params_str_validations/tutorial006c_an_py310.py hl[9] *} /// tip diff --git a/docs_src/query_params_str_validations/tutorial006c.py b/docs_src/query_params_str_validations/tutorial006c.py deleted file mode 100644 index 0a0e820da..000000000 --- a/docs_src/query_params_str_validations/tutorial006c.py +++ /dev/null @@ -1,13 +0,0 @@ -from typing import Union - -from fastapi import FastAPI, Query - -app = FastAPI() - - -@app.get("/items/") -async def read_items(q: Union[str, None] = Query(min_length=3)): - results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} - if q: - results.update({"q": q}) - return results diff --git a/docs_src/query_params_str_validations/tutorial006c_an.py b/docs_src/query_params_str_validations/tutorial006c_an.py index 55c4f4adc..344cf38e5 100644 --- a/docs_src/query_params_str_validations/tutorial006c_an.py +++ b/docs_src/query_params_str_validations/tutorial006c_an.py @@ -1,14 +1,20 @@ -from typing import Union +from typing import Optional, Union from fastapi import FastAPI, Query +from pydantic import BeforeValidator from typing_extensions import Annotated app = FastAPI() +def nullable_str(val: str) -> Union[str, None]: + if val in ("None", "", "null"): + return None + return val + + @app.get("/items/") -async def read_items(q: Annotated[Union[str, None], Query(min_length=3)]): - results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} - if q: - results.update({"q": q}) - return results +async def read_items( + q: Annotated[Optional[str], Query(min_length=3), BeforeValidator(nullable_str)], +): + return {"q": q} diff --git a/docs_src/query_params_str_validations/tutorial006c_an_py310.py b/docs_src/query_params_str_validations/tutorial006c_an_py310.py index 2995d9c97..95c63b259 100644 --- a/docs_src/query_params_str_validations/tutorial006c_an_py310.py +++ b/docs_src/query_params_str_validations/tutorial006c_an_py310.py @@ -1,13 +1,19 @@ from typing import Annotated from fastapi import FastAPI, Query +from pydantic import BeforeValidator app = FastAPI() +def nullable_str(val: str) -> str | None: + if val in ("None", "", "null"): + return None + return val + + @app.get("/items/") -async def read_items(q: Annotated[str | None, Query(min_length=3)]): - results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} - if q: - results.update({"q": q}) - return results +async def read_items( + q: Annotated[str | None, Query(min_length=3), BeforeValidator(nullable_str)], +): + return {"q": q} diff --git a/docs_src/query_params_str_validations/tutorial006c_an_py39.py b/docs_src/query_params_str_validations/tutorial006c_an_py39.py index 76a1cd49a..94af45722 100644 --- a/docs_src/query_params_str_validations/tutorial006c_an_py39.py +++ b/docs_src/query_params_str_validations/tutorial006c_an_py39.py @@ -1,13 +1,19 @@ -from typing import Annotated, Union +from typing import Annotated, Optional, Union from fastapi import FastAPI, Query +from pydantic import BeforeValidator app = FastAPI() +def nullable_str(val: str) -> Union[str, None]: + if val in ("None", "", "null"): + return None + return val + + @app.get("/items/") -async def read_items(q: Annotated[Union[str, None], Query(min_length=3)]): - results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} - if q: - results.update({"q": q}) - return results +async def read_items( + q: Annotated[Optional[str], Query(min_length=3), BeforeValidator(nullable_str)], +): + return {"q": q} diff --git a/docs_src/query_params_str_validations/tutorial006c_py310.py b/docs_src/query_params_str_validations/tutorial006c_py310.py deleted file mode 100644 index 88b499c7a..000000000 --- a/docs_src/query_params_str_validations/tutorial006c_py310.py +++ /dev/null @@ -1,11 +0,0 @@ -from fastapi import FastAPI, Query - -app = FastAPI() - - -@app.get("/items/") -async def read_items(q: str | None = Query(min_length=3)): - results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} - if q: - results.update({"q": q}) - return results diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py new file mode 100644 index 000000000..b87443945 --- /dev/null +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py @@ -0,0 +1,39 @@ +import importlib + +import pytest +from fastapi.testclient import TestClient + +from tests.utils import needs_pydanticv2 + +from ...utils import needs_py39, needs_py310 + + +@pytest.fixture( + name="client", + params=[ + "tutorial006c_an", + pytest.param("tutorial006c_an_py310", marks=needs_py310), + pytest.param("tutorial006c_an_py39", marks=needs_py39), + ], +) +def get_client(request: pytest.FixtureRequest): + mod = importlib.import_module( + f"docs_src.query_params_str_validations.{request.param}" + ) + yield TestClient(mod.app) + + +@needs_pydanticv2 +@pytest.mark.parametrize( + "q_value,expected", + [ + ("None", None), + ("", None), + ("null", None), + ("hello", "hello"), + ], +) +def test_read_items(q_value, expected, client: TestClient): + response = client.get("/items/", params={"q": q_value}) + assert response.status_code == 200 + assert response.json() == {"q": expected}