dynamicforms-fastapi-viewsets 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.
- dynamicforms_fastapi_viewsets-0.1.0.dist-info/METADATA +105 -0
- dynamicforms_fastapi_viewsets-0.1.0.dist-info/RECORD +22 -0
- dynamicforms_fastapi_viewsets-0.1.0.dist-info/WHEEL +4 -0
- dynamicforms_fastapi_viewsets-0.1.0.dist-info/licenses/LICENSE +21 -0
- fastapi_viewsets/__init__.py +1 -0
- fastapi_viewsets/celery_viewset.py +50 -0
- fastapi_viewsets/collection_viewset.py +216 -0
- fastapi_viewsets/decorators/__init__.py +2 -0
- fastapi_viewsets/decorators/build_schema.py +161 -0
- fastapi_viewsets/decorators/celery_viewset/__init__.py +51 -0
- fastapi_viewsets/decorators/celery_viewset/client.py +88 -0
- fastapi_viewsets/decorators/celery_viewset/result_reader.py +99 -0
- fastapi_viewsets/decorators/celery_viewset/server.py +138 -0
- fastapi_viewsets/decorators/lifecycle_runner.py +64 -0
- fastapi_viewsets/decorators/primary_key_model_helper.py +33 -0
- fastapi_viewsets/decorators/route_viewset.py +216 -0
- fastapi_viewsets/mixins.py +476 -0
- fastapi_viewsets/py.typed +0 -0
- fastapi_viewsets/response_classes.py +15 -0
- fastapi_viewsets/save_state/__init__.py +0 -0
- fastapi_viewsets/save_state/save_state.py +36 -0
- fastapi_viewsets/save_state/serialize_state.py +30 -0
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import logging
|
|
3
|
+
import uuid
|
|
4
|
+
|
|
5
|
+
from functools import wraps
|
|
6
|
+
from typing import TYPE_CHECKING, TypeVar
|
|
7
|
+
|
|
8
|
+
from fastapi.routing import APIRoute
|
|
9
|
+
from pydantic import BaseModel
|
|
10
|
+
|
|
11
|
+
from ..build_schema import build_schema
|
|
12
|
+
from . import result_reader
|
|
13
|
+
from .result_reader import get_result_queue_key
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
import redis
|
|
17
|
+
|
|
18
|
+
from celery import Celery
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def celery_viewset_client(
|
|
25
|
+
celery_app: "Celery",
|
|
26
|
+
task_prefix: str,
|
|
27
|
+
redis_client: "redis.Redis",
|
|
28
|
+
):
|
|
29
|
+
"""
|
|
30
|
+
Decorator for FastAPI side. Replaces viewset methods with async wrappers that
|
|
31
|
+
send Celery tasks and await results via a Redis result queue.
|
|
32
|
+
"""
|
|
33
|
+
queue_key = get_result_queue_key(task_prefix)
|
|
34
|
+
|
|
35
|
+
def decorator(cls: type[T]):
|
|
36
|
+
seen_tasks = set()
|
|
37
|
+
|
|
38
|
+
build_schema(cls)
|
|
39
|
+
for route in cls.__router.routes:
|
|
40
|
+
task_name = f"{task_prefix}.{route.name or route.endpoint.__name__}"
|
|
41
|
+
|
|
42
|
+
if task_name in seen_tasks:
|
|
43
|
+
continue
|
|
44
|
+
seen_tasks.add(task_name)
|
|
45
|
+
|
|
46
|
+
_patch_method(cls, route.endpoint, task_name, celery_app, redis_client, queue_key)
|
|
47
|
+
|
|
48
|
+
cls.__celery_viewset_metadata__ = {
|
|
49
|
+
"task_prefix": task_prefix,
|
|
50
|
+
"celery_app": celery_app,
|
|
51
|
+
"redis_client": redis_client,
|
|
52
|
+
"queue_key": queue_key,
|
|
53
|
+
"mode": "client",
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return cls
|
|
57
|
+
|
|
58
|
+
return decorator
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _patch_method(cls: type, original_endpoint, task_name: str, celery_app, redis_client, queue_key: str):
|
|
62
|
+
"""Replace the method on cls with an async version that sends a Celery task and awaits the result."""
|
|
63
|
+
method_name = original_endpoint.__name__
|
|
64
|
+
|
|
65
|
+
@wraps(original_endpoint)
|
|
66
|
+
async def async_client_wrapper(_self, *args, **kwargs):
|
|
67
|
+
correlation_id = str(uuid.uuid4())
|
|
68
|
+
loop = asyncio.get_event_loop()
|
|
69
|
+
future: asyncio.Future = loop.create_future()
|
|
70
|
+
result_reader.register_future(correlation_id, future)
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
serializable_kwargs = {
|
|
74
|
+
k: v.model_dump() if isinstance(v, BaseModel) else v
|
|
75
|
+
for k, v in kwargs.items()
|
|
76
|
+
}
|
|
77
|
+
logger.info("Celery task scheduling: %s (correlation_id=%s)", task_name, correlation_id)
|
|
78
|
+
celery_app.send_task(
|
|
79
|
+
task_name,
|
|
80
|
+
args=args,
|
|
81
|
+
kwargs={**serializable_kwargs, "_correlation_id": correlation_id, "_result_queue_key": queue_key},
|
|
82
|
+
)
|
|
83
|
+
return await future
|
|
84
|
+
except Exception:
|
|
85
|
+
result_reader.unregister_future(correlation_id)
|
|
86
|
+
raise
|
|
87
|
+
|
|
88
|
+
setattr(cls, method_name, async_client_wrapper)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import logging
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
from fastapi import HTTPException
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
import redis
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
# Global registry: correlation_id -> asyncio.Future
|
|
15
|
+
_pending_futures: dict[str, asyncio.Future] = {}
|
|
16
|
+
|
|
17
|
+
# Global reader task reference
|
|
18
|
+
_reader_task: asyncio.Task | None = None
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def register_future(correlation_id: str, future: asyncio.Future) -> None:
|
|
22
|
+
_pending_futures[correlation_id] = future
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def unregister_future(correlation_id: str) -> None:
|
|
26
|
+
_pending_futures.pop(correlation_id, None)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def get_result_queue_key(task_prefix: str) -> str:
|
|
30
|
+
return f"celery_viewset_results:{task_prefix}"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
async def result_reader_loop(redis_client: "redis.Redis", queue_key: str, poll_interval: float = 0.05) -> None:
|
|
34
|
+
"""Background coroutine that reads results from Redis list and resolves pending Futures."""
|
|
35
|
+
while True:
|
|
36
|
+
try:
|
|
37
|
+
# Non-blocking pop from Redis list
|
|
38
|
+
item = await redis_client.lpop(queue_key)
|
|
39
|
+
if item is None:
|
|
40
|
+
await asyncio.sleep(poll_interval)
|
|
41
|
+
continue
|
|
42
|
+
|
|
43
|
+
data = json.loads(item)
|
|
44
|
+
correlation_id = data.get("correlation_id")
|
|
45
|
+
result = data.get("result")
|
|
46
|
+
error = data.get("error")
|
|
47
|
+
|
|
48
|
+
future = _pending_futures.pop(correlation_id, None)
|
|
49
|
+
if future is None:
|
|
50
|
+
logger.warning("No pending future for correlation_id=%s", correlation_id)
|
|
51
|
+
continue
|
|
52
|
+
|
|
53
|
+
if not future.done():
|
|
54
|
+
if error is not None:
|
|
55
|
+
http_status_code = data.get("http_status_code")
|
|
56
|
+
http_detail = data.get("http_detail")
|
|
57
|
+
if http_status_code is not None:
|
|
58
|
+
future.set_exception(HTTPException(status_code=http_status_code, detail=http_detail))
|
|
59
|
+
else:
|
|
60
|
+
future.set_exception(Exception(error))
|
|
61
|
+
else:
|
|
62
|
+
future.set_result(result)
|
|
63
|
+
|
|
64
|
+
except asyncio.CancelledError:
|
|
65
|
+
break
|
|
66
|
+
except Exception as e:
|
|
67
|
+
logger.exception("Error in result_reader_loop: %s", e)
|
|
68
|
+
await asyncio.sleep(poll_interval)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def push_result(redis_client: "redis.Redis", queue_key: str, correlation_id: str, result=None, error=None) -> None:
|
|
72
|
+
"""Called from Celery worker to push result into Redis queue."""
|
|
73
|
+
data = {"correlation_id": correlation_id}
|
|
74
|
+
if error is not None:
|
|
75
|
+
data["error"] = str(error)
|
|
76
|
+
else:
|
|
77
|
+
data["result"] = result
|
|
78
|
+
redis_client.rpush(queue_key, json.dumps(data))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
async def start_result_reader(redis_client: "redis.Redis", queue_key: str, poll_interval: float = 0.05) -> asyncio.Task:
|
|
82
|
+
"""Start the background result reader task. Should be called on FastAPI startup."""
|
|
83
|
+
global _reader_task
|
|
84
|
+
if _reader_task is not None and not _reader_task.done():
|
|
85
|
+
return _reader_task
|
|
86
|
+
_reader_task = asyncio.create_task(result_reader_loop(redis_client, queue_key, poll_interval))
|
|
87
|
+
return _reader_task
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def stop_result_reader() -> None:
|
|
91
|
+
"""Stop the background result reader task. Should be called on FastAPI shutdown."""
|
|
92
|
+
global _reader_task
|
|
93
|
+
if _reader_task is not None and not _reader_task.done():
|
|
94
|
+
_reader_task.cancel()
|
|
95
|
+
try:
|
|
96
|
+
await _reader_task
|
|
97
|
+
except asyncio.CancelledError:
|
|
98
|
+
pass
|
|
99
|
+
_reader_task = None
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import inspect
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
|
|
6
|
+
from functools import wraps
|
|
7
|
+
from typing import get_type_hints, TYPE_CHECKING, TypeVar
|
|
8
|
+
|
|
9
|
+
from fastapi import HTTPException
|
|
10
|
+
from pydantic import BaseModel
|
|
11
|
+
|
|
12
|
+
from ..build_schema import build_schema
|
|
13
|
+
from ..lifecycle_runner import lifecycle_runner, LifecycleType
|
|
14
|
+
from ..route_viewset import build_type_map, resolve_typevars
|
|
15
|
+
|
|
16
|
+
if TYPE_CHECKING:
|
|
17
|
+
import redis
|
|
18
|
+
|
|
19
|
+
from celery import Celery
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
T = TypeVar("T")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _to_jsonable(value):
|
|
27
|
+
"""Recursively convert Pydantic models and lists to JSON-serializable structures."""
|
|
28
|
+
if isinstance(value, BaseModel):
|
|
29
|
+
return value.model_dump()
|
|
30
|
+
if isinstance(value, list):
|
|
31
|
+
return [_to_jsonable(v) for v in value]
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _reconstruct_kwargs(original_endpoint, kwargs: dict, cls: type = None) -> dict:
|
|
36
|
+
"""Reconstruct dict values back into Pydantic BaseModel instances based on endpoint type hints."""
|
|
37
|
+
try:
|
|
38
|
+
hints = get_type_hints(original_endpoint)
|
|
39
|
+
except Exception:
|
|
40
|
+
return kwargs
|
|
41
|
+
|
|
42
|
+
type_map = build_type_map(cls) if cls is not None else {}
|
|
43
|
+
|
|
44
|
+
result = {}
|
|
45
|
+
for key, value in kwargs.items():
|
|
46
|
+
hint = hints.get(key)
|
|
47
|
+
if hint is not None:
|
|
48
|
+
hint = resolve_typevars(type_map, hint)
|
|
49
|
+
if hint is not None and isinstance(value, dict) and inspect.isclass(hint) and issubclass(hint, BaseModel):
|
|
50
|
+
try:
|
|
51
|
+
result[key] = hint.model_validate(value)
|
|
52
|
+
except Exception:
|
|
53
|
+
result[key] = hint.model_construct(**value)
|
|
54
|
+
else:
|
|
55
|
+
result[key] = value
|
|
56
|
+
return result
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def celery_viewset_server(
|
|
60
|
+
celery_app: "Celery",
|
|
61
|
+
task_prefix: str,
|
|
62
|
+
lifecycle: LifecycleType = "singleton",
|
|
63
|
+
redis_client: "redis.Redis | None" = None,
|
|
64
|
+
):
|
|
65
|
+
def decorator(cls: type[T]):
|
|
66
|
+
seen_tasks = set()
|
|
67
|
+
instance = cls() if lifecycle == "singleton" else None
|
|
68
|
+
|
|
69
|
+
def get_sync_wrapper(original_endpoint, task_name: str):
|
|
70
|
+
@wraps(original_endpoint)
|
|
71
|
+
def sync_wrapper(*args, **kwargs):
|
|
72
|
+
nonlocal instance
|
|
73
|
+
|
|
74
|
+
# Extract pottery/result-queue params injected by client
|
|
75
|
+
correlation_id = kwargs.pop("_correlation_id", None)
|
|
76
|
+
result_queue_key = kwargs.pop("_result_queue_key", None)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
loop = asyncio.get_event_loop()
|
|
80
|
+
except RuntimeError:
|
|
81
|
+
loop = asyncio.new_event_loop()
|
|
82
|
+
asyncio.set_event_loop(loop)
|
|
83
|
+
|
|
84
|
+
if loop.is_running():
|
|
85
|
+
import nest_asyncio
|
|
86
|
+
|
|
87
|
+
nest_asyncio.apply()
|
|
88
|
+
|
|
89
|
+
logger.info("Celery task executing: %s (correlation_id=%s)", task_name, correlation_id)
|
|
90
|
+
try:
|
|
91
|
+
kwargs = _reconstruct_kwargs(original_endpoint, kwargs, cls)
|
|
92
|
+
result = loop.run_until_complete(
|
|
93
|
+
lifecycle_runner(original_endpoint, instance, cls, lifecycle, *args, **kwargs)
|
|
94
|
+
)
|
|
95
|
+
logger.info("Celery task completed: %s (correlation_id=%s)", task_name, correlation_id)
|
|
96
|
+
if correlation_id and result_queue_key and redis_client is not None:
|
|
97
|
+
redis_client.rpush(result_queue_key, json.dumps({
|
|
98
|
+
"correlation_id": correlation_id,
|
|
99
|
+
"result": _to_jsonable(result),
|
|
100
|
+
}))
|
|
101
|
+
return result
|
|
102
|
+
except Exception as e:
|
|
103
|
+
if isinstance(e, HTTPException):
|
|
104
|
+
logger.info(
|
|
105
|
+
"Celery task returned standard error: %s (correlation_id=%s): %d: %s",
|
|
106
|
+
task_name,
|
|
107
|
+
correlation_id,
|
|
108
|
+
e.status_code,
|
|
109
|
+
e.detail,
|
|
110
|
+
)
|
|
111
|
+
else:
|
|
112
|
+
logger.exception("Celery task failed: %s (correlation_id=%s): %s", task_name, correlation_id, e)
|
|
113
|
+
if correlation_id and result_queue_key and redis_client is not None:
|
|
114
|
+
error_payload = {"correlation_id": correlation_id, "error": str(e)}
|
|
115
|
+
if isinstance(e, HTTPException):
|
|
116
|
+
error_payload["http_status_code"] = e.status_code
|
|
117
|
+
error_payload["http_detail"] = e.detail
|
|
118
|
+
redis_client.rpush(result_queue_key, json.dumps(error_payload))
|
|
119
|
+
if not isinstance(e, HTTPException):
|
|
120
|
+
raise
|
|
121
|
+
|
|
122
|
+
return sync_wrapper
|
|
123
|
+
|
|
124
|
+
build_schema(cls)
|
|
125
|
+
for route in cls.__router.routes:
|
|
126
|
+
task_name = f"{task_prefix}.{route.name or route.endpoint.__name__}"
|
|
127
|
+
|
|
128
|
+
if task_name in seen_tasks:
|
|
129
|
+
continue
|
|
130
|
+
seen_tasks.add(task_name)
|
|
131
|
+
|
|
132
|
+
celery_app.task(name=task_name)(get_sync_wrapper(route.endpoint, task_name))
|
|
133
|
+
|
|
134
|
+
cls.__celery_viewset_metadata__ = {"task_prefix": task_prefix, "lifecycle": lifecycle, "celery_app": celery_app}
|
|
135
|
+
|
|
136
|
+
return cls
|
|
137
|
+
|
|
138
|
+
return decorator
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
from collections.abc import Callable
|
|
2
|
+
from typing import Any, Literal, TypeVar
|
|
3
|
+
|
|
4
|
+
T = TypeVar("T")
|
|
5
|
+
|
|
6
|
+
LifecycleType = Literal["singleton", "per-request", "instance-key"]
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
async def lifecycle_runner(
|
|
10
|
+
original_endpoint: Callable,
|
|
11
|
+
singleton_instance: T,
|
|
12
|
+
cls: type[T],
|
|
13
|
+
lifecycle: LifecycleType = "singleton",
|
|
14
|
+
*args,
|
|
15
|
+
response: Any = None,
|
|
16
|
+
**kwargs
|
|
17
|
+
):
|
|
18
|
+
"""
|
|
19
|
+
`response` is reserved for `route_viewset`'s optional `finalize_response` hook (see below) -
|
|
20
|
+
callers that have no live Response object (e.g. `celery_viewset_server`, running in a worker
|
|
21
|
+
process) simply omit it, and `finalize_response` is never invoked.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
async def run_with_state(req_instance: Any):
|
|
25
|
+
if hasattr(req_instance, "load_state"):
|
|
26
|
+
await req_instance.load_state()
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
bound_method = getattr(req_instance, original_endpoint.__name__, None)
|
|
30
|
+
if bound_method is not None:
|
|
31
|
+
return await bound_method(*args, **kwargs)
|
|
32
|
+
return await original_endpoint(req_instance, *args, **kwargs)
|
|
33
|
+
finally:
|
|
34
|
+
if hasattr(req_instance, "save_state"):
|
|
35
|
+
await req_instance.save_state()
|
|
36
|
+
|
|
37
|
+
async def finalize(req_instance: Any, result: Any) -> Any:
|
|
38
|
+
# Opt-in hook, mirroring load_state/save_state: only called when the viewset defines it
|
|
39
|
+
# AND a live Response was actually supplied (route_viewset HTTP wiring, not celery_viewset
|
|
40
|
+
# worker execution - a Celery worker has no Response object to hand it). Lets the viewset
|
|
41
|
+
# translate part of its (JSON-serializable) return value into response-level side effects
|
|
42
|
+
# (e.g. Set-Cookie) that can't survive a Celery/Redis round trip themselves - see
|
|
43
|
+
# docs/guide/routers.md "Response-level side effects: finalize_response".
|
|
44
|
+
if response is not None and hasattr(req_instance, "finalize_response"):
|
|
45
|
+
return await req_instance.finalize_response(response, result)
|
|
46
|
+
return result
|
|
47
|
+
|
|
48
|
+
if lifecycle == "singleton":
|
|
49
|
+
result = await run_with_state(singleton_instance)
|
|
50
|
+
return await finalize(singleton_instance, result)
|
|
51
|
+
elif lifecycle == "per-request":
|
|
52
|
+
req_instance = cls()
|
|
53
|
+
bound_method = getattr(req_instance, original_endpoint.__name__, None)
|
|
54
|
+
if bound_method is not None:
|
|
55
|
+
result = await bound_method(*args, **kwargs)
|
|
56
|
+
else:
|
|
57
|
+
result = await original_endpoint(req_instance, *args, **kwargs)
|
|
58
|
+
return await finalize(req_instance, result)
|
|
59
|
+
elif lifecycle == "instance-key":
|
|
60
|
+
req_instance = cls()
|
|
61
|
+
result = await run_with_state(req_instance)
|
|
62
|
+
return await finalize(req_instance, result)
|
|
63
|
+
|
|
64
|
+
return await original_endpoint(*args, **kwargs)
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
from typing import Any, get_origin, TypeVar
|
|
2
|
+
|
|
3
|
+
from pydantic import BaseModel, create_model
|
|
4
|
+
|
|
5
|
+
T = TypeVar("T", bound=BaseModel)
|
|
6
|
+
|
|
7
|
+
def create_model_without_pk(model: type[T], pk_field_name: str) -> type[BaseModel]:
|
|
8
|
+
"""
|
|
9
|
+
Creates a new Pydantic model based on the given model but without the specified primary key field.
|
|
10
|
+
"""
|
|
11
|
+
fields = {
|
|
12
|
+
name: (field.annotation, field.default if field.default is not ... else ...)
|
|
13
|
+
for name, field in model.model_fields.items()
|
|
14
|
+
if name != pk_field_name
|
|
15
|
+
}
|
|
16
|
+
return create_model(f"{model.__name__}NoPK", **fields)
|
|
17
|
+
|
|
18
|
+
def typecast_to_original_model(value: Any, original_annotation: type[T]) -> T:
|
|
19
|
+
"""
|
|
20
|
+
Typecasts a value (usually a Pydantic model without PK) back to the original Pydantic model.
|
|
21
|
+
If validation fails (e.g., due to missing PK that will be auto-incremented), it uses model_construct.
|
|
22
|
+
"""
|
|
23
|
+
origin = get_origin(original_annotation) or original_annotation
|
|
24
|
+
if not isinstance(value, BaseModel) or isinstance(value, origin):
|
|
25
|
+
return value
|
|
26
|
+
|
|
27
|
+
# noinspection PyBroadException
|
|
28
|
+
try:
|
|
29
|
+
return original_annotation.model_validate(value.model_dump())
|
|
30
|
+
except Exception:
|
|
31
|
+
# Fallback: create instance with model_construct if validation fails
|
|
32
|
+
# (e.g. required field missing but it will be filled by autoinc)
|
|
33
|
+
return original_annotation.model_construct(**value.model_dump())
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import inspect
|
|
2
|
+
|
|
3
|
+
from functools import wraps
|
|
4
|
+
from typing import Annotated, get_args, get_origin, TypeVar
|
|
5
|
+
|
|
6
|
+
from fastapi import APIRouter, Depends, Query, Response
|
|
7
|
+
from fastapi.params import FieldInfo
|
|
8
|
+
from pydantic import BaseModel
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
from typing_extensions import NoDefault as _TypeVarNoDefault
|
|
12
|
+
except ImportError:
|
|
13
|
+
_TypeVarNoDefault = object() # fallback sentinel — never equal to any real TypeVar default
|
|
14
|
+
|
|
15
|
+
from fastapi_viewsets.mixins import FilterParam, make_all_optional
|
|
16
|
+
|
|
17
|
+
from .build_schema import build_schema, route_to_add_api_route_kwargs
|
|
18
|
+
from .lifecycle_runner import lifecycle_runner, LifecycleType
|
|
19
|
+
from .primary_key_model_helper import create_model_without_pk, typecast_to_original_model
|
|
20
|
+
|
|
21
|
+
T = TypeVar("T")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_type_map(cls: type[T], current_map: dict = None) -> dict:
|
|
25
|
+
if current_map is None:
|
|
26
|
+
current_map = {}
|
|
27
|
+
|
|
28
|
+
type_map = dict(current_map)
|
|
29
|
+
|
|
30
|
+
if not hasattr(cls, "__orig_bases__"):
|
|
31
|
+
return type_map
|
|
32
|
+
|
|
33
|
+
for base in cls.__orig_bases__:
|
|
34
|
+
origin = getattr(base, "__origin__", None)
|
|
35
|
+
if not origin:
|
|
36
|
+
continue
|
|
37
|
+
|
|
38
|
+
args = get_args(base)
|
|
39
|
+
params = getattr(origin, "__parameters__", ())
|
|
40
|
+
|
|
41
|
+
new_map = {}
|
|
42
|
+
for param, arg in zip(params, args, strict=False):
|
|
43
|
+
# Try to resolve arg (which can be a TypeVar or e.g. List[T]) from current mapping
|
|
44
|
+
new_map[param] = resolve_typevars(type_map, arg)
|
|
45
|
+
|
|
46
|
+
# Update local mapping for this origin
|
|
47
|
+
type_map.update(new_map)
|
|
48
|
+
# Recursively search parents of this origin and add results to our mapping
|
|
49
|
+
type_map.update(build_type_map(origin, new_map))
|
|
50
|
+
|
|
51
|
+
return type_map
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def resolve_typevars(type_map, annotation):
|
|
55
|
+
if isinstance(annotation, TypeVar):
|
|
56
|
+
resolved = type_map.get(annotation, annotation)
|
|
57
|
+
if not isinstance(resolved, TypeVar):
|
|
58
|
+
return resolved
|
|
59
|
+
# Fall back to PEP 696 TypeVar default when the TypeVar is not bound in the type_map
|
|
60
|
+
tv_default = getattr(resolved, "__default__", _TypeVarNoDefault)
|
|
61
|
+
if tv_default is not _TypeVarNoDefault and not isinstance(tv_default, TypeVar):
|
|
62
|
+
return tv_default
|
|
63
|
+
elif get_origin(annotation) is not None:
|
|
64
|
+
origin = get_origin(annotation)
|
|
65
|
+
args = get_args(annotation)
|
|
66
|
+
new_args = tuple(resolve_typevars(type_map, arg) for arg in args)
|
|
67
|
+
if new_args != args:
|
|
68
|
+
# To deluje za List, Dict, Union, itd.
|
|
69
|
+
try:
|
|
70
|
+
return origin[new_args]
|
|
71
|
+
except:
|
|
72
|
+
return annotation
|
|
73
|
+
return annotation
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def route_viewset(
|
|
77
|
+
router: APIRouter,
|
|
78
|
+
base_path: str,
|
|
79
|
+
lifecycle: LifecycleType = "singleton",
|
|
80
|
+
pk_field_name: str = None,
|
|
81
|
+
):
|
|
82
|
+
def decorator(cls: type[T]):
|
|
83
|
+
seen_routes = set()
|
|
84
|
+
instance = cls() if lifecycle == "singleton" else None
|
|
85
|
+
|
|
86
|
+
# Opt-in: only viewsets that define `finalize_response` pay for the extra `Response`
|
|
87
|
+
# param - see docs/guide/routers.md "Response-level side effects: finalize_response".
|
|
88
|
+
has_finalize_response = hasattr(cls, "finalize_response")
|
|
89
|
+
|
|
90
|
+
type_map = build_type_map(cls)
|
|
91
|
+
|
|
92
|
+
# Derive tag from class name: strip "ViewSet" suffix if present
|
|
93
|
+
cls_name = cls.__name__
|
|
94
|
+
if cls_name.endswith("ViewSet"):
|
|
95
|
+
cls_name = cls_name[:-len("ViewSet")]
|
|
96
|
+
default_tags = [cls_name] if cls_name else None
|
|
97
|
+
|
|
98
|
+
def _is_filter_param(annotation) -> bool:
|
|
99
|
+
"""Return True when annotation is Annotated[T, FilterParam()]."""
|
|
100
|
+
return (
|
|
101
|
+
hasattr(annotation, "__metadata__")
|
|
102
|
+
and any(isinstance(m, FilterParam) for m in annotation.__metadata__)
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
def _is_model_query_param(annotation) -> bool:
|
|
106
|
+
"""Return True for Annotated[PydanticModel, <FieldInfo>] (e.g. Query() or Depends()).
|
|
107
|
+
These should use Depends() in the wrapper so FastAPI expands model fields as
|
|
108
|
+
individual query params even when other parameters are present on the same endpoint."""
|
|
109
|
+
args = getattr(annotation, "__args__", None)
|
|
110
|
+
metadata = getattr(annotation, "__metadata__", None)
|
|
111
|
+
if not (args and metadata):
|
|
112
|
+
return False
|
|
113
|
+
inner = args[0]
|
|
114
|
+
if not (inspect.isclass(inner) and issubclass(inner, BaseModel)):
|
|
115
|
+
return False
|
|
116
|
+
return any(isinstance(m, FieldInfo) for m in metadata)
|
|
117
|
+
|
|
118
|
+
def get_wrapper(original_endpoint, route_path, route_methods):
|
|
119
|
+
sig = inspect.signature(original_endpoint)
|
|
120
|
+
new_params = []
|
|
121
|
+
|
|
122
|
+
for name, p in sig.parameters.items():
|
|
123
|
+
if name == "self":
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
annotation = resolve_typevars(type_map, p.annotation)
|
|
127
|
+
new_p = p.replace(annotation=annotation)
|
|
128
|
+
|
|
129
|
+
# Transform Annotated[T, FilterParam()] → Annotated[TFilter, Query()]
|
|
130
|
+
if _is_filter_param(annotation):
|
|
131
|
+
inner_type = annotation.__args__[0]
|
|
132
|
+
if inspect.isclass(inner_type) and issubclass(inner_type, BaseModel):
|
|
133
|
+
# optional_model = make_all_optional(inner_type)
|
|
134
|
+
new_p = new_p.replace(annotation=Annotated[inner_type, Query()])
|
|
135
|
+
new_params.append(new_p)
|
|
136
|
+
continue
|
|
137
|
+
|
|
138
|
+
# Convert Annotated[PydanticModel, FieldInfo] → Annotated[PydanticModel, Depends()]
|
|
139
|
+
# so that FastAPI expands model fields as individual query params even when
|
|
140
|
+
# other parameters (e.g. sort) are present on the same endpoint.
|
|
141
|
+
if _is_model_query_param(annotation):
|
|
142
|
+
inner_type = annotation.__args__[0]
|
|
143
|
+
new_p = new_p.replace(annotation=Annotated[inner_type, Depends()])
|
|
144
|
+
new_params.append(new_p)
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
# If we have a PK field and this parameter is a model from which we want to exclude PK
|
|
148
|
+
if (pk_field_name and
|
|
149
|
+
inspect.isclass(annotation) and
|
|
150
|
+
issubclass(annotation, BaseModel)
|
|
151
|
+
):
|
|
152
|
+
# Check if {pk} is in the path and if the method is one that normally accepts a model in the body
|
|
153
|
+
# (POST without {pk} or PUT/PATCH with {pk})
|
|
154
|
+
is_create = "POST" in route_methods and "{pk}" not in route_path
|
|
155
|
+
is_update = False # ("PUT" in route_methods or "PATCH" in route_methods) and "{pk}" in route_path
|
|
156
|
+
|
|
157
|
+
if (is_create or is_update) and pk_field_name in annotation.model_fields:
|
|
158
|
+
new_model = create_model_without_pk(annotation, pk_field_name)
|
|
159
|
+
new_p = new_p.replace(annotation=new_model)
|
|
160
|
+
|
|
161
|
+
new_params.append(new_p)
|
|
162
|
+
|
|
163
|
+
if has_finalize_response:
|
|
164
|
+
# Reserved param name, only added for viewsets that opt in by defining
|
|
165
|
+
# finalize_response - FastAPI injects the REAL Response for this connection here
|
|
166
|
+
# (never forwarded to the underlying method itself, see wrapper below). Keyword-only
|
|
167
|
+
# so it can be appended regardless of what defaults precede it.
|
|
168
|
+
new_params.append(
|
|
169
|
+
inspect.Parameter("response", inspect.Parameter.KEYWORD_ONLY, annotation=Response)
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
new_return_annotation = resolve_typevars(type_map, sig.return_annotation)
|
|
173
|
+
new_sig = sig.replace(parameters=new_params, return_annotation=new_return_annotation)
|
|
174
|
+
|
|
175
|
+
@wraps(original_endpoint)
|
|
176
|
+
async def wrapper(*args, **kwargs):
|
|
177
|
+
nonlocal instance
|
|
178
|
+
|
|
179
|
+
response_obj = kwargs.pop("response", None) if has_finalize_response else None
|
|
180
|
+
|
|
181
|
+
# Check if any argument needs to be "typecasted" back to the original model
|
|
182
|
+
# This happens if we replaced the model type with a NoPK version in get_wrapper
|
|
183
|
+
new_kwargs = {}
|
|
184
|
+
for param_name, value in kwargs.items():
|
|
185
|
+
if param_name in sig.parameters:
|
|
186
|
+
orig_param = sig.parameters[param_name]
|
|
187
|
+
orig_annotation = resolve_typevars(type_map, orig_param.annotation)
|
|
188
|
+
# Filter params must not be typecast — they arrive as the all-optional model
|
|
189
|
+
if _is_filter_param(orig_annotation):
|
|
190
|
+
new_kwargs[param_name] = value
|
|
191
|
+
else:
|
|
192
|
+
new_kwargs[param_name] = typecast_to_original_model(value, orig_annotation)
|
|
193
|
+
else:
|
|
194
|
+
new_kwargs[param_name] = value
|
|
195
|
+
|
|
196
|
+
return await lifecycle_runner(
|
|
197
|
+
original_endpoint, instance, cls, lifecycle, *args, response=response_obj, **new_kwargs
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
wrapper.__signature__ = new_sig
|
|
201
|
+
return wrapper, new_return_annotation
|
|
202
|
+
|
|
203
|
+
build_schema(cls, base_path, default_tags, get_wrapper, disable_response_model=has_finalize_response)
|
|
204
|
+
|
|
205
|
+
for route in cls.__router.routes:
|
|
206
|
+
router.add_api_route(**route_to_add_api_route_kwargs(route))
|
|
207
|
+
|
|
208
|
+
cls.__viewset_metadata__ = {
|
|
209
|
+
"base_path": base_path,
|
|
210
|
+
"lifecycle": lifecycle,
|
|
211
|
+
"router": router
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return cls
|
|
215
|
+
|
|
216
|
+
return decorator
|