vitalik / django-ninja

💨 Fast, Async-ready, Openapi, type hints based framework for building APIs
https://django-ninja.dev
MIT License
7.38k stars 439 forks source link

Dynamic response schema based on request parameters #333

Open dralley opened 2 years ago

dralley commented 2 years ago

Hello,

Is it currently possible to implement functionality similar to djangorestframework-queryfields with Django Ninja? That is, given a request parameter parameter ?fields=field1,field2 or ?exclude_fields=field3, would it be possible to dynamically change the schema of the response?

The biggest reason you might want to do this is with a heavy endpoint that produces a great deal of data, you may want to avoid as much serialization cost as possible, and potentially restrict database IO to only the fields you care about (djangorestframework-queryfields cannot do this out of the box but with medium effort and a tolerance for hackiness you can implement it).

vitalik commented 2 years ago

@dralley

Well it's possible (I guess it's even somewhat OpenAPI compatible)

from django.contrib.auth.models import User
from ninja import NinjaAPI, Schema, Query

api = NinjaAPI()

class UserSchema(Schema):
    id: int = None  # !!! important that ALL schema fields are all optional
    username: str = None
    email: str = None
    first_name: str = None
    last_name: str = None

@api.get("/some", response=List[UserSchema], exclude_unset=True) # !!!! exclude_unset
def some(request, fields: List[str] = Query(...)):
    qs = User.objects.all()
    return qs.values(*fields)

result:

CleanShot 2022-01-22 at 14 17 09

I guess the only thing you need to keep in mind is validate incoming list of fields (but this can be automated with some function that will create both schema for result and schema for fields)

So yeah - it's possible - should it be in ninja by default - I think no - but a good candidate for external library

lucasrcezimbra commented 1 year ago

@vitalik what about nested fields? Example:

from typing import List

from django.db import models
from ninja import NinjaAPI, Query, Schema

from api.core.models import User

api = NinjaAPI()

class Post(models.Model):
    user = models.ForeignKey(User, on_delete=models.CASCADE)

class UserSchema(Schema):
    id: int = None  # !!! important that ALL schema fields are all optional
    username: str = None
    email: str = None
    first_name: str = None
    last_name: str = None
    post__id: str = None

@api.get("/some", response=List[UserSchema], exclude_unset=True) # !!!! exclude_unset
def some(request, fields: List[str] = Query(...)):
    qs = User.objects.all()
    return qs.values(*fields)

image

I would expect to return a nested dict like:

[
  {
    "id": 1,
    "email": "",
    "post": {"id": "1"}
  }
]

I created a custom query set as a workaround to handle it, but I'm looking for a more generic solution for all endpoints.

Do you still think that it doesn't make sense for Django Ninja to support it? It looks like the built-in Pagination support for me.

vitalik commented 1 year ago

Hi @lucasrcezimbra

Query params do not have a "standard" way to pass structured data, but since all query params are atumatically flattened by django ninja you can achive it like this:

class PostSchema(Schema):
    id: int = Field(None, alias="post__id") # unique ALIAS is important !!!
    title: str = Field(None, alias="post__title") # unique ALIAS is important !!!

class UserSchema(Schema):
    id: int = None
    username: str = None
    email: str = None
    post: PostSchema = None

@api.get("/some")
def some(request, params: UserSchema = Query(...)):
    return params.dict()
SCR-20230807-prjr
lucasrcezimbra commented 1 year ago

Thanks for the quick answer.

It works for filtering by the nested values, but I couldn't make it work for field selection/dynamic response.

...
class PostSchema(Schema):
    id: int = Field(None, alias="post__id")

class UserSchema(Schema):
    id: int = None  # !!! important that ALL schema fields are all optional
    username: str = None
    email: str = None
    first_name: str = None
    last_name: str = None
    post: PostSchema = None
...

image

I expected:

[
  {
    "id": 1,
    "email": "",
    "post": {"id": 1}
  }
]
vitalik commented 1 year ago

@lucasrcezimbra not sure if this is what you need:


class PostSchema(Schema):
    id: int = Field(None, alias="post__id")
    title: str = Field(None, alias="post__title")

class UserSchema(Schema):
    id: int = None
    username: str = None
    email: str = None
    post: PostSchema = None

@api.get("/some", response=UserSchema, exclude_unset=True)
def some(request, params: UserSchema = Query(...)):
    return params
SCR-20230808-ivas
lucasrcezimbra commented 1 year ago

not sure if this is what you need

What I'm suggesting is to have the ability to select the response fields using the query parameters. As suggested by the issue author:

That is, given a request parameter parameter ?fields=field1,field2 or ?exclude_fields=field3, would it be possible to dynamically change the schema of the response?

vitalik commented 1 year ago

@lucasrcezimbra

What I'm suggesting is to have the ability to select the response fields using the query parameters.

well this is not something that is standardised in OpenAPI (django ninja tries to be fully compatible)

but you should be able to achieve it with some custom decorator:


def add_exclude_fields(func):

   func._ninja_contribute_args = [  # adding automatically extra param exclude_fields
        (
            "exclude_fields",
            list[str],
            Query([]),
        ),
    ]

    @wraps(func)
     def wrapper(request, *a, *kw):
           exclude_fields = kw.pop('exclude_fields')
           result =  func(request, *a, **kw)
           # >>> do exclude magic here<<<
           return result

     return wrapper

...

@api.get('/some')
@add_exclude_fields
def my_view(request):
       return...
mustafa0x commented 1 year ago

What would "exclude magic" look like? :)

Related: is there a way to pass exclude to model_dump?

mustafa0x commented 11 months ago

fastapi has response_model_exclude.

You can also use the path operation decorator parameters response_model_include and response_model_exclude.

They take a set of str with the name of the attributes to include (omitting the rest) or to exclude (including the rest).

This can be used as a quick shortcut if you have only one Pydantic model and want to remove some data from the output.