Skip to main content

Pagination utilities for FastAPI with SQLModel ORM

Project description

FastAPI RestKit

Python PyPI License Ruff

A complete REST toolkit for FastAPI with SQLModel ORM - featuring pagination, filtering, and sorting.

Features

  • ๐Ÿš€ Easy pagination with customizable page sizes
  • ๐Ÿ” Powerful filtering with Django-style filter lookups
  • ๐Ÿ“Š Flexible sorting with multi-field support
  • ๐Ÿ“ฆ Generic response models with full type hints
  • โšก FastAPI dependencies ready to use
  • ๐Ÿ”„ Async support out of the box
  • ๐Ÿ“ Auto-generated OpenAPI docs for all parameters

Installation

pip install fastapi-restkit

Or with uv:

uv add fastapi-restkit

Table of Contents


Quick Start

from fastapi import FastAPI, Depends
from sqlmodel import Session, select
from fastapi_restkit import PaginationParams, paginate, PaginatedResponse

app = FastAPI()

@app.get("/items", response_model=PaginatedResponse[Item])
async def list_items(
    session: Session = Depends(get_session),
    pagination: PaginationParams = Depends(),
):
    return await paginate(session, select(Item), pagination)

Pagination

Simple pagination with configurable page size:

from fastapi_restkit import PaginationParams, paginate, PaginatedResponse

@app.get("/products", response_model=PaginatedResponse[Product])
async def list_products(
    session: Session = Depends(get_session),
    pagination: PaginationParams = Depends(),
):
    query = select(Product)
    return await paginate(session, query, pagination)

URL Examples:

GET /products?page=1&page_size=20
GET /products?page=2&page_size=50

Filtering

Available Filter Types

Filter Description Value Type URL Example
SearchFilter Text search (case-insensitive) str ?name=laptop
BooleanFilter Boolean filter bool ?is_active=true
NumberFilter Numeric with comparisons float ?price=99.99
ListFilter[T] List of values (IN clause) list ?status=active&status=pending
DateFilter Date filter date ?created_at=2024-01-15
DateRangeFilter Date range (min/max) date ?created_at[min]=2024-01-01
DateTimeFilter DateTime filter datetime ?updated_at=2024-01-15T10:30:00Z
NumericRangeFilter Numeric range float ?price[min]=10&price[max]=100
TimeRangeFilter Time range time ?hours[min]=08:00&hours[max]=17:00

Lookup Operators

Filters support Django-style lookup operators:

Lookup Description SQL Example
exact Exact match (default) column = 'value'
iexact Case-insensitive equal column ILIKE 'value'
contains Contains column LIKE '%value%'
icontains Case-insensitive contains column ILIKE '%value%'
startswith Starts with column LIKE 'value%'
endswith Ends with column LIKE '%value'
gt Greater than column > value
gte Greater or equal column >= value
lt Less than column < value
lte Less or equal column <= value
isnull Is null check column IS NULL

Creating a FilterSet

from typing import Optional
from pydantic import Field
from fastapi_restkit.filterset import FilterSet, filter_as_query
from fastapi_restkit.filters import (
    SearchFilter,
    BooleanFilter,
    ListFilter,
    NumericRangeFilter,
    DateRangeFilter,
)


class ProductFilterSet(FilterSet):
    """Filters for products."""
    
    # Text search (auto-mapped to 'name' column)
    name: Optional[SearchFilter] = Field(
        default_factory=SearchFilter,
        description="Search by product name"
    )
    
    # Boolean filter
    is_active: Optional[BooleanFilter] = Field(
        default_factory=BooleanFilter,
        description="Filter by active status"
    )
    
    # List filter (IN clause)
    category: Optional[ListFilter[str]] = Field(
        default_factory=ListFilter,
        description="Filter by categories"
    )
    
    # Numeric range
    price: Optional[NumericRangeFilter] = Field(
        default_factory=NumericRangeFilter,
        description="Filter by price range"
    )
    
    # Date range
    created_at: Optional[DateRangeFilter] = Field(
        default_factory=DateRangeFilter,
        description="Filter by creation date"
    )
    
    # Multi-column search
    search: Optional[SearchFilter] = Field(
        default_factory=SearchFilter,
        description="Search in name and description"
    )

    class Config:
        # Custom column mapping
        field_columns = {
            "search": ["name", "description"],  # OR search across columns
        }


# Use in endpoint
@app.get("/products")
async def list_products(
    session: Session = Depends(get_session),
    filters: ProductFilterSet = Depends(filter_as_query(ProductFilterSet)),
):
    query = select(Product)
    query = filters.apply_to_query(query, Product)
    return session.exec(query).all()

Filter URL Examples

