Skip to content

Commit cad9c2a

Browse files
authored
Fix invalid route path used in by path migrations when the path was renamed (#271)
Whenever a route was migrated by path, we used the path and methods of the request. So if the request wanted "/v1/webhook_settings" and we renamed them to "/v1/webhook_subscriptions" -- all migrations for "/v1/webhook_subscriptions" would not get applied to any requests that wanted "/v1/webhook_settings". This effectively means that previously route renamings were incompatible with by path converters. Now we use the head route id instead of the path and methods of the request for matching so we always know which migrations to apply.
1 parent 72321a6 commit cad9c2a

File tree

8 files changed

+131
-47
lines changed

8 files changed

+131
-47
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ Please follow [the Keep a Changelog standard](https://keepachangelog.com/en/1.0.
55

66
## [Unreleased]
77

8+
## [5.2.2]
9+
10+
### Fixed
11+
12+
* Whenever a route was migrated by path, we used the path and methods of the request. So if the request wanted "/v1/webhook_settings" and we renamed them to "/v1/webhook_subscriptions" -- all migrations for "/v1/webhook_subscriptions" would not get applied to any requests that wanted "/v1/webhook_settings". This effectively means that previously route renamings were incompatible with by path converters. Now we use the head route id instead of the path and methods of the request for matching so we always know which migrations to apply.
13+
814
## [5.2.1]
915

1016
### Fixed

cadwyn/route_generation.py

Lines changed: 59 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,11 +37,13 @@
3737
)
3838
from cadwyn.structure import Version, VersionBundle
3939
from cadwyn.structure.common import Endpoint, VersionType
40+
from cadwyn.structure.data import _AlterRequestByPathInstruction, _AlterResponseByPathInstruction
4041
from cadwyn.structure.endpoints import (
4142
EndpointDidntExistInstruction,
4243
EndpointExistedInstruction,
4344
EndpointHadInstruction,
4445
)
46+
from cadwyn.structure.versions import VersionChange
4547

4648
if TYPE_CHECKING:
4749
from fastapi.dependencies.models import Dependant
@@ -52,6 +54,9 @@
5254
_RouteT = TypeVar("_RouteT", bound=BaseRoute)
5355
# This is a hack we do because we can't guarantee how the user will use the router.
5456
_DELETED_ROUTE_TAG = "_CADWYN_DELETED_ROUTE"
57+
_RoutePath = str
58+
_RouteMethod = str
59+
_RouteId = int
5560

5661

5762
@dataclass(**DATACLASS_SLOTS, frozen=True, eq=True)
@@ -123,6 +128,7 @@ def __init__(self, parent_router: _R, versions: VersionBundle, webhooks: _WR) ->
123128
]
124129

125130
def transform(self) -> GeneratedRouters[_R, _WR]:
131+
# Copy MUST keep the order and number of routes. Otherwise, a ton of code below will break.
126132
router = copy_router(self.parent_router)
127133
webhook_router = copy_router(self.parent_webhooks_router)
128134
routers: dict[VersionType, _R] = {}
@@ -132,7 +138,7 @@ def transform(self) -> GeneratedRouters[_R, _WR]:
132138
self.schema_generators[str(version.value)].annotation_transformer.migrate_router_to_version(router)
133139
self.schema_generators[str(version.value)].annotation_transformer.migrate_router_to_version(webhook_router)
134140

135-
self._validate_all_data_converters_are_applied(router, version)
141+
self._attach_routes_to_data_converters(router, self.parent_router, version)
136142

137143
routers[version.value] = router
138144
webhook_routers[version.value] = webhook_router
@@ -193,9 +199,11 @@ def transform(self) -> GeneratedRouters[_R, _WR]:
193199
]
194200
return GeneratedRouters(routers, webhook_routers)
195201

196-
def _validate_all_data_converters_are_applied(self, router: APIRouter, version: Version):
197-
path_to_route_methods_mapping, head_response_models, head_request_bodies = self._extract_all_routes_identifiers(
198-
router
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)
199207
)
200208

201209
for version_change in version.changes:
@@ -204,21 +212,10 @@ def _validate_all_data_converters_are_applied(self, router: APIRouter, version:
204212
*version_change.alter_request_by_path_instructions.values(),
205213
]:
206214
for by_path_converter in by_path_converters:
207-
missing_methods = by_path_converter.methods.difference(
208-
path_to_route_methods_mapping[by_path_converter.path]
215+
self._attach_routes_by_path_converter(
216+
head_router, path_to_route_methods_mapping, version_change, by_path_converter
209217
)
210218

211-
if missing_methods:
212-
raise RouteByPathConverterDoesNotApplyToAnythingError(
213-
f"{by_path_converter.repr_name} "
214-
f'"{version_change.__name__}.{by_path_converter.transformer.__name__}" '
215-
f"failed to find routes with the following methods: {list(missing_methods)}. "
216-
f"This means that you are trying to apply this converter to non-existing endpoint(s). "
217-
"Please, check whether the path and methods are correct. (hint: path must include "
218-
"all path variables and have a name that was used in the version that this "
219-
"VersionChange resides in)"
220-
)
221-
222219
for by_schema_converters in version_change.alter_request_by_schema_instructions.values():
223220
for by_schema_converter in by_schema_converters:
224221
if not by_schema_converter.check_usage: # pragma: no cover
@@ -249,14 +246,50 @@ def _validate_all_data_converters_are_applied(self, router: APIRouter, version:
249246
f"{version_change.__name__}.{by_schema_converter.transformer.__name__}"
250247
)
251248

252-
def _extract_all_routes_identifiers(
253-
self, router: APIRouter
254-
) -> tuple[defaultdict[str, set[str]], set[Any], set[Any]]:
255-
response_models: set[Any] = set()
256-
request_bodies: set[Any] = set()
257-
path_to_route_methods_mapping: dict[str, set[str]] = defaultdict(set)
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+
)
258278

