]> git.ipfire.org Git - thirdparty/fastapi/fastapi.git/commitdiff
🐛 Add docs examples and tests (support) for `Annotated` custom validations, like...
authorSebastián Ramírez <tiangolo@gmail.com>
Sat, 1 Mar 2025 22:02:35 +0000 (23:02 +0100)
committerGitHub <noreply@github.com>
Sat, 1 Mar 2025 22:02:35 +0000 (22:02 +0000)
This reverts commit 15dd2b67d3f8763d5cd523b79a1c901c05d48bd7.

docs/en/docs/tutorial/query-params-str-validations.md
docs_src/query_params_str_validations/tutorial015_an.py [new file with mode: 0644]
docs_src/query_params_str_validations/tutorial015_an_py310.py [new file with mode: 0644]
docs_src/query_params_str_validations/tutorial015_an_py39.py [new file with mode: 0644]
fastapi/dependencies/utils.py
tests/test_analyze_param.py [deleted file]
tests/test_tutorial/test_query_params_str_validations/test_tutorial015.py [new file with mode: 0644]

index 5110991861cdb753a57ba7ecf1aa0a8e0919cba3..e50fc347c4a0b4912a8fc81208141305185b0b97 100644 (file)
@@ -406,6 +406,68 @@ To exclude a query parameter from the generated OpenAPI schema (and thus, from t
 
 {* ../../docs_src/query_params_str_validations/tutorial014_an_py310.py hl[10] *}
 
+## Custom Validation
+
+There could be cases where you need to do some **custom validation** that can't be done with the parameters shown above.
+
+In those cases, you can use a **custom validator function** that is applied after the normal validation (e.g. after validating that the value is a `str`).
+
+You can achieve that using <a href="https://docs.pydantic.dev/latest/concepts/validators/#field-after-validator" class="external-link" target="_blank">Pydantic's `AfterValidator`</a> inside of `Annotated`.
+
+/// tip
+
+Pydantic also has <a href="https://docs.pydantic.dev/latest/concepts/validators/#field-before-validator" class="external-link" target="_blank">`BeforeValidator`</a> and others. 🤓
+
+///
+
+For example, this custom validator checks that the item ID starts with `isbn-` for an <abbr title="ISBN means International Standard Book Number">ISBN</abbr> book number or with `imdb-` for an <abbr title="IMDB (Internet Movie Database) is a website with information about movies">IMDB</abbr> movie URL ID:
+
+{* ../../docs_src/query_params_str_validations/tutorial015_an_py310.py hl[5,16:19,24] *}
+
+/// info
+
+This is available with Pydantic version 2 or above. 😎
+
+///
+
+/// tip
+
+If you need to do any type of validation that requires communicating with any **external component**, like a database or another API, you should instead use **FastAPI Dependencies**, you will learn about them later.
+
+These custom validators are for things that can be checked with **only** the **same data** provided in the request.
+
+///
+
+### Understand that Code
+
+The important point is just using **`AfterValidator` with a function inside `Annotated`**. Feel free to skip this part. 🤸
+
+---
+
+But if you're curious about this specific code example and you're still entertained, here are some extra details.
+
+#### String with `value.startswith()`
+
+Did you notice? a string using `value.startswith()` can take a tuple, and it will check each value in the tuple:
+
+{* ../../docs_src/query_params_str_validations/tutorial015_an_py310.py ln[16:19] hl[17] *}
+
+#### A Random Item
+
+With `data.items()` we get an <abbr title="Something we can iterate on with a for loop, like a list, set, etc.">iterable object</abbr> with tuples containing the key and value for each dictionary item.
+
+We convert this iterable object into a proper `list` with `list(data.items())`.
+
+Then with `random.choice()` we can get a **random value** from the list, so, we get a tuple with `(id, name)`. It will be something like `("imdb-tt0371724", "The Hitchhiker's Guide to the Galaxy")`.
+
+Then we **assign those two values** of the tuple to the variables `id` and `name`.
+
+So, if the user didn't provide an item ID, they will still receive a random suggestion.
+
+...we do all this in a **single simple line**. 🤯 Don't you love Python? 🐍
+
+{* ../../docs_src/query_params_str_validations/tutorial015_an_py310.py ln[22:30] hl[29] *}
+
 ## Recap
 
 You can declare additional validations and metadata for your parameters.
@@ -423,6 +485,8 @@ Validations specific for strings:
 * `max_length`
 * `pattern`
 
+Custom validations using `AfterValidator`.
+
 In these examples you saw how to declare validations for `str` values.
 
 See the next chapters to learn how to declare validations for other types, like numbers.
diff --git a/docs_src/query_params_str_validations/tutorial015_an.py b/docs_src/query_params_str_validations/tutorial015_an.py
new file mode 100644 (file)
index 0000000..f2ec6db
--- /dev/null
@@ -0,0 +1,31 @@
+import random
+from typing import Union
+
+from fastapi import FastAPI
+from pydantic import AfterValidator
+from typing_extensions import Annotated
+
+app = FastAPI()
+
+data = {
+    "isbn-9781529046137": "The Hitchhiker's Guide to the Galaxy",
+    "imdb-tt0371724": "The Hitchhiker's Guide to the Galaxy",
+    "isbn-9781439512982": "Isaac Asimov: The Complete Stories, Vol. 2",
+}
+
+
+def check_valid_id(id: str):
+    if not id.startswith(("isbn-", "imdb-")):
+        raise ValueError('Invalid ID format, it must start with "isbn-" or "imdb-"')
+    return id
+
+
+@app.get("/items/")
+async def read_items(
+    id: Annotated[Union[str, None], AfterValidator(check_valid_id)] = None,
+):
+    if id:
+        item = data.get(id)
+    else:
+        id, item = random.choice(list(data.items()))
+    return {"id": id, "name": item}
diff --git a/docs_src/query_params_str_validations/tutorial015_an_py310.py b/docs_src/query_params_str_validations/tutorial015_an_py310.py
new file mode 100644 (file)
index 0000000..35f3680
--- /dev/null
@@ -0,0 +1,30 @@
+import random
+from typing import Annotated
+
+from fastapi import FastAPI
+from pydantic import AfterValidator
+
+app = FastAPI()
+
+data = {
+    "isbn-9781529046137": "The Hitchhiker's Guide to the Galaxy",
+    "imdb-tt0371724": "The Hitchhiker's Guide to the Galaxy",
+    "isbn-9781439512982": "Isaac Asimov: The Complete Stories, Vol. 2",
+}
+
+
+def check_valid_id(id: str):
+    if not id.startswith(("isbn-", "imdb-")):
+        raise ValueError('Invalid ID format, it must start with "isbn-" or "imdb-"')
+    return id
+
+
+@app.get("/items/")
+async def read_items(
+    id: Annotated[str | None, AfterValidator(check_valid_id)] = None,
+):
+    if id:
+        item = data.get(id)
+    else:
+        id, item = random.choice(list(data.items()))
+    return {"id": id, "name": item}
diff --git a/docs_src/query_params_str_validations/tutorial015_an_py39.py b/docs_src/query_params_str_validations/tutorial015_an_py39.py
new file mode 100644 (file)
index 0000000..989b6d2
--- /dev/null
@@ -0,0 +1,30 @@
+import random
+from typing import Annotated, Union
+
+from fastapi import FastAPI
+from pydantic import AfterValidator
+
+app = FastAPI()
+
+data = {
+    "isbn-9781529046137": "The Hitchhiker's Guide to the Galaxy",
+    "imdb-tt0371724": "The Hitchhiker's Guide to the Galaxy",
+    "isbn-9781439512982": "Isaac Asimov: The Complete Stories, Vol. 2",
+}
+
+
+def check_valid_id(id: str):
+    if not id.startswith(("isbn-", "imdb-")):
+        raise ValueError('Invalid ID format, it must start with "isbn-" or "imdb-"')
+    return id
+
+
+@app.get("/items/")
+async def read_items(
+    id: Annotated[Union[str, None], AfterValidator(check_valid_id)] = None,
+):
+    if id:
+        item = data.get(id)
+    else:
+        id, item = random.choice(list(data.items()))
+    return {"id": id, "name": item}
index 09dd6f1b920f3aab9a71d071ff22f994fd38a38e..e2866b48894b794c9fe7029a2c128b1751ff2754 100644 (file)
@@ -449,15 +449,15 @@ def analyze_param(
             # We might check here that `default_value is RequiredParam`, but the fact is that the same
             # parameter might sometimes be a path parameter and sometimes not. See
             # `tests/test_infer_param_optionality.py` for an example.
-            field_info = params.Path(annotation=type_annotation)
+            field_info = params.Path(annotation=use_annotation)
         elif is_uploadfile_or_nonable_uploadfile_annotation(
             type_annotation
         ) or is_uploadfile_sequence_annotation(type_annotation):
-            field_info = params.File(annotation=type_annotation, default=default_value)
+            field_info = params.File(annotation=use_annotation, default=default_value)
         elif not field_annotation_is_scalar(annotation=type_annotation):
-            field_info = params.Body(annotation=type_annotation, default=default_value)
+            field_info = params.Body(annotation=use_annotation, default=default_value)
         else:
-            field_info = params.Query(annotation=type_annotation, default=default_value)
+            field_info = params.Query(annotation=use_annotation, default=default_value)
 
     field = None
     # It's a field_info, not a dependency
diff --git a/tests/test_analyze_param.py b/tests/test_analyze_param.py
deleted file mode 100644 (file)
index 9fd3fa6..0000000
+++ /dev/null
@@ -1,22 +0,0 @@
-from inspect import signature
-
-from fastapi.dependencies.utils import ParamDetails, analyze_param
-from pydantic import Field
-from typing_extensions import Annotated
-
-from .utils import needs_pydanticv2
-
-
-def func(user: Annotated[int, Field(strict=True)]): ...
-
-
-@needs_pydanticv2
-def test_analyze_param():
-    result = analyze_param(
-        param_name="user",
-        annotation=signature(func).parameters["user"].annotation,
-        value=object(),
-        is_path_param=False,
-    )
-    assert isinstance(result, ParamDetails)
-    assert result.field.field_info.annotation is int
diff --git a/tests/test_tutorial/test_query_params_str_validations/test_tutorial015.py b/tests/test_tutorial/test_query_params_str_validations/test_tutorial015.py
new file mode 100644 (file)
index 0000000..ae1c402
--- /dev/null
@@ -0,0 +1,143 @@
+import importlib
+
+import pytest
+from dirty_equals import IsStr
+from fastapi.testclient import TestClient
+from inline_snapshot import snapshot
+
+from ...utils import needs_py39, needs_py310, needs_pydanticv2
+
+
+@pytest.fixture(
+    name="client",
+    params=[
+        pytest.param("tutorial015_an", marks=needs_pydanticv2),
+        pytest.param("tutorial015_an_py310", marks=(needs_py310, needs_pydanticv2)),
+        pytest.param("tutorial015_an_py39", marks=(needs_py39, needs_pydanticv2)),
+    ],
+)
+def get_client(request: pytest.FixtureRequest):
+    mod = importlib.import_module(
+        f"docs_src.query_params_str_validations.{request.param}"
+    )
+
+    client = TestClient(mod.app)
+    return client
+
+
+def test_get_random_item(client: TestClient):
+    response = client.get("/items")
+    assert response.status_code == 200, response.text
+    assert response.json() == {"id": IsStr(), "name": IsStr()}
+
+
+def test_get_item(client: TestClient):
+    response = client.get("/items?id=isbn-9781529046137")
+    assert response.status_code == 200, response.text
+    assert response.json() == {
+        "id": "isbn-9781529046137",
+        "name": "The Hitchhiker's Guide to the Galaxy",
+    }
+
+
+def test_get_item_does_not_exist(client: TestClient):
+    response = client.get("/items?id=isbn-nope")
+    assert response.status_code == 200, response.text
+    assert response.json() == {"id": "isbn-nope", "name": None}
+
+
+def test_get_invalid_item(client: TestClient):
+    response = client.get("/items?id=wtf-yes")
+    assert response.status_code == 422, response.text
+    assert response.json() == snapshot(
+        {
+            "detail": [
+                {
+                    "type": "value_error",
+                    "loc": ["query", "id"],
+                    "msg": 'Value error, Invalid ID format, it must start with "isbn-" or "imdb-"',
+                    "input": "wtf-yes",
+                    "ctx": {"error": {}},
+                }
+            ]
+        }
+    )
+
+
+def test_openapi_schema(client: TestClient):
+    response = client.get("/openapi.json")
+    assert response.status_code == 200, response.text
+    assert response.json() == snapshot(
+        {
+            "openapi": "3.1.0",
+            "info": {"title": "FastAPI", "version": "0.1.0"},
+            "paths": {
+                "/items/": {
+                    "get": {
+                        "summary": "Read Items",
+                        "operationId": "read_items_items__get",
+                        "parameters": [
+                            {
+                                "name": "id",
+                                "in": "query",
+                                "required": False,
+                                "schema": {
+                                    "anyOf": [{"type": "string"}, {"type": "null"}],
+                                    "title": "Id",
+                                },
+                            }
+                        ],
+                        "responses": {
+                            "200": {
+                                "description": "Successful Response",
+                                "content": {"application/json": {"schema": {}}},
+                            },
+                            "422": {
+                                "description": "Validation Error",
+                                "content": {
+                                    "application/json": {
+                                        "schema": {
+                                            "$ref": "#/components/schemas/HTTPValidationError"
+                                        }
+                                    }
+                                },
+                            },
+                        },
+                    }
+                }
+            },
+            "components": {
+                "schemas": {
+                    "HTTPValidationError": {
+                        "properties": {
+                            "detail": {
+                                "items": {
+                                    "$ref": "#/components/schemas/ValidationError"
+                                },
+                                "type": "array",
+                                "title": "Detail",
+                            }
+                        },
+                        "type": "object",
+                        "title": "HTTPValidationError",
+                    },
+                    "ValidationError": {
+                        "properties": {
+                            "loc": {
+                                "items": {
+                                    "anyOf": [{"type": "string"}, {"type": "integer"}]
+                                },
+                                "type": "array",
+                                "title": "Location",
+                            },
+                            "msg": {"type": "string", "title": "Message"},
+                            "type": {"type": "string", "title": "Error Type"},
+                        },
+                        "type": "object",
+                        "required": ["loc", "msg", "type"],
+                        "title": "ValidationError",
+                    },
+                }
+            },
+        }
+    )