fastapi-restly 0.5.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_restly/__init__.py +106 -0
- fastapi_restly/_exception_handlers.py +194 -0
- fastapi_restly/_pytest_fixtures.py +256 -0
- fastapi_restly/db/__init__.py +31 -0
- fastapi_restly/db/_globals.py +76 -0
- fastapi_restly/db/_proxy.py +42 -0
- fastapi_restly/db/_session.py +275 -0
- fastapi_restly/exceptions.py +12 -0
- fastapi_restly/models/__init__.py +18 -0
- fastapi_restly/models/_base.py +84 -0
- fastapi_restly/objects.py +144 -0
- fastapi_restly/py.typed +0 -0
- fastapi_restly/pytest_fixtures.py +24 -0
- fastapi_restly/query/__init__.py +13 -0
- fastapi_restly/query/_impl.py +594 -0
- fastapi_restly/query/_shared.py +22 -0
- fastapi_restly/schemas/__init__.py +29 -0
- fastapi_restly/schemas/_base.py +518 -0
- fastapi_restly/schemas/_generator.py +382 -0
- fastapi_restly/testing/__init__.py +20 -0
- fastapi_restly/testing/_client.py +98 -0
- fastapi_restly/testing/_fixtures.py +20 -0
- fastapi_restly/views/__init__.py +40 -0
- fastapi_restly/views/_async.py +216 -0
- fastapi_restly/views/_base.py +1294 -0
- fastapi_restly/views/_openapi.py +206 -0
- fastapi_restly/views/_react_admin.py +393 -0
- fastapi_restly/views/_sync.py +213 -0
- fastapi_restly-0.5.0.dist-info/METADATA +407 -0
- fastapi_restly-0.5.0.dist-info/RECORD +34 -0
- fastapi_restly-0.5.0.dist-info/WHEEL +5 -0
- fastapi_restly-0.5.0.dist-info/entry_points.txt +2 -0
- fastapi_restly-0.5.0.dist-info/licenses/LICENSE +21 -0
- fastapi_restly-0.5.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internal OpenAPI post-processing: x-resource-ref annotations.
|
|
3
|
+
|
|
4
|
+
Called automatically by include_view() — no public API.
|
|
5
|
+
|
|
6
|
+
FK columns and SQLAlchemy relationship fields backed by IDSchema/IDRef
|
|
7
|
+
are annotated with ``x-resource-ref: "<resource-name>"`` in the generated spec.
|
|
8
|
+
Full nested-object relationships (plain Pydantic model fields) are left untouched.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import inspect
|
|
12
|
+
import types
|
|
13
|
+
import weakref
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import Any, Union, get_args, get_origin
|
|
16
|
+
|
|
17
|
+
import fastapi
|
|
18
|
+
import pydantic
|
|
19
|
+
from sqlalchemy import inspect as sa_inspect
|
|
20
|
+
from sqlalchemy.orm import DeclarativeBase
|
|
21
|
+
|
|
22
|
+
from ..schemas import IDSchema
|
|
23
|
+
|
|
24
|
+
_PATCHED_ATTR = "_fr_resource_refs_patched"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class _Entry:
|
|
29
|
+
model: type[DeclarativeBase]
|
|
30
|
+
resource_name: str
|
|
31
|
+
schema: type[pydantic.BaseModel]
|
|
32
|
+
creation_schema: type[pydantic.BaseModel]
|
|
33
|
+
update_schema: type[pydantic.BaseModel]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
_registry: weakref.WeakKeyDictionary[
|
|
37
|
+
fastapi.FastAPI | fastapi.APIRouter, list[_Entry]
|
|
38
|
+
] = weakref.WeakKeyDictionary()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _register_for_resource_ref(
|
|
42
|
+
parent_router: fastapi.FastAPI | fastapi.APIRouter, view_cls: type
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Register a view's model→resource mapping and ensure the spec is patched.
|
|
45
|
+
|
|
46
|
+
Silently skips views without a SQLAlchemy model (e.g. plain View subclasses).
|
|
47
|
+
"""
|
|
48
|
+
model = getattr(view_cls, "model", None)
|
|
49
|
+
if model is None or not (
|
|
50
|
+
isinstance(model, type) and issubclass(model, DeclarativeBase)
|
|
51
|
+
):
|
|
52
|
+
return
|
|
53
|
+
|
|
54
|
+
resource_name = "".join(
|
|
55
|
+
c.__dict__["prefix"] for c in reversed(view_cls.mro()) if "prefix" in c.__dict__
|
|
56
|
+
).lstrip("/")
|
|
57
|
+
|
|
58
|
+
entry = _Entry(
|
|
59
|
+
model=model,
|
|
60
|
+
resource_name=resource_name,
|
|
61
|
+
schema=view_cls.schema,
|
|
62
|
+
creation_schema=view_cls.creation_schema,
|
|
63
|
+
update_schema=view_cls.update_schema,
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
entries = _registry.get(parent_router)
|
|
67
|
+
if entries is None:
|
|
68
|
+
entries = []
|
|
69
|
+
_registry[parent_router] = entries
|
|
70
|
+
entries.append(entry)
|
|
71
|
+
|
|
72
|
+
# Only the FastAPI app generates the OpenAPI spec; APIRouter parents have
|
|
73
|
+
# no ``.openapi`` to patch. Views registered on a router lose x-resource-ref
|
|
74
|
+
# annotations until the framework can walk to a root app, which is a
|
|
75
|
+
# separate gap.
|
|
76
|
+
if isinstance(parent_router, fastapi.FastAPI):
|
|
77
|
+
_ensure_patched(parent_router)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _ensure_patched(app: fastapi.FastAPI) -> None:
|
|
81
|
+
"""Wrap app.openapi() once so annotations are injected on first call."""
|
|
82
|
+
if getattr(app, _PATCHED_ATTR, False):
|
|
83
|
+
return
|
|
84
|
+
|
|
85
|
+
original_openapi = app.openapi
|
|
86
|
+
|
|
87
|
+
def patched_openapi() -> dict[str, Any]:
|
|
88
|
+
spec = original_openapi()
|
|
89
|
+
entries = _registry.get(app, [])
|
|
90
|
+
model_to_resource = {e.model: e.resource_name for e in entries}
|
|
91
|
+
_annotate_spec(spec, entries, model_to_resource)
|
|
92
|
+
return spec
|
|
93
|
+
|
|
94
|
+
app.openapi = patched_openapi # type: ignore[method-assign]
|
|
95
|
+
setattr(app, _PATCHED_ATTR, True)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _is_id_ref_annotation(annotation: Any) -> bool:
|
|
99
|
+
"""Return True if annotation is IDSchema[X], IDRef[X], or list/Optional thereof.
|
|
100
|
+
|
|
101
|
+
Returns False for full nested Pydantic model objects — those are not ID references.
|
|
102
|
+
Concrete user-defined subclasses like ``AuthorRead(IDSchema)`` return False;
|
|
103
|
+
only parametrized generics like ``IDSchema[Author]`` or ``IDRef[Author]``
|
|
104
|
+
return True, since those represent model ID references.
|
|
105
|
+
"""
|
|
106
|
+
origin = get_origin(annotation)
|
|
107
|
+
|
|
108
|
+
# Unwrap Optional / Union (X | None, Optional[X], Union[X, Y])
|
|
109
|
+
if origin in (Union, types.UnionType):
|
|
110
|
+
return any(
|
|
111
|
+
_is_id_ref_annotation(a)
|
|
112
|
+
for a in get_args(annotation)
|
|
113
|
+
if a is not type(None)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
# list[X] — check the element type
|
|
117
|
+
if origin is list:
|
|
118
|
+
args = get_args(annotation)
|
|
119
|
+
return bool(args and _is_id_ref_annotation(args[0]))
|
|
120
|
+
|
|
121
|
+
# Check for parametrized IDSchema/IDRef generics.
|
|
122
|
+
# In Pydantic v2, IDSchema[Author] and IDRef[Author] are concrete classes,
|
|
123
|
+
# so inspect.isclass() returns True for them too. We distinguish via
|
|
124
|
+
# __pydantic_generic_metadata__["origin"]:
|
|
125
|
+
# - Parametrized: IDSchema[Author] → origin = IDSchema
|
|
126
|
+
# - Parametrized: IDRef[Author] → origin = IDRef
|
|
127
|
+
# - User-defined subclass: AuthorRead(IDSchema) → origin = None (not a parametrization)
|
|
128
|
+
pydantic_meta = getattr(annotation, "__pydantic_generic_metadata__", {})
|
|
129
|
+
origin_cls = pydantic_meta.get("origin")
|
|
130
|
+
if inspect.isclass(origin_cls):
|
|
131
|
+
try:
|
|
132
|
+
return issubclass(origin_cls, IDSchema)
|
|
133
|
+
except TypeError:
|
|
134
|
+
return False
|
|
135
|
+
|
|
136
|
+
return False
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _field_openapi_key(schema_cls: type[pydantic.BaseModel], field_name: str) -> str:
|
|
140
|
+
"""Return the OpenAPI property key for a field, respecting serialization aliases."""
|
|
141
|
+
field_info = schema_cls.model_fields.get(field_name)
|
|
142
|
+
if field_info is None:
|
|
143
|
+
return field_name
|
|
144
|
+
if field_info.serialization_alias:
|
|
145
|
+
return field_info.serialization_alias
|
|
146
|
+
if field_info.alias:
|
|
147
|
+
return field_info.alias
|
|
148
|
+
return field_name
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _compute_refs(
|
|
152
|
+
schema_cls: type[pydantic.BaseModel],
|
|
153
|
+
model_cls: type[DeclarativeBase],
|
|
154
|
+
model_to_resource: dict[type[DeclarativeBase], str],
|
|
155
|
+
) -> dict[str, str]:
|
|
156
|
+
"""Return {openapi_property_key: resource_name} for FK columns and ID-ref relationship fields."""
|
|
157
|
+
result: dict[str, str] = {}
|
|
158
|
+
try:
|
|
159
|
+
mapper = sa_inspect(model_cls)
|
|
160
|
+
except Exception:
|
|
161
|
+
return result
|
|
162
|
+
|
|
163
|
+
for field_name, field_info in schema_cls.model_fields.items():
|
|
164
|
+
resource_name: str | None = None
|
|
165
|
+
|
|
166
|
+
if field_name in mapper.columns:
|
|
167
|
+
fks = list(mapper.columns[field_name].foreign_keys)
|
|
168
|
+
if fks:
|
|
169
|
+
target_table = fks[0].column.table # Table object identity
|
|
170
|
+
for m in model_cls.registry.mappers:
|
|
171
|
+
if m.local_table is target_table:
|
|
172
|
+
resource_name = model_to_resource.get(m.class_)
|
|
173
|
+
break
|
|
174
|
+
|
|
175
|
+
elif field_name in mapper.relationships:
|
|
176
|
+
# Only annotate if the schema field carries ID references, not full nested objects.
|
|
177
|
+
if _is_id_ref_annotation(field_info.annotation):
|
|
178
|
+
target_model = mapper.relationships[field_name].mapper.class_
|
|
179
|
+
resource_name = model_to_resource.get(target_model)
|
|
180
|
+
|
|
181
|
+
if resource_name is not None:
|
|
182
|
+
result[_field_openapi_key(schema_cls, field_name)] = resource_name
|
|
183
|
+
|
|
184
|
+
return result
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _annotate_spec(
|
|
188
|
+
spec: dict[str, Any],
|
|
189
|
+
entries: list[_Entry],
|
|
190
|
+
model_to_resource: dict[type[DeclarativeBase], str],
|
|
191
|
+
) -> None:
|
|
192
|
+
"""Mutate spec in-place, adding x-resource-ref to qualifying properties."""
|
|
193
|
+
schemas = spec.get("components", {}).get("schemas", {})
|
|
194
|
+
if not schemas:
|
|
195
|
+
return
|
|
196
|
+
|
|
197
|
+
for entry in entries:
|
|
198
|
+
refs = _compute_refs(entry.schema, entry.model, model_to_resource)
|
|
199
|
+
if not refs:
|
|
200
|
+
continue
|
|
201
|
+
|
|
202
|
+
for schema_cls in (entry.schema, entry.creation_schema, entry.update_schema):
|
|
203
|
+
props = schemas.get(schema_cls.__name__, {}).get("properties", {})
|
|
204
|
+
for prop_key, resource_name in refs.items():
|
|
205
|
+
if prop_key in props:
|
|
206
|
+
props[prop_key]["x-resource-ref"] = resource_name
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""
|
|
2
|
+
React Admin compatible views for fastapi-restly.
|
|
3
|
+
|
|
4
|
+
Implements the ra-data-simple-rest wire contract for list:
|
|
5
|
+
- response body: plain JSON array
|
|
6
|
+
- sort: sort=["field","ASC|DESC"]
|
|
7
|
+
- range: range=[start, end] (both inclusive, e.g. [0,24] = 25 items)
|
|
8
|
+
- filter: filter={"field":"value"} or filter={"id":[1,2,3]} for getMany
|
|
9
|
+
- Content-Range: items 0-24/315
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
from typing import Any, ClassVar, Protocol, Sequence, cast
|
|
14
|
+
|
|
15
|
+
import fastapi
|
|
16
|
+
import pydantic
|
|
17
|
+
import sqlalchemy
|
|
18
|
+
from sqlalchemy import func, select
|
|
19
|
+
from sqlalchemy.orm import DeclarativeBase, RelationshipProperty
|
|
20
|
+
|
|
21
|
+
from ..schemas import BaseSchema
|
|
22
|
+
from ._async import AsyncRestView
|
|
23
|
+
from ._base import _annotate, get, put
|
|
24
|
+
from ._sync import RestView
|
|
25
|
+
|
|
26
|
+
#: Default page size used when the react-admin client does not send a `range`
|
|
27
|
+
#: query parameter. Override per-view via ``default_page_size``.
|
|
28
|
+
DEFAULT_REACT_ADMIN_PAGE_SIZE = 25
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# ---------------------------------------------------------------------------
|
|
32
|
+
# Query parsing helpers (standalone, analogous to query/_impl.py)
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_react_admin_sort(sort_raw: str | None) -> tuple[str, str] | None:
|
|
37
|
+
"""
|
|
38
|
+
Parse a react-admin sort query parameter.
|
|
39
|
+
|
|
40
|
+
Expected: '["field","ASC"]' or '["field","DESC"]'
|
|
41
|
+
Returns (field, direction) or None if absent.
|
|
42
|
+
Raises HTTPException 400 on malformed input.
|
|
43
|
+
"""
|
|
44
|
+
if not sort_raw:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
parsed = json.loads(sort_raw)
|
|
48
|
+
except json.JSONDecodeError:
|
|
49
|
+
raise fastapi.HTTPException(400, "Invalid sort parameter: must be a JSON array")
|
|
50
|
+
if not isinstance(parsed, list) or len(parsed) != 2:
|
|
51
|
+
raise fastapi.HTTPException(
|
|
52
|
+
400, "Invalid sort parameter: must be [field, direction]"
|
|
53
|
+
)
|
|
54
|
+
field, direction = parsed
|
|
55
|
+
if not isinstance(field, str) or direction not in ("ASC", "DESC"):
|
|
56
|
+
raise fastapi.HTTPException(
|
|
57
|
+
400, "Invalid sort parameter: direction must be 'ASC' or 'DESC'"
|
|
58
|
+
)
|
|
59
|
+
return field, direction
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_react_admin_range(
|
|
63
|
+
range_raw: str | None, default_page_size: int = DEFAULT_REACT_ADMIN_PAGE_SIZE
|
|
64
|
+
) -> tuple[int, int]:
|
|
65
|
+
"""
|
|
66
|
+
Parse a react-admin range query parameter.
|
|
67
|
+
|
|
68
|
+
Expected: '[0,24]' (both inclusive).
|
|
69
|
+
Returns (start, end). Defaults to (0, default_page_size - 1) if absent.
|
|
70
|
+
Raises HTTPException 400 on malformed input.
|
|
71
|
+
"""
|
|
72
|
+
if not range_raw:
|
|
73
|
+
return 0, default_page_size - 1
|
|
74
|
+
try:
|
|
75
|
+
parsed = json.loads(range_raw)
|
|
76
|
+
except json.JSONDecodeError:
|
|
77
|
+
raise fastapi.HTTPException(
|
|
78
|
+
400, "Invalid range parameter: must be a JSON array"
|
|
79
|
+
)
|
|
80
|
+
if not isinstance(parsed, list) or len(parsed) != 2:
|
|
81
|
+
raise fastapi.HTTPException(
|
|
82
|
+
400, "Invalid range parameter: must be [start, end]"
|
|
83
|
+
)
|
|
84
|
+
start, end = parsed
|
|
85
|
+
if not isinstance(start, int) or not isinstance(end, int):
|
|
86
|
+
raise fastapi.HTTPException(
|
|
87
|
+
400, "Invalid range parameter: values must be integers"
|
|
88
|
+
)
|
|
89
|
+
return start, end
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def parse_react_admin_filter(filter_raw: str | None) -> dict:
|
|
93
|
+
"""
|
|
94
|
+
Parse a react-admin filter query parameter.
|
|
95
|
+
|
|
96
|
+
Expected: '{"field":"value"}' or '{"id":[1,2,3]}' for getMany.
|
|
97
|
+
Returns a dict. Defaults to {} if absent.
|
|
98
|
+
Raises HTTPException 400 on malformed input.
|
|
99
|
+
"""
|
|
100
|
+
if not filter_raw:
|
|
101
|
+
return {}
|
|
102
|
+
try:
|
|
103
|
+
parsed = json.loads(filter_raw)
|
|
104
|
+
except json.JSONDecodeError:
|
|
105
|
+
raise fastapi.HTTPException(
|
|
106
|
+
400, "Invalid filter parameter: must be a JSON object"
|
|
107
|
+
)
|
|
108
|
+
if not isinstance(parsed, dict):
|
|
109
|
+
raise fastapi.HTTPException(
|
|
110
|
+
400, "Invalid filter parameter: must be a JSON object"
|
|
111
|
+
)
|
|
112
|
+
return parsed
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _resolve_column(
|
|
116
|
+
model: type[DeclarativeBase], schema_cls: Any, field_name: str
|
|
117
|
+
) -> Any:
|
|
118
|
+
"""
|
|
119
|
+
Resolve a field name to a SQLAlchemy column.
|
|
120
|
+
|
|
121
|
+
Checks model attributes directly, then falls back to schema alias resolution.
|
|
122
|
+
Raises HTTPException 400 if the field cannot be resolved.
|
|
123
|
+
"""
|
|
124
|
+
col = getattr(model, field_name, None)
|
|
125
|
+
if col is not None:
|
|
126
|
+
if hasattr(col, "property") and isinstance(col.property, RelationshipProperty):
|
|
127
|
+
raise fastapi.HTTPException(
|
|
128
|
+
400, f"Cannot sort or filter by relationship field: {field_name!r}"
|
|
129
|
+
)
|
|
130
|
+
return col
|
|
131
|
+
|
|
132
|
+
if schema_cls is not None:
|
|
133
|
+
for name, field in schema_cls.model_fields.items():
|
|
134
|
+
if field.alias == field_name:
|
|
135
|
+
col = getattr(model, name, None)
|
|
136
|
+
if col is not None:
|
|
137
|
+
return col
|
|
138
|
+
|
|
139
|
+
raise fastapi.HTTPException(400, f"Unknown filter field: {field_name!r}")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _coerce_value(col: Any, value: Any) -> Any:
|
|
143
|
+
"""Coerce a filter value to the column's Python type if needed.
|
|
144
|
+
|
|
145
|
+
Handles cases such as UUID strings that must be converted to uuid.UUID
|
|
146
|
+
objects before being passed to SQLAlchemy's type processor.
|
|
147
|
+
"""
|
|
148
|
+
try:
|
|
149
|
+
py_type = col.type.python_type
|
|
150
|
+
except NotImplementedError:
|
|
151
|
+
return value
|
|
152
|
+
if not isinstance(value, py_type):
|
|
153
|
+
try:
|
|
154
|
+
return py_type(value)
|
|
155
|
+
except (ValueError, TypeError):
|
|
156
|
+
raise fastapi.HTTPException(
|
|
157
|
+
400, f"Invalid filter value for {col.key!r}: {value!r}"
|
|
158
|
+
)
|
|
159
|
+
return value
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _apply_react_admin_filters(
|
|
163
|
+
query: sqlalchemy.Select,
|
|
164
|
+
model: type[DeclarativeBase],
|
|
165
|
+
schema_cls: Any,
|
|
166
|
+
filters: dict,
|
|
167
|
+
) -> sqlalchemy.Select:
|
|
168
|
+
"""Apply a react-admin filter dict to a select query."""
|
|
169
|
+
for key, value in filters.items():
|
|
170
|
+
col = _resolve_column(model, schema_cls, key)
|
|
171
|
+
if isinstance(value, list):
|
|
172
|
+
coerced = [_coerce_value(col, v) for v in value]
|
|
173
|
+
query = query.where(col.in_(coerced))
|
|
174
|
+
else:
|
|
175
|
+
query = query.where(col == _coerce_value(col, value))
|
|
176
|
+
return query
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def apply_react_admin_query(
|
|
180
|
+
query: sqlalchemy.Select,
|
|
181
|
+
model: type[DeclarativeBase],
|
|
182
|
+
schema_cls: Any,
|
|
183
|
+
sort: tuple[str, str] | None,
|
|
184
|
+
start: int,
|
|
185
|
+
end: int,
|
|
186
|
+
filters: dict,
|
|
187
|
+
) -> sqlalchemy.Select:
|
|
188
|
+
"""
|
|
189
|
+
Apply filter, sort, and range (limit/offset) to a select query.
|
|
190
|
+
|
|
191
|
+
This is the main query transformation entry point, analogous to
|
|
192
|
+
:func:`fastapi_restly.query.apply_list_params` for the standard REST dialect.
|
|
193
|
+
"""
|
|
194
|
+
query = _apply_react_admin_filters(query, model, schema_cls, filters)
|
|
195
|
+
|
|
196
|
+
if sort:
|
|
197
|
+
field, direction = sort
|
|
198
|
+
col = _resolve_column(model, schema_cls, field)
|
|
199
|
+
order_fn = sqlalchemy.desc if direction == "DESC" else sqlalchemy.asc
|
|
200
|
+
query = query.order_by(order_fn(col))
|
|
201
|
+
else:
|
|
202
|
+
id_col = getattr(model, "id", None)
|
|
203
|
+
if id_col is not None:
|
|
204
|
+
query = query.order_by(id_col)
|
|
205
|
+
|
|
206
|
+
query = query.limit(end - start + 1).offset(start)
|
|
207
|
+
return query
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Shared implementation mixin
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
class _ReactAdminViewProtocol(Protocol):
|
|
216
|
+
request: fastapi.Request
|
|
217
|
+
model: ClassVar[type[DeclarativeBase]]
|
|
218
|
+
schema: ClassVar[type[pydantic.BaseModel]]
|
|
219
|
+
update_schema: ClassVar[type[pydantic.BaseModel]]
|
|
220
|
+
id_type: ClassVar[type[Any]]
|
|
221
|
+
default_page_size: ClassVar[int | None]
|
|
222
|
+
listing: ClassVar[Any]
|
|
223
|
+
put: ClassVar[Any]
|
|
224
|
+
|
|
225
|
+
def get_react_admin_range_unit(self) -> str: ...
|
|
226
|
+
def get_relationship_loader_options(self) -> list[Any]: ...
|
|
227
|
+
def to_response_schema(self, obj: Any) -> pydantic.BaseModel: ...
|
|
228
|
+
|
|
229
|
+
@classmethod
|
|
230
|
+
def before_include_view(cls) -> None: ...
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
class _ReactAdminMixin:
|
|
234
|
+
"""
|
|
235
|
+
Shared transport helpers for react-admin views.
|
|
236
|
+
|
|
237
|
+
This is an implementation detail shared by ReactAdminView and
|
|
238
|
+
AsyncReactAdminView. User-facing customization should happen by
|
|
239
|
+
subclassing one of those concrete view classes.
|
|
240
|
+
|
|
241
|
+
Set :attr:`default_page_size` on a subclass to change the implicit page
|
|
242
|
+
size used when the client does not send a ``range`` query parameter.
|
|
243
|
+
|
|
244
|
+
Type annotations on the mixin methods use ``_ReactAdminViewProtocol`` to
|
|
245
|
+
make the expected ``RestView`` surface explicit to static checkers.
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
#: Implicit page size when no ``range`` parameter is sent. Override per-view.
|
|
249
|
+
default_page_size: ClassVar[int | None] = DEFAULT_REACT_ADMIN_PAGE_SIZE
|
|
250
|
+
|
|
251
|
+
def get_react_admin_range_unit(self) -> str:
|
|
252
|
+
"""Return the unit string used in the Content-Range header."""
|
|
253
|
+
return "items"
|
|
254
|
+
|
|
255
|
+
def _parse_react_admin_params(
|
|
256
|
+
self,
|
|
257
|
+
) -> tuple[tuple[str, str] | None, tuple[int, int], dict]:
|
|
258
|
+
"""Parse sort, range, and filter from the current request query string."""
|
|
259
|
+
view = cast(_ReactAdminViewProtocol, self)
|
|
260
|
+
params = view.request.query_params
|
|
261
|
+
default_page_size = view.default_page_size or DEFAULT_REACT_ADMIN_PAGE_SIZE
|
|
262
|
+
sort = parse_react_admin_sort(params.get("sort"))
|
|
263
|
+
start, end = parse_react_admin_range(
|
|
264
|
+
params.get("range"), default_page_size=default_page_size
|
|
265
|
+
)
|
|
266
|
+
filters = parse_react_admin_filter(params.get("filter"))
|
|
267
|
+
return sort, (start, end), filters
|
|
268
|
+
|
|
269
|
+
def _serialize_items(self, items: Sequence[Any]) -> list[dict]:
|
|
270
|
+
"""Serialize ORM objects to JSON-compatible dicts via the view's response schema."""
|
|
271
|
+
view = cast(_ReactAdminViewProtocol, self)
|
|
272
|
+
return [
|
|
273
|
+
view.to_response_schema(obj).model_dump(mode="json", by_alias=True)
|
|
274
|
+
for obj in items
|
|
275
|
+
]
|
|
276
|
+
|
|
277
|
+
def _build_react_admin_list_response(
|
|
278
|
+
self,
|
|
279
|
+
serialized_items: list[dict],
|
|
280
|
+
total: int,
|
|
281
|
+
start: int,
|
|
282
|
+
end: int,
|
|
283
|
+
) -> fastapi.Response:
|
|
284
|
+
"""Build a JSON array response with a Content-Range header."""
|
|
285
|
+
view = cast(_ReactAdminViewProtocol, self)
|
|
286
|
+
unit = view.get_react_admin_range_unit()
|
|
287
|
+
last = start + len(serialized_items) - 1 if serialized_items else start
|
|
288
|
+
return fastapi.Response(
|
|
289
|
+
content=json.dumps(serialized_items),
|
|
290
|
+
media_type="application/json",
|
|
291
|
+
headers={
|
|
292
|
+
"Content-Range": f"{unit} {start}-{last}/{total}",
|
|
293
|
+
"Access-Control-Expose-Headers": "Content-Range",
|
|
294
|
+
},
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
def _build_count_query(self, filters: dict) -> sqlalchemy.Select:
|
|
298
|
+
"""Count query: filters only, no sort or pagination."""
|
|
299
|
+
view = cast(_ReactAdminViewProtocol, self)
|
|
300
|
+
base = sqlalchemy.select(view.model)
|
|
301
|
+
filtered = _apply_react_admin_filters(base, view.model, view.schema, filters)
|
|
302
|
+
return select(func.count()).select_from(filtered.subquery())
|
|
303
|
+
|
|
304
|
+
def _build_listing_query(
|
|
305
|
+
self,
|
|
306
|
+
sort: tuple[str, str] | None,
|
|
307
|
+
start: int,
|
|
308
|
+
end: int,
|
|
309
|
+
filters: dict,
|
|
310
|
+
) -> sqlalchemy.Select:
|
|
311
|
+
"""List query: filters, sort, and range applied."""
|
|
312
|
+
view = cast(_ReactAdminViewProtocol, self)
|
|
313
|
+
base = sqlalchemy.select(view.model)
|
|
314
|
+
loader_options = view.get_relationship_loader_options()
|
|
315
|
+
if loader_options:
|
|
316
|
+
base = base.options(*loader_options)
|
|
317
|
+
return apply_react_admin_query(
|
|
318
|
+
base, view.model, view.schema, sort, start, end, filters
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
@classmethod
|
|
322
|
+
def before_include_view(cls) -> None:
|
|
323
|
+
cast(Any, super()).before_include_view()
|
|
324
|
+
view_cls = cast(type[_ReactAdminViewProtocol], cls)
|
|
325
|
+
# Override the listing return annotation set by BaseRestView to Response,
|
|
326
|
+
# since we return a raw Response with Content-Range header.
|
|
327
|
+
if hasattr(view_cls, "listing"):
|
|
328
|
+
_annotate(view_cls.listing, return_annotation=fastapi.Response)
|
|
329
|
+
# Annotate the PUT handler with the same schema/types as PATCH.
|
|
330
|
+
if hasattr(view_cls, "put"):
|
|
331
|
+
_annotate(
|
|
332
|
+
view_cls.put,
|
|
333
|
+
return_annotation=view_cls.schema,
|
|
334
|
+
schema_obj=view_cls.update_schema,
|
|
335
|
+
id=view_cls.id_type,
|
|
336
|
+
)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
# ---------------------------------------------------------------------------
|
|
340
|
+
# Concrete view classes
|
|
341
|
+
# ---------------------------------------------------------------------------
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
class AsyncReactAdminView(_ReactAdminMixin, AsyncRestView):
|
|
345
|
+
"""
|
|
346
|
+
AsyncRestView that speaks the ra-data-simple-rest wire contract.
|
|
347
|
+
|
|
348
|
+
Use this instead of AsyncRestView when your frontend is react-admin
|
|
349
|
+
with ra-data-simple-rest.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
@get("/")
|
|
353
|
+
async def listing(self) -> Any:
|
|
354
|
+
sort, (start, end), filters = self._parse_react_admin_params()
|
|
355
|
+
total = int(await self.session.scalar(self._build_count_query(filters)) or 0)
|
|
356
|
+
items = (
|
|
357
|
+
await self.session.scalars(
|
|
358
|
+
self._build_listing_query(sort, start, end, filters)
|
|
359
|
+
)
|
|
360
|
+
).all()
|
|
361
|
+
return self._build_react_admin_list_response(
|
|
362
|
+
self._serialize_items(items), total, start, end
|
|
363
|
+
)
|
|
364
|
+
|
|
365
|
+
@put("/{id}")
|
|
366
|
+
async def put(self, id: Any, schema_obj: Any) -> Any:
|
|
367
|
+
obj = await self.perform_update(id, schema_obj)
|
|
368
|
+
return self.to_response_schema(obj)
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
class ReactAdminView(_ReactAdminMixin, RestView):
|
|
372
|
+
"""
|
|
373
|
+
RestView that speaks the ra-data-simple-rest wire contract.
|
|
374
|
+
|
|
375
|
+
Use this instead of RestView when your frontend is react-admin
|
|
376
|
+
with ra-data-simple-rest.
|
|
377
|
+
"""
|
|
378
|
+
|
|
379
|
+
@get("/")
|
|
380
|
+
def listing(self) -> Any:
|
|
381
|
+
sort, (start, end), filters = self._parse_react_admin_params()
|
|
382
|
+
total = int(self.session.scalar(self._build_count_query(filters)) or 0)
|
|
383
|
+
items = self.session.scalars(
|
|
384
|
+
self._build_listing_query(sort, start, end, filters)
|
|
385
|
+
).all()
|
|
386
|
+
return self._build_react_admin_list_response(
|
|
387
|
+
self._serialize_items(items), total, start, end
|
|
388
|
+
)
|
|
389
|
+
|
|
390
|
+
@put("/{id}")
|
|
391
|
+
def put(self, id: Any, schema_obj: Any) -> Any:
|
|
392
|
+
obj = self.perform_update(id, schema_obj)
|
|
393
|
+
return self.to_response_schema(obj)
|