litestar-org / litestar

Production-ready, Light, Flexible and Extensible ASGI API framework | Effortlessly Build Performant APIs
https://litestar.dev/
MIT License
5.57k stars 379 forks source link

Bug: Generic Controller OpenAPI broken (models not generated) #3667

Closed nchaikh closed 3 months ago

nchaikh commented 3 months ago

Description

I´ve been trying to do a generic controller, something like DRF generic Views. The controller works but in the OpenAPI docs the models of the controller don't generate.

URL to code causing the issue

No response

MCVE

from __future__ import annotations

from typing import Generic, Sequence, TypeVar
from collections.abc import AsyncGenerator
from datetime import date
from uuid import UUID
import uuid

from litestar import Litestar, get
from litestar.controller import Controller
from litestar.di import Provide
from litestar.exceptions import NotFoundException

from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import ForeignKey, func, select
from sqlalchemy.orm import (
    Mapped,
    mapped_column,
    relationship,
)

from advanced_alchemy.base import UUIDAuditBase, UUIDBase
from advanced_alchemy.extensions.litestar import (
    AsyncSessionConfig,
    SQLAlchemyAsyncConfig,
    SQLAlchemyPlugin,
    async_autocommit_before_send_handler,
)
from advanced_alchemy.repository import SQLAlchemyAsyncRepository
from advanced_alchemy.service import SQLAlchemyAsyncRepositoryService

from pydantic import BaseModel as _BaseModel, TypeAdapter

class BaseModel(_BaseModel):
    """Extend Pydantic's BaseModel to enable ORM mode"""

    model_config = {"from_attributes": True}

# Model = TypeVar("Model", bound=DeclarativeBase)
Model = TypeVar("Model", bound=BaseModel)
RepositoryService = TypeVar("RepositoryService", bound=SQLAlchemyAsyncRepositoryService)

class GenericController(Controller, Generic[Model, RepositoryService]):
    """Generic controller for all models."""

    model_type: type[Model]

    @get("/")
    async def list(self, service: RepositoryService) -> list[Model]:
        """List all instances."""
        objs = await service.list()

        type_adapter = TypeAdapter(Sequence[self.model_type])

        return type_adapter.validate_python(objs)

    @get("/{id:uuid}")
    async def get(self, service: RepositoryService, id: UUID) -> Model:
        """Get a single instance by ID."""
        obj = await service.get_one_or_none(id)

        if not obj:
            raise NotFoundException(f"{self.model_type.__name__} not found")

        return self.model_type.model_validate(obj)

# the SQLAlchemy base includes a declarative model for you to use in your models.
# The `Base` class includes a `UUID` based primary key (`id`)
class AuthorModel(UUIDBase):
    # we can optionally provide the table name instead of auto-generating it
    __tablename__ = "author"
    name: Mapped[str]
    dob: Mapped[date | None]
    books: Mapped[list[BookModel]] = relationship(
        back_populates="author", lazy="noload"
    )

# The `AuditBase` class includes the same UUID` based primary key (`id`) and 2
# additional columns: `created` and `updated`. `created` is a timestamp of when the
# record created, and `updated` is the last time the record was modified.
class BookModel(UUIDAuditBase):
    __tablename__ = "book"
    title: Mapped[str]
    author_id: Mapped[UUID] = mapped_column(ForeignKey("author.id"))
    author: Mapped[AuthorModel] = relationship(
        lazy="joined", innerjoin=True, viewonly=True
    )

# AuthorDTO = SQLAlchemyDTO[Author]
# BookDTO = SQLAlchemyDTO[Book]

class Book(BaseModel):
    id: UUID
    title: str
    author_id: UUID

class Author(BaseModel):
    id: UUID
    name: str
    dob: date | None
    books: list[Book]

class AuthorRepository(SQLAlchemyAsyncRepository[AuthorModel]):
    """Author repository."""

    model_type = AuthorModel

class AuthorService(SQLAlchemyAsyncRepositoryService[AuthorModel]):
    """Author repository."""

    repository_type = AuthorRepository

async def provide_authors_service(
    db_session: AsyncSession,
) -> AsyncGenerator[AuthorService, None]:
    """This provides the default Authors repository."""
    async with AuthorService.new(
        session=db_session,
    ) as service:
        yield service

class AuthorController(GenericController[AuthorModel, AuthorService]):
    """Author CRUD"""

    path = "/authors"

    dependencies = {"service": Provide(provide_authors_service)}

    model_type = Author

session_config = AsyncSessionConfig(expire_on_commit=False)
sqlalchemy_config = SQLAlchemyAsyncConfig(
    connection_string="sqlite+aiosqlite:///test.sqlite",
    before_send_handler=async_autocommit_before_send_handler,
    session_config=session_config,
    create_all=True,
)  # Create 'db_session' dependency.
sqlalchemy_plugin = SQLAlchemyPlugin(config=sqlalchemy_config)

async def on_startup() -> None:
    """Adds some dummy data if no data is present."""
    async with sqlalchemy_config.get_session() as session:
        statement = select(func.count()).select_from(AuthorModel)
        count = await session.execute(statement)
        if not count.scalar():
            author_id = uuid.uuid4()
            session.add(
                AuthorModel(name="Stephen King", dob=date(1954, 9, 21), id=author_id)
            )
            session.add(BookModel(title="It", author_id=author_id))
            await session.commit()

app = Litestar(
    on_startup=[on_startup],
    route_handlers=[AuthorController],
    plugins=[sqlalchemy_plugin],
)

Steps to reproduce

No response

Screenshots

image

Logs

No response

Litestar Version

2.10.0

Platform


[!NOTE]
While we are open for sponsoring on GitHub Sponsors and OpenCollective, we also utilize Polar.sh to engage in pledge-based sponsorship.

Check out all issues funded or available for funding on our Polar.sh dashboard

  • If you would like to see an issue prioritized, make a pledge towards it!
  • We receive the pledge once the issue is completed & verified
  • This, along with engagement in the community, helps us know which features are a priority to our users.

Fund with Polar

provinzkraut commented 3 months ago

This is not a bug. It's just how generics work in Python.

When you do GenericController[AuthorModel, AuthorService], the Model type var won't automagically be replaced behind the scenes with AuthorModel. This would require the subclass to create a deep copy of the generic base class, with all the type var's usages substituted with the type you passed.

There is a feature request to support generic controllers: #1311