Skip to content

Commit

Permalink
feat: filters can be dynamically generated per route (#196)
Browse files Browse the repository at this point in the history
Filters are now completely configurable per-route.  There's a dependency generator that will allow you to enable specific feature types per endpoint.

Here are the currently enabled configurable parameters:

```py
    id_filter: NotRequired[type[UUID | int]]
    """Indicates that the id filter should be enabled.  When set, the type specified will be used for the :class:`CollectionFilter`."""
    id_field: NotRequired[str]
    """The field on the model that stored the primary key or identifier."""
    sort_field: NotRequired[str]
    """The default field to use for the sort filter."""
    sort_order: NotRequired[SortOrder]
    """The default order to use for the sort filter."""
    pagination_type: NotRequired[Literal["limit_offset"]]
    """When set, pagination is enabled based on the type specified."""
    pagination_size: NotRequired[int]
    """The size of the pagination."""
    search: NotRequired[bool]
    """When set, search is enabled."""
    search_ignore_case: NotRequired[bool]
    """When set, search is case insensitive by default."""
    created_at: NotRequired[bool]
    """When set, created_at filter is enabled."""
    updated_at: NotRequired[bool]
    """When set, updated_at filter is enabled."""
```
  • Loading branch information
cofin authored Jan 20, 2025
1 parent 795592a commit fd571a9
Show file tree
Hide file tree
Showing 15 changed files with 812 additions and 437 deletions.
332 changes: 166 additions & 166 deletions package-lock.json

Large diffs are not rendered by default.

30 changes: 15 additions & 15 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slot": "^1.1.1",
"@radix-ui/react-toast": "^1.2.4",
"@tanstack/react-query": "^5.62.16",
"@tanstack/react-query": "^5.64.2",
"@tanstack/react-table": "^8.20.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
Expand All @@ -29,8 +29,8 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-router-dom": "^7.1.1",
"sonner": "^1.7.1",
"react-router-dom": "^7.1.3",
"sonner": "^1.7.2",
"tailwind-merge": "^2.6.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.24.1"
Expand All @@ -39,23 +39,23 @@
"devDependencies": {
"@tailwindcss/forms": "^0.5.10",
"@tailwindcss/typography": "^0.5.16",
"@types/node": "^22.10.5",
"@types/react": "^19.0.3",
"@types/react-dom": "^19.0.2",
"@typescript-eslint/eslint-plugin": "^8.19.1",
"@typescript-eslint/parser": "^8.19.1",
"@types/node": "^22.10.7",
"@types/react": "^19.0.7",
"@types/react-dom": "^19.0.3",
"@typescript-eslint/eslint-plugin": "^8.21.0",
"@typescript-eslint/parser": "^8.21.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"axios": "^1.7.9",
"eslint": "^9.17.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.2.1",
"eslint": "^9.18.0",
"eslint-config-prettier": "^10.0.1",
"eslint-plugin-prettier": "^5.2.3",
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"postcss": "^8.4.49",
"eslint-plugin-react-refresh": "^0.4.18",
"postcss": "^8.5.1",
"prettier": "^3.4.2",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.2",
"vite": "^6.0.7"
"typescript": "^5.7.3",
"vite": "^6.0.10"
}
}
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ dependencies = [
"litestar-granian",
"aiosqlite",
"httptools",
"httpx-oauth>=0.16.1",
]
description = "Opinionated template for a Litestar application."
keywords = ["litestar", "sqlalchemy", "alembic", "fullstack", "api", "asgi", "litestar", "vite", "spa"]
Expand Down
40 changes: 38 additions & 2 deletions src/app/config/app.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
import logging
import sys
from functools import lru_cache
from typing import cast

import structlog
from httpx_oauth.clients.github import GitHubOAuth2
from litestar.config.compression import CompressionConfig
from litestar.config.cors import CORSConfig
from litestar.config.csrf import CSRFConfig
from litestar.contrib.jinja import JinjaTemplateEngine
from litestar.logging.config import LoggingConfig, StructLoggingConfig
from litestar.logging.config import (
LoggingConfig,
StructLoggingConfig,
default_logger_factory,
default_structlog_processors,
default_structlog_standard_lib_processors,
)
from litestar.middleware.logging import LoggingMiddlewareConfig
from litestar.plugins.problem_details import ProblemDetailsConfig
from litestar.plugins.sqlalchemy import (
AlembicAsyncConfig,
AsyncSessionConfig,
Expand Down Expand Up @@ -39,7 +50,7 @@
),
)
templates = TemplateConfig(engine=JinjaTemplateEngine(directory=settings.vite.TEMPLATE_DIR))

