diff --git a/docs/en/docs/tutorial/query-params-str-validations.md b/docs/en/docs/tutorial/query-params-str-validations.md index 4b8cc9d297..82abae5301 100644 --- a/docs/en/docs/tutorial/query-params-str-validations.md +++ b/docs/en/docs/tutorial/query-params-str-validations.md @@ -246,11 +246,22 @@ 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 a parameter that 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 can not 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 tries to send `q=None`, `q=null` or `q=`, these values will be treated by FastAPI as strings `"None"`, `"null"` and `""` (empty string) respectively. + +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] *} ## Query parameter list / multiple values { #query-parameter-list-multiple-values } 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 2995d9c979..d5c3d3058a 100644 --- a/docs_src/query_params_str_validations/tutorial006c_an_py310.py +++ b/docs_src/query_params_str_validations/tutorial006c_an_py310.py @@ -1,12 +1,21 @@ 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)]): +async def read_items( + q: Annotated[str | None, Query(min_length=3), BeforeValidator(nullable_str)], +): results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} if q: results.update({"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 76a1cd49ac..79516eb1d8 100644 --- a/docs_src/query_params_str_validations/tutorial006c_an_py39.py +++ b/docs_src/query_params_str_validations/tutorial006c_an_py39.py @@ -1,12 +1,21 @@ from typing import Annotated, 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)]): +async def read_items( + q: Annotated[Union[str, None], Query(min_length=3), BeforeValidator(nullable_str)], +): results = {"items": [{"item_id": "Foo"}, {"item_id": "Bar"}]} if q: results.update({"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 88b499c7af..0000000000 --- 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/docs_src/query_params_str_validations/tutorial006c_py39.py b/docs_src/query_params_str_validations/tutorial006c_py39.py deleted file mode 100644 index 0a0e820da3..0000000000 --- a/docs_src/query_params_str_validations/tutorial006c_py39.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/tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py index d31cb5036a..a06cfa591d 100644 --- a/tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py +++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial006c.py @@ -9,8 +9,6 @@ from ...utils import needs_py310 @pytest.fixture( name="client", params=[ - pytest.param("tutorial006c_py39"), - pytest.param("tutorial006c_py310", marks=needs_py310), pytest.param("tutorial006c_an_py39"), pytest.param("tutorial006c_an_py310", marks=needs_py310), ], @@ -23,24 +21,28 @@ def get_client(request: pytest.FixtureRequest): return client -@pytest.mark.xfail( - reason="Code example is not valid. See https://github.com/fastapi/fastapi/issues/12419" -) def test_query_params_str_validations_no_query(client: TestClient): response = client.get("/items/") - assert response.status_code == 200 - assert response.json() == { # pragma: no cover - "items": [{"item_id": "Foo"}, {"item_id": "Bar"}], + assert response.status_code == 422 + assert response.json() == { + "detail": [ + { + "type": "missing", + "loc": ["query", "q"], + "msg": "Field required", + "input": None, + } + ] } -@pytest.mark.xfail( - reason="Code example is not valid. See https://github.com/fastapi/fastapi/issues/12419" -) -def test_query_params_str_validations_empty_str(client: TestClient): - response = client.get("/items/?q=") +@pytest.mark.parametrize("q_value", ["None", "null", ""]) +def test_query_params_str_validations_send_explicit_none( + client: TestClient, q_value: str +): + response = client.get("/items/", params={"q": q_value}) assert response.status_code == 200 - assert response.json() == { # pragma: no cover + assert response.json() == { "items": [{"item_id": "Foo"}, {"item_id": "Bar"}], }