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.
@@ -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