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] *}
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -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