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/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)
|