diff --git a/docs/en/docs/tutorial/query-params-str-validations.md b/docs/en/docs/tutorial/query-params-str-validations.md
index 511099186..e50fc347c 100644
--- a/docs/en/docs/tutorial/query-params-str-validations.md
+++ b/docs/en/docs/tutorial/query-params-str-validations.md
@@ -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 Pydantic's `AfterValidator` inside of `Annotated`.
+
+/// tip
+
+Pydantic also has `BeforeValidator` and others. 🤓
+
+///
+
+For example, this custom validator checks that the item ID starts with `isbn-` for an ISBN book number or with `imdb-` for an IMDB 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 iterable object 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
index 000000000..f2ec6db12
--- /dev/null
+++ b/docs_src/query_params_str_validations/tutorial015_an.py
@@ -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
index 000000000..35f368094
--- /dev/null
+++ b/docs_src/query_params_str_validations/tutorial015_an_py310.py
@@ -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
index 000000000..989b6d2c2
--- /dev/null
+++ b/docs_src/query_params_str_validations/tutorial015_an_py39.py
@@ -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}
diff --git a/fastapi/dependencies/utils.py b/fastapi/dependencies/utils.py
index 09dd6f1b9..e2866b488 100644
--- a/fastapi/dependencies/utils.py
+++ b/fastapi/dependencies/utils.py
@@ -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
index 9fd3fa6d0..000000000
--- a/tests/test_analyze_param.py
+++ /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
index 000000000..ae1c40286
--- /dev/null
+++ b/tests/test_tutorial/test_query_params_str_validations/test_tutorial015.py
@@ -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",
+ },
+ }
+ },
+ }
+ )