259-
for route in router.routes:
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):
260293
if isinstance(route, APIRoute):
261294
if route.response_model is not None and lenient_issubclass(route.response_model, BaseModel):
262295
response_models.add(route.response_model)
@@ -265,7 +298,8 @@ def _extract_all_routes_identifiers(
265298
annotation = route.body_field.field_info.annotation
266299
if annotation is not None and lenient_issubclass(annotation, BaseModel):
267300
request_bodies.add(annotation)
268-
path_to_route_methods_mapping[route.path] |= route.methods
301+
for method in route.methods:
302+
path_to_route_methods_mapping[route.path][method].add(index)
269303

270304
head_response_models = {model.__cadwyn_original_model__ for model in response_models}
271305
head_request_bodies = {getattr(body, "__cadwyn_original_model__", body) for body in request_bodies}

cadwyn/schema_generation.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -196,8 +196,7 @@ def migrate_response_body(
196196
response,
197197
current_version=version,
198198
head_response_model=latest_response_model,
199-
path="\0\0\0",
200-
method="GET",
199+
head_route=None,
201200
)
202201

203202
versioned_response_model: type[pydantic.BaseModel] = generate_versioned_models(versions)[str(version)][

cadwyn/structure/versions.py

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
_CADWYN_RESPONSE_PARAM_NAME = "cadwyn_response_param"
5454
_P = ParamSpec("_P")
5555
_R = TypeVar("_R")
56+
_RouteId = int
5657

5758
PossibleInstructions: TypeAlias = Union[
5859
AlterSchemaSubInstruction, AlterEndpointSubInstruction, AlterEnumSubInstruction, SchemaHadInstruction, staticmethod
@@ -85,6 +86,8 @@ class VersionChange:
8586
alter_response_by_schema_instructions: ClassVar[dict[type, list[_AlterResponseBySchemaInstruction]]] = Sentinel
8687
alter_response_by_path_instructions: ClassVar[dict[str, list[_AlterResponseByPathInstruction]]] = Sentinel
8788
_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
8891

8992
def __init_subclass__(cls, _abstract: bool = False) -> None:
9093
super().__init_subclass__()
@@ -96,6 +99,8 @@ def __init_subclass__(cls, _abstract: bool = False) -> None:
9699
cls._extract_body_instructions_into_correct_containers()
97100
cls._check_no_subclassing()
98101
cls._bound_version_bundle = None
102+
cls._route_to_request_migration_mapping = defaultdict(list)
103+
cls._route_to_response_migration_mapping = defaultdict(list)
99104

100105
@classmethod
101106
def _extract_body_instructions_into_correct_containers(cls):
@@ -358,7 +363,6 @@ async def _migrate_request(
358363
self,
359364
body_type: Union[type[BaseModel], None],
360365
head_dependant: Dependant,
361-
path: str,
362366
request: FastapiRequest,
363367
response: FastapiResponse,
364368
request_info: RequestInfo,
@@ -369,18 +373,17 @@ async def _migrate_request(
369373
embed_body_fields: bool,
370374
background_tasks: Union[BackgroundTasks, None],
371375
) -> dict[str, Any]:
372-
method = request.method
373-
374376
start = self.reversed_version_values.index(current_version)
377+
head_route_id = id(head_route)
375378
for v in self.reversed_versions[start + 1 :]:
376379
for version_change in v.changes:
377380
if body_type is not None and body_type in version_change.alter_request_by_schema_instructions:
378381
for instruction in version_change.alter_request_by_schema_instructions[body_type]:
379382
instruction(request_info)
380-
if path in version_change.alter_request_by_path_instructions:
381-
for instruction in version_change.alter_request_by_path_instructions[path]:
382-
if method in instruction.methods: # pragma: no branch # safe branch to skip
383-
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+
384387
request.scope["headers"] = tuple((key.encode(), value.encode()) for key, value in request_info.headers.items())
385388
del request._headers
386389
# This gives us the ability to tell the user whether cadwyn is running its dependencies or FastAPI
@@ -406,10 +409,10 @@ def _migrate_response(
406409
self,
407410
response_info: ResponseInfo,
408411
current_version: VersionType,
409-
head_response_model: type[BaseModel],
410-
path: str,
411-
method: str,
412+
head_response_model: Union[type[BaseModel], None],
413+
head_route: Union[APIRoute, None],
412414
) -> ResponseInfo:
415+
head_route_id = id(head_route)
413416
end = self.version_values.index(current_version)
414417
for v in self.versions[:end]:
415418
for version_change in v.changes:
@@ -420,10 +423,8 @@ def _migrate_response(
420423
version_change.alter_response_by_schema_instructions[head_response_model]
421424
)
422425

423-
if path in version_change.alter_response_by_path_instructions:
424-
for instruction in version_change.alter_response_by_path_instructions[path]:
425-
if method in instruction.methods: # pragma: no branch # Safe branch to skip
426-
migrations_to_apply.append(instruction) # noqa: PERF401
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])
427428

428429
for migration in migrations_to_apply:
429430
if response_info.status_code < 300 or migration.migrate_http_errors:
@@ -576,8 +577,7 @@ async def _convert_endpoint_response_to_version( # noqa: C901
576577
response_info,
577578
api_version,
578579
head_route.response_model,
579-
route.path,
580-
method,
580+
head_route,
581581
)
582582
if isinstance(response_or_response_body, FastapiResponse):
583583
# a webserver (uvicorn for instance) calculates the body at the endpoint level.
@@ -671,7 +671,6 @@ async def _convert_endpoint_kwargs_to_version(
671671
new_kwargs = await self._migrate_request(
672672
head_body_field,
673673
head_dependant,
674-
route.path,
675674
request,
676675
response,
677676
request_info,

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "cadwyn"
3-
version = "5.2.1"
3+
version = "5.2.2"
44
description = "Production-ready community-driven modern Stripe-like API versioning in FastAPI"
55
authors = [{ name = "Stanislav Zmiev", email = "zmievsa@gmail.com" }]
66
license = "MIT"

ruff.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,7 +135,7 @@ ignore = [
135135
]
136136

137137
[lint.mccabe]
138-
max-complexity = 14
138+
max-complexity = 15
139139

140140
[format]
141141
quote-style = "double"

tests/test_router_generation.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@
2020
from typing_extensions import Any, NewType, TypeAlias, TypeAliasType, get_args
2121

2222
from cadwyn import VersionBundle, VersionedAPIRouter
23+
from cadwyn.applications import Cadwyn
2324
from cadwyn.dependencies import current_dependency_solver
2425
from cadwyn.exceptions import CadwynError, RouterGenerationError, RouterPathParamsModifiedError
2526
from cadwyn.route_generation import generate_versioned_routers
2627
from cadwyn.schema_generation import generate_versioned_models
2728
from cadwyn.structure import Version, convert_request_to_next_version_for, endpoint, schema
29+
from cadwyn.structure.data import RequestInfo, ResponseInfo, convert_response_to_previous_version_for
2830
from cadwyn.structure.enums import enum
2931
from cadwyn.structure.versions import VersionChange
3032
from tests._data.unversioned_schema_dir import UnversionedSchema2
@@ -252,6 +254,50 @@ def test__endpoint_had_another_path_variable(
252254
)
253255

254256

257+
def test__endpoint_had__another_path_with_the_other_migration_at_the_same_time__should_require_old_name(
258+
create_versioned_app: CreateVersionedApp,
259+
):
260+
router = VersionedAPIRouter()
261+
262+
@router.post("/A")
263+
async def test_endpoint_post(body: list[str]):
264+
return body
265+
266+
@router.put("/A")
267+
async def test_endpoint_put(body: list[str]):
268+
return body
269+
270+
@convert_response_to_previous_version_for("/A", ["POST"])
271+
def response_migration(response: ResponseInfo):
272+
response.body.append("response")
273+
274+
@convert_request_to_next_version_for("/A", ["POST"])
275+
def request_migration(request: RequestInfo):
276+
request.body.append("request")
277+
278+
app = Cadwyn(
279+
versions=VersionBundle(
280+
Version(
281+
"2001-01-01",
282+
version_change(
283+
endpoint("/A", ["POST"]).had(path="/B"),
284+
request_migration=request_migration,
285+
response_migration=response_migration,
286+
),
287+
),
288+
Version("2000-01-01"),
289+
)
290+
)
291+
app.generate_and_include_versioned_routers(router)
292+
293+
with TestClient(app) as client:
294+
assert client.post("/B", headers={"X-API-VERSION": "2000-01-01"}, json=[]).json() == ["request", "response"]
295+
assert client.post("/A", headers={"X-API-VERSION": "2001-01-01"}, json=[]).json() == []
296+
297+
assert client.put("/A", headers={"X-API-VERSION": "2000-01-01"}, json=[]).json() == []
298+
assert client.put("/A", headers={"X-API-VERSION": "2001-01-01"}, json=[]).json() == []
299+
300+
255301
def test__endpoint_had_dependencies(
256302
test_endpoint: Endpoint,
257303
test_path: str,

uv.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)