Browse Source

🐛 Add docs examples and tests (support) for `Annotated` custom validations, like `AfterValidator`, revert #13440 (#13442)

This reverts commit 15dd2b67d3.
pull/13446/head
Sebastián Ramírez 1 month ago
committed by GitHub
parent
commit
74fe89bf35
No known key found for this signature in database GPG Key ID: B5690EEEBB952194
  1. 64
      docs/en/docs/tutorial/query-params-str-validations.md
  2. 31
      docs_src/query_params_str_validations/tutorial015_an.py
  3. 30
      docs_src/query_params_str_validations/tutorial015_an_py310.py
  4. 30
      docs_src/query_params_str_validations/tutorial015_an_py39.py
  5. 8
      fastapi/dependencies/utils.py
  6. 22
      tests/test_analyze_param.py
  7. 143
      tests/test_tutorial/test_query_params_str_validations/test_tutorial015.py

64
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 <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.

31
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}

30
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}

30
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}

8
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

22
tests/test_analyze_param.py

@ -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

143
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",
},
}
},
}
)
Loading…
Cancel
Save