cadwyn 5.4.6__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.
- cadwyn/__init__.py +44 -0
- cadwyn/__main__.py +78 -0
- cadwyn/_asts.py +155 -0
- cadwyn/_importer.py +31 -0
- cadwyn/_internal/__init__.py +0 -0
- cadwyn/_internal/context_vars.py +9 -0
- cadwyn/_render.py +155 -0
- cadwyn/_utils.py +79 -0
- cadwyn/applications.py +484 -0
- cadwyn/changelogs.py +503 -0
- cadwyn/dependencies.py +5 -0
- cadwyn/exceptions.py +78 -0
- cadwyn/middleware.py +131 -0
- cadwyn/py.typed +0 -0
- cadwyn/route_generation.py +536 -0
- cadwyn/routing.py +159 -0
- cadwyn/schema_generation.py +1162 -0
- cadwyn/static/__init__.py +0 -0
- cadwyn/static/docs.html +136 -0
- cadwyn/structure/__init__.py +31 -0
- cadwyn/structure/common.py +18 -0
- cadwyn/structure/data.py +249 -0
- cadwyn/structure/endpoints.py +170 -0
- cadwyn/structure/enums.py +42 -0
- cadwyn/structure/schemas.py +338 -0
- cadwyn/structure/versions.py +756 -0
- cadwyn-5.4.6.dist-info/METADATA +90 -0
- cadwyn-5.4.6.dist-info/RECORD +31 -0
- cadwyn-5.4.6.dist-info/WHEEL +4 -0
- cadwyn-5.4.6.dist-info/entry_points.txt +2 -0
- cadwyn-5.4.6.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
import email.message
|
|
2
|
+
import functools
|
|
3
|
+
import http
|
|
4
|
+
import inspect
|
|
5
|
+
import json
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
8
|
+
from contextlib import AsyncExitStack
|
|
9
|
+
from contextvars import ContextVar
|
|
10
|
+
from datetime import date
|
|
11
|
+
from enum import Enum
|
|
12
|
+
from typing import TYPE_CHECKING, ClassVar, Union, cast
|
|
13
|
+
|
|
14
|
+
from fastapi import BackgroundTasks, HTTPException, params
|
|
15
|
+
from fastapi import Request as FastapiRequest
|
|
16
|
+
from fastapi import Response as FastapiResponse
|
|
17
|
+
from fastapi._compat import ModelField, _normalize_errors
|
|
18
|
+
from fastapi.concurrency import run_in_threadpool
|
|
19
|
+
from fastapi.dependencies.models import Dependant
|
|
20
|
+
from fastapi.dependencies.utils import solve_dependencies
|
|
21
|
+
from fastapi.exceptions import RequestValidationError
|
|
22
|
+
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
|
23
|
+
from fastapi.routing import APIRoute, _prepare_response_content
|
|
24
|
+
from pydantic import BaseModel
|
|
25
|
+
from pydantic_core import PydanticUndefined
|
|
26
|
+
from starlette._utils import is_async_callable
|
|
27
|
+
from typing_extensions import Any, ParamSpec, TypeAlias, TypeVar, assert_never, deprecated, get_args
|
|
28
|
+
|
|
29
|
+
from cadwyn._internal.context_vars import CURRENT_DEPENDENCY_SOLVER_VAR
|
|
30
|
+
from cadwyn._utils import classproperty
|
|
31
|
+
from cadwyn.exceptions import (
|
|
32
|
+
CadwynError,
|
|
33
|
+
CadwynHeadRequestValidationError,
|
|
34
|
+
CadwynStructureError,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
from .._utils import Sentinel
|
|
38
|
+
from .common import Endpoint, VersionedModel, VersionType
|
|
39
|
+
from .data import (
|
|
40
|
+
RequestInfo,
|
|
41
|
+
ResponseInfo,
|
|
42
|
+
_AlterRequestByPathInstruction,
|
|
43
|
+
_AlterRequestBySchemaInstruction,
|
|
44
|
+
_AlterResponseByPathInstruction,
|
|
45
|
+
_AlterResponseBySchemaInstruction,
|
|
46
|
+
_BaseAlterResponseInstruction,
|
|
47
|
+
)
|
|
48
|
+
from .endpoints import AlterEndpointSubInstruction
|
|
49
|
+
from .enums import AlterEnumSubInstruction
|
|
50
|
+
from .schemas import AlterSchemaSubInstruction, SchemaHadInstruction
|
|
51
|
+
|
|
52
|
+
_CADWYN_REQUEST_PARAM_NAME = "cadwyn_request_param"
|
|
53
|
+
_CADWYN_RESPONSE_PARAM_NAME = "cadwyn_response_param"
|
|
54
|
+
_P = ParamSpec("_P")
|
|
55
|
+
_R = TypeVar("_R")
|
|
56
|
+
_RouteId = int
|
|
57
|
+
|
|
58
|
+
PossibleInstructions: TypeAlias = Union[
|
|
59
|
+
AlterSchemaSubInstruction, AlterEndpointSubInstruction, AlterEnumSubInstruction, SchemaHadInstruction, staticmethod
|
|
60
|
+
]
|
|
61
|
+
APIVersionVarType: TypeAlias = Union[ContextVar[Union[VersionType, None]], ContextVar[VersionType]]
|
|
62
|
+
IdentifierPythonPath = str
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
if TYPE_CHECKING:
|
|
66
|
+
_AlterSchemaSubInstructionArgs = AlterSchemaSubInstruction
|
|
67
|
+
_AlterEnumSubInstructionArgs = AlterEnumSubInstruction
|
|
68
|
+
_AlterEndpointSubInstructionArgs = AlterEndpointSubInstruction
|
|
69
|
+
else:
|
|
70
|
+
_AlterSchemaSubInstructionArgs = get_args(AlterSchemaSubInstruction)
|
|
71
|
+
_AlterEnumSubInstructionArgs = get_args(AlterEnumSubInstruction)
|
|
72
|
+
_AlterEndpointSubInstructionArgs = get_args(AlterEndpointSubInstruction)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class VersionChange:
|
|
76
|
+
description: ClassVar[str] = Sentinel
|
|
77
|
+
is_hidden_from_changelog: bool = False
|
|
78
|
+
instructions_to_migrate_to_previous_version: ClassVar[Sequence[PossibleInstructions]] = Sentinel
|
|
79
|
+
alter_schema_instructions: ClassVar[list[Union[AlterSchemaSubInstruction, SchemaHadInstruction]]] = Sentinel
|
|
80
|
+
alter_enum_instructions: ClassVar[list[AlterEnumSubInstruction]] = Sentinel
|
|
81
|
+
alter_endpoint_instructions: ClassVar[list[AlterEndpointSubInstruction]] = Sentinel
|
|
82
|
+
alter_request_by_schema_instructions: ClassVar[dict[type[BaseModel], list[_AlterRequestBySchemaInstruction]]] = (
|
|
83
|
+
Sentinel
|
|
84
|
+
)
|
|
85
|
+
alter_request_by_path_instructions: ClassVar[dict[str, list[_AlterRequestByPathInstruction]]] = Sentinel
|
|
86
|
+
alter_response_by_schema_instructions: ClassVar[dict[type, list[_AlterResponseBySchemaInstruction]]] = Sentinel
|
|
87
|
+
alter_response_by_path_instructions: ClassVar[dict[str, list[_AlterResponseByPathInstruction]]] = Sentinel
|
|
88
|
+
_bound_version_bundle: "Union[VersionBundle, None]"
|
|
89
|
+
_route_to_request_migration_mapping: ClassVar[dict[_RouteId, list[_AlterRequestByPathInstruction]]] = Sentinel
|
|
90
|
+
_route_to_response_migration_mapping: ClassVar[dict[_RouteId, list[_AlterResponseByPathInstruction]]] = Sentinel
|
|
91
|
+
|
|
92
|
+
def __init_subclass__(cls, _abstract: bool = False) -> None:
|
|
93
|
+
super().__init_subclass__()
|
|
94
|
+
|
|
95
|
+
if _abstract:
|
|
96
|
+
return
|
|
97
|
+
cls._validate_subclass()
|
|
98
|
+
cls._extract_list_instructions_into_correct_containers()
|
|
99
|
+
cls._extract_body_instructions_into_correct_containers()
|
|
100
|
+
cls._check_no_subclassing()
|
|
101
|
+
cls._bound_version_bundle = None
|
|
102
|
+
cls._route_to_request_migration_mapping = defaultdict(list)
|
|
103
|
+
cls._route_to_response_migration_mapping = defaultdict(list)
|
|
104
|
+
|
|
105
|
+
@classmethod
|
|
106
|
+
def _extract_body_instructions_into_correct_containers(cls):
|
|
107
|
+
for instruction in cls.__dict__.values():
|
|
108
|
+
if isinstance(instruction, _AlterRequestBySchemaInstruction):
|
|
109
|
+
for schema in instruction.schemas:
|
|
110
|
+
cls.alter_request_by_schema_instructions[schema].append(instruction)
|
|
111
|
+
elif isinstance(instruction, _AlterRequestByPathInstruction):
|
|
112
|
+
cls.alter_request_by_path_instructions[instruction.path].append(instruction)
|
|
113
|
+
elif isinstance(instruction, _AlterResponseBySchemaInstruction):
|
|
114
|
+
for schema in instruction.schemas:
|
|
115
|
+
cls.alter_response_by_schema_instructions[schema].append(instruction)
|
|
116
|
+
elif isinstance(instruction, _AlterResponseByPathInstruction):
|
|
117
|
+
cls.alter_response_by_path_instructions[instruction.path].append(instruction)
|
|
118
|
+
|
|
119
|
+
@classmethod
|
|
120
|
+
def _extract_list_instructions_into_correct_containers(cls):
|
|
121
|
+
cls.alter_schema_instructions = []
|
|
122
|
+
cls.alter_enum_instructions = []
|
|
123
|
+
cls.alter_endpoint_instructions = []
|
|
124
|
+
cls.alter_request_by_schema_instructions = defaultdict(list)
|
|
125
|
+
cls.alter_request_by_path_instructions = defaultdict(list)
|
|
126
|
+
cls.alter_response_by_schema_instructions = defaultdict(list)
|
|
127
|
+
cls.alter_response_by_path_instructions = defaultdict(list)
|
|
128
|
+
for alter_instruction in cls.instructions_to_migrate_to_previous_version:
|
|
129
|
+
if isinstance(alter_instruction, SchemaHadInstruction) or isinstance( # noqa: SIM101
|
|
130
|
+
alter_instruction, _AlterSchemaSubInstructionArgs
|
|
131
|
+
):
|
|
132
|
+
cls.alter_schema_instructions.append(alter_instruction)
|
|
133
|
+
elif isinstance(alter_instruction, _AlterEnumSubInstructionArgs):
|
|
134
|
+
cls.alter_enum_instructions.append(alter_instruction)
|
|
135
|
+
elif isinstance(alter_instruction, _AlterEndpointSubInstructionArgs):
|
|
136
|
+
cls.alter_endpoint_instructions.append(alter_instruction)
|
|
137
|
+
elif isinstance(alter_instruction, staticmethod): # pragma: no cover
|
|
138
|
+
raise NotImplementedError(f'"{alter_instruction}" is an unacceptable version change instruction')
|
|
139
|
+
else:
|
|
140
|
+
assert_never(alter_instruction)
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
def _validate_subclass(cls):
|
|
144
|
+
if cls.description is Sentinel:
|
|
145
|
+
raise CadwynStructureError(
|
|
146
|
+
f"Version change description is not set on '{cls.__name__}' but is required.",
|
|
147
|
+
)
|
|
148
|
+
if cls.instructions_to_migrate_to_previous_version is Sentinel:
|
|
149
|
+
raise CadwynStructureError(
|
|
150
|
+
f"Attribute 'instructions_to_migrate_to_previous_version' is not set on '{cls.__name__}'"
|
|
151
|
+
" but is required.",
|
|
152
|
+
)
|
|
153
|
+
if not isinstance(cls.instructions_to_migrate_to_previous_version, Sequence):
|
|
154
|
+
raise CadwynStructureError(
|
|
155
|
+
f"Attribute 'instructions_to_migrate_to_previous_version' must be a sequence in '{cls.__name__}'.",
|
|
156
|
+
)
|
|
157
|
+
for instruction in cls.instructions_to_migrate_to_previous_version:
|
|
158
|
+
if not isinstance(instruction, get_args(PossibleInstructions)):
|
|
159
|
+
raise CadwynStructureError(
|
|
160
|
+
f"Instruction '{instruction}' is not allowed. Please, use the correct instruction types",
|
|
161
|
+
)
|
|
162
|
+
for attr_name, attr_value in cls.__dict__.items():
|
|
163
|
+
if not isinstance(
|
|
164
|
+
attr_value,
|
|
165
|
+
(
|
|
166
|
+
_AlterRequestBySchemaInstruction,
|
|
167
|
+
_AlterRequestByPathInstruction,
|
|
168
|
+
_AlterResponseBySchemaInstruction,
|
|
169
|
+
_AlterResponseByPathInstruction,
|
|
170
|
+
),
|
|
171
|
+
) and attr_name not in {
|
|
172
|
+
"description",
|
|
173
|
+
"side_effects",
|
|
174
|
+
"instructions_to_migrate_to_previous_version",
|
|
175
|
+
"__module__",
|
|
176
|
+
"__doc__",
|
|
177
|
+
"__firstlineno__",
|
|
178
|
+
"__static_attributes__",
|
|
179
|
+
}:
|
|
180
|
+
raise CadwynStructureError(
|
|
181
|
+
f"Found: '{attr_name}' attribute of type '{type(attr_value)}' in '{cls.__name__}'."
|
|
182
|
+
" Only migration instructions and schema properties are allowed in version change class body.",
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
@classmethod
|
|
186
|
+
def _check_no_subclassing(cls):
|
|
187
|
+
if cls.mro() != [cls, VersionChange, object]:
|
|
188
|
+
raise TypeError(
|
|
189
|
+
f"Can't subclass {cls.__name__} as it was never meant to be subclassed.",
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
def __init__(self) -> None: # pyright: ignore[reportMissingSuperCall]
|
|
193
|
+
raise TypeError(
|
|
194
|
+
f"Can't instantiate {self.__class__.__name__} as it was never meant to be instantiated.",
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
class VersionChangeWithSideEffects(VersionChange, _abstract=True):
|
|
199
|
+
@classmethod
|
|
200
|
+
def _check_no_subclassing(cls):
|
|
201
|
+
if cls.mro() != [cls, VersionChangeWithSideEffects, VersionChange, object]:
|
|
202
|
+
raise TypeError(
|
|
203
|
+
f"Can't subclass {cls.__name__} as it was never meant to be subclassed.",
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
@classproperty
|
|
207
|
+
def is_applied(cls: type["VersionChangeWithSideEffects"]) -> bool: # pyright: ignore[reportGeneralTypeIssues]
|
|
208
|
+
if (
|
|
209
|
+
cls._bound_version_bundle is None
|
|
210
|
+
or cls not in cls._bound_version_bundle._version_changes_to_version_mapping
|
|
211
|
+
):
|
|
212
|
+
raise CadwynError(
|
|
213
|
+
f"You tried to check whether '{cls.__name__}' is active but it was never bound to any version.",
|
|
214
|
+
)
|
|
215
|
+
api_version = cls._bound_version_bundle.api_version_var.get()
|
|
216
|
+
if api_version is None:
|
|
217
|
+
return True
|
|
218
|
+
return cls._bound_version_bundle._version_changes_to_version_mapping[cls] <= api_version
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
class Version:
|
|
222
|
+
def __init__(self, value: Union[str, date], *changes: type[VersionChange]) -> None:
|
|
223
|
+
super().__init__()
|
|
224
|
+
|
|
225
|
+
if isinstance(value, date):
|
|
226
|
+
value = value.isoformat()
|
|
227
|
+
self.value = value
|
|
228
|
+
self.changes = changes
|
|
229
|
+
|
|
230
|
+
@property
|
|
231
|
+
@deprecated("'version_changes' attribute is deprecated and will be removed in Cadwyn 5.x.x. Use 'changes' instead.")
|
|
232
|
+
def version_changes(self): # pragma: no cover
|
|
233
|
+
return self.changes
|
|
234
|
+
|
|
235
|
+
def __repr__(self) -> str:
|
|
236
|
+
return f"Version('{self.value}')"
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
class HeadVersion:
|
|
240
|
+
def __init__(self, *changes: type[VersionChange]) -> None:
|
|
241
|
+
super().__init__()
|
|
242
|
+
self.changes = changes
|
|
243
|
+
|
|
244
|
+
for version_change in changes:
|
|
245
|
+
if any(
|
|
246
|
+
[
|
|
247
|
+
version_change.alter_request_by_path_instructions,
|
|
248
|
+
version_change.alter_request_by_schema_instructions,
|
|
249
|
+
version_change.alter_response_by_path_instructions,
|
|
250
|
+
version_change.alter_response_by_schema_instructions,
|
|
251
|
+
]
|
|
252
|
+
):
|
|
253
|
+
raise NotImplementedError(
|
|
254
|
+
f"HeadVersion does not support request or response migrations but {version_change} contained one."
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
@property
|
|
258
|
+
@deprecated("'version_changes' attribute is deprecated and will be removed in Cadwyn 5.x.x. Use 'changes' instead.")
|
|
259
|
+
def version_changes(self): # pragma: no cover
|
|
260
|
+
return self.changes
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def get_cls_pythonpath(cls: type) -> IdentifierPythonPath:
|
|
264
|
+
return f"{cls.__module__}.{cls.__name__}"
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class VersionBundle:
|
|
268
|
+
def __init__(
|
|
269
|
+
self,
|
|
270
|
+
latest_version_or_head_version: Union[Version, HeadVersion],
|
|
271
|
+
/,
|
|
272
|
+
*other_versions: Version,
|
|
273
|
+
api_version_var: Union[ContextVar[Union[VersionType, None]], None] = None,
|
|
274
|
+
) -> None:
|
|
275
|
+
super().__init__()
|
|
276
|
+
|
|
277
|
+
if isinstance(latest_version_or_head_version, HeadVersion):
|
|
278
|
+
self.head_version = latest_version_or_head_version
|
|
279
|
+
self.versions = other_versions
|
|
280
|
+
else:
|
|
281
|
+
self.head_version = HeadVersion()
|
|
282
|
+
self.versions = (latest_version_or_head_version, *other_versions)
|
|
283
|
+
self.reversed_versions = tuple(reversed(self.versions))
|
|
284
|
+
|
|
285
|
+
if api_version_var is None:
|
|
286
|
+
api_version_var = ContextVar("cadwyn_api_version")
|
|
287
|
+
|
|
288
|
+
self.version_values = tuple(version.value for version in self.versions)
|
|
289
|
+
self.reversed_version_values = tuple(reversed(self.version_values))
|
|
290
|
+
self.api_version_var = api_version_var
|
|
291
|
+
self._all_versions = (self.head_version, *self.versions)
|
|
292
|
+
self._version_changes_to_version_mapping = {
|
|
293
|
+
version_change: version.value for version in self.versions for version_change in version.changes
|
|
294
|
+
}
|
|
295
|
+
self._version_values_set: set[str] = set()
|
|
296
|
+
for version in self.versions:
|
|
297
|
+
if version.value not in self._version_values_set:
|
|
298
|
+
self._version_values_set.add(version.value)
|
|
299
|
+
else:
|
|
300
|
+
raise CadwynStructureError(
|
|
301
|
+
f"You tried to define two versions with the same value in the same "
|
|
302
|
+
f"{VersionBundle.__name__}: '{version.value}'.",
|
|
303
|
+
)
|
|
304
|
+
for version_change in version.changes:
|
|
305
|
+
if version_change._bound_version_bundle is not None:
|
|
306
|
+
raise CadwynStructureError(
|
|
307
|
+
f"You tried to bind version change '{version_change.__name__}' to two different versions. "
|
|
308
|
+
"It is prohibited.",
|
|
309
|
+
)
|
|
310
|
+
version_change._bound_version_bundle = self
|
|
311
|
+
if not self.versions:
|
|
312
|
+
raise CadwynStructureError("You must define at least one non-head version in a VersionBundle.")
|
|
313
|
+
|
|
314
|
+
if self.versions[-1].changes:
|
|
315
|
+
raise CadwynStructureError(
|
|
316
|
+
f'The first version "{self.versions[-1].value}" cannot have any version changes. '
|
|
317
|
+
"Version changes are defined to migrate to/from a previous version so you "
|
|
318
|
+
"cannot define one for the very first version.",
|
|
319
|
+
)
|
|
320
|
+
|
|
321
|
+
def __iter__(self) -> Iterator[Version]:
|
|
322
|
+
yield from self.versions
|
|
323
|
+
|
|
324
|
+
@property
|
|
325
|
+
@deprecated("Use 'version_values' instead.")
|
|
326
|
+
def version_dates(self): # pragma: no cover
|
|
327
|
+
return self.version_values
|
|
328
|
+
|
|
329
|
+
@functools.cached_property
|
|
330
|
+
def versioned_schemas(self) -> dict[IdentifierPythonPath, type[VersionedModel]]:
|
|
331
|
+
altered_schemas = {
|
|
332
|
+
get_cls_pythonpath(instruction.schema): instruction.schema
|
|
333
|
+
for version in self._all_versions
|
|
334
|
+
for version_change in version.changes
|
|
335
|
+
for instruction in list(version_change.alter_schema_instructions)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
migrated_schemas = {
|
|
339
|
+
get_cls_pythonpath(schema): schema
|
|
340
|
+
for version in self._all_versions
|
|
341
|
+
for version_change in version.changes
|
|
342
|
+
for schema in list(version_change.alter_request_by_schema_instructions.keys())
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
return altered_schemas | migrated_schemas
|
|
346
|
+
|
|
347
|
+
@functools.cached_property
|
|
348
|
+
def versioned_enums(self) -> dict[IdentifierPythonPath, type[Enum]]:
|
|
349
|
+
return {
|
|
350
|
+
get_cls_pythonpath(instruction.enum): instruction.enum
|
|
351
|
+
for version in self._all_versions
|
|
352
|
+
for version_change in version.changes
|
|
353
|
+
for instruction in version_change.alter_enum_instructions
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
def _get_closest_lesser_version(self, version: VersionType):
|
|
357
|
+
for defined_version in self.version_values:
|
|
358
|
+
if defined_version <= version:
|
|
359
|
+
return defined_version
|
|
360
|
+
raise CadwynError("You tried to migrate to version that is earlier than the first version which is prohibited.")
|
|
361
|
+
|
|
362
|
+
async def _migrate_request(
|
|
363
|
+
self,
|
|
364
|
+
body_type: Union[type[BaseModel], None],
|
|
365
|
+
head_dependant: Dependant,
|
|
366
|
+
request: FastapiRequest,
|
|
367
|
+
response: FastapiResponse,
|
|
368
|
+
request_info: RequestInfo,
|
|
369
|
+
current_version: VersionType,
|
|
370
|
+
head_route: APIRoute,
|
|
371
|
+
*,
|
|
372
|
+
exit_stack: AsyncExitStack,
|
|
373
|
+
embed_body_fields: bool,
|
|
374
|
+
background_tasks: Union[BackgroundTasks, None],
|
|
375
|
+
) -> dict[str, Any]:
|
|
376
|
+
start = self.reversed_version_values.index(current_version)
|
|
377
|
+
head_route_id = id(head_route)
|
|
378
|
+
for v in self.reversed_versions[start + 1 :]:
|
|
379
|
+
for version_change in v.changes:
|
|
380
|
+
if body_type is not None and body_type in version_change.alter_request_by_schema_instructions:
|
|
381
|
+
for instruction in version_change.alter_request_by_schema_instructions[body_type]:
|
|
382
|
+
instruction(request_info)
|
|
383
|
+
if head_route_id in version_change._route_to_request_migration_mapping:
|
|
384
|
+
for instruction in version_change._route_to_request_migration_mapping[head_route_id]:
|
|
385
|
+
instruction(request_info)
|
|
386
|
+
|
|
387
|
+
request.scope["headers"] = tuple((key.encode(), value.encode()) for key, value in request_info.headers.items())
|
|
388
|
+
del request._headers
|
|
389
|
+
# This gives us the ability to tell the user whether cadwyn is running its dependencies or FastAPI
|
|
390
|
+
CURRENT_DEPENDENCY_SOLVER_VAR.set("cadwyn")
|
|
391
|
+
# Remember this: if len(body_params) == 1, then route.body_schema == route.dependant.body_params[0]
|
|
392
|
+
result = await solve_dependencies(
|
|
393
|
+
request=request,
|
|
394
|
+
response=response,
|
|
395
|
+
dependant=head_dependant,
|
|
396
|
+
body=request_info.body,
|
|
397
|
+
dependency_overrides_provider=head_route.dependency_overrides_provider,
|
|
398
|
+
async_exit_stack=exit_stack,
|
|
399
|
+
embed_body_fields=embed_body_fields,
|
|
400
|
+
background_tasks=background_tasks,
|
|
401
|
+
)
|
|
402
|
+
if result.errors:
|
|
403
|
+
raise CadwynHeadRequestValidationError(
|
|
404
|
+
_normalize_errors(result.errors), body=request_info.body, version=current_version
|
|
405
|
+
)
|
|
406
|
+
return result.values
|
|
407
|
+
|
|
408
|
+
def _migrate_response(
|
|
409
|
+
self,
|
|
410
|
+
response_info: ResponseInfo,
|
|
411
|
+
current_version: VersionType,
|
|
412
|
+
head_response_model: Union[type[BaseModel], None],
|
|
413
|
+
head_route: Union[APIRoute, None],
|
|
414
|
+
) -> ResponseInfo:
|
|
415
|
+
head_route_id = id(head_route)
|
|
416
|
+
end = self.version_values.index(current_version)
|
|
417
|
+
for v in self.versions[:end]:
|
|
418
|
+
for version_change in v.changes:
|
|
419
|
+
migrations_to_apply: list[_BaseAlterResponseInstruction] = []
|
|
420
|
+
|
|
421
|
+
if head_response_model and head_response_model in version_change.alter_response_by_schema_instructions:
|
|
422
|
+
migrations_to_apply.extend(
|
|
423
|
+
version_change.alter_response_by_schema_instructions[head_response_model]
|
|
424
|
+
)
|
|
425
|
+
|
|
426
|
+
if head_route_id in version_change._route_to_response_migration_mapping:
|
|
427
|
+
migrations_to_apply.extend(version_change._route_to_response_migration_mapping[head_route_id])
|
|
428
|
+
|
|
429
|
+
for migration in migrations_to_apply:
|
|
430
|
+
if response_info.status_code < 300 or migration.migrate_http_errors:
|
|
431
|
+
migration(response_info)
|
|
432
|
+
return response_info
|
|
433
|
+
|
|
434
|
+
# TODO (https://github.com/zmievsa/cadwyn/issues/113): Refactor this function and all functions it calls.
|
|
435
|
+
def _versioned(
|
|
436
|
+
self,
|
|
437
|
+
head_body_field: Union[type[BaseModel], None],
|
|
438
|
+
module_body_field_name: Union[str, None],
|
|
439
|
+
route: APIRoute,
|
|
440
|
+
head_route: APIRoute,
|
|
441
|
+
dependant_for_request_migrations: Dependant,
|
|
442
|
+
*,
|
|
443
|
+
request_param_name: str,
|
|
444
|
+
background_tasks_param_name: Union[str, None],
|
|
445
|
+
response_param_name: str,
|
|
446
|
+
) -> "Callable[[Endpoint[_P, _R]], Endpoint[_P, _R]]":
|
|
447
|
+
def wrapper(endpoint: "Endpoint[_P, _R]") -> "Endpoint[_P, _R]":
|
|
448
|
+
@functools.wraps(endpoint)
|
|
449
|
+
async def decorator(*args: Any, **kwargs: Any) -> _R:
|
|
450
|
+
request_param: FastapiRequest = kwargs[request_param_name]
|
|
451
|
+
response_param: FastapiResponse = kwargs[response_param_name]
|
|
452
|
+
background_tasks: Union[BackgroundTasks, None] = kwargs.get(
|
|
453
|
+
background_tasks_param_name, # pyright: ignore[reportArgumentType]
|
|
454
|
+
)
|
|
455
|
+
method = request_param.method
|
|
456
|
+
response = Sentinel
|
|
457
|
+
async with AsyncExitStack() as exit_stack:
|
|
458
|
+
kwargs = await self._convert_endpoint_kwargs_to_version(
|
|
459
|
+
head_body_field,
|
|
460
|
+
module_body_field_name,
|
|
461
|
+
# Dependant must be from the version of the finally migrated request,
|
|
462
|
+
# not the version of endpoint
|
|
463
|
+
dependant_for_request_migrations,
|
|
464
|
+
request_param_name,
|
|
465
|
+
kwargs,
|
|
466
|
+
response_param,
|
|
467
|
+
route,
|
|
468
|
+
head_route,
|
|
469
|
+
exit_stack=exit_stack,
|
|
470
|
+
embed_body_fields=route._embed_body_fields,
|
|
471
|
+
background_tasks=background_tasks,
|
|
472
|
+
)
|
|
473
|
+
|
|
474
|
+
response = await self._convert_endpoint_response_to_version(
|
|
475
|
+
endpoint,
|
|
476
|
+
head_route,
|
|
477
|
+
route,
|
|
478
|
+
method,
|
|
479
|
+
response_param_name,
|
|
480
|
+
kwargs,
|
|
481
|
+
response_param,
|
|
482
|
+
)
|
|
483
|
+
if response is Sentinel: # pragma: no cover
|
|
484
|
+
raise CadwynError(
|
|
485
|
+
"No response object was returned. There's a high chance that the "
|
|
486
|
+
"application code is raising an exception and a dependency with yield "
|
|
487
|
+
"has a block with a bare except, or a block with except Exception, "
|
|
488
|
+
"and is not raising the exception again. Read more about it in the "
|
|
489
|
+
"docs: https://fastapi.tiangolo.com/tutorial/dependencies/dependencies-with-yield/#dependencies-with-yield-and-except"
|
|
490
|
+
)
|
|
491
|
+
return response
|
|
492
|
+
|
|
493
|
+
if request_param_name == _CADWYN_REQUEST_PARAM_NAME:
|
|
494
|
+
_add_keyword_only_parameter(decorator, _CADWYN_REQUEST_PARAM_NAME, FastapiRequest)
|
|
495
|
+
if response_param_name == _CADWYN_RESPONSE_PARAM_NAME:
|
|
496
|
+
_add_keyword_only_parameter(decorator, _CADWYN_RESPONSE_PARAM_NAME, FastapiResponse)
|
|
497
|
+
|
|
498
|
+
return decorator # pyright: ignore[reportReturnType]
|
|
499
|
+
|
|
500
|
+
return wrapper
|
|
501
|
+
|
|
502
|
+
# TODO: Simplify it
|
|
503
|
+
async def _convert_endpoint_response_to_version( # noqa: C901
|
|
504
|
+
self,
|
|
505
|
+
func_to_get_response_from: Endpoint,
|
|
506
|
+
head_route: APIRoute,
|
|
507
|
+
route: APIRoute,
|
|
508
|
+
method: str,
|
|
509
|
+
response_param_name: str,
|
|
510
|
+
kwargs: dict[str, Any],
|
|
511
|
+
fastapi_response_dependency: FastapiResponse,
|
|
512
|
+
) -> Any:
|
|
513
|
+
raised_exception = None
|
|
514
|
+
if response_param_name == _CADWYN_RESPONSE_PARAM_NAME:
|
|
515
|
+
kwargs.pop(response_param_name)
|
|
516
|
+
try:
|
|
517
|
+
if is_async_callable(func_to_get_response_from):
|
|
518
|
+
response_or_response_body: Union[FastapiResponse, object] = await func_to_get_response_from(**kwargs)
|
|
519
|
+
else:
|
|
520
|
+
response_or_response_body: Union[FastapiResponse, object] = await run_in_threadpool(
|
|
521
|
+
func_to_get_response_from,
|
|
522
|
+
**kwargs,
|
|
523
|
+
)
|
|
524
|
+
except HTTPException as exc:
|
|
525
|
+
raised_exception = exc
|
|
526
|
+
response_or_response_body = FastapiResponse(
|
|
527
|
+
content=json.dumps({"detail": raised_exception.detail}),
|
|
528
|
+
status_code=raised_exception.status_code,
|
|
529
|
+
headers=raised_exception.headers,
|
|
530
|
+
)
|
|
531
|
+
if (raised_exception.headers or {}).get("content-length") is None: # pragma: no branch
|
|
532
|
+
del response_or_response_body.headers["content-length"]
|
|
533
|
+
api_version = self.api_version_var.get()
|
|
534
|
+
if api_version is None:
|
|
535
|
+
return response_or_response_body
|
|
536
|
+
|
|
537
|
+
if isinstance(response_or_response_body, FastapiResponse):
|
|
538
|
+
# TODO (https://github.com/zmievsa/cadwyn/issues/125): Add support for migrating `StreamingResponse`
|
|
539
|
+
# TODO (https://github.com/zmievsa/cadwyn/issues/126): Add support for migrating `FileResponse`
|
|
540
|
+
# Starlette breaks Liskov Substitution principle and
|
|
541
|
+
# doesn't define `body` for `StreamingResponse` and `FileResponse`
|
|
542
|
+
if isinstance(response_or_response_body, (StreamingResponse, FileResponse)):
|
|
543
|
+
body = None
|
|
544
|
+
elif response_or_response_body.body:
|
|
545
|
+
if (isinstance(response_or_response_body, JSONResponse) or raised_exception is not None) and isinstance(
|
|
546
|
+
response_or_response_body.body, (str, bytes)
|
|
547
|
+
):
|
|
548
|
+
body = json.loads(response_or_response_body.body)
|
|
549
|
+
elif isinstance(response_or_response_body.body, bytes):
|
|
550
|
+
body = response_or_response_body.body.decode(response_or_response_body.charset)
|
|
551
|
+
else: # pragma: no cover # I don't see a good use case here yet
|
|
552
|
+
body = response_or_response_body.body
|
|
553
|
+
else:
|
|
554
|
+
body = None
|
|
555
|
+
# TODO (https://github.com/zmievsa/cadwyn/issues/51): Only do this if there are migrations
|
|
556
|
+
|
|
557
|
+
response_info = ResponseInfo(response_or_response_body, body)
|
|
558
|
+
else:
|
|
559
|
+
if fastapi_response_dependency.status_code is not None: # pyright: ignore[reportUnnecessaryComparison]
|
|
560
|
+
status_code = fastapi_response_dependency.status_code
|
|
561
|
+
elif route.status_code is not None:
|
|
562
|
+
status_code = route.status_code
|
|
563
|
+
elif raised_exception is not None:
|
|
564
|
+
raise NotImplementedError
|
|
565
|
+
else:
|
|
566
|
+
status_code = 200
|
|
567
|
+
fastapi_response_dependency.status_code = status_code
|
|
568
|
+
response_info = ResponseInfo(
|
|
569
|
+
fastapi_response_dependency,
|
|
570
|
+
_prepare_response_content(
|
|
571
|
+
response_or_response_body,
|
|
572
|
+
exclude_unset=head_route.response_model_exclude_unset,
|
|
573
|
+
exclude_defaults=head_route.response_model_exclude_defaults,
|
|
574
|
+
exclude_none=head_route.response_model_exclude_none,
|
|
575
|
+
),
|
|
576
|
+
)
|
|
577
|
+
|
|
578
|
+
response_info = self._migrate_response(
|
|
579
|
+
response_info,
|
|
580
|
+
api_version,
|
|
581
|
+
head_route.response_model,
|
|
582
|
+
head_route,
|
|
583
|
+
)
|
|
584
|
+
|
|
585
|
+
if isinstance(response_or_response_body, FastapiResponse):
|
|
586
|
+
# a webserver (uvicorn for instance) calculates the body at the endpoint level.
|
|
587
|
+
# if an endpoint returns no "body", its content-length will be set to 0
|
|
588
|
+
# json.dumps(None) results into "null", and content-length should be 4,
|
|
589
|
+
# but it was already calculated to 0 which causes
|
|
590
|
+
# `RuntimeError: Response content longer than Content-Length` or
|
|
591
|
+
# `Too much data for declared Content-Length`, based on the protocol
|
|
592
|
+
# which is why we skip the None case.
|
|
593
|
+
|
|
594
|
+
# We skip cases without "body" attribute because of StreamingResponse and FileResponse
|
|
595
|
+
# that do not have it. We don't support it too.
|
|
596
|
+
if response_info.body is not None and hasattr(response_info._response, "body"):
|
|
597
|
+
# TODO (https://github.com/zmievsa/cadwyn/issues/51): Only do this if there are migrations
|
|
598
|
+
if (
|
|
599
|
+
isinstance(response_info.body, str)
|
|
600
|
+
and response_info._response.headers.get("content-type") != "application/json"
|
|
601
|
+
):
|
|
602
|
+
response_info._response.body = response_info.body.encode(response_info._response.charset)
|
|
603
|
+
else:
|
|
604
|
+
response_info._response.body = json.dumps(
|
|
605
|
+
response_info.body,
|
|
606
|
+
ensure_ascii=False,
|
|
607
|
+
allow_nan=False,
|
|
608
|
+
indent=None,
|
|
609
|
+
separators=(",", ":"),
|
|
610
|
+
).encode("utf-8")
|
|
611
|
+
if response_info.headers.get("content-length") is not None:
|
|
612
|
+
# It makes sense to re-calculate content length because the previously calculated one
|
|
613
|
+
# might slightly differ. If it differs -- uvicorn will break.
|
|
614
|
+
response_info.headers["content-length"] = str(len(response_info._response.body))
|
|
615
|
+
|
|
616
|
+
if raised_exception is not None and response_info.status_code >= 400:
|
|
617
|
+
if isinstance(response_info.body, dict) and "detail" in response_info.body:
|
|
618
|
+
detail = response_info.body["detail"]
|
|
619
|
+
else:
|
|
620
|
+
detail = response_info.body
|
|
621
|
+
if detail is None:
|
|
622
|
+
detail = http.HTTPStatus(response_info.status_code).phrase
|
|
623
|
+
raised_exception.detail = cast("str", detail)
|
|
624
|
+
raised_exception.headers = dict(response_info.headers)
|
|
625
|
+
raised_exception.status_code = response_info.status_code
|
|
626
|
+
|
|
627
|
+
raise raised_exception
|
|
628
|
+
return response_info._response
|
|
629
|
+
return response_info.body
|
|
630
|
+
|
|
631
|
+
async def _convert_endpoint_kwargs_to_version(
|
|
632
|
+
self,
|
|
633
|
+
head_body_field: Union[type[BaseModel], None],
|
|
634
|
+
body_field_alias: Union[str, None],
|
|
635
|
+
head_dependant: Dependant,
|
|
636
|
+
request_param_name: str,
|
|
637
|
+
kwargs: dict[str, Any],
|
|
638
|
+
response: FastapiResponse,
|
|
639
|
+
route: APIRoute,
|
|
640
|
+
head_route: APIRoute,
|
|
641
|
+
*,
|
|
642
|
+
exit_stack: AsyncExitStack,
|
|
643
|
+
embed_body_fields: bool,
|
|
644
|
+
background_tasks: Union[BackgroundTasks, None],
|
|
645
|
+
) -> dict[str, Any]:
|
|
646
|
+
request: FastapiRequest = kwargs[request_param_name]
|
|
647
|
+
if request_param_name == _CADWYN_REQUEST_PARAM_NAME:
|
|
648
|
+
kwargs.pop(request_param_name)
|
|
649
|
+
|
|
650
|
+
api_version = self.api_version_var.get()
|
|
651
|
+
if api_version is None:
|
|
652
|
+
return kwargs
|
|
653
|
+
|
|
654
|
+
# This is a kind of body param you get when you define a single pydantic schema in your route's body
|
|
655
|
+
if (
|
|
656
|
+
len(route.dependant.body_params) == 1
|
|
657
|
+
and head_body_field is not None
|
|
658
|
+
and body_field_alias is not None
|
|
659
|
+
and body_field_alias in kwargs
|
|
660
|
+
):
|
|
661
|
+
raw_body: Union[BaseModel, None] = kwargs.get(body_field_alias)
|
|
662
|
+
if raw_body is None: # pragma: no cover # This is likely an impossible case but we would like to be safe
|
|
663
|
+
body = None
|
|
664
|
+
# It means we have a dict or a list instead of a full model.
|
|
665
|
+
# This covers the following use case in the endpoint definition: "payload: dict = Body(None)"
|
|
666
|
+
elif not isinstance(raw_body, BaseModel):
|
|
667
|
+
body = raw_body
|
|
668
|
+
else:
|
|
669
|
+
body = raw_body.model_dump(by_alias=True, exclude_unset=True)
|
|
670
|
+
else:
|
|
671
|
+
# This is for requests without body or with complex body such as form or file
|
|
672
|
+
body = await _get_body(request, route.body_field, exit_stack)
|
|
673
|
+
|
|
674
|
+
request_info = RequestInfo(request, body)
|
|
675
|
+
new_kwargs = await self._migrate_request(
|
|
676
|
+
head_body_field,
|
|
677
|
+
head_dependant,
|
|
678
|
+
request,
|
|
679
|
+
response,
|
|
680
|
+
request_info,
|
|
681
|
+
api_version,
|
|
682
|
+
head_route,
|
|
683
|
+
exit_stack=exit_stack,
|
|
684
|
+
embed_body_fields=embed_body_fields,
|
|
685
|
+
background_tasks=background_tasks,
|
|
686
|
+
)
|
|
687
|
+
# Because we re-added it into our kwargs when we did solve_dependencies
|
|
688
|
+
if _CADWYN_REQUEST_PARAM_NAME in new_kwargs:
|
|
689
|
+
new_kwargs.pop(_CADWYN_REQUEST_PARAM_NAME)
|
|
690
|
+
|
|
691
|
+
return new_kwargs
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
# We use this instead of `.body()` to automatically guess body type and load the correct body, even if it's a form
|
|
695
|
+
async def _get_body(
|
|
696
|
+
request: FastapiRequest, body_field: Union[ModelField, None], exit_stack: AsyncExitStack
|
|
697
|
+
): # pragma: no cover # This is from FastAPI
|
|
698
|
+
is_body_form = body_field and isinstance(body_field.field_info, params.Form)
|
|
699
|
+
try:
|
|
700
|
+
body: Any = None
|
|
701
|
+
if body_field:
|
|
702
|
+
if is_body_form:
|
|
703
|
+
body = await request.form()
|
|
704
|
+
exit_stack.push_async_callback(body.close)
|
|
705
|
+
else:
|
|
706
|
+
body_bytes = await request.body()
|
|
707
|
+
if body_bytes:
|
|
708
|
+
json_body: Any = PydanticUndefined
|
|
709
|
+
content_type_value = request.headers.get("content-type")
|
|
710
|
+
if not content_type_value:
|
|
711
|
+
json_body = await request.json()
|
|
712
|
+
else:
|
|
713
|
+
message = email.message.Message()
|
|
714
|
+
message["content-type"] = content_type_value
|
|
715
|
+
if message.get_content_maintype() == "application":
|
|
716
|
+
subtype = message.get_content_subtype()
|
|
717
|
+
if subtype == "json" or subtype.endswith("+json"):
|
|
718
|
+
json_body = await request.json()
|
|
719
|
+
if json_body != PydanticUndefined:
|
|
720
|
+
body = json_body
|
|
721
|
+
else:
|
|
722
|
+
body = body_bytes
|
|
723
|
+
except json.JSONDecodeError as e:
|
|
724
|
+
raise RequestValidationError(
|
|
725
|
+
[
|
|
726
|
+
{
|
|
727
|
+
"type": "json_invalid",
|
|
728
|
+
"loc": ("body", e.pos),
|
|
729
|
+
"msg": "JSON decode error",
|
|
730
|
+
"input": {},
|
|
731
|
+
"ctx": {"error": e.msg},
|
|
732
|
+
},
|
|
733
|
+
],
|
|
734
|
+
body=e.doc,
|
|
735
|
+
) from e
|
|
736
|
+
except HTTPException:
|
|
737
|
+
raise
|
|
738
|
+
except Exception as e:
|
|
739
|
+
raise HTTPException(status_code=400, detail="There was an error parsing the body") from e
|
|
740
|
+
return body
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
def _add_keyword_only_parameter(
|
|
744
|
+
func: Callable,
|
|
745
|
+
param_name: str,
|
|
746
|
+
param_annotation: type,
|
|
747
|
+
):
|
|
748
|
+
signature = inspect.signature(func)
|
|
749
|
+
func.__signature__ = signature.replace(
|
|
750
|
+
parameters=(
|
|
751
|
+
[
|
|
752
|
+
*list(signature.parameters.values()),
|
|
753
|
+
inspect.Parameter(param_name, kind=inspect._ParameterKind.KEYWORD_ONLY, annotation=param_annotation),
|
|
754
|
+
]
|
|
755
|
+
),
|
|
756
|
+
)
|