fastapi-refine 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_refine/__init__.py +22 -0
- fastapi_refine/core/__init__.py +27 -0
- fastapi_refine/core/query.py +187 -0
- fastapi_refine/core/types.py +63 -0
- fastapi_refine/dependencies/__init__.py +6 -0
- fastapi_refine/dependencies/query.py +156 -0
- fastapi_refine/dependencies/response.py +51 -0
- fastapi_refine/hooks/__init__.py +6 -0
- fastapi_refine/hooks/base.py +78 -0
- fastapi_refine/hooks/builtin.py +93 -0
- fastapi_refine/py.typed +0 -0
- fastapi_refine/routers/__init__.py +5 -0
- fastapi_refine/routers/factory.py +331 -0
- fastapi_refine/utils/__init__.py +0 -0
- fastapi_refine-0.1.0.dist-info/METADATA +282 -0
- fastapi_refine-0.1.0.dist-info/RECORD +18 -0
- fastapi_refine-0.1.0.dist-info/WHEEL +4 -0
- fastapi_refine-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""fastapi-refine: FastAPI integration for Refine simple-rest data provider."""
|
|
2
|
+
|
|
3
|
+
__version__ = "0.1.0"
|
|
4
|
+
|
|
5
|
+
from fastapi_refine.core import FilterConfig, FilterField, PaginationConfig, SortConfig
|
|
6
|
+
from fastapi_refine.dependencies import RefineQuery, RefineResponse, refine_query, refine_response
|
|
7
|
+
from fastapi_refine.hooks import HookContext, RefineHooks
|
|
8
|
+
from fastapi_refine.routers import RefineCRUDRouter
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"FilterConfig",
|
|
12
|
+
"FilterField",
|
|
13
|
+
"SortConfig",
|
|
14
|
+
"PaginationConfig",
|
|
15
|
+
"RefineQuery",
|
|
16
|
+
"RefineResponse",
|
|
17
|
+
"refine_query",
|
|
18
|
+
"refine_response",
|
|
19
|
+
"HookContext",
|
|
20
|
+
"RefineHooks",
|
|
21
|
+
"RefineCRUDRouter",
|
|
22
|
+
]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Core modules for fastapi-refine."""
|
|
2
|
+
|
|
3
|
+
from fastapi_refine.core.query import (
|
|
4
|
+
parse_bool,
|
|
5
|
+
parse_filters,
|
|
6
|
+
parse_sorters,
|
|
7
|
+
parse_uuid,
|
|
8
|
+
resolve_pagination,
|
|
9
|
+
)
|
|
10
|
+
from fastapi_refine.core.types import (
|
|
11
|
+
FilterConfig,
|
|
12
|
+
FilterField,
|
|
13
|
+
PaginationConfig,
|
|
14
|
+
SortConfig,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"FilterConfig",
|
|
19
|
+
"FilterField",
|
|
20
|
+
"SortConfig",
|
|
21
|
+
"PaginationConfig",
|
|
22
|
+
"parse_bool",
|
|
23
|
+
"parse_filters",
|
|
24
|
+
"parse_sorters",
|
|
25
|
+
"parse_uuid",
|
|
26
|
+
"resolve_pagination",
|
|
27
|
+
]
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
"""Query parsing logic for Refine simple-rest conventions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
from sqlalchemy import ColumnElement, or_
|
|
9
|
+
from starlette.datastructures import QueryParams
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"parse_filters",
|
|
13
|
+
"parse_sorters",
|
|
14
|
+
"resolve_pagination",
|
|
15
|
+
"parse_bool",
|
|
16
|
+
"parse_uuid",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
IGNORED_QUERY_KEYS = {"_start", "_end", "_sort", "_order", "id", "skip", "limit"}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_bool(value: str) -> bool:
|
|
23
|
+
"""Parse boolean from string.
|
|
24
|
+
|
|
25
|
+
Supports: 1/0, true/false, t/f, yes/y, no/n (case-insensitive).
|
|
26
|
+
"""
|
|
27
|
+
lowered = value.strip().lower()
|
|
28
|
+
if lowered in {"1", "true", "t", "yes", "y"}:
|
|
29
|
+
return True
|
|
30
|
+
if lowered in {"0", "false", "f", "no", "n"}:
|
|
31
|
+
return False
|
|
32
|
+
raise ValueError(f"Invalid boolean value: {value}")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def parse_uuid(value: str) -> uuid.UUID:
|
|
36
|
+
"""Parse UUID from string."""
|
|
37
|
+
return uuid.UUID(value)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def split_filter_key(key: str) -> tuple[str, str]:
|
|
41
|
+
"""Split filter key into field name and operator.
|
|
42
|
+
|
|
43
|
+
Examples:
|
|
44
|
+
"name" -> ("name", "eq")
|
|
45
|
+
"age_gte" -> ("age", "gte")
|
|
46
|
+
"title_like" -> ("title", "like")
|
|
47
|
+
"""
|
|
48
|
+
for suffix in ("_ne", "_gte", "_lte", "_like"):
|
|
49
|
+
if key.endswith(suffix):
|
|
50
|
+
return key[: -len(suffix)], suffix[1:]
|
|
51
|
+
return key, "eq"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def parse_filters(
|
|
55
|
+
query_params: QueryParams,
|
|
56
|
+
*,
|
|
57
|
+
filter_fields: dict[str, Any],
|
|
58
|
+
search_fields: list[ColumnElement[Any]] | None = None,
|
|
59
|
+
) -> list[ColumnElement[Any]]:
|
|
60
|
+
"""Parse Refine simple-rest filters from query parameters.
|
|
61
|
+
|
|
62
|
+
Supports json-server style operators:
|
|
63
|
+
- eq (default): field=value
|
|
64
|
+
- ne: field_ne=value
|
|
65
|
+
- gte: field_gte=value
|
|
66
|
+
- lte: field_lte=value
|
|
67
|
+
- like: field_like=value (contains match)
|
|
68
|
+
|
|
69
|
+
Also supports full-text search via q parameter.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
query_params: FastAPI/Starlette query parameters
|
|
73
|
+
filter_fields: Mapping of field names to FilterField configs
|
|
74
|
+
search_fields: Columns to search for q parameter
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
List of SQLAlchemy conditions
|
|
78
|
+
"""
|
|
79
|
+
conditions: list[ColumnElement[Any]] = []
|
|
80
|
+
|
|
81
|
+
for key, value in query_params.multi_items():
|
|
82
|
+
if key in IGNORED_QUERY_KEYS:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
# Full-text search
|
|
86
|
+
if key == "q":
|
|
87
|
+
if search_fields:
|
|
88
|
+
pattern = f"%{value}%"
|
|
89
|
+
conditions.append(
|
|
90
|
+
or_(*(field.ilike(pattern) for field in search_fields))
|
|
91
|
+
)
|
|
92
|
+
continue
|
|
93
|
+
|
|
94
|
+
# Field filters
|
|
95
|
+
field, op = split_filter_key(key)
|
|
96
|
+
field_spec = filter_fields.get(field)
|
|
97
|
+
if not field_spec:
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
try:
|
|
101
|
+
typed_value = field_spec.cast(value)
|
|
102
|
+
except (TypeError, ValueError):
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
column = field_spec.column
|
|
106
|
+
if op == "eq":
|
|
107
|
+
conditions.append(column == typed_value)
|
|
108
|
+
elif op == "ne":
|
|
109
|
+
conditions.append(column != typed_value)
|
|
110
|
+
elif op == "gte":
|
|
111
|
+
conditions.append(column >= typed_value)
|
|
112
|
+
elif op == "lte":
|
|
113
|
+
conditions.append(column <= typed_value)
|
|
114
|
+
elif op == "like":
|
|
115
|
+
if isinstance(typed_value, str):
|
|
116
|
+
conditions.append(column.ilike(f"%{value}%"))
|
|
117
|
+
else:
|
|
118
|
+
conditions.append(column == typed_value)
|
|
119
|
+
|
|
120
|
+
return conditions
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def parse_sorters(
|
|
124
|
+
_sort: str | None,
|
|
125
|
+
_order: str | None,
|
|
126
|
+
*,
|
|
127
|
+
sort_fields: dict[str, ColumnElement[Any]],
|
|
128
|
+
) -> list[ColumnElement[Any]]:
|
|
129
|
+
"""Parse Refine simple-rest sorters from query parameters.
|
|
130
|
+
|
|
131
|
+
Supports comma-separated fields and orders:
|
|
132
|
+
_sort=title,createdAt&_order=asc,desc
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
_sort: Comma-separated field names to sort by
|
|
136
|
+
_order: Comma-separated order directions (asc/desc)
|
|
137
|
+
sort_fields: Mapping of field names to SQLAlchemy columns
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
List of SQLAlchemy order by clauses
|
|
141
|
+
"""
|
|
142
|
+
if not _sort:
|
|
143
|
+
return []
|
|
144
|
+
|
|
145
|
+
sort_fields_list = [field.strip() for field in _sort.split(",") if field.strip()]
|
|
146
|
+
order_list = (
|
|
147
|
+
[order.strip().lower() for order in _order.split(",")] if _order else []
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
order_by: list[ColumnElement[Any]] = []
|
|
151
|
+
for index, field in enumerate(sort_fields_list):
|
|
152
|
+
column = sort_fields.get(field)
|
|
153
|
+
if not column:
|
|
154
|
+
continue
|
|
155
|
+
|
|
156
|
+
order = order_list[index] if index < len(order_list) else "asc"
|
|
157
|
+
order_by.append(column.desc() if order == "desc" else column.asc())
|
|
158
|
+
|
|
159
|
+
return order_by
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def resolve_pagination(
|
|
163
|
+
*,
|
|
164
|
+
_start: int | None,
|
|
165
|
+
_end: int | None,
|
|
166
|
+
skip: int,
|
|
167
|
+
limit: int,
|
|
168
|
+
) -> tuple[int, int]:
|
|
169
|
+
"""Resolve pagination from Refine simple-rest parameters.
|
|
170
|
+
|
|
171
|
+
Supports both range-based (_start, _end) and offset-based (skip, limit) pagination.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
_start: Range start (0-based, inclusive)
|
|
175
|
+
_end: Range end (0-based, exclusive)
|
|
176
|
+
skip: Offset for skip/limit pagination
|
|
177
|
+
limit: Maximum number of items to return
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Tuple of (offset, limit) for SQLAlchemy queries
|
|
181
|
+
"""
|
|
182
|
+
if _start is None and _end is None:
|
|
183
|
+
return skip, limit
|
|
184
|
+
|
|
185
|
+
start = _start or 0
|
|
186
|
+
end = _end if _end is not None else start + limit
|
|
187
|
+
return start, max(0, end - start)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Core type definitions for fastapi-refine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import ColumnElement
|
|
10
|
+
|
|
11
|
+
__all__ = ["FilterField", "FilterConfig", "SortConfig", "PaginationConfig"]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class FilterField:
|
|
16
|
+
"""Field filter configuration.
|
|
17
|
+
|
|
18
|
+
Args:
|
|
19
|
+
column: SQLAlchemy column reference
|
|
20
|
+
cast: Type converter function (str -> target type)
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
column: ColumnElement[Any]
|
|
24
|
+
cast: Callable[[str], Any]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class FilterConfig:
|
|
29
|
+
"""Filter configuration.
|
|
30
|
+
|
|
31
|
+
Args:
|
|
32
|
+
fields: Mapping of field names to FilterField configs
|
|
33
|
+
search_fields: List of columns for full-text search (q parameter)
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
fields: dict[str, FilterField]
|
|
37
|
+
search_fields: list[ColumnElement[Any]] | None = None
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class SortConfig:
|
|
42
|
+
"""Sort configuration.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
fields: Mapping of field names to SQLAlchemy columns
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
fields: dict[str, ColumnElement[Any]]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class PaginationConfig:
|
|
53
|
+
"""Pagination configuration.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
default_skip: Default offset for skip pagination
|
|
57
|
+
default_limit: Default limit for skip/limit pagination
|
|
58
|
+
max_limit: Maximum allowed limit (prevents excessive queries)
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
default_skip: int = 0
|
|
62
|
+
default_limit: int = 100
|
|
63
|
+
max_limit: int = 1000
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""Dependency injection modules for fastapi-refine."""
|
|
2
|
+
|
|
3
|
+
from fastapi_refine.dependencies.query import RefineQuery, refine_query
|
|
4
|
+
from fastapi_refine.dependencies.response import RefineResponse, refine_response
|
|
5
|
+
|
|
6
|
+
__all__ = ["RefineQuery", "RefineResponse", "refine_query", "refine_response"]
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
"""Dependency injection for Refine query parsing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import Query, Request
|
|
8
|
+
from sqlalchemy import ColumnElement
|
|
9
|
+
from sqlmodel import SQLModel
|
|
10
|
+
|
|
11
|
+
from fastapi_refine.core import FilterConfig, PaginationConfig, SortConfig
|
|
12
|
+
from fastapi_refine.core.query import (
|
|
13
|
+
parse_filters,
|
|
14
|
+
parse_sorters,
|
|
15
|
+
resolve_pagination,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = ["RefineQuery", "refine_query"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class RefineQuery:
|
|
22
|
+
"""Refine query parameters parsed from request.
|
|
23
|
+
|
|
24
|
+
This class provides convenient access to parsed query parameters
|
|
25
|
+
following Refine simple-rest conventions.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
model: The SQLModel class for this resource
|
|
29
|
+
conditions: List of SQLAlchemy filter conditions
|
|
30
|
+
order_by: List of SQLAlchemy order by clauses
|
|
31
|
+
offset: Query offset for pagination
|
|
32
|
+
limit: Query limit for pagination
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(
|
|
36
|
+
self,
|
|
37
|
+
model: type[SQLModel],
|
|
38
|
+
filter_config: FilterConfig,
|
|
39
|
+
sort_config: SortConfig,
|
|
40
|
+
pagination_config: PaginationConfig | None = None,
|
|
41
|
+
*,
|
|
42
|
+
_start: int | None = None,
|
|
43
|
+
_end: int | None = None,
|
|
44
|
+
_sort: str | None = None,
|
|
45
|
+
_order: str | None = None,
|
|
46
|
+
skip: int = 0,
|
|
47
|
+
limit: int = 100,
|
|
48
|
+
request: Request | None = None,
|
|
49
|
+
):
|
|
50
|
+
self.model = model
|
|
51
|
+
self.filter_config = filter_config
|
|
52
|
+
self.sort_config = sort_config
|
|
53
|
+
self.pagination_config = pagination_config or PaginationConfig()
|
|
54
|
+
|
|
55
|
+
# Parse filters
|
|
56
|
+
if request:
|
|
57
|
+
self.conditions = parse_filters(
|
|
58
|
+
request.query_params,
|
|
59
|
+
filter_fields=filter_config.fields,
|
|
60
|
+
search_fields=filter_config.search_fields,
|
|
61
|
+
)
|
|
62
|
+
else:
|
|
63
|
+
self.conditions = []
|
|
64
|
+
|
|
65
|
+
# Parse sorters
|
|
66
|
+
self.order_by = parse_sorters(_sort, _order, sort_fields=sort_config.fields)
|
|
67
|
+
|
|
68
|
+
# Parse pagination
|
|
69
|
+
self.offset, self.limit = resolve_pagination(
|
|
70
|
+
_start=_start,
|
|
71
|
+
_end=_end,
|
|
72
|
+
skip=skip,
|
|
73
|
+
limit=min(limit, self.pagination_config.max_limit),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def get_count(
|
|
77
|
+
self, session: Any, conditions: list[ColumnElement[Any]] | None = None
|
|
78
|
+
) -> int:
|
|
79
|
+
"""Get total count of records matching conditions.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
session: SQLAlchemy/SQLModel session
|
|
83
|
+
conditions: Optional list of conditions (uses self.conditions if None)
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Total count of matching records
|
|
87
|
+
"""
|
|
88
|
+
from sqlalchemy import func, select
|
|
89
|
+
|
|
90
|
+
conditions = conditions if conditions is not None else self.conditions
|
|
91
|
+
|
|
92
|
+
count_statement = select(func.count()).select_from(self.model)
|
|
93
|
+
if conditions:
|
|
94
|
+
count_statement = count_statement.where(*conditions)
|
|
95
|
+
|
|
96
|
+
return session.scalar(count_statement) or 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def refine_query(
|
|
100
|
+
model: type[SQLModel],
|
|
101
|
+
filter_config: FilterConfig,
|
|
102
|
+
sort_config: SortConfig,
|
|
103
|
+
pagination_config: PaginationConfig | None = None,
|
|
104
|
+
) -> type[RefineQuery]:
|
|
105
|
+
"""Create a RefineQuery dependency.
|
|
106
|
+
|
|
107
|
+
Use this function with FastAPI's Depends:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
query_dep = refine_query(Item, filter_config, sort_config)
|
|
111
|
+
|
|
112
|
+
@router.get("/")
|
|
113
|
+
def read_items(
|
|
114
|
+
query: RefineQuery = Depends(query_dep),
|
|
115
|
+
...
|
|
116
|
+
):
|
|
117
|
+
conditions = query.conditions
|
|
118
|
+
order_by = query.order_by
|
|
119
|
+
offset, limit = query.offset, query.limit
|
|
120
|
+
...
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Args:
|
|
124
|
+
model: SQLModel class
|
|
125
|
+
filter_config: Filter configuration
|
|
126
|
+
sort_config: Sort configuration
|
|
127
|
+
pagination_config: Optional pagination configuration
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
A callable that can be used with FastAPI's Depends
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def dependency(
|
|
134
|
+
request: Request,
|
|
135
|
+
_start: int | None = Query(None, alias="_start"),
|
|
136
|
+
_end: int | None = Query(None, alias="_end"),
|
|
137
|
+
_sort: str | None = Query(None, alias="_sort"),
|
|
138
|
+
_order: str | None = Query(None, alias="_order"),
|
|
139
|
+
skip: int = 0,
|
|
140
|
+
limit: int = 100,
|
|
141
|
+
) -> RefineQuery:
|
|
142
|
+
return RefineQuery(
|
|
143
|
+
model=model,
|
|
144
|
+
filter_config=filter_config,
|
|
145
|
+
sort_config=sort_config,
|
|
146
|
+
pagination_config=pagination_config,
|
|
147
|
+
_start=_start,
|
|
148
|
+
_end=_end,
|
|
149
|
+
_sort=_sort,
|
|
150
|
+
_order=_order,
|
|
151
|
+
skip=skip,
|
|
152
|
+
limit=limit,
|
|
153
|
+
request=request,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
return dependency # type: ignore[return-value]
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Response handling for Refine simple-rest conventions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from fastapi import Response
|
|
6
|
+
|
|
7
|
+
__all__ = ["RefineResponse", "refine_response"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class _RefineResponseHelper:
|
|
11
|
+
"""Helper for setting Refine-specific response headers.
|
|
12
|
+
|
|
13
|
+
Refine's simple-rest data provider expects an x-total-count header
|
|
14
|
+
for list responses to support server-side pagination.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
def __init__(self, response: Response):
|
|
18
|
+
self._response = response
|
|
19
|
+
|
|
20
|
+
def set_total_count(self, count: int) -> None:
|
|
21
|
+
"""Set the x-total-count header.
|
|
22
|
+
|
|
23
|
+
Args:
|
|
24
|
+
count: Total number of records (for pagination)
|
|
25
|
+
"""
|
|
26
|
+
self._response.headers["x-total-count"] = str(count)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Type alias for backwards compatibility
|
|
30
|
+
RefineResponse = _RefineResponseHelper
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def refine_response() -> _RefineResponseHelper:
|
|
34
|
+
"""Dependency that creates a RefineResponse helper.
|
|
35
|
+
|
|
36
|
+
Usage:
|
|
37
|
+
```python
|
|
38
|
+
@router.get("/")
|
|
39
|
+
def read_items(
|
|
40
|
+
response: RefineResponse = Depends(refine_response),
|
|
41
|
+
...
|
|
42
|
+
):
|
|
43
|
+
response.set_total_count(100)
|
|
44
|
+
return items
|
|
45
|
+
```
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
def dependency(resp: Response) -> _RefineResponseHelper:
|
|
49
|
+
return _RefineResponseHelper(resp)
|
|
50
|
+
|
|
51
|
+
return dependency # type: ignore[return-value]
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""Hook system for customizing CRUD behavior."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Awaitable, Callable
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from sqlalchemy import ColumnElement
|
|
10
|
+
from sqlmodel import SQLModel
|
|
11
|
+
|
|
12
|
+
__all__ = ["RefineHooks", "HookContext"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# Hook type aliases
|
|
16
|
+
BeforeQueryHook = Callable[
|
|
17
|
+
["HookContext", list[ColumnElement[Any]]],
|
|
18
|
+
list[ColumnElement[Any]] | Awaitable[list[ColumnElement[Any]]],
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
AfterQueryHook = Callable[
|
|
22
|
+
["HookContext", list[Any]],
|
|
23
|
+
list[Any] | Awaitable[list[Any]],
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
BeforeMutationHook = Callable[
|
|
27
|
+
["HookContext", Any],
|
|
28
|
+
None | Awaitable[None],
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
AfterMutationHook = Callable[
|
|
32
|
+
["HookContext", Any, Any],
|
|
33
|
+
Any | Awaitable[Any],
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class HookContext:
|
|
39
|
+
"""Context passed to hooks during execution.
|
|
40
|
+
|
|
41
|
+
Attributes:
|
|
42
|
+
model: The SQLModel class being operated on
|
|
43
|
+
session: Database session
|
|
44
|
+
current_user: Currently authenticated user (if available)
|
|
45
|
+
request: Current FastAPI request (if available)
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
model: type[SQLModel]
|
|
49
|
+
session: Any
|
|
50
|
+
current_user: Any | None = None
|
|
51
|
+
request: Any | None = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class RefineHooks:
|
|
56
|
+
"""Collection of lifecycle hooks for CRUD operations.
|
|
57
|
+
|
|
58
|
+
All hooks are optional. Define only the ones you need.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
before_query: Called before query execution, can modify conditions
|
|
62
|
+
after_query: Called after query execution, can modify results
|
|
63
|
+
before_create: Called before creating a record, can raise for permission check
|
|
64
|
+
after_create: Called after creating a record, can modify the result
|
|
65
|
+
before_update: Called before updating a record, can raise for permission check
|
|
66
|
+
after_update: Called after updating a record, can modify the result
|
|
67
|
+
before_delete: Called before deleting a record, can raise for permission check
|
|
68
|
+
after_delete: Called after deleting a record
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
before_query: BeforeQueryHook | None = None
|
|
72
|
+
after_query: AfterQueryHook | None = None
|
|
73
|
+
before_create: BeforeMutationHook | None = None
|
|
74
|
+
after_create: AfterMutationHook | None = None
|
|
75
|
+
before_update: BeforeMutationHook | None = None
|
|
76
|
+
after_update: AfterMutationHook | None = None
|
|
77
|
+
before_delete: BeforeMutationHook | None = None
|
|
78
|
+
after_delete: AfterMutationHook | None = None
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""Built-in hook implementations for common use cases."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
|
|
7
|
+
from fastapi import HTTPException, status
|
|
8
|
+
from sqlalchemy import ColumnElement
|
|
9
|
+
|
|
10
|
+
from fastapi_refine.hooks.base import HookContext, RefineHooks
|
|
11
|
+
|
|
12
|
+
__all__ = ["OwnerBasedHooks"]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class OwnerBasedHooks(RefineHooks):
|
|
16
|
+
"""Hooks for owner-based permission control.
|
|
17
|
+
|
|
18
|
+
Ensures users can only access records they own, unless they are superusers.
|
|
19
|
+
|
|
20
|
+
Example:
|
|
21
|
+
```python
|
|
22
|
+
hooks = OwnerBasedHooks(
|
|
23
|
+
owner_field="owner_id",
|
|
24
|
+
allow_superuser=True,
|
|
25
|
+
)
|
|
26
|
+
```
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(
|
|
30
|
+
self,
|
|
31
|
+
owner_field: str = "owner_id",
|
|
32
|
+
allow_superuser: bool = True,
|
|
33
|
+
):
|
|
34
|
+
"""Initialize owner-based hooks.
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
owner_field: Name of the field containing the owner's user ID
|
|
38
|
+
allow_superuser: Whether to allow superusers to access all records
|
|
39
|
+
"""
|
|
40
|
+
self.owner_field = owner_field
|
|
41
|
+
self.allow_superuser = allow_superuser
|
|
42
|
+
super().__init__(
|
|
43
|
+
before_query=self._before_query,
|
|
44
|
+
before_update=self._before_mutation,
|
|
45
|
+
before_delete=self._before_mutation,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
def _before_query(
|
|
49
|
+
self,
|
|
50
|
+
context: HookContext,
|
|
51
|
+
conditions: list[ColumnElement[Any]],
|
|
52
|
+
) -> list[ColumnElement[Any]]:
|
|
53
|
+
"""Add owner filter to query conditions."""
|
|
54
|
+
if not context.current_user:
|
|
55
|
+
return conditions
|
|
56
|
+
|
|
57
|
+
if self.allow_superuser and getattr(
|
|
58
|
+
context.current_user, "is_superuser", False
|
|
59
|
+
):
|
|
60
|
+
return conditions
|
|
61
|
+
|
|
62
|
+
user_id = getattr(context.current_user, "id", None)
|
|
63
|
+
if not user_id:
|
|
64
|
+
return conditions
|
|
65
|
+
|
|
66
|
+
# Add owner_id filter
|
|
67
|
+
model_class = context.model
|
|
68
|
+
owner_column = getattr(model_class, self.owner_field)
|
|
69
|
+
conditions.append(owner_column == user_id)
|
|
70
|
+
|
|
71
|
+
return conditions
|
|
72
|
+
|
|
73
|
+
def _before_mutation(self, context: HookContext, item: Any) -> None:
|
|
74
|
+
"""Check if user has permission to modify/delete this item."""
|
|
75
|
+
if not context.current_user:
|
|
76
|
+
raise HTTPException(
|
|
77
|
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
|
78
|
+
detail="Authentication required",
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if self.allow_superuser and getattr(
|
|
82
|
+
context.current_user, "is_superuser", False
|
|
83
|
+
):
|
|
84
|
+
return
|
|
85
|
+
|
|
86
|
+
user_id = getattr(context.current_user, "id", None)
|
|
87
|
+
owner_id = getattr(item, self.owner_field, None)
|
|
88
|
+
|
|
89
|
+
if owner_id != user_id:
|
|
90
|
+
raise HTTPException(
|
|
91
|
+
status_code=status.HTTP_403_FORBIDDEN,
|
|
92
|
+
detail="Not enough permissions",
|
|
93
|
+
)
|
fastapi_refine/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
"""CRUD Router factory for generating standard Refine-compatible endpoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Generic, TypeVar
|
|
6
|
+
|
|
7
|
+
from fastapi import APIRouter, HTTPException, Query, Response, status
|
|
8
|
+
from sqlmodel import Session, SQLModel, select
|
|
9
|
+
|
|
10
|
+
from fastapi_refine.core import FilterConfig, PaginationConfig, SortConfig
|
|
11
|
+
from fastapi_refine.dependencies import RefineQuery, RefineResponse
|
|
12
|
+
from fastapi_refine.hooks import HookContext, RefineHooks
|
|
13
|
+
|
|
14
|
+
__all__ = ["RefineCRUDRouter"]
|
|
15
|
+
|
|
16
|
+
ModelT = TypeVar("ModelT", bound=SQLModel)
|
|
17
|
+
CreateSchemaT = TypeVar("CreateSchemaT", bound=SQLModel)
|
|
18
|
+
UpdateSchemaT = TypeVar("UpdateSchemaT", bound=SQLModel)
|
|
19
|
+
PublicSchemaT = TypeVar("PublicSchemaT", bound=SQLModel)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class RefineCRUDRouter(Generic[ModelT, CreateSchemaT, UpdateSchemaT, PublicSchemaT]):
|
|
23
|
+
"""Factory for generating Refine-compatible CRUD routers.
|
|
24
|
+
|
|
25
|
+
Automatically creates standard CRUD endpoints that follow Refine simple-rest conventions:
|
|
26
|
+
- GET /{resource}/ - List with pagination, sorting, filtering
|
|
27
|
+
- GET /{resource}/{id} - Get single item
|
|
28
|
+
- POST /{resource}/ - Create new item
|
|
29
|
+
- PATCH /{resource}/{id} - Update item
|
|
30
|
+
- DELETE /{resource}/{id} - Delete item
|
|
31
|
+
|
|
32
|
+
Example:
|
|
33
|
+
```python
|
|
34
|
+
router = RefineCRUDRouter(
|
|
35
|
+
model=Item,
|
|
36
|
+
prefix="/items",
|
|
37
|
+
create_schema=ItemCreate,
|
|
38
|
+
update_schema=ItemUpdate,
|
|
39
|
+
public_schema=ItemPublic,
|
|
40
|
+
session_dep=SessionDep,
|
|
41
|
+
filter_config=filter_config,
|
|
42
|
+
sort_config=sort_config,
|
|
43
|
+
current_user_dep=CurrentUser,
|
|
44
|
+
hooks=OwnerBasedHooks(owner_field="owner_id"),
|
|
45
|
+
).router
|
|
46
|
+
```
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
model: type[ModelT],
|
|
52
|
+
prefix: str,
|
|
53
|
+
create_schema: type[CreateSchemaT],
|
|
54
|
+
update_schema: type[UpdateSchemaT],
|
|
55
|
+
public_schema: type[PublicSchemaT],
|
|
56
|
+
session_dep: Any,
|
|
57
|
+
filter_config: FilterConfig,
|
|
58
|
+
sort_config: SortConfig,
|
|
59
|
+
pagination_config: PaginationConfig | None = None,
|
|
60
|
+
hooks: RefineHooks | None = None,
|
|
61
|
+
current_user_dep: Any | None = None,
|
|
62
|
+
tags: list[str] | None = None,
|
|
63
|
+
):
|
|
64
|
+
"""Initialize the CRUD router.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
model: SQLModel database class
|
|
68
|
+
prefix: URL prefix for routes (e.g., "/items")
|
|
69
|
+
create_schema: Pydantic schema for creating items
|
|
70
|
+
update_schema: Pydantic schema for updating items
|
|
71
|
+
public_schema: Pydantic schema for API responses
|
|
72
|
+
session_dep: FastAPI dependency for database session
|
|
73
|
+
filter_config: Filter configuration
|
|
74
|
+
sort_config: Sort configuration
|
|
75
|
+
pagination_config: Optional pagination configuration
|
|
76
|
+
hooks: Optional lifecycle hooks
|
|
77
|
+
current_user_dep: Optional FastAPI dependency for current user
|
|
78
|
+
tags: OpenAPI tags for documentation
|
|
79
|
+
"""
|
|
80
|
+
self.model = model
|
|
81
|
+
self.create_schema = create_schema
|
|
82
|
+
self.update_schema = update_schema
|
|
83
|
+
self.public_schema = public_schema
|
|
84
|
+
self.session_dep = session_dep
|
|
85
|
+
self.filter_config = filter_config
|
|
86
|
+
self.sort_config = sort_config
|
|
87
|
+
self.pagination_config = pagination_config or PaginationConfig()
|
|
88
|
+
self.hooks = hooks or RefineHooks()
|
|
89
|
+
self.current_user_dep = current_user_dep
|
|
90
|
+
|
|
91
|
+
self.router = APIRouter(prefix=prefix, tags=tags or [prefix.strip("/")]) # type: ignore[arg-type]
|
|
92
|
+
self._setup_routes()
|
|
93
|
+
|
|
94
|
+
def _setup_routes(self) -> None:
|
|
95
|
+
"""Setup all CRUD routes."""
|
|
96
|
+
self.router.add_api_route(
|
|
97
|
+
"/",
|
|
98
|
+
self.get_list,
|
|
99
|
+
methods=["GET"],
|
|
100
|
+
response_model=list[self.public_schema], # type: ignore[name-defined]
|
|
101
|
+
)
|
|
102
|
+
self.router.add_api_route(
|
|
103
|
+
"/{id}",
|
|
104
|
+
self.get_one,
|
|
105
|
+
methods=["GET"],
|
|
106
|
+
response_model=self.public_schema,
|
|
107
|
+
)
|
|
108
|
+
self.router.add_api_route(
|
|
109
|
+
"/",
|
|
110
|
+
self.create,
|
|
111
|
+
methods=["POST"],
|
|
112
|
+
response_model=self.public_schema,
|
|
113
|
+
)
|
|
114
|
+
self.router.add_api_route(
|
|
115
|
+
"/{id}",
|
|
116
|
+
self.update,
|
|
117
|
+
methods=["PATCH"],
|
|
118
|
+
response_model=self.public_schema,
|
|
119
|
+
)
|
|
120
|
+
self.router.add_api_route(
|
|
121
|
+
"/{id}",
|
|
122
|
+
self.delete,
|
|
123
|
+
methods=["DELETE"],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
def get_list(
|
|
127
|
+
self,
|
|
128
|
+
request: Any, # Request
|
|
129
|
+
response: Response,
|
|
130
|
+
session: Session,
|
|
131
|
+
skip: int = 0,
|
|
132
|
+
limit: int = 100,
|
|
133
|
+
_start: int | None = Query(None, alias="_start"),
|
|
134
|
+
_end: int | None = Query(None, alias="_end"),
|
|
135
|
+
_sort: str | None = Query(None, alias="_sort"),
|
|
136
|
+
_order: str | None = Query(None, alias="_order"),
|
|
137
|
+
id: list[Any] | None = Query(None),
|
|
138
|
+
) -> list[Any]:
|
|
139
|
+
"""Get list of items (Refine getList)."""
|
|
140
|
+
# Parse query
|
|
141
|
+
query = RefineQuery(
|
|
142
|
+
model=self.model,
|
|
143
|
+
filter_config=self.filter_config,
|
|
144
|
+
sort_config=self.sort_config,
|
|
145
|
+
pagination_config=self.pagination_config,
|
|
146
|
+
_start=_start,
|
|
147
|
+
_end=_end,
|
|
148
|
+
_sort=_sort,
|
|
149
|
+
_order=_order,
|
|
150
|
+
skip=skip,
|
|
151
|
+
limit=limit,
|
|
152
|
+
request=request,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
conditions = query.conditions
|
|
156
|
+
if id:
|
|
157
|
+
conditions.append(self.model.id.in_(id)) # type: ignore[attr-defined]
|
|
158
|
+
|
|
159
|
+
# Execute before_query hook
|
|
160
|
+
if self.hooks.before_query:
|
|
161
|
+
current_user = self.current_user() if self.current_user_dep else None # type: ignore[attr-defined]
|
|
162
|
+
context = HookContext(
|
|
163
|
+
model=self.model,
|
|
164
|
+
session=session,
|
|
165
|
+
current_user=current_user,
|
|
166
|
+
request=request,
|
|
167
|
+
)
|
|
168
|
+
conditions = self._run_hook(self.hooks.before_query, context, conditions)
|
|
169
|
+
|
|
170
|
+
# Get count
|
|
171
|
+
count = query.get_count(session, conditions)
|
|
172
|
+
refine_response = RefineResponse(response)
|
|
173
|
+
refine_response.set_total_count(count)
|
|
174
|
+
|
|
175
|
+
# Execute query
|
|
176
|
+
statement = select(self.model)
|
|
177
|
+
if conditions:
|
|
178
|
+
statement = statement.where(*conditions)
|
|
179
|
+
if query.order_by:
|
|
180
|
+
statement = statement.order_by(*query.order_by)
|
|
181
|
+
|
|
182
|
+
items = list(
|
|
183
|
+
session.exec(statement.offset(query.offset).limit(query.limit)).all()
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# Execute after_query hook
|
|
187
|
+
if self.hooks.after_query:
|
|
188
|
+
current_user = self.current_user() if self.current_user_dep else None # type: ignore[attr-defined]
|
|
189
|
+
context = HookContext(
|
|
190
|
+
model=self.model,
|
|
191
|
+
session=session,
|
|
192
|
+
current_user=current_user,
|
|
193
|
+
request=request,
|
|
194
|
+
)
|
|
195
|
+
items = self._run_hook(self.hooks.after_query, context, items)
|
|
196
|
+
|
|
197
|
+
return items
|
|
198
|
+
|
|
199
|
+
def get_one(self, id: Any, session: Session) -> Any:
|
|
200
|
+
"""Get single item by ID (Refine getOne)."""
|
|
201
|
+
item = session.get(self.model, id)
|
|
202
|
+
if not item:
|
|
203
|
+
raise HTTPException(
|
|
204
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
205
|
+
detail=f"{self.model.__name__} not found",
|
|
206
|
+
)
|
|
207
|
+
return item
|
|
208
|
+
|
|
209
|
+
def create(
|
|
210
|
+
self,
|
|
211
|
+
item_in: CreateSchemaT,
|
|
212
|
+
session: Session,
|
|
213
|
+
) -> Any:
|
|
214
|
+
"""Create new item (Refine create)."""
|
|
215
|
+
current_user = self.current_user_dep() if self.current_user_dep else None
|
|
216
|
+
|
|
217
|
+
# Execute before_create hook
|
|
218
|
+
if self.hooks.before_create:
|
|
219
|
+
context = HookContext(
|
|
220
|
+
model=self.model,
|
|
221
|
+
session=session,
|
|
222
|
+
current_user=current_user,
|
|
223
|
+
)
|
|
224
|
+
self._run_hook(self.hooks.before_create, context, item_in)
|
|
225
|
+
|
|
226
|
+
# Create item
|
|
227
|
+
item = self.model.model_validate(item_in)
|
|
228
|
+
session.add(item)
|
|
229
|
+
session.commit()
|
|
230
|
+
session.refresh(item)
|
|
231
|
+
|
|
232
|
+
# Execute after_create hook
|
|
233
|
+
if self.hooks.after_create:
|
|
234
|
+
context = HookContext(
|
|
235
|
+
model=self.model,
|
|
236
|
+
session=session,
|
|
237
|
+
current_user=current_user,
|
|
238
|
+
)
|
|
239
|
+
item = self._run_hook(self.hooks.after_create, context, item_in, item)
|
|
240
|
+
|
|
241
|
+
return item
|
|
242
|
+
|
|
243
|
+
def update(
|
|
244
|
+
self,
|
|
245
|
+
id: Any,
|
|
246
|
+
item_in: UpdateSchemaT,
|
|
247
|
+
session: Session,
|
|
248
|
+
) -> Any:
|
|
249
|
+
"""Update item (Refine update)."""
|
|
250
|
+
item = session.get(self.model, id)
|
|
251
|
+
if not item:
|
|
252
|
+
raise HTTPException(
|
|
253
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
254
|
+
detail=f"{self.model.__name__} not found",
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
current_user = self.current_user_dep() if self.current_user_dep else None
|
|
258
|
+
|
|
259
|
+
# Execute before_update hook
|
|
260
|
+
if self.hooks.before_update:
|
|
261
|
+
context = HookContext(
|
|
262
|
+
model=self.model,
|
|
263
|
+
session=session,
|
|
264
|
+
current_user=current_user,
|
|
265
|
+
)
|
|
266
|
+
self._run_hook(self.hooks.before_update, context, item)
|
|
267
|
+
|
|
268
|
+
# Update item
|
|
269
|
+
update_data = item_in.model_dump(exclude_unset=True)
|
|
270
|
+
item.sqlmodel_update(update_data)
|
|
271
|
+
session.add(item)
|
|
272
|
+
session.commit()
|
|
273
|
+
session.refresh(item)
|
|
274
|
+
|
|
275
|
+
# Execute after_update hook
|
|
276
|
+
if self.hooks.after_update:
|
|
277
|
+
context = HookContext(
|
|
278
|
+
model=self.model,
|
|
279
|
+
session=session,
|
|
280
|
+
current_user=current_user,
|
|
281
|
+
)
|
|
282
|
+
item = self._run_hook(self.hooks.after_update, context, item, item)
|
|
283
|
+
|
|
284
|
+
return item
|
|
285
|
+
|
|
286
|
+
def delete(self, id: Any, session: Session) -> dict[str, str]:
|
|
287
|
+
"""Delete item (Refine delete)."""
|
|
288
|
+
item = session.get(self.model, id)
|
|
289
|
+
if not item:
|
|
290
|
+
raise HTTPException(
|
|
291
|
+
status_code=status.HTTP_404_NOT_FOUND,
|
|
292
|
+
detail=f"{self.model.__name__} not found",
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
current_user = self.current_user_dep() if self.current_user_dep else None
|
|
296
|
+
|
|
297
|
+
# Execute before_delete hook
|
|
298
|
+
if self.hooks.before_delete:
|
|
299
|
+
context = HookContext(
|
|
300
|
+
model=self.model,
|
|
301
|
+
session=session,
|
|
302
|
+
current_user=current_user,
|
|
303
|
+
)
|
|
304
|
+
self._run_hook(self.hooks.before_delete, context, item)
|
|
305
|
+
|
|
306
|
+
# Delete item
|
|
307
|
+
session.delete(item)
|
|
308
|
+
session.commit()
|
|
309
|
+
|
|
310
|
+
# Execute after_delete hook
|
|
311
|
+
if self.hooks.after_delete:
|
|
312
|
+
context = HookContext(
|
|
313
|
+
model=self.model,
|
|
314
|
+
session=session,
|
|
315
|
+
current_user=current_user,
|
|
316
|
+
)
|
|
317
|
+
self._run_hook(self.hooks.after_delete, context, item)
|
|
318
|
+
|
|
319
|
+
return {"message": f"{self.model.__name__} deleted successfully"}
|
|
320
|
+
|
|
321
|
+
def _run_hook(self, hook: Any, *args: Any) -> Any:
|
|
322
|
+
"""Run a hook, handling both sync and async hooks."""
|
|
323
|
+
import inspect
|
|
324
|
+
|
|
325
|
+
result = hook(*args)
|
|
326
|
+
|
|
327
|
+
if inspect.isawaitable(result):
|
|
328
|
+
# For now, we'll just return the awaitable as-is
|
|
329
|
+
# In a full async implementation, we'd await it here
|
|
330
|
+
return result
|
|
331
|
+
return result
|
|
File without changes
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: fastapi-refine
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: FastAPI integration for Refine simple-rest data provider
|
|
5
|
+
Project-URL: Homepage, https://github.com/koch3092/fastapi-refine
|
|
6
|
+
Project-URL: Documentation, https://github.com/koch3092/fastapi-refine#readme
|
|
7
|
+
Project-URL: Repository, https://github.com/koch3092/fastapi-refine
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/koch3092/fastapi-refine/issues
|
|
9
|
+
Author-email: koko <developer@dorakoch.com>
|
|
10
|
+
License: MIT
|
|
11
|
+
License-File: LICENSE
|
|
12
|
+
Keywords: crud,fastapi,refine,rest,sqlalchemy,sqlmodel
|
|
13
|
+
Classifier: Development Status :: 3 - Alpha
|
|
14
|
+
Classifier: Framework :: FastAPI
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Requires-Python: >=3.10
|
|
26
|
+
Requires-Dist: fastapi>=0.114.2
|
|
27
|
+
Requires-Dist: sqlmodel>=0.0.21
|
|
28
|
+
Provides-Extra: dev
|
|
29
|
+
Requires-Dist: mypy>=1.8.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: pytest>=7.4.3; extra == 'dev'
|
|
31
|
+
Requires-Dist: ruff>=0.2.2; extra == 'dev'
|
|
32
|
+
Description-Content-Type: text/markdown
|
|
33
|
+
|
|
34
|
+
# fastapi-refine
|
|
35
|
+
|
|
36
|
+
[](https://badge.fury.io/py/fastapi-refine)
|
|
37
|
+
[](https://pypi.org/project/fastapi-refine/)
|
|
38
|
+
[](https://opensource.org/licenses/MIT)
|
|
39
|
+
|
|
40
|
+
FastAPI integration for [Refine](https://refine.dev/) simple-rest data provider. Build type-safe, production-ready REST APIs that work seamlessly with Refine's data provider conventions.
|
|
41
|
+
|
|
42
|
+
## Features
|
|
43
|
+
|
|
44
|
+
- **Automatic Query Parsing**: Parse Refine's filter, sort, and pagination parameters out-of-the-box
|
|
45
|
+
- **Type-Safe**: Full type hints and mypy strict mode compliance
|
|
46
|
+
- **SQLModel Integration**: First-class support for SQLModel/SQLAlchemy ORM
|
|
47
|
+
- **CRUD Router Factory**: Generate complete CRUD endpoints with one class
|
|
48
|
+
- **Flexible Filtering**: Support for `eq`, `ne`, `gte`, `lte`, `like` operators and full-text search
|
|
49
|
+
- **Hook System**: Inject custom logic before/after operations (permissions, validation, etc.)
|
|
50
|
+
- **Production Ready**: Built with FastAPI best practices
|
|
51
|
+
|
|
52
|
+
## Installation
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
pip install fastapi-refine
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## Quick Start
|
|
59
|
+
|
|
60
|
+
### Basic Usage with Manual Endpoints
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from typing import Annotated
|
|
64
|
+
|
|
65
|
+
from fastapi import APIRouter, Depends
|
|
66
|
+
from sqlmodel import Session, select
|
|
67
|
+
from fastapi_refine import (
|
|
68
|
+
FilterConfig,
|
|
69
|
+
FilterField,
|
|
70
|
+
SortConfig,
|
|
71
|
+
RefineQuery,
|
|
72
|
+
RefineResponse,
|
|
73
|
+
refine_query,
|
|
74
|
+
refine_response,
|
|
75
|
+
)
|
|
76
|
+
from fastapi_refine.core import parse_bool
|
|
77
|
+
|
|
78
|
+
from .models import Item, ItemPublic
|
|
79
|
+
from .database import get_session
|
|
80
|
+
|
|
81
|
+
router = APIRouter(prefix="/items", tags=["items"])
|
|
82
|
+
|
|
83
|
+
SessionDep = Annotated[Session, Depends(get_session)]
|
|
84
|
+
|
|
85
|
+
# Configure which fields can be filtered and sorted
|
|
86
|
+
filter_config = FilterConfig(
|
|
87
|
+
fields={
|
|
88
|
+
"id": FilterField(Item.id, str),
|
|
89
|
+
"title": FilterField(Item.title, str),
|
|
90
|
+
"is_active": FilterField(Item.is_active, parse_bool),
|
|
91
|
+
},
|
|
92
|
+
search_fields=[Item.title, Item.description], # Full-text search fields
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
sort_config = SortConfig(
|
|
96
|
+
fields={
|
|
97
|
+
"id": Item.id,
|
|
98
|
+
"title": Item.title,
|
|
99
|
+
"created_at": Item.created_at,
|
|
100
|
+
}
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
@router.get("/", response_model=list[ItemPublic])
|
|
104
|
+
def read_items(
|
|
105
|
+
session: SessionDep,
|
|
106
|
+
refine_resp: Annotated[RefineResponse, Depends(refine_response())],
|
|
107
|
+
query: Annotated[RefineQuery, Depends(refine_query(Item, filter_config, sort_config))],
|
|
108
|
+
) -> list[ItemPublic]:
|
|
109
|
+
# query.conditions contains parsed WHERE clauses
|
|
110
|
+
# query.order_by contains ORDER BY clauses
|
|
111
|
+
# query.offset and query.limit are ready for pagination
|
|
112
|
+
|
|
113
|
+
items = session.exec(
|
|
114
|
+
select(Item)
|
|
115
|
+
.where(*query.conditions)
|
|
116
|
+
.order_by(*query.order_by)
|
|
117
|
+
.offset(query.offset)
|
|
118
|
+
.limit(query.limit)
|
|
119
|
+
).all()
|
|
120
|
+
|
|
121
|
+
# Set x-total-count header for Refine pagination
|
|
122
|
+
total = query.get_count(session, query.conditions)
|
|
123
|
+
refine_resp.set_total_count(total)
|
|
124
|
+
|
|
125
|
+
return items
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Automatic CRUD Router (Recommended)
|
|
129
|
+
|
|
130
|
+
Generate all CRUD endpoints automatically:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from fastapi import FastAPI
|
|
134
|
+
from fastapi_refine import RefineCRUDRouter, FilterConfig, FilterField, SortConfig
|
|
135
|
+
from fastapi_refine.core import parse_bool
|
|
136
|
+
from .models import Item, ItemCreate, ItemUpdate, ItemPublic
|
|
137
|
+
from .database import get_session
|
|
138
|
+
|
|
139
|
+
app = FastAPI()
|
|
140
|
+
|
|
141
|
+
# Create router with full CRUD operations
|
|
142
|
+
crud_router = RefineCRUDRouter(
|
|
143
|
+
model=Item,
|
|
144
|
+
prefix="/items",
|
|
145
|
+
create_schema=ItemCreate,
|
|
146
|
+
update_schema=ItemUpdate,
|
|
147
|
+
public_schema=ItemPublic,
|
|
148
|
+
session_dep=get_session,
|
|
149
|
+
filter_config=FilterConfig(
|
|
150
|
+
fields={
|
|
151
|
+
"title": FilterField(Item.title, str),
|
|
152
|
+
"is_active": FilterField(Item.is_active, parse_bool),
|
|
153
|
+
},
|
|
154
|
+
search_fields=[Item.title],
|
|
155
|
+
),
|
|
156
|
+
sort_config=SortConfig(
|
|
157
|
+
fields={"title": Item.title, "created_at": Item.created_at}
|
|
158
|
+
),
|
|
159
|
+
tags=["items"],
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
app.include_router(crud_router.router)
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
This automatically creates:
|
|
166
|
+
- `GET /items/` - List with filtering, sorting, pagination
|
|
167
|
+
- `GET /items/{id}` - Get single item
|
|
168
|
+
- `POST /items/` - Create item
|
|
169
|
+
- `PATCH /items/{id}` - Update item
|
|
170
|
+
- `DELETE /items/{id}` - Delete item
|
|
171
|
+
|
|
172
|
+
## Advanced Usage
|
|
173
|
+
|
|
174
|
+
### Custom Hooks for Permissions
|
|
175
|
+
|
|
176
|
+
```python
|
|
177
|
+
from fastapi import Depends, HTTPException
|
|
178
|
+
from fastapi_refine import RefineHooks, HookContext, RefineCRUDRouter
|
|
179
|
+
|
|
180
|
+
def before_query(context: HookContext, conditions: list) -> list:
|
|
181
|
+
"""Filter items to only show user's own items"""
|
|
182
|
+
if context.current_user:
|
|
183
|
+
conditions.append(context.model.owner_id == context.current_user.id)
|
|
184
|
+
return conditions
|
|
185
|
+
|
|
186
|
+
def before_delete(context: HookContext, item) -> None:
|
|
187
|
+
"""Only allow deleting own items"""
|
|
188
|
+
if item.owner_id != context.current_user.id:
|
|
189
|
+
raise HTTPException(status_code=403, detail="Not authorized")
|
|
190
|
+
|
|
191
|
+
hooks = RefineHooks(
|
|
192
|
+
before_query=before_query,
|
|
193
|
+
before_delete=before_delete,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
crud_router = RefineCRUDRouter(
|
|
197
|
+
model=Item,
|
|
198
|
+
hooks=hooks,
|
|
199
|
+
current_user_dep=get_current_user,
|
|
200
|
+
# ... other config
|
|
201
|
+
)
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### Pagination Configuration
|
|
205
|
+
|
|
206
|
+
```python
|
|
207
|
+
from fastapi_refine import PaginationConfig, RefineCRUDRouter
|
|
208
|
+
|
|
209
|
+
pagination_config = PaginationConfig(
|
|
210
|
+
default_skip=0,
|
|
211
|
+
default_limit=50,
|
|
212
|
+
max_limit=500, # Prevent excessive queries
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
crud_router = RefineCRUDRouter(
|
|
216
|
+
pagination_config=pagination_config,
|
|
217
|
+
# ... other config
|
|
218
|
+
)
|
|
219
|
+
```
|
|
220
|
+
|
|
221
|
+
## Supported Query Parameters
|
|
222
|
+
|
|
223
|
+
The library parses Refine simple-rest query parameters:
|
|
224
|
+
|
|
225
|
+
### Filtering
|
|
226
|
+
- `field=value` - Exact match (eq)
|
|
227
|
+
- `field_ne=value` - Not equal
|
|
228
|
+
- `field_gte=value` - Greater than or equal
|
|
229
|
+
- `field_lte=value` - Less than or equal
|
|
230
|
+
- `field_like=value` - Contains (case-insensitive)
|
|
231
|
+
- `q=search` - Full-text search across configured fields
|
|
232
|
+
|
|
233
|
+
### Sorting
|
|
234
|
+
- `_sort=field1,field2` - Sort by multiple fields
|
|
235
|
+
- `_order=asc,desc` - Sort order for each field
|
|
236
|
+
|
|
237
|
+
### Pagination
|
|
238
|
+
- Range-based: `_start=0&_end=20`
|
|
239
|
+
- Offset-based: `skip=0&limit=20`
|
|
240
|
+
|
|
241
|
+
### Example Query
|
|
242
|
+
```
|
|
243
|
+
GET /items?title_like=hello&is_active=true&_sort=created_at&_order=desc&_start=0&_end=10
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
## Type Converters
|
|
247
|
+
|
|
248
|
+
Built-in converters for common types:
|
|
249
|
+
|
|
250
|
+
```python
|
|
251
|
+
from fastapi_refine import FilterConfig, FilterField
|
|
252
|
+
from fastapi_refine.core import parse_bool, parse_uuid
|
|
253
|
+
|
|
254
|
+
filter_config = FilterConfig(
|
|
255
|
+
fields={
|
|
256
|
+
"id": FilterField(Item.id, parse_uuid),
|
|
257
|
+
"is_active": FilterField(Item.is_active, parse_bool),
|
|
258
|
+
"price": FilterField(Item.price, float),
|
|
259
|
+
"quantity": FilterField(Item.quantity, int),
|
|
260
|
+
}
|
|
261
|
+
)
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
## Requirements
|
|
265
|
+
|
|
266
|
+
- Python 3.10+
|
|
267
|
+
- FastAPI 0.114.2+
|
|
268
|
+
- SQLModel 0.0.21+
|
|
269
|
+
|
|
270
|
+
## Contributing
|
|
271
|
+
|
|
272
|
+
Contributions are welcome! Please feel free to submit a Pull Request.
|
|
273
|
+
|
|
274
|
+
## License
|
|
275
|
+
|
|
276
|
+
MIT License - see LICENSE file for details.
|
|
277
|
+
|
|
278
|
+
## Links
|
|
279
|
+
|
|
280
|
+
- [Refine Documentation](https://refine.dev/docs/)
|
|
281
|
+
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
|
|
282
|
+
- [SQLModel Documentation](https://sqlmodel.tiangolo.com/)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
fastapi_refine/__init__.py,sha256=jB3ePqiyivSvboP6Oiwyu-Y0DJzfTe_Pkz48pnFwBfI,643
|
|
2
|
+
fastapi_refine/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
fastapi_refine/core/__init__.py,sha256=4WeRIBcgUjNDjtO6UCiA5pD6wu1o-X1RokfOqahlrw0,491
|
|
4
|
+
fastapi_refine/core/query.py,sha256=x0poTtwpiT06a2aHqHLjM3BPpgK_QNafb4HxdEWG6gA,5242
|
|
5
|
+
fastapi_refine/core/types.py,sha256=hO_x5rRIs5FQt3LaQcvWLezyzNTXHOMDFGUxEzEIaEY,1412
|
|
6
|
+
fastapi_refine/dependencies/__init__.py,sha256=G0Ls3DmmqmOW-gcruNdskP1uZHsj_8c-aXQrazA1Zqk,289
|
|
7
|
+
fastapi_refine/dependencies/query.py,sha256=mAGCICC2sxMXwX7vtzRT05nrPq4tenEoWGRZ2PHd7Qs,4528
|
|
8
|
+
fastapi_refine/dependencies/response.py,sha256=1r_DgSSHK8FqKe0vB8OTUqST1m8k3YV35YTkRZzQbY0,1328
|
|
9
|
+
fastapi_refine/hooks/__init__.py,sha256=NMw5nZ2rTZeantO51rbIHhHBcmo3zuhyRnSoic1BtV4,231
|
|
10
|
+
fastapi_refine/hooks/base.py,sha256=mv_OaRIUYSq-wu5vcNeayb-51yGomRQSowayrEdTGf0,2358
|
|
11
|
+
fastapi_refine/hooks/builtin.py,sha256=NsNnITbg_qQN9-vuLpcvXPCLY9yFrb3rGZ-uuhRMG30,2733
|
|
12
|
+
fastapi_refine/routers/__init__.py,sha256=N4bnZXJjpJRpP-sS-aBXV-EwOBod3rStoMhycZ_HvLg,163
|
|
13
|
+
fastapi_refine/routers/factory.py,sha256=RUiP_MkZbv6vo8q3xDUlpH_9eNq55Nsplfu60kCFMsU,11182
|
|
14
|
+
fastapi_refine/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
15
|
+
fastapi_refine-0.1.0.dist-info/METADATA,sha256=60CpkMAOG1qgo1wULyHbvSYyum1ha15JmcKZBfgrl8g,8320
|
|
16
|
+
fastapi_refine-0.1.0.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
17
|
+
fastapi_refine-0.1.0.dist-info/licenses/LICENSE,sha256=fmW7UZeYMMP-iK31FXjWS3ZXQMpIlAYIdtohot23Fqo,1061
|
|
18
|
+
fastapi_refine-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 koko
|
|
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.
|