# Simple text search
GET /products?name=laptop

# With lookup operator
GET /products?name=lap&name[lookup]=startswith

# Boolean filter
GET /products?is_active=true

# List filter (IN clause)
GET /products?category=electronics&category=computers

# Numeric range
GET /products?price[min]=100&price[max]=500

# Date range
GET /products?created_at[min]=2024-01-01&created_at[max]=2024-12-31

# Combined filters
GET /products?is_active=true&category=electronics&price[min]=100

Relationship Expansion (expand / omit)

You can configure relationship expansion directly in a FilterSet using expand and omit query params. By default, expansions use selectinload. Use default_joined to allow joinedload for specific relationships.

from typing import Optional
from sqlmodel import select
from fastapi_restkit.filterset import FilterSet, filter_as_query


class ProductFilterSet(FilterSet):
    name: Optional[SearchFilter] = Field(default_factory=SearchFilter)

    class Config:
        # api_name -> relationship path
        expandable = {
            "owner": "owner",         # Product.owner
            "reviews": "reviews",     # Product.reviews
        }
        # Always expand owner by default
        default_expand = {"owner"}
        # Only owner uses joinedload; reviews uses selectinload
        default_joined = {"owner"}


@app.get("/products")
async def list_products(
    session: Session = Depends(get_session),
    filters: ProductFilterSet = Depends(filter_as_query(ProductFilterSet)),
):
    query = select(Product)
    query = filters.apply_to_query(query, Product)
    query = filters.apply_expands_to_query(query, Product)
    return session.exec(query).all()

URL Examples:

# Default expansion (owner) is applied
GET /products

# Expand reviews (selectinload)
GET /products?expand=reviews

# Omit default expansion
GET /products?omit=owner

Notes:

  • expand and omit only accept fields defined in Config.expandable.
  • selectinload is always the default strategy.
  • joinedload is only used for relationships in Config.default_joined.
  • only acts as a whitelist and has precedence over expand and omit.

Using projection in detail endpoints

You can also use expand / omit / only in detail endpoints (e.g. GET /users/{id}) without defining any filter fields.

Create a projection-only FilterSet (only Config) and apply it with apply_expands_to_query():

from fastapi_restkit.filterset import FilterSet, filter_as_query


class UserDetailProjection(FilterSet):
    class Config:
        expandable = {"orders": "orders", "company": "company"}


@app.get("/users/{user_id}")
async def get_user(
    user_id: int,
    session: Session = Depends(get_session),
    projection: UserDetailProjection = Depends(filter_as_query(UserDetailProjection)),
):
    query = select(User).where(User.id == user_id)
    query = projection.apply_expands_to_query(query, User)
    return session.exec(query).one()

Omitting columns from the main model

omit in FilterSet can also omit columns from the main model when you define Config.column_fields. The default omission strategy is defer, but you can switch to load_only with Config.column_omit_mode.

class ProductFilterSet(FilterSet):
    class Config:
        column_fields = {
            "description": "description",
            "internal_notes": "internal_notes",
        }
        # "defer" (default) or "load_only"
        column_omit_mode = "defer"


@app.get(
    "/products",
    response_model=PaginatedResponse[ProductRead],
)
async def list_products(
    session: Session = Depends(get_session),
    filters: ProductFilterSet = Depends(filter_as_query(ProductFilterSet)),
    pagination: PaginationParams = Depends(),
):
    query = select(Product)
    query = filters.apply_to_query(query, Product)
    query = filters.apply_expands_to_query(query, Product)
    return await paginate(session, query, pagination)

URL Examples:

GET /products?omit=description
GET /products?omit=description,internal_notes

Only include specific fields

Use only to return only specific relationships or columns. only has precedence over expand and omit.

# Only the owner relationship and name column
GET /products?only=owner,name

For strict contracts, define a dedicated response schema without those columns, or use response_model_exclude when appropriate.


Sorting

Creating a SortingSet

from fastapi_restkit.sortingset import SortingSet, SortableField, sorting_as_query


class ProductSortingSet(SortingSet):
    """Sorting options for products."""
    
    id: SortableField = SortableField(description="Product ID")
    name: SortableField = SortableField(description="Product name")
    price: SortableField = SortableField(description="Price")
    created_at: SortableField = SortableField(description="Creation date")
    
    # Map to different column name
    updated: SortableField = SortableField(
        description="Update date",
        column="updated_at",
    )

    class Config:
        default_sorting = ["created_at:desc"]


# Use in endpoint
@app.get("/products")
async def list_products(
    session: Session = Depends(get_session),
    sorting: ProductSortingSet = Depends(sorting_as_query(ProductSortingSet)),
):
    query = select(Product)
    query = sorting.apply_to_query(query, Product)
    return session.exec(query).all()