problem_details = ProblemDetailsConfig(enable_for_all_http_exceptions=True)
vite = ViteConfig(
bundle_dir=settings.vite.BUNDLE_DIR,
resource_dir=settings.vite.RESOURCE_DIR,
Expand All @@ -50,6 +61,11 @@
port=settings.vite.PORT,
host=settings.vite.HOST,
)
github_oauth = GitHubOAuth2(
client_id=settings.app.GITHUB_OAUTH2_CLIENT_ID,
client_secret=settings.app.GITHUB_OAUTH2_CLIENT_SECRET,
)

saq = SAQConfig(
redis=settings.redis.client,
web_enabled=settings.saq.WEB_ENABLED,
Expand Down Expand Up @@ -83,11 +99,31 @@
],
)


@lru_cache
def _is_tty() -> bool:
return bool(sys.stderr.isatty() or sys.stdout.isatty())


_render_as_json = not _is_tty()
_structlog_default_processors = default_structlog_processors(as_json=_render_as_json)
_structlog_default_processors.insert(1, structlog.processors.EventRenamer("message"))
_structlog_standard_lib_processors = default_structlog_standard_lib_processors(as_json=_render_as_json)
_structlog_standard_lib_processors.insert(1, structlog.processors.EventRenamer("message"))

log = StructlogConfig(
structlog_logging_config=StructLoggingConfig(
log_exceptions="always",
processors=_structlog_default_processors,
logger_factory=default_logger_factory(as_json=_render_as_json),
standard_lib_logging_config=LoggingConfig(
root={"level": logging.getLevelName(settings.log.LEVEL), "handlers": ["queue_listener"]},
formatters={
"standard": {
"()": structlog.stdlib.ProcessorFormatter,
"processors": _structlog_standard_lib_processors,
},
},
loggers={
"granian.access": {
"propagate": False,
Expand Down
11 changes: 5 additions & 6 deletions src/app/config/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -320,25 +320,20 @@ class RedisSettings:
"""Length of time to wait (in seconds) before testing connection health."""
SOCKET_KEEPALIVE: bool = field(default_factory=get_env("REDIS_SOCKET_KEEPALIVE", True))
"""Length of time to wait (in seconds) between keepalive commands."""
_redis_instance: Redis | None = None
"""Redis instance generated from settings."""

@property
def client(self) -> Redis:
return self.get_client()

def get_client(self) -> Redis:
if self._redis_instance is not None:
return self._redis_instance
self._redis_instance = Redis.from_url(
return Redis.from_url(
url=self.URL,
encoding="utf-8",
decode_responses=False,
socket_connect_timeout=self.SOCKET_CONNECT_TIMEOUT,
socket_keepalive=self.SOCKET_KEEPALIVE,
health_check_interval=self.HEALTH_CHECK_INTERVAL,
)
return self._redis_instance


@dataclass
Expand All @@ -365,6 +360,10 @@ class AppSettings:
"""CSRF Secure Cookie"""
JWT_ENCRYPTION_ALGORITHM: str = field(default_factory=lambda: "HS256")
"""JWT Encryption Algorithm"""
GITHUB_OAUTH2_CLIENT_ID: str = field(default_factory=get_env("GITHUB_OAUTH2_CLIENT_ID", ""))
"""Github OAuth2 Client ID"""
GITHUB_OAUTH2_CLIENT_SECRET: str = field(default_factory=get_env("GITHUB_OAUTH2_CLIENT_SECRET", ""))
"""Github OAuth2 Client Secret"""

@property
def slug(self) -> str:
Expand Down
17 changes: 14 additions & 3 deletions src/app/domain/accounts/controllers/users.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Annotated
from uuid import UUID

from litestar import Controller, delete, get, patch, post
from litestar.di import Provide
Expand All @@ -12,10 +13,9 @@
from app.domain.accounts.deps import provide_users_service
from app.domain.accounts.guards import requires_superuser
from app.domain.accounts.schemas import User, UserCreate, UserUpdate
from app.lib.deps import create_filter_dependencies

if TYPE_CHECKING:
from uuid import UUID

from advanced_alchemy.filters import FilterTypes
from advanced_alchemy.service import OffsetPagination

Expand All @@ -29,7 +29,18 @@ class UserController(Controller):
guards = [requires_superuser]
dependencies = {
"users_service": Provide(provide_users_service),
}
} | create_filter_dependencies(
{
"id_filter": UUID,
"search": True,
"pagination_type": "limit_offset",
"pagination_size": 20,
"created_at": True,
"updated_at": True,
"sort_field": "name",
"sort_order": "asc",
},
)

@get(operation_id="ListUsers", path=urls.ACCOUNT_LIST, cache=60)
async def list_users(
Expand Down
29 changes: 11 additions & 18 deletions src/app/domain/tags/controllers.py
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Annotated
from uuid import UUID

from advanced_alchemy.extensions.litestar.dto import SQLAlchemyDTO
from litestar import Controller, delete, get, patch, post
from sqlalchemy.orm import selectinload

from app.db import models as m
from app.domain.accounts.guards import requires_active_user, requires_superuser
from app.domain.tags.services import TagService
from app.lib import dto
from app.lib.deps import create_service_provider
from app.lib.deps import create_service_dependencies

from . import urls

if TYPE_CHECKING:
from uuid import UUID

from advanced_alchemy.filters import FilterTypes
from advanced_alchemy.service import OffsetPagination
from litestar.dto import DTOData
from litestar.params import Dependency, Parameter
from litestar.params import Parameter


class TagDTO(SQLAlchemyDTO[m.Tag]):
Expand All @@ -44,22 +42,17 @@ class TagController(Controller):
"""

guards = [requires_active_user]
dependencies = {
"tags_service": create_service_provider(
TagService,
load=[selectinload(m.Tag.teams, recursion_depth=2)],
),
}
signature_types = [TagService]
dependencies = create_service_dependencies(
TagService,
key="tags_service",
load=[m.Tag.teams],
filters={"id_filter": UUID, "created_at": True, "updated_at": True, "sort_field": "name", "search": True},
)
tags = ["Tags"]
return_dto = TagDTO

@get(operation_id="ListTags", path=urls.TAG_LIST)
async def list_tags(
self,
tags_service: TagService,
filters: Annotated[list[FilterTypes], Dependency(skip_validation=True)],
) -> OffsetPagination[m.Tag]:
async def list_tags(self, tags_service: TagService, filters: list[FilterTypes]) -> OffsetPagination[m.Tag]:
"""List tags."""
results, total = await tags_service.list_and_count(*filters)
return tags_service.to_schema(data=results, total=total, filters=filters)
Expand All @@ -77,7 +70,7 @@ async def get_tag(
@post(operation_id="CreateTag", guards=[requires_superuser], path=urls.TAG_CREATE, dto=TagCreateDTO)
async def create_tag(self, tags_service: TagService, data: DTOData[m.Tag]) -> m.Tag:
"""Create a new tag."""
db_obj = await tags_service.create(data.create_instance())
db_obj = await tags_service.create(data)
return tags_service.to_schema(db_obj)

@patch(operation_id="UpdateTag", path=urls.TAG_UPDATE, guards=[requires_superuser], dto=TagUpdateDTO)
Expand Down
15 changes: 9 additions & 6 deletions src/app/domain/teams/controllers/teams.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Annotated
from uuid import UUID

from advanced_alchemy.service import FilterTypeT # noqa: TC002
from litestar import Controller, delete, get, patch, post
Expand All @@ -15,11 +16,9 @@
from app.domain.teams.guards import requires_team_admin, requires_team_membership
from app.domain.teams.schemas import Team, TeamCreate, TeamUpdate
from app.domain.teams.services import TeamService
from app.lib.deps import create_service_provider
from app.lib.deps import create_service_dependencies

if TYPE_CHECKING:
from uuid import UUID

from advanced_alchemy.service.pagination import OffsetPagination
from litestar.params import Dependency, Parameter

Expand All @@ -28,9 +27,13 @@ class TeamController(Controller):
"""Teams."""

tags = ["Teams"]
dependencies = {
"teams_service": create_service_provider(TeamService, load=[m.Team.tags, m.Team.members]),
}
dependencies = create_service_dependencies(
TeamService,
key="teams_service",
load=[m.Team.tags, m.Team.members],
filters={"id_filter": UUID},
)

guards = [requires_active_user]

@get(component="team/list", operation_id="ListTeams", path=urls.TEAM_LIST)
Expand Down
Loading

0 comments on commit fd571a9

Please sign in to comment.