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
cadwyn/middleware.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# NOTE: It's OK that any_string might not be correctly sortable such as v10 vs v9.
|
|
2
|
+
# we can simply remove waterfalling from any_string api version style.
|
|
3
|
+
|
|
4
|
+
import inspect
|
|
5
|
+
import re
|
|
6
|
+
from collections.abc import Awaitable, Callable
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from typing import Annotated, Any, Literal, Optional, Protocol, Union
|
|
9
|
+
|
|
10
|
+
import fastapi
|
|
11
|
+
from fastapi import Request
|
|
12
|
+
from starlette.middleware.base import BaseHTTPMiddleware, DispatchFunction, RequestResponseEndpoint
|
|
13
|
+
from starlette.types import ASGIApp
|
|
14
|
+
|
|
15
|
+
from cadwyn._internal.context_vars import DEFAULT_API_VERSION_VAR
|
|
16
|
+
from cadwyn.structure.common import VersionType
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class VersionManager(Protocol):
|
|
20
|
+
def get(self, request: Request) -> Union[str, None]: ...
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
VersionValidatorC = Callable[[str], VersionType]
|
|
24
|
+
VersionDependencyFactoryC = Callable[[], Callable[..., Any]]
|
|
25
|
+
|
|
26
|
+
APIVersionLocation = Literal["custom_header", "path"]
|
|
27
|
+
APIVersionFormat = Literal["date", "string"]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class HeaderVersionManager:
|
|
31
|
+
__slots__ = ("api_version_parameter_name",)
|
|
32
|
+
|
|
33
|
+
def __init__(self, *, api_version_parameter_name: str) -> None:
|
|
34
|
+
super().__init__()
|
|
35
|
+
self.api_version_parameter_name = api_version_parameter_name
|
|
36
|
+
|
|
37
|
+
def get(self, request: Request) -> Union[str, None]:
|
|
38
|
+
return request.headers.get(self.api_version_parameter_name)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class URLVersionManager:
|
|
42
|
+
__slots__ = ("possible_version_values", "url_version_regex")
|
|
43
|
+
|
|
44
|
+
def __init__(self, *, possible_version_values: set[str]) -> None:
|
|
45
|
+
super().__init__()
|
|
46
|
+
self.possible_version_values = possible_version_values
|
|
47
|
+
self.url_version_regex = re.compile(f"/({'|'.join(re.escape(v) for v in possible_version_values)})/")
|
|
48
|
+
|
|
49
|
+
def get(self, request: Request) -> Union[str, None]:
|
|
50
|
+
if m := self.url_version_regex.search(request.url.path):
|
|
51
|
+
return m.group(1)
|
|
52
|
+
return None
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _generate_api_version_dependency(
|
|
56
|
+
*,
|
|
57
|
+
api_version_pythonic_parameter_name: str,
|
|
58
|
+
default_value: str,
|
|
59
|
+
fastapi_depends_class: Callable[..., Any],
|
|
60
|
+
validation_data_type: Any,
|
|
61
|
+
title: Optional[str] = None,
|
|
62
|
+
description: Optional[str] = None,
|
|
63
|
+
):
|
|
64
|
+
def api_version_dependency(**kwargs: Any):
|
|
65
|
+
# TODO: What do I return?
|
|
66
|
+
return next(iter(kwargs.values()))
|
|
67
|
+
|
|
68
|
+
api_version_dependency.__signature__ = inspect.Signature(
|
|
69
|
+
parameters=[
|
|
70
|
+
inspect.Parameter(
|
|
71
|
+
api_version_pythonic_parameter_name,
|
|
72
|
+
inspect.Parameter.KEYWORD_ONLY,
|
|
73
|
+
annotation=Annotated[
|
|
74
|
+
validation_data_type,
|
|
75
|
+
fastapi_depends_class(
|
|
76
|
+
openapi_examples={"default": {"value": default_value}},
|
|
77
|
+
title=title,
|
|
78
|
+
description=description,
|
|
79
|
+
),
|
|
80
|
+
],
|
|
81
|
+
# Path-based parameters do not support a default value in FastAPI :(
|
|
82
|
+
default=default_value if fastapi_depends_class != fastapi.Path else inspect.Signature.empty,
|
|
83
|
+
),
|
|
84
|
+
],
|
|
85
|
+
)
|
|
86
|
+
return api_version_dependency
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
class VersionPickingMiddleware(BaseHTTPMiddleware):
|
|
90
|
+
def __init__(
|
|
91
|
+
self,
|
|
92
|
+
app: ASGIApp,
|
|
93
|
+
*,
|
|
94
|
+
api_version_parameter_name: str,
|
|
95
|
+
api_version_default_value: Union[str, None, Callable[[Request], Awaitable[str]]],
|
|
96
|
+
api_version_var: ContextVar[Union[VersionType, None]],
|
|
97
|
+
api_version_manager: VersionManager,
|
|
98
|
+
dispatch: Union[DispatchFunction, None] = None,
|
|
99
|
+
) -> None:
|
|
100
|
+
super().__init__(app, dispatch)
|
|
101
|
+
|
|
102
|
+
self.api_version_parameter_name = api_version_parameter_name
|
|
103
|
+
self._api_version_manager = api_version_manager
|
|
104
|
+
self.api_version_var = api_version_var
|
|
105
|
+
self.api_version_default_value = api_version_default_value
|
|
106
|
+
|
|
107
|
+
async def dispatch(
|
|
108
|
+
self,
|
|
109
|
+
request: Request,
|
|
110
|
+
call_next: RequestResponseEndpoint,
|
|
111
|
+
):
|
|
112
|
+
# We handle api version at middleware level because if we try to add a Dependency to all routes, it won't work:
|
|
113
|
+
# we use this header for routing so the user will simply get a 404 if the header is invalid.
|
|
114
|
+
api_version = self._api_version_manager.get(request)
|
|
115
|
+
|
|
116
|
+
if api_version is None:
|
|
117
|
+
if callable(self.api_version_default_value):
|
|
118
|
+
api_version = await self.api_version_default_value(request)
|
|
119
|
+
else:
|
|
120
|
+
api_version = self.api_version_default_value
|
|
121
|
+
DEFAULT_API_VERSION_VAR.set(api_version)
|
|
122
|
+
|
|
123
|
+
self.api_version_var.set(api_version)
|
|
124
|
+
response = await call_next(request)
|
|
125
|
+
|
|
126
|
+
if api_version is not None:
|
|
127
|
+
# We return it because we will be returning the **matched** version, not the requested one.
|
|
128
|
+
# In date-based versioning with waterfalling, it makes sense.
|
|
129
|
+
response.headers[self.api_version_parameter_name] = api_version
|
|
130
|
+
|
|
131
|
+
return response
|
cadwyn/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import re
|
|
2
|
+
from collections import defaultdict
|
|
3
|
+
from collections.abc import Callable, Sequence
|
|
4
|
+
from copy import copy, deepcopy
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import (
|
|
7
|
+
TYPE_CHECKING,
|
|
8
|
+
Any,
|
|
9
|
+
Generic,
|
|
10
|
+
Union,
|
|
11
|
+
cast,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
import fastapi.params
|
|
15
|
+
import fastapi.routing
|
|
16
|
+
import fastapi.security.base
|
|
17
|
+
import fastapi.utils
|
|
18
|
+
from fastapi import APIRouter
|
|
19
|
+
from fastapi.routing import APIRoute
|
|
20
|
+
from pydantic import BaseModel
|
|
21
|
+
from starlette.routing import BaseRoute
|
|
22
|
+
from typing_extensions import TypeVar, assert_never
|
|
23
|
+
|
|
24
|
+
from cadwyn._utils import DATACLASS_SLOTS, Sentinel, lenient_issubclass
|
|
25
|
+
from cadwyn.exceptions import (
|
|
26
|
+
CadwynError,
|
|
27
|
+
RouteAlreadyExistsError,
|
|
28
|
+
RouteByPathConverterDoesNotApplyToAnythingError,
|
|
29
|
+
RouteRequestBySchemaConverterDoesNotApplyToAnythingError,
|
|
30
|
+
RouteResponseBySchemaConverterDoesNotApplyToAnythingError,
|
|
31
|
+
RouterGenerationError,
|
|
32
|
+
RouterPathParamsModifiedError,
|
|
33
|
+
)
|
|
34
|
+
from cadwyn.schema_generation import (
|
|
35
|
+
_add_request_and_response_params,
|
|
36
|
+
generate_versioned_models,
|
|
37
|
+
)
|
|
38
|
+
from cadwyn.structure import Version, VersionBundle
|
|
39
|
+
from cadwyn.structure.common import Endpoint, VersionType
|
|
40
|
+
from cadwyn.structure.data import _AlterRequestByPathInstruction, _AlterResponseByPathInstruction
|
|
41
|
+
from cadwyn.structure.endpoints import (
|
|
42
|
+
EndpointDidntExistInstruction,
|
|
43
|
+
EndpointExistedInstruction,
|
|
44
|
+
EndpointHadInstruction,
|
|
45
|
+
)
|
|
46
|
+
from cadwyn.structure.versions import VersionChange
|
|
47
|
+
|
|
48
|
+
if TYPE_CHECKING:
|
|
49
|
+
from fastapi.dependencies.models import Dependant
|
|
50
|
+
|
|
51
|
+
_Call = TypeVar("_Call", bound=Callable[..., Any])
|
|
52
|
+
_R = TypeVar("_R", bound=APIRouter)
|
|
53
|
+
_WR = TypeVar("_WR", bound=APIRouter, default=APIRouter)
|
|
54
|
+
_RouteT = TypeVar("_RouteT", bound=BaseRoute)
|
|
55
|
+
# This is a hack we do because we can't guarantee how the user will use the router.
|
|
56
|
+
_DELETED_ROUTE_TAG = "_CADWYN_DELETED_ROUTE"
|
|
57
|
+
_RoutePath = str
|
|
58
|
+
_RouteMethod = str
|
|
59
|
+
_RouteId = int
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(**DATACLASS_SLOTS, frozen=True, eq=True)
|
|
63
|
+
class _EndpointInfo:
|
|
64
|
+
endpoint_path: str
|
|
65
|
+
endpoint_methods: frozenset[str]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(**DATACLASS_SLOTS, frozen=True)
|
|
69
|
+
class GeneratedRouters(Generic[_R, _WR]):
|
|
70
|
+
endpoints: dict[VersionType, _R]
|
|
71
|
+
webhooks: dict[VersionType, _WR]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def generate_versioned_routers(
|
|
75
|
+
router: _R,
|
|
76
|
+
versions: VersionBundle,
|
|
77
|
+
*,
|
|
78
|
+
webhooks: Union[_WR, None] = None,
|
|
79
|
+
) -> GeneratedRouters[_R, _WR]:
|
|
80
|
+
if webhooks is None:
|
|
81
|
+
webhooks = cast("_WR", APIRouter())
|
|
82
|
+
return _EndpointTransformer(router, versions, webhooks).transform()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class VersionedAPIRouter(fastapi.routing.APIRouter):
|
|
86
|
+
def only_exists_in_older_versions(self, endpoint: _Call) -> _Call:
|
|
87
|
+
route = _get_route_from_func(self.routes, endpoint)
|
|
88
|
+
if route is None:
|
|
89
|
+
raise LookupError(
|
|
90
|
+
f'Route not found on endpoint: "{endpoint.__name__}". '
|
|
91
|
+
"Are you sure it's a route and decorators are in the correct order?",
|
|
92
|
+
)
|
|
93
|
+
if _DELETED_ROUTE_TAG in route.tags:
|
|
94
|
+
raise CadwynError(f'The route "{endpoint.__name__}" was already deleted. You can\'t delete it again.')
|
|
95
|
+
route.tags.append(_DELETED_ROUTE_TAG)
|
|
96
|
+
return endpoint
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def copy_router(router: _R) -> _R:
|
|
100
|
+
router = copy(router)
|
|
101
|
+
router.routes = [copy_route(r) for r in router.routes]
|
|
102
|
+
return router
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def copy_route(route: _RouteT) -> _RouteT:
|
|
106
|
+
if not isinstance(route, APIRoute):
|
|
107
|
+
return copy(route)
|
|
108
|
+
|
|
109
|
+
# This is slightly wasteful in terms of resources but it makes it easy for us
|
|
110
|
+
# to make sure that new versions of FastAPI are going to be supported even if
|
|
111
|
+
# APIRoute gets new attributes.
|
|
112
|
+
new_route = deepcopy(route)
|
|
113
|
+
new_route.dependant = copy(route.dependant)
|
|
114
|
+
new_route.dependencies = copy(route.dependencies)
|
|
115
|
+
return new_route
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class _EndpointTransformer(Generic[_R, _WR]):
|
|
119
|
+
def __init__(self, parent_router: _R, versions: VersionBundle, webhooks: _WR) -> None:
|
|
120
|
+
super().__init__()
|
|
121
|
+
self.parent_router = parent_router
|
|
122
|
+
self.versions = versions
|
|
123
|
+
self.parent_webhooks_router = webhooks
|
|
124
|
+
self.schema_generators = generate_versioned_models(versions)
|
|
125
|
+
|
|
126
|
+
self.routes_that_never_existed = [
|
|
127
|
+
route for route in parent_router.routes if isinstance(route, APIRoute) and _DELETED_ROUTE_TAG in route.tags
|
|
128
|
+
]
|
|
129
|
+
|
|
130
|
+
def transform(self) -> GeneratedRouters[_R, _WR]:
|
|
131
|
+
# Copy MUST keep the order and number of routes. Otherwise, a ton of code below will break.
|
|
132
|
+
router = copy_router(self.parent_router)
|
|
133
|
+
webhook_router = copy_router(self.parent_webhooks_router)
|
|
134
|
+
routers: dict[VersionType, _R] = {}
|
|
135
|
+
webhook_routers: dict[VersionType, _WR] = {}
|
|
136
|
+
|
|
137
|
+
for version in self.versions:
|
|
138
|
+
self.schema_generators[str(version.value)].annotation_transformer.migrate_router_to_version(router)
|
|
139
|
+
self.schema_generators[str(version.value)].annotation_transformer.migrate_router_to_version(webhook_router)
|
|
140
|
+
|
|
141
|
+
self._attach_routes_to_data_converters(router, self.parent_router, version)
|
|
142
|
+
|
|
143
|
+
routers[version.value] = router
|
|
144
|
+
webhook_routers[version.value] = webhook_router
|
|
145
|
+
# Applying changes for the next version
|
|
146
|
+
router = copy_router(router)
|
|
147
|
+
webhook_router = copy_router(webhook_router)
|
|
148
|
+
self._apply_endpoint_changes_to_router(router.routes + webhook_router.routes, version)
|
|
149
|
+
|
|
150
|
+
if self.routes_that_never_existed:
|
|
151
|
+
raise RouterGenerationError(
|
|
152
|
+
"Every route you mark with "
|
|
153
|
+
f"@VersionedAPIRouter.{VersionedAPIRouter.only_exists_in_older_versions.__name__} "
|
|
154
|
+
"must be restored in one of the older versions. Otherwise you just need to delete it altogether. "
|
|
155
|
+
"The following routes have been marked with that decorator but were never restored: "
|
|
156
|
+
f"{self.routes_that_never_existed}",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
for route_index, head_route in enumerate(self.parent_router.routes):
|
|
160
|
+
if not isinstance(head_route, APIRoute):
|
|
161
|
+
continue
|
|
162
|
+
_add_request_and_response_params(head_route)
|
|
163
|
+
copy_of_dependant = copy(head_route.dependant)
|
|
164
|
+
|
|
165
|
+
for older_router in list(routers.values()):
|
|
166
|
+
older_route = older_router.routes[route_index]
|
|
167
|
+
|
|
168
|
+
# We know they are APIRoutes because of the check at the very beginning of the top loop.
|
|
169
|
+
# I.e. Because head_route is an APIRoute, both routes are APIRoutes too
|
|
170
|
+
older_route = cast("APIRoute", older_route)
|
|
171
|
+
# Wait.. Why do we need this code again?
|
|
172
|
+
if older_route.body_field is not None and _route_has_a_simple_body_schema(older_route):
|
|
173
|
+
if hasattr(older_route.body_field.type_, "__cadwyn_original_model__"):
|
|
174
|
+
template_older_body_model = older_route.body_field.type_.__cadwyn_original_model__
|
|
175
|
+
else:
|
|
176
|
+
template_older_body_model = older_route.body_field.type_
|
|
177
|
+
else:
|
|
178
|
+
template_older_body_model = None
|
|
179
|
+
_add_data_migrations_to_route(
|
|
180
|
+
older_route,
|
|
181
|
+
# NOTE: The fact that we use latest here assumes that the route can never change its response schema
|
|
182
|
+
head_route,
|
|
183
|
+
template_older_body_model,
|
|
184
|
+
older_route.body_field.alias if older_route.body_field is not None else None,
|
|
185
|
+
copy_of_dependant,
|
|
186
|
+
self.versions,
|
|
187
|
+
)
|
|
188
|
+
for router in routers.values():
|
|
189
|
+
router.routes = [
|
|
190
|
+
route
|
|
191
|
+
for route in router.routes
|
|
192
|
+
if not (isinstance(route, fastapi.routing.APIRoute) and _DELETED_ROUTE_TAG in route.tags)
|
|
193
|
+
]
|
|
194
|
+
for webhook_router in webhook_routers.values():
|
|
195
|
+
webhook_router.routes = [
|
|
196
|
+
route
|
|
197
|
+
for route in webhook_router.routes
|
|
198
|
+
if not (isinstance(route, fastapi.routing.APIRoute) and _DELETED_ROUTE_TAG in route.tags)
|
|
199
|
+
]
|
|
200
|
+
return GeneratedRouters(routers, webhook_routers)
|
|
201
|
+
|
|
202
|
+
def _attach_routes_to_data_converters(self, router: APIRouter, head_router: APIRouter, version: Version):
|
|
203
|
+
# This method is way out of its league in terms of complexity. We gotta refactor it.
|
|
204
|
+
|
|
205
|
+
path_to_route_methods_mapping, head_response_models, head_request_bodies = (
|
|
206
|
+
self._extract_all_routes_identifiers_for_route_to_converter_matching(router)
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
for version_change in version.changes:
|
|
210
|
+
for by_path_converters in [
|
|
211
|
+
*version_change.alter_response_by_path_instructions.values(),
|
|
212
|
+
*version_change.alter_request_by_path_instructions.values(),
|
|
213
|
+
]:
|
|
214
|
+
for by_path_converter in by_path_converters:
|
|
215
|
+
self._attach_routes_by_path_converter(
|
|
216
|
+
head_router, path_to_route_methods_mapping, version_change, by_path_converter
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
for by_schema_converters in version_change.alter_request_by_schema_instructions.values():
|
|
220
|
+
for by_schema_converter in by_schema_converters:
|
|
221
|
+
if not by_schema_converter.check_usage: # pragma: no cover
|
|
222
|
+
continue
|
|
223
|
+
missing_models = set(by_schema_converter.schemas) - head_request_bodies
|
|
224
|
+
if missing_models:
|
|
225
|
+
raise RouteRequestBySchemaConverterDoesNotApplyToAnythingError(
|
|
226
|
+
f"Request by body schema converter "
|
|
227
|
+
f'"{version_change.__name__}.{by_schema_converter.transformer.__name__}" '
|
|
228
|
+
f"failed to find routes with the following body schemas: "
|
|
229
|
+
f"{[m.__name__ for m in missing_models]}. "
|
|
230
|
+
f"This means that you are trying to apply this converter to non-existing endpoint(s). "
|
|
231
|
+
)
|
|
232
|
+
for by_schema_converters in version_change.alter_response_by_schema_instructions.values():
|
|
233
|
+
for by_schema_converter in by_schema_converters:
|
|
234
|
+
if not by_schema_converter.check_usage: # pragma: no cover
|
|
235
|
+
continue
|
|
236
|
+
missing_models = set(by_schema_converter.schemas) - head_response_models
|
|
237
|
+
if missing_models:
|
|
238
|
+
raise RouteResponseBySchemaConverterDoesNotApplyToAnythingError(
|
|
239
|
+
f"Response by response model converter "
|
|
240
|
+
f'"{version_change.__name__}.{by_schema_converter.transformer.__name__}" '
|
|
241
|
+
f"failed to find routes with the following response models: "
|
|
242
|
+
f"{[m.__name__ for m in missing_models]}. "
|
|
243
|
+
f"This means that you are trying to apply this converter to non-existing endpoint(s). "
|
|
244
|
+
"If this is intentional and this converter really does not apply to any endpoints, then "
|
|
245
|
+
"pass check_usage=False argument to "
|
|
246
|
+
f"{version_change.__name__}.{by_schema_converter.transformer.__name__}"
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
def _attach_routes_by_path_converter(
|
|
250
|
+
self,
|
|
251
|
+
head_router: APIRouter,
|
|
252
|
+
path_to_route_methods_mapping: dict[_RoutePath, dict[_RouteMethod, set[_RouteId]]],
|
|
253
|
+
version_change: type[VersionChange],
|
|
254
|
+
by_path_converter: Union[_AlterResponseByPathInstruction, _AlterRequestByPathInstruction],
|
|
255
|
+
):
|
|
256
|
+
missing_methods = set()
|
|
257
|
+
for method in by_path_converter.methods:
|
|
258
|
+
if method in path_to_route_methods_mapping[by_path_converter.path]:
|
|
259
|
+
for route_index in path_to_route_methods_mapping[by_path_converter.path][method]:
|
|
260
|
+
route = head_router.routes[route_index]
|
|
261
|
+
if isinstance(by_path_converter, _AlterResponseByPathInstruction):
|
|
262
|
+
version_change._route_to_response_migration_mapping[id(route)].append(by_path_converter)
|
|
263
|
+
else:
|
|
264
|
+
version_change._route_to_request_migration_mapping[id(route)].append(by_path_converter)
|
|
265
|
+
else:
|
|
266
|
+
missing_methods.add(method)
|
|
267
|
+
|
|
268
|
+
if missing_methods:
|
|
269
|
+
raise RouteByPathConverterDoesNotApplyToAnythingError(
|
|
270
|
+
f"{by_path_converter.repr_name} "
|
|
271
|
+
f'"{version_change.__name__}.{by_path_converter.transformer.__name__}" '
|
|
272
|
+
f"failed to find routes with the following methods: {list(missing_methods)}. "
|
|
273
|
+
f"This means that you are trying to apply this converter to non-existing endpoint(s). "
|
|
274
|
+
"Please, check whether the path and methods are correct. (hint: path must include "
|
|
275
|
+
"all path variables and have a name that was used in the version that this "
|
|
276
|
+
"VersionChange resides in)"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _extract_all_routes_identifiers_for_route_to_converter_matching(
|
|
280
|
+
self, router: APIRouter
|
|
281
|
+
) -> tuple[dict[_RoutePath, dict[_RouteMethod, set[_RouteId]]], set[Any], set[Any]]:
|
|
282
|
+
# int is the index of the route in the router.routes list.
|
|
283
|
+
# So we essentially keep track of which routes have which response models and request bodies.
|
|
284
|
+
# and their indices in the router.routes list. The indices will allow us to match them to the same
|
|
285
|
+
# routes in the head version. This gives us the ability to later apply changes to these routes
|
|
286
|
+
# without thinking about any renamings or response model changes.
|
|
287
|
+
|
|
288
|
+
response_models = set()
|
|
289
|
+
request_bodies = set()
|
|
290
|
+
path_to_route_methods_mapping: dict[str, dict[str, set[int]]] = defaultdict(lambda: defaultdict(set))
|
|
291
|
+
|
|
292
|
+
for index, route in enumerate(router.routes):
|
|
293
|
+
if isinstance(route, APIRoute):
|
|
294
|
+
if route.response_model is not None and lenient_issubclass(route.response_model, BaseModel):
|
|
295
|
+
response_models.add(route.response_model)
|
|
296
|
+
# Not sure if it can ever be None when it's a simple schema. Eh, I would rather be safe than sorry
|
|
297
|
+
if _route_has_a_simple_body_schema(route) and route.body_field is not None:
|
|
298
|
+
annotation = route.body_field.field_info.annotation
|
|
299
|
+
if annotation is not None and lenient_issubclass(annotation, BaseModel):
|
|
300
|
+
request_bodies.add(annotation)
|
|
301
|
+
for method in route.methods:
|
|
302
|
+
path_to_route_methods_mapping[route.path][method].add(index)
|
|
303
|
+
|
|
304
|
+
head_response_models = {model.__cadwyn_original_model__ for model in response_models}
|
|
305
|
+
head_request_bodies = {getattr(body, "__cadwyn_original_model__", body) for body in request_bodies}
|
|
306
|
+
|
|
307
|
+
return path_to_route_methods_mapping, head_response_models, head_request_bodies
|
|
308
|
+
|
|
309
|
+
# TODO (https://github.com/zmievsa/cadwyn/issues/28): Simplify
|
|
310
|
+
def _apply_endpoint_changes_to_router( # noqa: C901
|
|
311
|
+
self,
|
|
312
|
+
routes: Union[list[BaseRoute], list[APIRoute]],
|
|
313
|
+
version: Version,
|
|
314
|
+
):
|
|
315
|
+
for version_change in version.changes:
|
|
316
|
+
for instruction in version_change.alter_endpoint_instructions:
|
|
317
|
+
original_routes = _get_routes(
|
|
318
|
+
routes,
|
|
319
|
+
instruction.endpoint_path,
|
|
320
|
+
instruction.endpoint_methods,
|
|
321
|
+
instruction.endpoint_func_name,
|
|
322
|
+
is_deleted=False,
|
|
323
|
+
)
|
|
324
|
+
methods_to_which_we_applied_changes = set()
|
|
325
|
+
methods_we_should_have_applied_changes_to = instruction.endpoint_methods.copy()
|
|
326
|
+
|
|
327
|
+
if isinstance(instruction, EndpointDidntExistInstruction):
|
|
328
|
+
deleted_routes = _get_routes(
|
|
329
|
+
routes,
|
|
330
|
+
instruction.endpoint_path,
|
|
331
|
+
instruction.endpoint_methods,
|
|
332
|
+
instruction.endpoint_func_name,
|
|
333
|
+
is_deleted=True,
|
|
334
|
+
)
|
|
335
|
+
if deleted_routes:
|
|
336
|
+
method_union = set()
|
|
337
|
+
for deleted_route in deleted_routes:
|
|
338
|
+
method_union |= deleted_route.methods
|
|
339
|
+
raise RouterGenerationError(
|
|
340
|
+
f'Endpoint "{list(method_union)} {instruction.endpoint_path}" you tried to delete in '
|
|
341
|
+
f'"{version_change.__name__}" was already deleted in a newer version. If you really have '
|
|
342
|
+
f'two routes with the same paths and methods, please, use "endpoint(..., func_name=...)" '
|
|
343
|
+
f"to distinguish between them. Function names of endpoints that were already deleted: "
|
|
344
|
+
f"{[r.endpoint.__name__ for r in deleted_routes]}",
|
|
345
|
+
)
|
|
346
|
+
for original_route in original_routes:
|
|
347
|
+
methods_to_which_we_applied_changes |= original_route.methods
|
|
348
|
+
original_route.tags.append(_DELETED_ROUTE_TAG)
|
|
349
|
+
err = (
|
|
350
|
+
'Endpoint "{endpoint_methods} {endpoint_path}" you tried to delete in'
|
|
351
|
+
' "{version_change_name}" doesn\'t exist in a newer version'
|
|
352
|
+
)
|
|
353
|
+
elif isinstance(instruction, EndpointExistedInstruction):
|
|
354
|
+
if original_routes:
|
|
355
|
+
method_union = set()
|
|
356
|
+
for original_route in original_routes:
|
|
357
|
+
method_union |= original_route.methods
|
|
358
|
+
raise RouterGenerationError(
|
|
359
|
+
f'Endpoint "{list(method_union)} {instruction.endpoint_path}" you tried to restore in'
|
|
360
|
+
f' "{version_change.__name__}" already existed in a newer version. If you really have two '
|
|
361
|
+
f'routes with the same paths and methods, please, use "endpoint(..., func_name=...)" to '
|
|
362
|
+
f"distinguish between them. Function names of endpoints that already existed: "
|
|
363
|
+
f"{[r.endpoint.__name__ for r in original_routes]}",
|
|
364
|
+
)
|
|
365
|
+
deleted_routes = _get_routes(
|
|
366
|
+
routes,
|
|
367
|
+
instruction.endpoint_path,
|
|
368
|
+
instruction.endpoint_methods,
|
|
369
|
+
instruction.endpoint_func_name,
|
|
370
|
+
is_deleted=True,
|
|
371
|
+
)
|
|
372
|
+
try:
|
|
373
|
+
_validate_no_repetitions_in_routes(deleted_routes)
|
|
374
|
+
except RouteAlreadyExistsError as e:
|
|
375
|
+
raise RouterGenerationError(
|
|
376
|
+
f'Endpoint "{list(instruction.endpoint_methods)} {instruction.endpoint_path}" you tried to '
|
|
377
|
+
f'restore in "{version_change.__name__}" has {len(e.routes)} applicable routes that could '
|
|
378
|
+
f"be restored. If you really have two routes with the same paths and methods, please, use "
|
|
379
|
+
f'"endpoint(..., func_name=...)" to distinguish between them. Function names of '
|
|
380
|
+
f"endpoints that can be restored: {[r.endpoint.__name__ for r in e.routes]}",
|
|
381
|
+
) from e
|
|
382
|
+
for deleted_route in deleted_routes:
|
|
383
|
+
methods_to_which_we_applied_changes |= deleted_route.methods
|
|
384
|
+
deleted_route.tags.remove(_DELETED_ROUTE_TAG)
|
|
385
|
+
|
|
386
|
+
routes_that_never_existed = _get_routes(
|
|
387
|
+
self.routes_that_never_existed,
|
|
388
|
+
deleted_route.path,
|
|
389
|
+
deleted_route.methods,
|
|
390
|
+
deleted_route.endpoint.__name__,
|
|
391
|
+
is_deleted=True,
|
|
392
|
+
)
|
|
393
|
+
if len(routes_that_never_existed) == 1:
|
|
394
|
+
self.routes_that_never_existed.remove(routes_that_never_existed[0])
|
|
395
|
+
elif len(routes_that_never_existed) > 1: # pragma: no cover
|
|
396
|
+
# I am not sure if it's possible to get to this error but I also don't want
|
|
397
|
+
# to remove it because I like its clarity very much
|
|
398
|
+
routes = routes_that_never_existed
|
|
399
|
+
raise RouterGenerationError(
|
|
400
|
+
f'Endpoint "{list(deleted_route.methods)} {deleted_route.path}" you tried to restore '
|
|
401
|
+
f'in "{version_change.__name__}" has {len(routes_that_never_existed)} applicable '
|
|
402
|
+
f"routes with the same function name and path that could be restored. This can cause "
|
|
403
|
+
f"problems during version generation. Specifically, Cadwyn won't be able to warn "
|
|
404
|
+
f"you when you deleted a route and never restored it. Please, make sure that "
|
|
405
|
+
f"functions for all these routes have different names: "
|
|
406
|
+
f"{[f'{r.endpoint.__module__}.{r.endpoint.__name__}' for r in routes]}",
|
|
407
|
+
)
|
|
408
|
+
err = (
|
|
409
|
+
'Endpoint "{endpoint_methods} {endpoint_path}" you tried to restore in'
|
|
410
|
+
' "{version_change_name}" wasn\'t among the deleted routes'
|
|
411
|
+
)
|
|
412
|
+
elif isinstance(instruction, EndpointHadInstruction):
|
|
413
|
+
for original_route in original_routes:
|
|
414
|
+
methods_to_which_we_applied_changes |= original_route.methods
|
|
415
|
+
_apply_endpoint_had_instruction(version_change.__name__, instruction, original_route)
|
|
416
|
+
err = (
|
|
417
|
+
'Endpoint "{endpoint_methods} {endpoint_path}" you tried to change in'
|
|
418
|
+
' "{version_change_name}" doesn\'t exist'
|
|
419
|
+
)
|
|
420
|
+
else:
|
|
421
|
+
assert_never(instruction)
|
|
422
|
+
method_diff = methods_we_should_have_applied_changes_to - methods_to_which_we_applied_changes
|
|
423
|
+
if method_diff:
|
|
424
|
+
raise RouterGenerationError(
|
|
425
|
+
err.format(
|
|
426
|
+
endpoint_methods=list(method_diff),
|
|
427
|
+
endpoint_path=instruction.endpoint_path,
|
|
428
|
+
version_change_name=version_change.__name__,
|
|
429
|
+
),
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _validate_no_repetitions_in_routes(routes: list[fastapi.routing.APIRoute]):
|
|
434
|
+
route_map = {}
|
|
435
|
+
|
|
436
|
+
for route in routes:
|
|
437
|
+
route_info = _EndpointInfo(route.path, frozenset(route.methods))
|
|
438
|
+
if route_info in route_map:
|
|
439
|
+
raise RouteAlreadyExistsError(route, route_map[route_info])
|
|
440
|
+
route_map[route_info] = route
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _add_data_migrations_to_route(
|
|
444
|
+
route: APIRoute,
|
|
445
|
+
head_route: Any,
|
|
446
|
+
template_body_field: Union[type[BaseModel], None],
|
|
447
|
+
template_body_field_name: Union[str, None],
|
|
448
|
+
dependant_for_request_migrations: "Dependant",
|
|
449
|
+
versions: VersionBundle,
|
|
450
|
+
):
|
|
451
|
+
if not (route.dependant.request_param_name and route.dependant.response_param_name): # pragma: no cover
|
|
452
|
+
raise CadwynError(
|
|
453
|
+
f"{route.dependant.request_param_name=}, {route.dependant.response_param_name=} "
|
|
454
|
+
f"for route {list(route.methods)} {route.path} which should not be possible. Please, contact my author.",
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
route.endpoint = versions._versioned(
|
|
458
|
+
template_body_field,
|
|
459
|
+
template_body_field_name,
|
|
460
|
+
route,
|
|
461
|
+
head_route,
|
|
462
|
+
dependant_for_request_migrations,
|
|
463
|
+
request_param_name=route.dependant.request_param_name,
|
|
464
|
+
background_tasks_param_name=route.dependant.background_tasks_param_name,
|
|
465
|
+
response_param_name=route.dependant.response_param_name,
|
|
466
|
+
)(route.endpoint)
|
|
467
|
+
route.dependant.call = route.endpoint
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _apply_endpoint_had_instruction(
|
|
471
|
+
version_change_name: str,
|
|
472
|
+
instruction: EndpointHadInstruction,
|
|
473
|
+
original_route: APIRoute,
|
|
474
|
+
):
|
|
475
|
+
for attr_name in instruction.attributes.__dataclass_fields__:
|
|
476
|
+
attr = getattr(instruction.attributes, attr_name)
|
|
477
|
+
if attr is not Sentinel:
|
|
478
|
+
if getattr(original_route, attr_name) == attr:
|
|
479
|
+
raise RouterGenerationError(
|
|
480
|
+
f'Expected attribute "{attr_name}" of endpoint'
|
|
481
|
+
f' "{list(original_route.methods)} {original_route.path}"'
|
|
482
|
+
f' to be different in "{version_change_name}", but it was the same.'
|
|
483
|
+
" It means that your version change has no effect on the attribute"
|
|
484
|
+
" and can be removed.",
|
|
485
|
+
)
|
|
486
|
+
if attr_name == "path":
|
|
487
|
+
original_path_params = {p.alias for p in original_route.dependant.path_params}
|
|
488
|
+
new_path_params = set(re.findall("{(.*?)}", attr))
|
|
489
|
+
if new_path_params != original_path_params:
|
|
490
|
+
raise RouterPathParamsModifiedError(
|
|
491
|
+
f'When altering the path of "{list(original_route.methods)} {original_route.path}" '
|
|
492
|
+
f'in "{version_change_name}", you have tried to change its path params '
|
|
493
|
+
f'from "{list(original_path_params)}" to "{list(new_path_params)}". It is not allowed to '
|
|
494
|
+
"change the path params of a route because the endpoint was created to handle the old path "
|
|
495
|
+
"params. In fact, there is no need to change them because the change of path params is "
|
|
496
|
+
"not a breaking change. If you really need to change the path params, you should create a "
|
|
497
|
+
"new route with the new path params and delete the old one.",
|
|
498
|
+
)
|
|
499
|
+
setattr(original_route, attr_name, attr)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _get_routes(
|
|
503
|
+
routes: Sequence[BaseRoute],
|
|
504
|
+
endpoint_path: str,
|
|
505
|
+
endpoint_methods: set[str],
|
|
506
|
+
endpoint_func_name: Union[str, None] = None,
|
|
507
|
+
*,
|
|
508
|
+
is_deleted: bool = False,
|
|
509
|
+
) -> list[fastapi.routing.APIRoute]:
|
|
510
|
+
endpoint_path = endpoint_path.rstrip("/")
|
|
511
|
+
return [
|
|
512
|
+
route
|
|
513
|
+
for route in routes
|
|
514
|
+
if (
|
|
515
|
+
isinstance(route, fastapi.routing.APIRoute)
|
|
516
|
+
and route.path.rstrip("/") == endpoint_path
|
|
517
|
+
and set(route.methods).issubset(endpoint_methods)
|
|
518
|
+
and (endpoint_func_name is None or route.endpoint.__name__ == endpoint_func_name)
|
|
519
|
+
and (_DELETED_ROUTE_TAG in route.tags) == is_deleted
|
|
520
|
+
)
|
|
521
|
+
]
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def _get_route_from_func(
|
|
525
|
+
routes: Sequence[BaseRoute],
|
|
526
|
+
endpoint: Endpoint,
|
|
527
|
+
) -> Union[fastapi.routing.APIRoute, None]:
|
|
528
|
+
for route in routes:
|
|
529
|
+
if isinstance(route, fastapi.routing.APIRoute) and (route.endpoint == endpoint):
|
|
530
|
+
return route
|
|
531
|
+
return None
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
def _route_has_a_simple_body_schema(route: APIRoute) -> bool:
|
|
535
|
+
# Remember this: if len(body_params) == 1, then route.body_schema == route.dependant.body_params[0]
|
|
536
|
+
return len(route.dependant.body_params) == 1
|