Skip to content

Commit

Permalink
update codebase to support fastapi>=0.100.0 and pydantic>=2.0.0 (#447)
Browse files Browse the repository at this point in the history
Co-authored-by: Arthur Rio <[email protected]>
  • Loading branch information
johnybx and arthurio committed Sep 28, 2023
1 parent 7f1f367 commit 827d2e3
Show file tree
Hide file tree
Showing 16 changed files with 1,040 additions and 746 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ fastapi_filter.sqlite
poetry.toml
.pytest_cache/
.ruff_cache/
__pycache__
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
**Required:**

- Python: >=3.8, <4.0
- Fastapi: >=0.78, <1.0
- Pydantic: >=1.10.0, <2.0.0
- Fastapi: >=0.100, <1.0
- Pydantic: >=2.0.0, <3.0.0

**Optional**

Expand Down
38 changes: 20 additions & 18 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ Add querystring filters to your api endpoints and show them in the swagger UI.
The supported backends are [SQLAlchemy](https://github.com/sqlalchemy/sqlalchemy) and
[MongoEngine](https://github.com/MongoEngine/mongoengine).


## Example

![Swagger UI](./swagger-ui.png)
Expand All @@ -25,16 +24,16 @@ as well as the type of operator, then tie your filter to a specific model.

By default, **fastapi_filter** supports the following operators:

- `neq`
- `gt`
- `gte`
- `in`
- `isnull`
- `lt`
- `lte`
- `not`/`ne`
- `not_in`/`nin`
- `like`/`ilike`
- `neq`
- `gt`
- `gte`
- `in`
- `isnull`
- `lt`
- `lte`
- `not`/`ne`
- `not_in`/`nin`
- `like`/`ilike`

_**Note:** Mysql excludes `None` values when using `in` filter_

Expand Down Expand Up @@ -89,6 +88,11 @@ Wherever you would use a `Depends`, replace it with `FilterDepends` if you are p
that `FilterDepends` converts the `list` filter fields to `str` so that they can be displayed and used in swagger.
It also handles turning `ValidationError` into `HTTPException(status_code=422)`.

#### Limitations

`FilterDepends` does not convert `list` type to `str` if it is part of union type with other
types except for `None`. For example type `list[str] | None` will work but `list[str] | str` will
not.

### with_prefix

Expand Down Expand Up @@ -133,12 +137,11 @@ There is a specific field on the filter class that can be used for ordering. The
takes a list of string. From an API call perspective, just like the `__in` filters, you simply pass a comma separated
list of strings.

You can change the **direction** of the sorting (*asc* or *desc*) by prefixing with `-` or `+` (Optional, it's the
You can change the **direction** of the sorting (_asc_ or _desc_) by prefixing with `-` or `+` (Optional, it's the
default behavior if omitted).

If you don't want to allow ordering on your filter, just don't add `order_by` (or custom `ordering_field_name`) as a field and you are all set.


## Search

There is a specific field on the filter class that can be used for searching. The default name is `search` and it takes
Expand All @@ -148,7 +151,6 @@ You have to define what fields/columns to search in with the `search_model_field

If you don't want to allow searching on your filter, just don't add `search` (or custom `search_field_name`) as a field and you are all set.


### Example - Basic

```python
Expand Down Expand Up @@ -215,17 +217,17 @@ curl /users?custom_order_by=+id

### Restrict the `order_by` values

Add the following validator to your filter class:
Add the following field_validator to your filter class:

```python
from typing import Optional
from fastapi_filter.contrib.sqlalchemy import Filter
from pydantic import validator
from pydantic import field_validator

class MyFilter(Filter):
order_by: Optional[list[str]]

@validator("order_by")
@field_validator("order_by")
def restrict_sortable_fields(cls, value):
if value is None:
return None
Expand All @@ -241,7 +243,7 @@ class MyFilter(Filter):
```

1. If you want to restrict only on specific directions, like `-created_at` and `name` for example, you can remove this
line. Your `allowed_field_names` would be something like `["age", "-age", "-created_at"]`.
line. Your `allowed_field_names` would be something like `["age", "-age", "-created_at"]`.

### Example - Search

Expand Down
56 changes: 30 additions & 26 deletions examples/fastapi_filter_mongoengine.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import logging
from typing import Any, Dict, Generator, List, Optional
from typing import Any, List, Optional

import click
import uvicorn
from bson.objectid import ObjectId
from faker import Faker
from fastapi import FastAPI, Query
from mongoengine import Document, connect, fields
from pydantic import BaseModel, EmailStr, Field
from pydantic import BaseModel, ConfigDict, EmailStr, Field, GetCoreSchemaHandler
from pydantic_core import CoreSchema, core_schema

from fastapi_filter import FilterDepends, with_prefix
from fastapi_filter.contrib.mongoengine import Filter
Expand All @@ -19,18 +20,22 @@

class PydanticObjectId(ObjectId):
@classmethod
def __get_validators__(cls) -> Generator:
yield cls.validate

@classmethod
def validate(cls, v: ObjectId) -> str:
def __get_pydantic_core_schema__(cls, source_type: Any, handler: GetCoreSchemaHandler) -> CoreSchema:
return core_schema.no_info_after_validator_function(
cls.validate,
core_schema.is_instance_schema(cls=ObjectId),
serialization=core_schema.plain_serializer_function_ser_schema(
str,
info_arg=False,
return_schema=core_schema.str_schema(),
),
)

@staticmethod
def validate(v: ObjectId) -> ObjectId:
if not ObjectId.is_valid(v):
raise ValueError("Invalid objectid")
return str(v)

@classmethod
def __modify_schema__(cls, field_schema: Dict[str, Any]) -> None:
field_schema.update(type="string")
return v


class Address(Document):
Expand Down Expand Up @@ -63,23 +68,22 @@ class UserIn(BaseModel):


class UserOut(UserIn):
model_config = ConfigDict(from_attributes=True)

id: PydanticObjectId = Field(..., alias="_id")
name: str
email: EmailStr
age: int
address: Optional[AddressOut]

class Config:
orm_mode = True
address: Optional[AddressOut] = None


class AddressFilter(Filter):
street: Optional[str]
country: Optional[str]
city: Optional[str]
city__in: Optional[List[str]]
custom_order_by: Optional[List[str]]
custom_search: Optional[str]
street: Optional[str] = None
country: Optional[str] = None
city: Optional[str] = None
city__in: Optional[List[str]] = None
custom_order_by: Optional[List[str]] = None
custom_search: Optional[str] = None

class Constants(Filter.Constants):
model = Address
Expand All @@ -89,16 +93,16 @@ class Constants(Filter.Constants):


class UserFilter(Filter):
name: Optional[str]
name: Optional[str] = None
address: Optional[AddressFilter] = FilterDepends(with_prefix("address", AddressFilter))
age__lt: Optional[int]
age__lt: Optional[int] = None
age__gte: int = Field(Query(description="this is a nice description"))
"""Required field with a custom description.
See: https://github.com/tiangolo/fastapi/issues/4700 for why we need to wrap `Query` in `Field`.
"""
order_by: List[str] = ["age"]
search: Optional[str]
search: Optional[str] = None

class Constants(Filter.Constants):
model = User
Expand Down Expand Up @@ -138,7 +142,7 @@ async def get_users(user_filter: UserFilter = FilterDepends(UserFilter)) -> Any:

@app.get("/addresses", response_model=List[AddressOut])
async def get_addresses(
address_filter: AddressFilter = FilterDepends(with_prefix("my_prefix", AddressFilter), by_alias=True),
address_filter: AddressFilter = FilterDepends(with_prefix("my_custom_prefix", AddressFilter), by_alias=True),
) -> Any:
query = address_filter.filter(Address.objects())
query = address_filter.sort(query)
Expand Down
40 changes: 19 additions & 21 deletions examples/fastapi_filter_sqlalchemy.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import uvicorn
from faker import Faker
from fastapi import Depends, FastAPI, Query
from pydantic import BaseModel, Field
from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import Column, ForeignKey, Integer, String, event, select
from sqlalchemy.engine import Engine
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
Expand Down Expand Up @@ -53,14 +53,13 @@ class User(Base):


class AddressOut(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: int
street: str
city: str
country: str

class Config:
orm_mode = True


class UserIn(BaseModel):
name: str
Expand All @@ -69,20 +68,19 @@ class UserIn(BaseModel):


class UserOut(UserIn):
id: int
address: Optional[AddressOut]
model_config = ConfigDict(from_attributes=True)

class Config:
orm_mode = True
id: int
address: Optional[AddressOut] = None


class AddressFilter(Filter):
street: Optional[str]
country: Optional[str]
city: Optional[str]
city__in: Optional[List[str]]
custom_order_by: Optional[List[str]]
custom_search: Optional[str]
street: Optional[str] = None
country: Optional[str] = None
city: Optional[str] = None
city__in: Optional[List[str]] = None
custom_order_by: Optional[List[str]] = None
custom_search: Optional[str] = None

class Constants(Filter.Constants):
model = Address
Expand All @@ -92,19 +90,19 @@ class Constants(Filter.Constants):


class UserFilter(Filter):
name: Optional[str]
name__ilike: Optional[str]
name__like: Optional[str]
name__neq: Optional[str]
name: Optional[str] = None
name__ilike: Optional[str] = None
name__like: Optional[str] = None
name__neq: Optional[str] = None
address: Optional[AddressFilter] = FilterDepends(with_prefix("address", AddressFilter))
age__lt: Optional[int]
age__lt: Optional[int] = None
age__gte: int = Field(Query(description="this is a nice description"))
"""Required field with a custom description.
See: https://github.com/tiangolo/fastapi/issues/4700 for why we need to wrap `Query` in `Field`.
"""
order_by: List[str] = ["age"]
search: Optional[str]
search: Optional[str] = None

class Constants(Filter.Constants):
model = User
Expand Down Expand Up @@ -158,7 +156,7 @@ async def get_users(

@app.get("/addresses", response_model=List[AddressOut])
async def get_addresses(
address_filter: AddressFilter = FilterDepends(with_prefix("my_prefix", AddressFilter), by_alias=True),
address_filter: AddressFilter = FilterDepends(with_prefix("my_custom_prefix", AddressFilter), by_alias=True),
db: AsyncSession = Depends(get_db),
) -> Any:
query = select(Address)
Expand Down
Loading

0 comments on commit 827d2e3

Please sign in to comment.