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/routing.py ADDED
@@ -0,0 +1,159 @@
1
+ import bisect
2
+ from collections.abc import Sequence
3
+ from contextvars import ContextVar
4
+ from functools import cached_property
5
+ from logging import getLogger
6
+ from typing import Any, Union
7
+
8
+ from fastapi.routing import APIRouter
9
+ from starlette.datastructures import URL
10
+ from starlette.responses import RedirectResponse
11
+ from starlette.routing import BaseRoute, Match
12
+ from starlette.types import Receive, Scope, Send
13
+
14
+ from cadwyn._internal.context_vars import DEFAULT_API_VERSION_VAR
15
+ from cadwyn._utils import same_definition_as_in
16
+ from cadwyn.middleware import APIVersionFormat
17
+ from cadwyn.structure.common import VersionType
18
+
19
+ _logger = getLogger(__name__)
20
+
21
+
22
+ class _RootCadwynAPIRouter(APIRouter):
23
+ def __init__(
24
+ self,
25
+ *args: Any,
26
+ api_version_parameter_name: str,
27
+ api_version_var: ContextVar[Union[str, None]],
28
+ api_version_format: APIVersionFormat,
29
+ **kwargs: Any,
30
+ ):
31
+ super().__init__(*args, **kwargs)
32
+ self.versioned_routers: dict[VersionType, APIRouter] = {}
33
+ self.api_version_parameter_name = api_version_parameter_name.lower()
34
+ self.api_version_var = api_version_var
35
+ self.unversioned_routes: list[BaseRoute] = []
36
+ self.api_version_format = api_version_format
37
+
38
+ async def _get_routes_from_closest_suitable_version(self, version: VersionType) -> list[BaseRoute]:
39
+ """Pick the versioned routes for the given version in case we failed to pick a concrete version
40
+
41
+ If the app has two versions: 2022-01-02 and 2022-01-05, and the request header
42
+ is 2022-01-03, then the request will be routed to 2022-01-02 version as it the closest
43
+ version, but lower than the request header.
44
+
45
+ Exact match is always preferred over partial match and a request will never be
46
+ matched to the higher versioned route.
47
+
48
+ We implement routing like this because it is extremely convenient with microservice
49
+ architecture. For example, imagine that you have two Cadwyn services: Payables and Receivables,
50
+ each defining its own API versions. Payables service might contain 10 versions while receivables
51
+ service might contain only 2 versions because it didn't need as many breaking changes.
52
+ If a client requests a version that does not exist in receivables -- we will just waterfall
53
+ to some earlier version, making receivables behavior consistent even if API keeps getting new versions.
54
+ """
55
+ if self.api_version_format == "date":
56
+ index = bisect.bisect_left(self.versions, version)
57
+ # That's when we try to get a version earlier than the earliest possible version
58
+ if index == 0:
59
+ return []
60
+ picked_version = self.versions[index - 1]
61
+ self.api_version_var.set(picked_version)
62
+ # as bisect_left returns the index where to insert item x in list a, assuming a is sorted
63
+ # we need to get the previous item and that will be a match
64
+ return self.versioned_routers[picked_version].routes
65
+ return [] # pragma: no cover # This should not be possible
66
+
67
+ async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
68
+ if "router" not in scope: # pragma: no cover
69
+ scope["router"] = self
70
+
71
+ if scope["type"] == "lifespan":
72
+ await self.lifespan(scope, receive, send)
73
+ return
74
+ version = self.api_version_var.get(None)
75
+ default_version_that_was_picked = DEFAULT_API_VERSION_VAR.get(None)
76
+
77
+ # if version is None, then it's an unversioned request and we need to use the unversioned routes
78
+ # if there will be a value, we search for the most suitable version
79
+ if not version:
80
+ routes = self.unversioned_routes
81
+ elif version in self.versioned_routers:
82
+ routes = self.versioned_routers[version].routes
83
+ else:
84
+ routes = await self._get_routes_from_closest_suitable_version(version)
85
+ if default_version_that_was_picked:
86
+ # We add unversioned routes to versioned routes because otherwise unversioned routes
87
+ # will be completely unavailable when a default version is passed. So routes such as
88
+ # /docs will not be accessible at all.
89
+
90
+ # We use this order because if versioned routes go first and there is a versioned route that is
91
+ # the same as an unversioned route -- the unversioned one becomes impossible to match.
92
+ routes = self.unversioned_routes + routes
93
+ await self.process_request(scope=scope, receive=receive, send=send, routes=routes)
94
+
95
+ @cached_property
96
+ def versions(self):
97
+ return sorted(self.versioned_routers.keys())
98
+
99
+ @same_definition_as_in(APIRouter.add_api_route)
100
+ def add_api_route(self, *args: Any, **kwargs: Any):
101
+ super().add_api_route(*args, **kwargs)
102
+ self.unversioned_routes.append(self.routes[-1])
103
+
104
+ @same_definition_as_in(APIRouter.add_route)
105
+ def add_route(self, *args: Any, **kwargs: Any):
106
+ super().add_route(*args, **kwargs)
107
+ self.unversioned_routes.append(self.routes[-1])
108
+
109
+ @same_definition_as_in(APIRouter.add_api_websocket_route)
110
+ def add_api_websocket_route(self, *args: Any, **kwargs: Any): # pragma: no cover
111
+ super().add_api_websocket_route(*args, **kwargs)
112
+ self.unversioned_routes.append(self.routes[-1])
113
+
114
+ @same_definition_as_in(APIRouter.add_websocket_route)
115
+ def add_websocket_route(self, *args: Any, **kwargs: Any): # pragma: no cover
116
+ super().add_websocket_route(*args, **kwargs)
117
+ self.unversioned_routes.append(self.routes[-1])
118
+
119
+ async def process_request(self, scope: Scope, receive: Receive, send: Send, routes: Sequence[BaseRoute]) -> None:
120
+ # It's a copy-paste from starlette.routing.Router
121
+ # but in this version self.routes were replaced with routes from the function arguments
122
+
123
+ partial = None
124
+ partial_scope = {}
125
+ for route in routes:
126
+ # Determine if any route matches the incoming scope,
127
+ # and hand over to the matching route if found.
128
+ match, child_scope = route.matches(scope)
129
+ if match == Match.FULL:
130
+ scope.update(child_scope)
131
+ await route.handle(scope, receive, send)
132
+ return None
133
+ if match == Match.PARTIAL and partial is None:
134
+ partial = route
135
+ partial_scope = child_scope
136
+
137
+ if partial is not None:
138
+ # Handle partial matches. These are cases where an endpoint is
139
+ # able to handle the request, but is not a preferred option.
140
+ # We use this in particular to deal with "405 Method Not Allowed".
141
+ scope.update(partial_scope)
142
+ return await partial.handle(scope, receive, send)
143
+
144
+ if scope["type"] == "http" and self.redirect_slashes and scope["path"] != "/":
145
+ redirect_scope = dict(scope)
146
+ if scope["path"].endswith("/"):
147
+ redirect_scope["path"] = redirect_scope["path"].rstrip("/")
148
+ else:
149
+ redirect_scope["path"] = redirect_scope["path"] + "/"
150
+
151
+ for route in routes:
152
+ match, child_scope = route.matches(redirect_scope)
153
+ if match != Match.NONE:
154
+ redirect_url = URL(scope=redirect_scope)
155
+ response = RedirectResponse(url=str(redirect_url))
156
+ await response(scope, receive, send)
157
+ return None
158
+
159
+ return await self.default(scope, receive, send)