mirror of https://github.com/tiangolo/fastapi.git
🐛 Add docs examples and tests (support) for `Annotated` custom validations, like `AfterValidator`, revert #13440 (#13442)
This reverts commit 15dd2b67d3.
This commit is contained in:
parent
f5056f84b6
commit
74fe89bf35
|
|
@ -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] *}
|
{* ../../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
|
## Recap
|
||||||
|
|
||||||
You can declare additional validations and metadata for your parameters.
|
You can declare additional validations and metadata for your parameters.
|
||||||
|
|
@ -423,6 +485,8 @@ Validations specific for strings:
|
||||||
* `max_length`
|
* `max_length`
|
||||||
* `pattern`
|
* `pattern`
|
||||||
|
|
||||||
|
Custom validations using `AfterValidator`.
|
||||||
|
|
||||||
In these examples you saw how to declare validations for `str` values.
|
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.
|
See the next chapters to learn how to declare validations for other types, like numbers.
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -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}
|
||||||
|
|
@ -449,15 +449,15 @@ def analyze_param(
|
||||||
# We might check here that `default_value is RequiredParam`, but the fact is that the same
|
# 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
|
# parameter might sometimes be a path parameter and sometimes not. See
|
||||||
# `tests/test_infer_param_optionality.py` for an example.
|
# `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(
|
elif is_uploadfile_or_nonable_uploadfile_annotation(
|
||||||
type_annotation
|
type_annotation
|
||||||
) or is_uploadfile_sequence_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):
|
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:
|
else:
|
||||||
field_info = params.Query(annotation=type_annotation, default=default_value)
|
field_info = params.Query(annotation=use_annotation, default=default_value)
|
||||||
|
|
||||||
field = None
|
field = None
|
||||||
# It's a field_info, not a dependency
|
# It's a field_info, not a dependency
|
||||||
|
|
|
||||||
|
|
@ -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
|
|
||||||
|
|
@ -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…
Reference in New Issue