django-ninja icon indicating copy to clipboard operation
django-ninja copied to clipboard

[BUG] Path and Query parameter Annotated description + title are removed from OpenAPI docs

Open jceipek opened this issue 1 year ago • 5 comments

Describe the bug Given:

@api.get("/items/{item_id}")
def read_item(request, item_id: Annotated[str, Field(examples=["an example"], title="Some Title", description="A description")]):
    return {"item_id": item_id}

I expect "Some Title" and "A description" show up in the generated OpenAPI docs. However, only "an example" shows up in the generated OpenAPI docs.

Versions (please complete the following information):

  • Python version: 3.11.9
  • Django version: 5.0.6
  • Django-Ninja version: 1.2.0
  • Pydantic version: 2.7.4

Root Cause

ninja's class Param(FieldInfo) in ninja.params.models calls:

        super().__init__(
            default=default,
            alias=alias,
            title=title,
            description=description,
            gt=gt,
            ge=ge,
            lt=lt,
            le=le,
            min_length=min_length,
            max_length=max_length,
            json_schema_extra=json_schema_extra,
            **extra,
        )

This ultimately causes a Path instance to be created with these _attributes_set values:

{'default': Ellipsis, 'alias': None, 'title': None, 'description': None, 'gt': None, 'ge': None, 'lt': None, 'le': None, 'min_length': None, 'max_length': None, 'json_schema_extra': {}}

Note that the values of title and description are None.

Direct Cause

When ninja constructs an Operation, it creates a ViewSignature, which calls _create_models. The end of the _create_models function calls model_cls = type(cls_name, (base_cls,), attrs). This causes Pydantic's ModelMetaclass __new__ method to be called, which results in calls to set_model_fields, collect_model_fields, and from_annotated_attribute. from_annotated_attribute calls merge_field_infos on a tuple of three elements (I'm not sure why the first two elements are identical):

FieldInfo(annotation=NoneType, required=True, title='Some Title', description='A description', examples=['an example'])
FieldInfo(annotation=NoneType, required=True, title='Some Title', description='A description', examples=['an example'])
Path(annotation=str, required=True, json_schema_extra={}, metadata=[FieldInfo(annotation=NoneType, required=True, title='Some Title', description='A description', examples=['an example'])])

The Path element in the tuple has an _attributes_set attribute containing this dictionary:

{'default': Ellipsis, 'alias': None, 'title': None, 'description': None, 'gt': None, 'ge': None, 'lt': None, 'le': None, 'min_length': None, 'max_length': None, 'json_schema_extra': {}}

Note that title and description are set to None, so merge_field_infos overwrites the desired description and title with None.

Potential Solution

This bug disappears if FieldInfo.__init__ in class Param(FieldInfo) does not receive None values in the default case. For example, adding this to the bottom of __init__ in class Param(FieldInfo) addresses the immediate problem:

    initializer: dict[str, Any] = {}
    if alias is not None:
        initializer["alias"] = alias
    if title is not None:
        initializer["title"] = title
    if description is not None:
        initializer["description"] = description
    if gt is not None:
        initializer["gt"] = gt
    if ge is not None:
        initializer["ge"] = ge
    if lt is not None:
        initializer["lt"] = lt
    if le is not None:
        initializer["le"] = le
    if min_length is not None:
        initializer["min_length"] = min_length
    if max_length is not None:
        initializer["max_length"] = max_length

    FieldInfo.__init__(
        self,
        default=default,
        json_schema_extra=json_schema_extra,
        **initializer,
        **extra,
    )

I'd be happy to make a pull request making this change, but first I'd like to know if this is an appropriate solution. Is there an easier way to do this?

jceipek avatar Jul 05 '24 23:07 jceipek

I think the intended way to do this is to use param: type = Path(...) directly in the function signature as shown here.

dmartin avatar Jul 10 '24 16:07 dmartin

Hi @jceipek

Basically you need to use Path marker (and PathEx to pass EXtras + P param):

from ninja import NinjaAPI, PathEx, P
api = NinjaAPI()


@api.get("/items/{item_id}")
def read_item(request, item_id: PathEx[str, P(example="an example", title="Some Title", description="A description")]):
    return {"item_id": item_id}

Note: swagger supports only 1 example (not multiple examples) for path params

vitalik avatar Jul 10 '24 19:07 vitalik

I think the intended way to do this is to use param: type = Path(...) directly in the function signature as shown here.

@dmartin That doesn't quite work; Path from from ninja import Path expects a generic parameter and mypy warns that one is missing. It does work with param_functions.Path from from ninja.params import functions as param_functions.

Basically you need to use Path marker (and PathEx to pass EXtras + P param):

from ninja import NinjaAPI, PathEx, P
api = NinjaAPI()


@api.get("/items/{item_id}")
def read_item(request, item_id: PathEx[str, P(example="an example", title="Some Title", description="A description")]):
    return {"item_id": item_id}

@vitalik That works as a workaround; thank you! Is the problem I reported with using Annotated expected behavior, then? It's especially confusing when usingAnnotated indirectly; for example:

ItemType = Annotated[str, Field(examples=["an example"], title="Some Title", description="A description")]

...

@api.get("/items/{item_id}")
def read_item(request, item_id: ItemType):
    return {"item_id": item_id}

In that case, it seems odd to need to repeat the information like this:

ItemType = Annotated[str, Field(examples=["an example"], title="Some Title", description="A description")]

...

@api.get("/items/{item_id}")
def read_item(request, item_id: PathEx[ItemType, P(example="an example", title="Some Title", description="A description")]):
    return {"item_id": item_id}

Note: swagger supports only 1 example (not multiple examples) for path params

Good to know; thanks!

jceipek avatar Jul 10 '24 20:07 jceipek

@dmartin That doesn't quite work; Path from from ninja import Path expects a generic parameter. It does work with param_functions.Path from from ninja.params import functions as param_functions.

Ah, sorry for the misinformation there. I have some functions like

def submissions(
    request: Request,
    id: int = Path(
        ...,
        description="Must be a member of a classroom in which the user is a leader.",
    )
):

which seem to be working (the description appears in the OpenAPI spec), but maybe that is simply coincidental.

Is there any documentation about when and how to use different parameter constructs like: params: MyParams = Query(...) vs params: Query[MyParams] vs QueryEx?

dmartin avatar Jul 10 '24 21:07 dmartin

Is there any documentation about when and how to use different parameter constructs like: params: MyParams = Query(...) vs params: Query[MyParams] vs QueryEx?

Query - QueryEx - is basically the same thing in runtime

it just because it's a special Annotated alias - mypy does not like it with parameters

  • so Query[type, P(options..)] is equal to QueryEx[type, P(options)] - but mypy will give you linting error saying that that's not allowed - I still did not find any useful solution how to overcome this

vitalik avatar Jul 11 '24 07:07 vitalik

For future readers, it looks like there's currently a bug with PathEx/QueryEx in conjunction with Annotated types, which can cause them to silently lose schema information. See https://github.com/vitalik/django-ninja/issues/1575

jceipek avatar Oct 21 '25 22:10 jceipek

Update: https://github.com/vitalik/django-ninja/issues/1575 is fixed in https://github.com/vitalik/django-ninja/pull/1574

jceipek avatar Oct 23 '25 15:10 jceipek