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.
- fastapi_fsp/__init__.py +9 -0
- fastapi_fsp/fsp.py +269 -0
- fastapi_fsp/models.py +81 -0
- fastapi_fsp-0.1.0.dist-info/METADATA +203 -0
- fastapi_fsp-0.1.0.dist-info/RECORD +7 -0
- fastapi_fsp-0.1.0.dist-info/WHEEL +4 -0
- fastapi_fsp-0.1.0.dist-info/licenses/LICENSE +21 -0
fastapi_fsp/__init__.py
ADDED
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,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.
|