Allow custom type for Query parameters #8777
-
First Check
Commit to Help
Example Codefrom typing import Optional
from fastapi import APIRouter, Query
from datetime import datetime
router = APIRouter()
class DateTimeRange:
start: datetime
end: datetime
def __init__(self, value: str):
# value supposed to be in format: `2021-01-01,2021-02-01`
start, end = value.split(",")
self.start = datetime.strptime(start, "%Y-%m-%d")
self.end = datetime.strptime(end, "%Y-%m-%d")
@router.get("/messages")
def api_filter_message(
created_at_range: Optional[DateTimeRange] = Query(None),
modified_at_range: Optional[DateTimeRange] = Query(None),
):
"""
GET /messages?created_at_range=...&modified_at_range=...
List messages filtered by Message.created_at and Message.modified_at
"""
...DescriptionThe above code won't run, which complains:
If I change implementation of
Currently what only I can do is like following: @router.get("/messages")
def api_filter_message(
created_at_range_param: Optional[str] = Query(None, alias="created_at_range"),
modified_at_range_param: Optional[str] = Query(None, alias="modified_at_range"),
):
created_at_range = DateTimeRange(created_at_range_param) if created_at_range_param else None
modified_at_range = DateTimeRange(updated_at_range_param) if updated_at_range_param else None
...which is very annoying and repeating work. Wanted SolutionIt would be nice to have a custom validator (data converter) for Wanted Code@router.get("/messages")
def api_filter_messages(
created_at_range: Optional[DateTimeRange] = Query(None, validator=lambda v: DateTimeRange(v)),
modified_at_range: Optional[DateTimeRange] = Query(None, validator=lambda v: DateTimeRange(v)),
):
...
The argument `validator` takes idea from Pydantic's validator, which takes the input value, do validate, and convert to target type.AlternativesAn alternative approach is to make class ConvertableFromStr(Protocol):
@classmethod
def from_str(cls, v):
...When Operating SystemmacOS Operating System DetailsNo response FastAPI Version0.67.0 Python VersionPython 3.9.0 Additional ContextNo response |
Beta Was this translation helpful? Give feedback.
Replies: 12 comments
-
|
For custom validator , how about this way? |
Beta Was this translation helpful? Give feedback.
-
|
@Dustyposa I was not asking how to implement custom validators, but asking for if FastAPI's Correct me if I did'n get your point. |
Beta Was this translation helpful? Give feedback.
-
|
Oh, I'm so sorry. But i can't get your point until now. |
Beta Was this translation helpful? Give feedback.
-
|
You can use a custom validator to parse values, see the documentation linked above |
Beta Was this translation helpful? Give feedback.
-
|
@Dustyposa Here is an example code: # filename: app.py
from datetime import datetime
from functools import cached_property
from fastapi import Body, FastAPI, Query
from pydantic import BaseModel
app = FastAPI()
class DateRange(BaseModel):
__root__: str
class Config:
keep_untouched = (cached_property,)
@cached_property
def start(self):
start, _ = self.__root__.split(",")
return datetime.strptime(start, "%Y-%m-%d")
@cached_property
def end(self):
_, end = self.__root__.split(",")
return datetime.strptime(end, "%Y-%m-%d")
@app.post("/")
def echo_date_range(created_at_range: DateRange = Body(..., embed=True)):
return dict(start=created_at_range.start, end=created_at_range.end)run with $ curl -X 'POST' \
'http://127.0.0.1:8000/' \
-H 'accept: application/json' \
-H 'Content-Type: application/json' \
-d '{
"created_at_range": "2021-01-01,2021-02-01"
}'
> {"start":"2021-01-01T00:00:00","end":"2021-02-01T00:00:00"}When use
|
Beta Was this translation helpful? Give feedback.
-
|
The following code shows another way to implement the custom data type using from datetime import datetime
from typing import Optional
from fastapi import Body, FastAPI, Query
from dataclasses import dataclass
app = FastAPI()
@dataclass
class AlignedDateRange:
start: Optional[datetime]
end: Optional[datetime]
@classmethod
def validate(cls, v):
aligned_start: Optional[datetime] = None
aligned_end: Optional[datetime] = None
start, end = v.split(",")
if start:
# align start to beginning of day
aligned_start = datetime.strptime(start, "%Y-%m-%d").replace(hour=0, minute=0, second=0)
if end:
# align end to end of day
aligned_end = datetime.strptime(end, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
return cls(start=aligned_start, end=aligned_end)
@classmethod
def __get_validators__(cls):
yield cls.validate
@classmethod
def __modify_schema__(cls, field_schema):
field_schema.update(
pattern='YYYY-MM-DD,YYYY-MM-DD',
examples=['2021-01-01,2021-02-01'],
type="string",
)
@app.post("/")
def echo_date_range(created_at_range: AlignedDateRange = Body(..., embed=True)):
"""
Use case for aligned date range:
query = session.query(MessageModel)
if created_at_range.start:
query = query.filter(MessageModel.created_at >= created_at_range.start)
if created_at_range.end:
query = query.filter(MessageModel.created_at <= created_at_range.end)
# filter by more timestamp fields ...
Having an AlignedDateRange class, we can assemble the above code into a helper function:
def filter_by_range(query: Query, model: Model, field_name: str, range: AlignedDateRange) -> Query:
if range.start:
query = query.filter(getattr(model, field_name) >= range.start)
if range.end:
query = query.filter(getattr(model, field_name) <= range.end)
return query
Assume this api endpoint is for listing MessageModel instances,
with support to filter by multiple timestamp fields, the request url looks like:
GET /messages?created_at_range=2021-01-01,2021-02-01&read_at_range=2021-01-01,2021-02-01&...
The code for router looks like:
@router.get("/messages", reaponse_model=List[MessageResponse])
def list_messages(
created_at_range: AlignedDateRange = Query(...),
read_at_range: AlignedDateRange = Query(...),
)
query = session.query(Message)
query = filter_by_range(query, MessageModel, "created_at", created_at_range)
query = filter_by_range(query, MessageModel, "read_at", read_at_range)
return query.all()
"""
return dict(start=created_at_range.start, end=created_at_range.end) |
Beta Was this translation helpful? Give feedback.
-
|
I guess I know what do you want. @dataclass
class XXX:
start: Optional[datetime]
end: Optional[datetime]use, @app.post("/")
def echo_date_range(created_at_range: XXX = Depends(convert_func)): |
Beta Was this translation helpful? Give feedback.
-
|
@Dustyposa I had considered your approach, but there is one problem I don't know how to solve: @app.post("/")
def echo_date_range(created_at_range: XXX = Depends(convert_func)):How to tell |
Beta Was this translation helpful? Give feedback.
-
|
You can get some example like here |
Beta Was this translation helpful? Give feedback.
-
|
I have read all docs on FastAPI Dependency, but just can not think of an elegant way. My goal is that there is just one-shot in view function to get the desired data. For example, the view code could look like this: @router.get("/")
def view(
created_at_range: AlignedDateRange = Depends(AlignedDateRange.from_query("created_at_range")),
read_at_range: AlignedDateRange = Depends(AlignedDateRange.from_query("read_at_range")),
):
...But I can't figure out how to implement such class AlignedDateRange:
@classmethod
def from_query(cls, query_field_name: str):
# here, should return a function which signature looks like:
def get_date_range_from_query(${query_field_name}: Query()): # <-- how to create such a function?
return AlignedDateRange(${query_field_name})
return get_date_range_from_query
# other ways, like using class.__init__(), class.__call__() both have the same problem.
class AlignedDateRange:
def __init__(self, query_field_name: str):
self.query_field_name = query_field_name
def __call__(self, ${query_field_name}: Query()): # <- since FastAPI get query field name by signature name, this seems can only be hardcoded.
...Currently, my local working code requires two-shot, like this: @router.get("/")
def view(
created_at_range_raw: str = Query(...),
read_at_range_raw: str = Query(...),
):
created_at_range = AlignedDateRange(created_at_range_raw)
read_at_range = AlignedDateRange(read_at_range_raw)
...or in even verbose way, for each possible query field name, create a separate depends func: def get_created_at_range(created_at: str = Query(...)):
return AlignedDateRange(created_at)
def get_read_at_range(read_at: str = Query(...)):
return AlignedDateRange(created_at)
def get_xxx_range(xxx: str = Query(...)): # <- repeat for all possible query names
return AlignedDateRange(xxx)
@router.get("/")
def view(
created_at_range: AlignedDateRange = Depends(get_created_at_range),
read_at_range: AlignedDateRange = Depends(get_read_at_range),
xxx_range: AlignedDateRange = Depends(get_xxx_range),
):
...I believe this is not an elegant approach. That is why I created this issue. I believe the most elegant use case is: @router.get("/")
def view(
created_at_range: AlignedDateRange = Query(...),
read_at_range: AlignedDateRange = Query(...),
xxx_range: AlignedDateRange = Query(...),
):
... |
Beta Was this translation helpful? Give feedback.
-
|
Is this not elegant? from fastapi import Depends, FastAPI, Query
from fastapi.testclient import TestClient
from datetime import date
from pydantic import BaseModel
from typing import Optional, TypeVar, Type, Protocol
class AlignedDateRange(BaseModel):
start: Optional[date]
end: Optional[date]
@classmethod
def validate(cls, v):
start, end = v.split(",")
return cls(start=start or None, end=end or None)
@classmethod
def __get_validators__(cls):
yield cls.validate
@classmethod
def __modify_schema__(cls, field_schema):
field_schema.update(
pattern="YYYY-MM-DD,YYYY-MM-DD",
examples=["2021-01-01,2021-02-01"],
type="string",
)
@classmethod
def query(cls, name: str):
def factory(field=Query(..., alias=name)):
return cls.validate(field)
return factory
app = FastAPI()
@app.get("/")
def index(ranger=Depends(AlignedDateRange.query("hello"))):
return ranger
tc = TestClient(app)
print(tc.get("/").text)
print(tc.get("/", params={"hello": "2021-01-01,2021-02-01"}).text)
print(tc.get("/", params={"hello": ",2021-02-01"}).text)
print(tc.get("/", params={"hello": "2021-01-01,"}).text)$ python .\3656.py
{"detail":[{"loc":["query","hello"],"msg":"field required","type":"value_error.missing"}]}
{"start":"2021-01-01","end":"2021-02-01"}
{"start":null,"end":"2021-02-01"}
{"start":"2021-01-01","end":null} |
Beta Was this translation helpful? Give feedback.
-
|
@Mause Wow, the factory function is great! That's what I didn't come up with. Thanks a lot! |
Beta Was this translation helpful? Give feedback.
Is this not elegant?