fastapi-fsp 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,9 @@
1
+ """fastapi-fsp: Filter, Sort, and Paginate utilities for FastAPI + SQLModel."""
2
+
3
+ from . import models as models # noqa: F401
4
+ from .fsp import FSPManager # noqa: F401
5
+
6
+ __all__ = [
7
+ "FSPManager",
8
+ "models",
9
+ ]
fastapi_fsp/fsp.py ADDED
@@ -0,0 +1,269 @@
1
+ import math
2
+ from typing import Annotated, Any, List, Optional
3
+
4
+ from fastapi import Depends, HTTPException, Query, Request, status
5
+ from sqlalchemy import Select, func
6
+ from sqlmodel import Session, select
7
+
8
+ from fastapi_fsp.models import (
9
+ Filter,
10
+ FilterOperator,
11
+ Links,
12
+ Meta,
13
+ PaginatedResponse,
14
+ Pagination,
15
+ PaginationQuery,
16
+ SortingOrder,
17
+ SortingQuery,
18
+ )
19
+
20
+
21
+ def _parse_filters(
22
+ fields: Optional[List[str]] = Query(None, alias="field"),
23
+ operators: Optional[List[FilterOperator]] = Query(None, alias="operator"),
24
+ values: Optional[List[str]] = Query(None, alias="value"),
25
+ ) -> List[Filter] | None:
26
+ if not fields:
27
+ return None
28
+ filters: List[Filter] = []
29
+ if not (len(fields) == len(operators) == len(values)):
30
+ raise HTTPException(
31
+ status_code=status.HTTP_400_BAD_REQUEST,
32
+ detail="Mismatched filter parameters.",
33
+ )
34
+ for field, operator, value in zip(fields, operators, values):
35
+ filters.append(Filter(field=field, operator=operator, value=value))
36
+ return filters
37
+
38
+
39
+ def _parse_sort(
40
+ sort_by: Optional[str] = Query(None, alias="sort_by"),
41
+ order: Optional[SortingOrder] = Query(SortingOrder.ASC, alias="order"),
42
+ ):
43
+ if not sort_by:
44
+ return None
45
+ return SortingQuery(sort_by=sort_by, order=order)
46
+
47
+
48
+ def _parse_pagination(
49
+ page: Optional[int] = Query(1, ge=1, description="Page number"),
50
+ per_page: Optional[int] = Query(10, ge=1, le=100, description="Items per page"),
51
+ ) -> PaginationQuery:
52
+ return PaginationQuery(page=page, per_page=per_page)
53
+
54
+
55
+ class FSPManager:
56
+ def __init__(
57
+ self,
58
+ request: Request,
59
+ filters: Annotated[List[Filter], Depends(_parse_filters)],
60
+ sorting: Annotated[SortingQuery, Depends(_parse_sort)],
61
+ pagination: Annotated[PaginationQuery, Depends(_parse_pagination)],
62
+ ):
63
+ self.request = request
64
+ self.filters = filters
65
+ self.sorting = sorting
66
+ self.pagination = pagination
67
+
68
+ def paginate(self, query: Select, session: Session) -> Any:
69
+ return session.exec(
70
+ query.offset((self.pagination.page - 1) * self.pagination.per_page).limit(
71
+ self.pagination.per_page
72
+ )
73
+ ).all()
74
+
75
+ def _count_total(self, query: Select, session: Session) -> int:
76
+ # Count the total rows of the given query (with filters/sort applied) ignoring pagination
77
+ count_query = select(func.count()).select_from(query.subquery())
78
+ return session.exec(count_query).one()
79
+
80
+ def _apply_filters(self, query: Select) -> Select:
81
+ # Helper: build a map of column name -> column object from the select statement
82
+ try:
83
+ columns_map = {
84
+ col.key: col for col in query.selected_columns
85
+ } # SQLAlchemy 1.4+ ColumnCollection is iterable
86
+ except Exception:
87
+ columns_map = {}
88
+
89
+ if not self.filters:
90
+ return query
91
+
92
+ def coerce_value(column, raw):
93
+ # Try to coerce raw (str or other) to the column's python type for proper comparisons
94
+ try:
95
+ pytype = getattr(column.type, "python_type", None)
96
+ except Exception:
97
+ pytype = None
98
+ if pytype is None or raw is None:
99
+ return raw
100
+ if isinstance(raw, pytype):
101
+ return raw
102
+ # Handle booleans represented as strings
103
+ if pytype is bool and isinstance(raw, str):
104
+ val = raw.strip().lower()
105
+ if val in {"true", "1", "t", "yes", "y"}:
106
+ return True
107
+ if val in {"false", "0", "f", "no", "n"}:
108
+ return False
109
+ # Generic cast with fallback
110
+ try:
111
+ return pytype(raw)
112
+ except Exception:
113
+ return raw
114
+
115
+ def split_values(raw):
116
+ if raw is None:
117
+ return []
118
+ if isinstance(raw, (list, tuple)):
119
+ return list(raw)
120
+ if isinstance(raw, str):
121
+ return [item.strip() for item in raw.split(",")]
122
+ return [raw]
123
+
124
+ def ilike_supported(col):
125
+ return hasattr(col, "ilike")
126
+
127
+ for f in self.filters:
128
+ if not f or not f.field:
129
+ continue
130
+
131
+ column = columns_map.get(f.field)
132
+ if column is None:
133
+ # Skip unknown fields silently
134
+ continue
135
+
136
+ op = str(f.operator).lower() if f.operator is not None else "eq"
137
+ raw_value = f.value
138
+
139
+ # Build conditions based on operator
140
+ if op == "eq":
141
+ query = query.where(column == coerce_value(column, raw_value))
142
+ elif op == "ne":
143
+ query = query.where(column != coerce_value(column, raw_value))
144
+ elif op == "gt":
145
+ query = query.where(column > coerce_value(column, raw_value))
146
+ elif op == "gte":
147
+ query = query.where(column >= coerce_value(column, raw_value))
148
+ elif op == "lt":
149
+ query = query.where(column < coerce_value(column, raw_value))
150
+ elif op == "lte":
151
+ query = query.where(column <= coerce_value(column, raw_value))
152
+ elif op == "like":
153
+ query = query.where(column.like(str(raw_value)))
154
+ elif op == "not_like":
155
+ query = query.where(~column.like(str(raw_value)))
156
+ elif op == "ilike":
157
+ pattern = str(raw_value)
158
+ if ilike_supported(column):
159
+ query = query.where(column.ilike(pattern))
160
+ else:
161
+ query = query.where(func.lower(column).like(pattern.lower()))
162
+ elif op == "not_ilike":
163
+ pattern = str(raw_value)
164
+ if ilike_supported(column):
165
+ query = query.where(~column.ilike(pattern))
166
+ else:
167
+ query = query.where(~func.lower(column).like(pattern.lower()))
168
+ elif op == "in":
169
+ vals = [coerce_value(column, v) for v in split_values(raw_value)]
170
+ query = query.where(column.in_(vals))
171
+ elif op == "not_in":
172
+ vals = [coerce_value(column, v) for v in split_values(raw_value)]
173
+ query = query.where(~column.in_(vals))
174
+ elif op == "between":
175
+ vals = split_values(raw_value)
176
+ if len(vals) != 2:
177
+ # Ignore malformed between; alternatively raise 400
178
+ continue
179
+ low = coerce_value(column, vals[0])
180
+ high = coerce_value(column, vals[1])
181
+ query = query.where(column.between(low, high))
182
+ elif op == "is_null":
183
+ query = query.where(column.is_(None))
184
+ elif op == "is_not_null":
185
+ query = query.where(column.is_not(None))
186
+ elif op == "starts_with":
187
+ pattern = f"{str(raw_value)}%"
188
+ query = query.where(column.like(pattern))
189
+ elif op == "ends_with":
190
+ pattern = f"%{str(raw_value)}"
191
+ query = query.where(column.like(pattern))
192
+ elif op == "contains":
193
+ pattern = f"%{str(raw_value)}%"
194
+ query = query.where(column.like(pattern))
195
+ else:
196
+ # Unknown operator: skip
197
+ continue
198
+
199
+ return query
200
+
201
+ def _apply_sort(self, query: Select) -> Select:
202
+ # Build a map of column name -> column object from the select statement
203
+ try:
204
+ columns_map = {col.key: col for col in query.selected_columns}
205
+ except Exception:
206
+ columns_map = {}
207
+
208
+ if not self.sorting or not self.sorting.sort_by:
209
+ return query
210
+
211
+ column = columns_map.get(self.sorting.sort_by)
212
+ if column is None:
213
+ # Unknown sort column; skip sorting
214
+ return query
215
+
216
+ order = str(self.sorting.order).lower() if self.sorting.order else "asc"
217
+ if order == "desc":
218
+ return query.order_by(column.desc())
219
+ else:
220
+ return query.order_by(column.asc())
221
+
222
+ def generate_response(self, query: Select, session: Session) -> PaginatedResponse[Any]:
223
+ query = self._apply_filters(query)
224
+
225
+ query = self._apply_sort(query)
226
+
227
+ total_items = self._count_total(query, session)
228
+ per_page = self.pagination.per_page
229
+ current_page = self.pagination.page
230
+ total_pages = max(1, math.ceil(total_items / per_page)) if total_items is not None else 1
231
+
232
+ data_page = self.paginate(query, session)
233
+
234
+ # Build links based on current URL, replacing/adding page and per_page parameters
235
+ url = self.request.url
236
+ first_url = str(url.include_query_params(page=1, per_page=per_page))
237
+ last_url = str(url.include_query_params(page=total_pages, per_page=per_page))
238
+ next_url = (
239
+ str(url.include_query_params(page=current_page + 1, per_page=per_page))
240
+ if current_page < total_pages
241
+ else None
242
+ )
243
+ prev_url = (
244
+ str(url.include_query_params(page=current_page - 1, per_page=per_page))
245
+ if current_page > 1
246
+ else None
247
+ )
248
+ self_url = str(url.include_query_params(page=current_page, per_page=per_page))
249
+
250
+ return PaginatedResponse(
251
+ data=data_page,
252
+ meta=Meta(
253
+ pagination=Pagination(
254
+ total_items=total_items,
255
+ per_page=per_page,
256
+ current_page=current_page,
257
+ total_pages=total_pages,
258
+ ),
259
+ filters=self.filters,
260
+ sort=self.sorting,
261
+ ),
262
+ links=Links(
263
+ self=self_url,
264
+ first=first_url,
265
+ last=last_url,
266
+ next=next_url,
267
+ prev=prev_url,
268
+ ),
269
+ )
fastapi_fsp/models.py ADDED
@@ -0,0 +1,81 @@
1
+ from enum import StrEnum
2
+ from typing import Generic, List, Optional, TypeVar
3
+
4
+ from pydantic import BaseModel
5
+
6
+
7
+ class FilterOperator(StrEnum):
8
+ EQ = "eq" # equals (=)
9
+ NE = "ne" # not equals (!=)
10
+ GT = "gt" # greater than (>)
11
+ GTE = "gte" # greater than or equal (>=)
12
+ LT = "lt" # less than (<)
13
+ LTE = "lte" # less than or equal (<=)
14
+
15
+ LIKE = "like" # case-sensitive LIKE
16
+ NOT_LIKE = "not_like" # NOT LIKE
17
+ ILIKE = "ilike" # case-insensitive LIKE (if supported by backend)
18
+ NOT_ILIKE = "not_ilike" # NOT ILIKE
19
+
20
+ IN = "in" # IN (...)
21
+ NOT_IN = "not_in" # NOT IN (...)
22
+
23
+ BETWEEN = "between" # BETWEEN x AND y
24
+
25
+ IS_NULL = "is_null" # IS NULL
26
+ IS_NOT_NULL = "is_not_null" # IS NOT NULL
27
+
28
+ STARTS_WITH = "starts_with" # value%
29
+ ENDS_WITH = "ends_with" # %value
30
+ CONTAINS = "contains" # %value%
31
+
32
+
33
+ class SortingOrder(StrEnum):
34
+ ASC = "asc" # ascending order
35
+ DESC = "desc" # descending order
36
+
37
+
38
+ T = TypeVar("T")
39
+
40
+
41
+ class Filter(BaseModel):
42
+ field: str
43
+ operator: FilterOperator
44
+ value: str
45
+
46
+
47
+ class PaginationQuery(BaseModel):
48
+ page: int
49
+ per_page: int
50
+
51
+
52
+ class SortingQuery(BaseModel):
53
+ sort_by: str
54
+ order: SortingOrder
55
+
56
+
57
+ class Pagination(BaseModel):
58
+ total_items: Optional[int] = None
59
+ per_page: int
60
+ current_page: int
61
+ total_pages: Optional[int] = None
62
+
63
+
64
+ class Meta(BaseModel):
65
+ pagination: Pagination
66
+ filters: Optional[List[Filter]] = None
67
+ sort: Optional[SortingQuery] = None
68
+
69
+
70
+ class Links(BaseModel):
71
+ self: str
72
+ first: str
73
+ next: Optional[str] = None
74
+ prev: Optional[str] = None
75
+ last: Optional[str] = None
76
+
77
+
78
+ class PaginatedResponse(BaseModel, Generic[T]):
79
+ data: List[T]
80
+ meta: Meta
81
+ links: Links
@@ -0,0 +1,203 @@
1
+ Metadata-Version: 2.4
2
+ Name: fastapi-fsp
3
+ Version: 0.1.0
4
+ Summary: Filter, Sort, and Paginate (FSP) utilities for FastAPI + SQLModel
5
+ Project-URL: Homepage, https://github.com/your-org/fastapi-fsp
6
+ Project-URL: Repository, https://github.com/your-org/fastapi-fsp
7
+ Project-URL: Issues, https://github.com/your-org/fastapi-fsp/issues
8
+ Author-email: Evert Jan Stamhuis <ej@fromejdevelopment.nl>
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: api,fastapi,filtering,pagination,sorting,sqlmodel
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Framework :: FastAPI
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: License :: OSI Approved :: MIT License
16
+ Classifier: Programming Language :: Python
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Internet :: WWW/HTTP
20
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
21
+ Requires-Python: >=3.12
22
+ Requires-Dist: fastapi>=0.111
23
+ Requires-Dist: sqlmodel>=0.0.24
24
+ Description-Content-Type: text/markdown
25
+
26
+ # fastapi-fsp
27
+
28
+ Filter, Sort, and Paginate (FSP) utilities for FastAPI + SQLModel.
29
+
30
+ fastapi-fsp helps you build standardized list endpoints that support:
31
+ - Filtering on arbitrary fields with rich operators (eq, ne, lt, lte, gt, gte, in, between, like/ilike, null checks, contains/starts_with/ends_with)
32
+ - Sorting by field (asc/desc)
33
+ - Pagination with page/per_page and convenient HATEOAS links
34
+
35
+ It is framework-friendly: you declare it as a FastAPI dependency and feed it a SQLModel/SQLAlchemy Select query and a Session.
36
+
37
+ ## Installation
38
+
39
+ Using uv (recommended):
40
+
41
+ ```
42
+ # create & activate virtual env with uv
43
+ uv venv
44
+ . .venv/bin/activate
45
+
46
+ # add runtime dependency
47
+ uv add fastapi-fsp
48
+ ```
49
+
50
+ Using pip:
51
+
52
+ ```
53
+ pip install fastapi-fsp
54
+ ```
55
+
56
+ ## Quick start
57
+
58
+ Below is a minimal example using FastAPI and SQLModel.
59
+
60
+ ```python
61
+ from typing import Optional
62
+ from fastapi import Depends, FastAPI
63
+ from sqlmodel import Field, SQLModel, Session, create_engine, select
64
+
65
+ from fastapi_fsp.fsp import FSPManager
66
+ from fastapi_fsp.models import PaginatedResponse
67
+
68
+ class HeroBase(SQLModel):
69
+ name: str = Field(index=True)
70
+ secret_name: str
71
+ age: Optional[int] = Field(default=None, index=True)
72
+
73
+ class Hero(HeroBase, table=True):
74
+ id: Optional[int] = Field(default=None, primary_key=True)
75
+
76
+ class HeroPublic(HeroBase):
77
+ id: int
78
+
79
+ engine = create_engine("sqlite:///database.db", connect_args={"check_same_thread": False})
80
+ SQLModel.metadata.create_all(engine)
81
+
82
+ app = FastAPI()
83
+
84
+ def get_session():
85
+ with Session(engine) as session:
86
+ yield session
87
+
88
+ @app.get("/heroes/", response_model=PaginatedResponse[HeroPublic])
89
+ def read_heroes(*, session: Session = Depends(get_session), fsp: FSPManager = Depends(FSPManager)):
90
+ query = select(Hero)
91
+ return fsp.generate_response(query, session)
92
+ ```
93
+
94
+ Run the app and query:
95
+
96
+ - Pagination: `GET /heroes/?page=1&per_page=10`
97
+ - Sorting: `GET /heroes/?sort_by=name&order=asc`
98
+ - Filtering: `GET /heroes/?field=age&operator=gte&value=21`
99
+
100
+ The response includes data, meta (pagination, filters, sorting), and links (self, first, next, prev, last).
101
+
102
+ ## Query parameters
103
+
104
+ Pagination:
105
+ - page: integer (>=1), default 1
106
+ - per_page: integer (1..100), default 10
107
+
108
+ Sorting:
109
+ - sort_by: the field name, e.g., `name`
110
+ - order: `asc` or `desc`
111
+
112
+ Filtering (repeatable sets; arrays are supported by sending multiple parameters):
113
+ - field: the field/column name, e.g., `name`
114
+ - operator: one of
115
+ - eq, ne
116
+ - lt, lte, gt, gte
117
+ - in, not_in (comma-separated values)
118
+ - between (two comma-separated values)
119
+ - like, not_like
120
+ - ilike, not_ilike (if backend supports ILIKE)
121
+ - is_null, is_not_null
122
+ - contains, starts_with, ends_with (translated to LIKE patterns)
123
+ - value: raw string value (or list-like comma-separated depending on operator)
124
+
125
+ Examples:
126
+ - `?field=name&operator=eq&value=Deadpond`
127
+ - `?field=age&operator=between&value=18,30`
128
+ - `?field=name&operator=in&value=Deadpond,Rusty-Man`
129
+ - `?field=name&operator=contains&value=man`
130
+
131
+ You can chain multiple filters by repeating the triplet:
132
+ ```
133
+ ?field=age&operator=gte&value=18&field=name&operator=ilike&value=rust
134
+ ```
135
+
136
+ ## Response model
137
+
138
+ ```
139
+ {
140
+ "data": [ ... ],
141
+ "meta": {
142
+ "pagination": {
143
+ "total_items": 42,
144
+ "per_page": 10,
145
+ "current_page": 1,
146
+ "total_pages": 5
147
+ },
148
+ "filters": [
149
+ {"field": "name", "operator": "eq", "value": "Deadpond"}
150
+ ],
151
+ "sort": {"sort_by": "name", "order": "asc"}
152
+ },
153
+ "links": {
154
+ "self": "/heroes/?page=1&per_page=10",
155
+ "first": "/heroes/?page=1&per_page=10",
156
+ "next": "/heroes/?page=2&per_page=10",
157
+ "prev": null,
158
+ "last": "/heroes/?page=5&per_page=10"
159
+ }
160
+ }
161
+ ```
162
+
163
+ ## Development
164
+
165
+ This project uses uv as the package manager.
166
+
167
+ - Create env and sync deps:
168
+ ```
169
+ uv venv
170
+ . .venv/bin/activate
171
+ uv sync --dev
172
+ ```
173
+
174
+ - Run lint and format checks:
175
+ ```
176
+ uv run ruff check .
177
+ uv run ruff format --check .
178
+ ```
179
+
180
+ - Run tests:
181
+ ```
182
+ uv run pytest -q
183
+ ```
184
+
185
+ - Build the package:
186
+ ```
187
+ uv build
188
+ ```
189
+
190
+ ## CI/CD and Releases
191
+
192
+ GitHub Actions workflows are included:
193
+ - CI (lint + tests) runs on pushes and PRs.
194
+ - Release: pushing a tag matching `v*.*.*` runs tests, builds, and publishes to PyPI using `PYPI_API_TOKEN` secret.
195
+
196
+ To release:
197
+ 1. Update the version in `pyproject.toml`.
198
+ 2. Push a tag, e.g. `git tag v0.1.1 && git push origin v0.1.1`.
199
+ 3. Ensure the repository has `PYPI_API_TOKEN` secret set (an API token from PyPI).
200
+
201
+ ## License
202
+
203
+ MIT License. See LICENSE.
@@ -0,0 +1,7 @@
1
+ fastapi_fsp/__init__.py,sha256=0I_XN_ptNu1NyNRpjdyVm6nwvrilAB8FyT2EfgVF_QA,215
2
+ fastapi_fsp/fsp.py,sha256=lC9CV0T4Zjf4MRaVdtq2HZlpRusxuR-na_AQnZa1w6A,10033
3
+ fastapi_fsp/models.py,sha256=_P72r_kgVh_ZvoKQqU359vnbJTxeWnvnT6yV5_M558Q,1740
4
+ fastapi_fsp-0.1.0.dist-info/METADATA,sha256=qxwzS_WR_pCfJTPSktw-5LDSm6UyU1WulD4UTTIR-T8,5430
5
+ fastapi_fsp-0.1.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
6
+ fastapi_fsp-0.1.0.dist-info/licenses/LICENSE,sha256=Btzdu2kIoMbdSp6OyCLupB1aRgpTCJ_szMimgEnpkkE,1056
7
+ fastapi_fsp-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.