django-ninja
django-ninja copied to clipboard
OR filter for values of the same field
What I'm trying to achieve is changing the URL to a more comprehensive format when doing an OR selection for several values of the same field.
This is my custom filter
from ninja import FilterSchema, Field
from models import (
TradeIn,
)
from django.db.models import Q
import enum
class TradeInFilterSchema(FilterSchema):
state: list[
enum.Enum(
"MyEnum", [(name, name) for name in TradeIn.TradeInState._member_names_]
)
] = Field([])
# member names
def filter_state(self, value: list[str]) -> Q:
"""Custom filter to it translates the values to"""
filter_on_states = []
for state in value:
filter_on_states.append(TradeIn.TradeInState.__members__[state.value].value)
return Q(
state__in=filter_on_states # as we use the member value in the filter do this to filter against the value in the DB
)
applying that filter on a /trade-ins/ endpoint results in this URL:
'http://localhost:8008/api/trade-ins?state=INITIAL&state=CONFIRMED&limit=100&offset=0'
Although this perfectly does what I want it to, one could misinterpret the URL as filtering on INITIAL AND CONFIRMED
Is there a possibility, while using django-ninja, to transform the url to something where it's clearer that I'm doing an OR statement here? Something like
'http://localhost:8008/api/trade-ins?state=INITIAL,CONFIRMED&limit=100&offset=0'?
Hi @stvdrsch
You can achieve this by making custom type (that access comma separated string and turns to list) - docs are here
example:
from typing_extensions import Annotated
from pydantic import BeforeValidator, BaseModel
CommaToStrList = Annotated[
list[str],
BeforeValidator(lambda x: x.split(',')),
]
class Data(BaseModel):
state: CommaToStrList
data = Data(state='a,b,c')
print(data)
output
state=['a', 'b', 'c']
@vitalik - thanks for this. Is that code supposed to fit within the FilterSchema?
@cameroon16, yes. Since ninja.FilterSchema inherits from ninja.Schema which in turn inherits from pydantic.BaseModel, you can leverage the power of Pydantic:
CommaToStrList = Annotated[
list[str],
BeforeValidator(lambda x: x.split(',')),
]
class TradeInFilterSchema(FilterSchema):
state: CommaToStrList | None = Field(None, q="state__in")
@api.get("/tradein")
def test(request, filters: Query[TradeInFilterSchema]):
print(filters.state)
return filters.filter(TradeIn.objects.all())
# ...
You no longer need a custom filter_state method and can just use q="state__in" kwarg on the filter field definition since you already have a proper list that can be passed to Django ORM:
GET /tradein will print None, TradeIns will not be filtered (FilterSchema ignores Nones by default);
GET /tradein?state=FOO will print ["FOO"], TradeIns will be filtered by state__in=["FOO"];
GET /tradein?state=FOO,BAR will print ["FOO", "BAR"], TradeIns will be filtered by `state__in=["FOO", "BAR"].
Note: since CommaToStrList annotation is using a BeforeValidator, it's dealing with raw input. You might want to cover some corner cases, such as:
- stripping away possible whitespaces and empty values (e.g.
"FOO,BAR,".split(",") == ["FOO", "BAR", ""]) - validating the list against a predefined list of possible states.
You might upgrade ComaToStrList to something like ListOfStates:
def raw_str_to_list_of_states(value: str) -> list[TradeIn.TradeInState]:
# split value
# trim whitespaces, discard empty items
# validate each individual item against TradeInState enum
# return list of TradeInEnum items
ListOfStates = Annotated[list[TradeIn.TradeInState], BeforeValidator(raw_str_to_list_of_states)]
Update 1 (good news)
Thanks to Pydantic, the validation can be as simple as following (verified in my own projects):
from typing import TypeVar, Annotated
from pydantic import BeforeValidator
from ninja import FilterSchema
T = TypeVar("T")
# Splits comma-separated string into a list of items.
# Strips whitespaces and trims leading/trailing commas
# This annotation can be reused many times for different enums or plain strings
CommaList = Annotated[list[T], BeforeValidator(lambda x: [v for v in (v.strip() for v in x.split(",")) if v])]
class TradeInFilterSchema(FilterSchema):
state: CommaList[TradeIn.TradeInState] | None = Field(None, q="state__in")
The TradeInFilterSchema.state will ultimately be transformed (and validated to conform) to a list of TradeIn.TradeInState enum values which can be fed straight to Django ORM for filtering (thanks to q="state__in" lookup parameter in Field).
Update 2 (not so good news)
By default, Swagger UI (OpenAPI 3.1 to be more precise) uses a combination of style: form + explode: true default attributes when serializing query params of an API method. Essentially that means that no matter the code above, Swagger UI will still attempt to send the state options as state=STATE1&state=STATE2, not state=STATE1,STATE2.
To fix that, one must force a style: form + explode: **false** combination for the comma-separated parameters. I found no easy way to do this because Ninja seems to not configure these parameter attributes. One hacky workaround to have this fixed is to override NinjaAPI.get_openapi_schema and inject those attributes in the schema where needed:
class MyNinjaAPI(NinjaAPI):
def get_openapi_schema(self, *, path_prefix: Optional[str] = None, path_params: Optional[DictStrAny] = None,
) -> OpenAPISchema:
schema = super().get_openapi_schema(path_prefix=path_prefix, path_params=path_params)
# traverse the schema here and insert "explode: false" where needed
return schema