Sorting URL Examples

# Ascending (default)
GET /products?sort_by=name

# Descending
GET /products?sort_by=price:desc

# Multiple fields
GET /products?sort_by=category:asc&sort_by=price:desc

# Results in: ORDER BY category ASC, price DESC

Complete Example

Combining pagination, filtering, and sorting:

from fastapi import APIRouter, Depends
from sqlmodel import Session, select
from fastapi_restkit import PaginationParams, paginate, PaginatedResponse
from fastapi_restkit.filterset import FilterSet, filter_as_query
from fastapi_restkit.sortingset import SortingSet, SortableField, sorting_as_query
from fastapi_restkit.filters import SearchFilter, BooleanFilter, NumericRangeFilter


class ProductFilterSet(FilterSet):
    name: Optional[SearchFilter] = Field(default_factory=SearchFilter)
    is_active: Optional[BooleanFilter] = Field(default_factory=BooleanFilter)
    price: Optional[NumericRangeFilter] = Field(default_factory=NumericRangeFilter)


class ProductSortingSet(SortingSet):
    name: SortableField = SortableField(description="Name")
    price: SortableField = SortableField(description="Price")
    created_at: SortableField = SortableField(description="Created")

    class Config:
        default_sorting = ["created_at:desc"]


router = APIRouter(prefix="/products", tags=["Products"])


@router.get("", response_model=PaginatedResponse[ProductRead])
async def list_products(
    session: Session = Depends(get_session),
    filters: ProductFilterSet = Depends(filter_as_query(ProductFilterSet)),
    sorting: ProductSortingSet = Depends(sorting_as_query(ProductSortingSet)),
    pagination: PaginationParams = Depends(),
):
    """
    List products with filtering, sorting, and pagination.
    
    **Filters:** name, is_active, price
    **Sorting:** name, price, created_at
    **Pagination:** page, page_size
    """
    query = select(Product)
    query = filters.apply_to_query(query, Product)
    query = sorting.apply_to_query(query, Product)
    return await paginate(session, query, pagination)

Combined URL Example:

GET /products?is_active=true&price[min]=100&price[max]=500&sort_by=price:asc&page=1&page_size=20

Development

Setup

# Clone the repository
git clone https://github.com/cacenot/fastapi-restkit.git
cd fastapi-restkit

# Install dependencies with uv
uv sync --dev

# Run tests
uv run pytest

# Run linter
uv run ruff check .

# Run formatter
uv run ruff format .

Project Structure

fastapi-restkit/
โ”œโ”€โ”€ src/
โ”‚   โ””โ”€โ”€ fastapi_restkit/
โ”‚       โ”œโ”€โ”€ __init__.py
โ”‚       โ”œโ”€โ”€ pagination.py     # Pagination logic
โ”‚       โ”œโ”€โ”€ filters.py        # Filter types
โ”‚       โ”œโ”€โ”€ filterset.py      # FilterSet base class
โ”‚       โ”œโ”€โ”€ sorting.py        # Sorting utilities
โ”‚       โ”œโ”€โ”€ sortingset.py     # SortingSet base class
โ”‚       โ”œโ”€โ”€ models.py         # Response models
โ”‚       โ””โ”€โ”€ dependencies.py   # FastAPI dependencies
โ”œโ”€โ”€ tests/
โ”œโ”€โ”€ docs/
โ””โ”€โ”€ pyproject.toml

License

MIT License - see LICENSE for details.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

fastapi_restkit-0.2.0.tar.gz (25.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

fastapi_restkit-0.2.0-py3-none-any.whl (30.2 kB view details)

Uploaded Python 3

File details

Details for the file fastapi_restkit-0.2.0.tar.gz.

File metadata

  • Download URL: fastapi_restkit-0.2.0.tar.gz
  • Upload date:
  • Size: 25.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for fastapi_restkit-0.2.0.tar.gz
Algorithm Hash digest
SHA256 0fbfb1400bd06e72cbefb13ef111952fa05ff153301d89e7a49eec8d6a902825
MD5 24c4cf68a1e2bfab967fffdf0bc5726d
BLAKE2b-256 a01f1f8a23a1a04b5581b9a4df0a1a26eb0b7e8cf19f57fd57475a3d7a648b85

See more details on using hashes here.

File details

Details for the file fastapi_restkit-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for fastapi_restkit-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 2a16f6ffbba70c294af498224adebef7654886d078da31f90a22070b74b28f87
MD5 45f73e7b03b1d963a9738cad87d8e7c2
BLAKE2b-256 8b1a407994ec9652cb6f9535d16f7f5e1b50d65b3bfcf5876b9fa170f103a13e

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page