cadwyn 2.1.0rc1__tar.gz → 2.3.0__tar.gz
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.
Potentially problematic release.
This version of cadwyn might be problematic. Click here for more details.
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/PKG-INFO +6 -6
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/README.md +4 -4
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/__init__.py +2 -2
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/codegen.py +2 -2
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/exceptions.py +13 -0
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/main.py +12 -22
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/routing.py +59 -38
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/structure/endpoints.py +12 -0
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/structure/versions.py +71 -24
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/pyproject.toml +2 -2
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/setup.py +5 -5
- cadwyn-2.1.0rc1/cadwyn/header.py +0 -28
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/LICENSE +0 -0
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/__main__.py +0 -0
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/_utils.py +0 -0
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/py.typed +0 -0
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/structure/__init__.py +0 -0
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/structure/common.py +0 -0
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/structure/data.py +0 -0
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/structure/enums.py +0 -0
- {cadwyn-2.1.0rc1 → cadwyn-2.3.0}/cadwyn/structure/schemas.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: cadwyn
|
|
3
|
-
Version: 2.
|
|
3
|
+
Version: 2.3.0
|
|
4
4
|
Summary: Modern Stripe-like API versioning in FastAPI
|
|
5
5
|
Home-page: https://github.com/zmievsa/cadwyn
|
|
6
6
|
License: MIT
|
|
@@ -32,10 +32,10 @@ Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
|
32
32
|
Classifier: Typing :: Typed
|
|
33
33
|
Provides-Extra: cli
|
|
34
34
|
Requires-Dist: fastapi (>=0.96.1)
|
|
35
|
-
Requires-Dist: fastapi-header-versioning (>=1.1.0)
|
|
36
35
|
Requires-Dist: pydantic (>=1.10.0,<2.0.0)
|
|
37
36
|
Requires-Dist: typer (>=0.7.0); extra == "cli"
|
|
38
37
|
Requires-Dist: typing-extensions
|
|
38
|
+
Requires-Dist: verselect-zmievsa (>=0.0.5)
|
|
39
39
|
Project-URL: Repository, https://github.com/zmievsa/cadwyn
|
|
40
40
|
Description-Content-Type: text/markdown
|
|
41
41
|
|
|
@@ -73,10 +73,10 @@ Its [approach](./docs/theory.md#ii-migration-based-response-building) will be us
|
|
|
73
73
|
|
|
74
74
|
The [documentation](https://docs.cadwyn.dev) has everything you need to get started. It is recommended to read it in the following order:
|
|
75
75
|
|
|
76
|
-
1. [Tutorial](
|
|
77
|
-
2. [Recipes](
|
|
78
|
-
3. [Reference](
|
|
79
|
-
4. [Theory](
|
|
76
|
+
1. [Tutorial](./tutorial.md)
|
|
77
|
+
2. [Recipes](./recipes.md)
|
|
78
|
+
3. [Reference](./reference.md)
|
|
79
|
+
4. [Theory](./theory.md) <!-- TODO: Move section about cadwyn's approach to the beginning and move other approaches and "how we got here" to another article -->
|
|
80
80
|
|
|
81
81
|
## Similar projects
|
|
82
82
|
|
|
@@ -32,10 +32,10 @@ Its [approach](./docs/theory.md#ii-migration-based-response-building) will be us
|
|
|
32
32
|
|
|
33
33
|
The [documentation](https://docs.cadwyn.dev) has everything you need to get started. It is recommended to read it in the following order:
|
|
34
34
|
|
|
35
|
-
1. [Tutorial](
|
|
36
|
-
2. [Recipes](
|
|
37
|
-
3. [Reference](
|
|
38
|
-
4. [Theory](
|
|
35
|
+
1. [Tutorial](./tutorial.md)
|
|
36
|
+
2. [Recipes](./recipes.md)
|
|
37
|
+
3. [Reference](./reference.md)
|
|
38
|
+
4. [Theory](./theory.md) <!-- TODO: Move section about cadwyn's approach to the beginning and move other approaches and "how we got here" to another article -->
|
|
39
39
|
|
|
40
40
|
## Similar projects
|
|
41
41
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import importlib.metadata
|
|
2
2
|
|
|
3
3
|
from .codegen import generate_code_for_versioned_packages
|
|
4
|
-
from .
|
|
4
|
+
from .main import Cadwyn
|
|
5
5
|
from .routing import VersionedAPIRouter, generate_versioned_routers
|
|
6
6
|
from .structure import VersionBundle, internal_body_representation_of
|
|
7
7
|
|
|
8
8
|
__version__ = importlib.metadata.version("cadwyn")
|
|
9
9
|
__all__ = [
|
|
10
|
+
"Cadwyn",
|
|
10
11
|
"VersionedAPIRouter",
|
|
11
|
-
"get_cadwyn_dependency",
|
|
12
12
|
"generate_code_for_versioned_packages",
|
|
13
13
|
"VersionBundle",
|
|
14
14
|
"generate_versioned_routers",
|
|
@@ -15,8 +15,8 @@ from types import GenericAlias, LambdaType, ModuleType, NoneType
|
|
|
15
15
|
from typing import (
|
|
16
16
|
Any,
|
|
17
17
|
TypeAlias,
|
|
18
|
-
_BaseGenericAlias,
|
|
19
|
-
cast,
|
|
18
|
+
_BaseGenericAlias, # pyright: ignore[reportGeneralTypeIssues]
|
|
19
|
+
cast,
|
|
20
20
|
final,
|
|
21
21
|
get_args,
|
|
22
22
|
get_origin,
|
|
@@ -1,4 +1,17 @@
|
|
|
1
1
|
from fastapi.routing import APIRoute
|
|
2
|
+
from verselect.exceptions import AppCreationError
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"AppCreationError",
|
|
6
|
+
"CadwynError",
|
|
7
|
+
"LintingError",
|
|
8
|
+
"CodeGenerationError",
|
|
9
|
+
"InvalidGenerationInstructionError",
|
|
10
|
+
"RouterGenerationError",
|
|
11
|
+
"RouteAlreadyExistsError",
|
|
12
|
+
"CadwynStructureError",
|
|
13
|
+
"ModuleIsNotVersionedError",
|
|
14
|
+
]
|
|
2
15
|
|
|
3
16
|
|
|
4
17
|
class CadwynError(Exception):
|
|
@@ -6,29 +6,26 @@ from fastapi import APIRouter, routing
|
|
|
6
6
|
from fastapi.datastructures import Default
|
|
7
7
|
from fastapi.params import Depends
|
|
8
8
|
from fastapi.utils import generate_unique_id
|
|
9
|
-
from fastapi_header_versioning import HeaderRoutingFastAPI
|
|
10
|
-
from fastapi_header_versioning.fastapi import HeaderVersionedAPIRouter
|
|
11
9
|
from starlette.middleware import Middleware
|
|
12
10
|
from starlette.requests import Request
|
|
13
11
|
from starlette.responses import JSONResponse, Response
|
|
14
12
|
from starlette.routing import BaseRoute
|
|
15
13
|
from starlette.types import Lifespan
|
|
16
14
|
from typing_extensions import Self
|
|
15
|
+
from verselect import HeaderRoutingFastAPI
|
|
17
16
|
|
|
18
|
-
from cadwyn.header import get_cadwyn_dependency
|
|
19
17
|
from cadwyn.routing import generate_versioned_routers
|
|
20
18
|
from cadwyn.structure import VersionBundle
|
|
21
19
|
|
|
22
20
|
|
|
23
|
-
class
|
|
21
|
+
class Cadwyn(HeaderRoutingFastAPI):
|
|
24
22
|
def __init__(
|
|
25
23
|
self,
|
|
26
24
|
*,
|
|
27
25
|
versions: VersionBundle,
|
|
28
26
|
latest_schemas_module: ModuleType,
|
|
29
|
-
|
|
27
|
+
api_version_header_name: str = "x-api-version",
|
|
30
28
|
debug: bool = False,
|
|
31
|
-
routes: list[BaseRoute] | None = None,
|
|
32
29
|
title: str = "FastAPI",
|
|
33
30
|
summary: str | None = None,
|
|
34
31
|
description: str = "",
|
|
@@ -40,7 +37,7 @@ class _Cadwyn(HeaderRoutingFastAPI):
|
|
|
40
37
|
default_response_class: type[Response] = Default(JSONResponse), # noqa: B008
|
|
41
38
|
redirect_slashes: bool = True,
|
|
42
39
|
docs_url: str | None = "/docs",
|
|
43
|
-
redoc_url:
|
|
40
|
+
redoc_url: None = None,
|
|
44
41
|
swagger_ui_oauth2_redirect_url: str | None = "/docs/oauth2-redirect",
|
|
45
42
|
swagger_ui_init_oauth: dict[str, Any] | None = None,
|
|
46
43
|
middleware: Sequence[Middleware] | None = None,
|
|
@@ -66,9 +63,9 @@ class _Cadwyn(HeaderRoutingFastAPI):
|
|
|
66
63
|
**extra: Any,
|
|
67
64
|
) -> None:
|
|
68
65
|
super().__init__(
|
|
69
|
-
|
|
66
|
+
api_version_header_name=api_version_header_name,
|
|
67
|
+
api_version_var=versions.api_version_var,
|
|
70
68
|
debug=debug,
|
|
71
|
-
routes=routes,
|
|
72
69
|
title=title,
|
|
73
70
|
summary=summary,
|
|
74
71
|
description=description,
|
|
@@ -80,7 +77,6 @@ class _Cadwyn(HeaderRoutingFastAPI):
|
|
|
80
77
|
default_response_class=default_response_class,
|
|
81
78
|
redirect_slashes=redirect_slashes,
|
|
82
79
|
docs_url=docs_url,
|
|
83
|
-
redoc_url=redoc_url,
|
|
84
80
|
swagger_ui_oauth2_redirect_url=swagger_ui_oauth2_redirect_url,
|
|
85
81
|
swagger_ui_init_oauth=swagger_ui_init_oauth,
|
|
86
82
|
middleware=middleware,
|
|
@@ -106,21 +102,15 @@ class _Cadwyn(HeaderRoutingFastAPI):
|
|
|
106
102
|
)
|
|
107
103
|
self.versions = versions
|
|
108
104
|
self.latest_schemas_module = latest_schemas_module
|
|
109
|
-
self.version_header = version_header
|
|
110
|
-
self.cadwyn_header_dependency = get_cadwyn_dependency(
|
|
111
|
-
version_header_name=version_header,
|
|
112
|
-
api_version_var=self.versions.api_version_var,
|
|
113
|
-
)
|
|
114
105
|
|
|
115
|
-
def
|
|
106
|
+
def generate_and_include_versioned_routers(self, *routers: APIRouter) -> None:
|
|
107
|
+
root_router = APIRouter()
|
|
108
|
+
for router in routers:
|
|
109
|
+
root_router.include_router(router)
|
|
116
110
|
router_versions = generate_versioned_routers(
|
|
117
|
-
|
|
111
|
+
root_router,
|
|
118
112
|
versions=self.versions,
|
|
119
113
|
latest_schemas_module=self.latest_schemas_module,
|
|
120
114
|
)
|
|
121
|
-
root_router = HeaderVersionedAPIRouter()
|
|
122
|
-
|
|
123
115
|
for version, router in router_versions.items():
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
self.include_router(root_router, dependencies=[self.cadwyn_header_dependency])
|
|
116
|
+
self.add_header_versioned_routers(router, header_value=version.isoformat())
|
|
@@ -12,6 +12,7 @@ from pathlib import Path
|
|
|
12
12
|
from types import GenericAlias, MappingProxyType, ModuleType
|
|
13
13
|
from typing import (
|
|
14
14
|
Any,
|
|
15
|
+
Generic,
|
|
15
16
|
TypeAlias,
|
|
16
17
|
TypeVar,
|
|
17
18
|
_BaseGenericAlias, # pyright: ignore[reportGeneralTypeIssues]
|
|
@@ -40,6 +41,7 @@ from starlette.routing import (
|
|
|
40
41
|
request_response,
|
|
41
42
|
)
|
|
42
43
|
from typing_extensions import assert_never
|
|
44
|
+
from verselect.routing import VERSION_HEADER_FORMAT
|
|
43
45
|
|
|
44
46
|
from cadwyn._utils import Sentinel, UnionType, get_another_version_of_module
|
|
45
47
|
from cadwyn.codegen import _get_package_path_from_module, _get_version_dir_path
|
|
@@ -54,12 +56,18 @@ from cadwyn.structure.endpoints import (
|
|
|
54
56
|
)
|
|
55
57
|
from cadwyn.structure.versions import _CADWYN_REQUEST_PARAM_NAME, _CADWYN_RESPONSE_PARAM_NAME, VersionChange
|
|
56
58
|
|
|
59
|
+
__all__ = [
|
|
60
|
+
"generate_versioned_routers",
|
|
61
|
+
"VersionedAPIRouter",
|
|
62
|
+
"VERSION_HEADER_FORMAT",
|
|
63
|
+
]
|
|
64
|
+
|
|
57
65
|
_T = TypeVar("_T", bound=Callable[..., Any])
|
|
58
66
|
_R = TypeVar("_R", bound=fastapi.routing.APIRouter)
|
|
59
67
|
# This is a hack we do because we can't guarantee how the user will use the router.
|
|
60
68
|
_DELETED_ROUTE_TAG = "_CADWYN_DELETED_ROUTE"
|
|
61
|
-
|
|
62
|
-
|
|
69
|
+
_EndpointPath: TypeAlias = str
|
|
70
|
+
_EndpointMethod: TypeAlias = str
|
|
63
71
|
|
|
64
72
|
|
|
65
73
|
@dataclass(slots=True, frozen=True, eq=True)
|
|
@@ -69,9 +77,9 @@ class _EndpointInfo:
|
|
|
69
77
|
|
|
70
78
|
|
|
71
79
|
@dataclass(slots=True)
|
|
72
|
-
class _RouterInfo:
|
|
73
|
-
router:
|
|
74
|
-
routes_with_migrated_requests: dict[
|
|
80
|
+
class _RouterInfo(Generic[_R]):
|
|
81
|
+
router: _R
|
|
82
|
+
routes_with_migrated_requests: dict[_EndpointPath, set[_EndpointMethod]]
|
|
75
83
|
route_bodies_with_migrated_requests: set[type[BaseModel]]
|
|
76
84
|
|
|
77
85
|
|
|
@@ -97,14 +105,14 @@ class VersionedAPIRouter(fastapi.routing.APIRouter):
|
|
|
97
105
|
return endpoint
|
|
98
106
|
|
|
99
107
|
|
|
100
|
-
|
|
101
|
-
class _EndpointTransformer:
|
|
108
|
+
class _EndpointTransformer(Generic[_R]):
|
|
102
109
|
def __init__(
|
|
103
110
|
self,
|
|
104
|
-
parent_router:
|
|
111
|
+
parent_router: _R,
|
|
105
112
|
versions: VersionBundle,
|
|
106
113
|
latest_schemas_module: ModuleType,
|
|
107
114
|
) -> None:
|
|
115
|
+
super().__init__()
|
|
108
116
|
self.parent_router = parent_router
|
|
109
117
|
self.versions = versions
|
|
110
118
|
self.annotation_transformer = _AnnotationTransformer(latest_schemas_module, versions)
|
|
@@ -113,7 +121,7 @@ class _EndpointTransformer:
|
|
|
113
121
|
route for route in parent_router.routes if isinstance(route, APIRoute) and _DELETED_ROUTE_TAG in route.tags
|
|
114
122
|
]
|
|
115
123
|
|
|
116
|
-
def transform(self):
|
|
124
|
+
def transform(self) -> dict[VersionDate, _R]:
|
|
117
125
|
router = deepcopy(self.parent_router)
|
|
118
126
|
router_infos: dict[VersionDate, _RouterInfo] = {}
|
|
119
127
|
routes_with_migrated_requests = {}
|
|
@@ -446,41 +454,23 @@ class _AnnotationTransformer:
|
|
|
446
454
|
return self._change_version_of_type(annotation, version_dir)
|
|
447
455
|
elif callable(annotation):
|
|
448
456
|
# TASK: https://github.com/zmievsa/cadwyn/issues/48
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
@functools.wraps(annotation)
|
|
452
|
-
async def new_callable( # pyright: ignore[reportGeneralTypeIssues]
|
|
453
|
-
*args: Any,
|
|
454
|
-
**kwargs: Any,
|
|
455
|
-
) -> Any:
|
|
456
|
-
return await annotation(*args, **kwargs)
|
|
457
|
-
|
|
458
|
-
else:
|
|
459
|
-
|
|
460
|
-
@functools.wraps(annotation)
|
|
461
|
-
def new_callable( # pyright: ignore[reportGeneralTypeIssues]
|
|
462
|
-
*args: Any,
|
|
463
|
-
**kwargs: Any,
|
|
464
|
-
) -> Any:
|
|
465
|
-
return annotation(*args, **kwargs)
|
|
466
|
-
|
|
467
|
-
# Otherwise it will have the same signature as __wrapped__
|
|
468
|
-
new_callable.__alt_wrapped__ = new_callable.__wrapped__ # pyright: ignore[reportGeneralTypeIssues]
|
|
469
|
-
del new_callable.__wrapped__
|
|
457
|
+
annotation_modifying_decorator = _copy_function(annotation)
|
|
470
458
|
old_params = inspect.signature(annotation).parameters
|
|
471
|
-
callable_annotations =
|
|
459
|
+
callable_annotations = annotation_modifying_decorator.__annotations__
|
|
472
460
|
|
|
473
|
-
|
|
474
|
-
new_callable.__annotations__ = self._change_version_of_annotations(
|
|
461
|
+
annotation_modifying_decorator.__annotations__ = self._change_version_of_annotations(
|
|
475
462
|
callable_annotations,
|
|
476
463
|
version_dir,
|
|
477
464
|
)
|
|
478
|
-
|
|
465
|
+
annotation_modifying_decorator.__defaults__ = self._change_version_of_annotations(
|
|
479
466
|
tuple(p.default for p in old_params.values() if p.default is not inspect.Signature.empty),
|
|
480
467
|
version_dir,
|
|
481
468
|
)
|
|
482
|
-
|
|
483
|
-
|
|
469
|
+
annotation_modifying_decorator.__signature__ = _generate_signature(
|
|
470
|
+
annotation_modifying_decorator,
|
|
471
|
+
old_params,
|
|
472
|
+
)
|
|
473
|
+
return annotation_modifying_decorator
|
|
484
474
|
else:
|
|
485
475
|
return annotation
|
|
486
476
|
|
|
@@ -581,7 +571,7 @@ def _add_data_migrations_to_route(
|
|
|
581
571
|
route.endpoint = versions._versioned(
|
|
582
572
|
template_body_field,
|
|
583
573
|
template_body_field_name,
|
|
584
|
-
route
|
|
574
|
+
route,
|
|
585
575
|
dependant_for_request_migrations,
|
|
586
576
|
latest_response_model,
|
|
587
577
|
request_param_name=route.dependant.request_param_name,
|
|
@@ -672,7 +662,7 @@ def _get_route_from_func(
|
|
|
672
662
|
return None
|
|
673
663
|
|
|
674
664
|
|
|
675
|
-
def _get_migrated_routes_by_path(version: Version) -> dict[
|
|
665
|
+
def _get_migrated_routes_by_path(version: Version) -> dict[_EndpointPath, set[_EndpointMethod]]:
|
|
676
666
|
request_by_path_migration_instructions = [
|
|
677
667
|
version_change.alter_request_by_path_instructions for version_change in version.version_changes
|
|
678
668
|
]
|
|
@@ -682,3 +672,34 @@ def _get_migrated_routes_by_path(version: Version) -> dict[EndpointPath, set[End
|
|
|
682
672
|
for instruction in instruction_list:
|
|
683
673
|
migrated_routes[path] |= instruction.methods
|
|
684
674
|
return migrated_routes
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
def _copy_function(function: _T) -> _T:
|
|
678
|
+
while hasattr(function, "__alt_wrapped__"):
|
|
679
|
+
function = function.__alt_wrapped__
|
|
680
|
+
|
|
681
|
+
if inspect.iscoroutinefunction(function):
|
|
682
|
+
|
|
683
|
+
@functools.wraps(function)
|
|
684
|
+
async def annotation_modifying_decorator( # pyright: ignore[reportGeneralTypeIssues]
|
|
685
|
+
*args: Any,
|
|
686
|
+
**kwargs: Any,
|
|
687
|
+
) -> Any:
|
|
688
|
+
return await function(*args, **kwargs)
|
|
689
|
+
|
|
690
|
+
else:
|
|
691
|
+
|
|
692
|
+
@functools.wraps(function)
|
|
693
|
+
def annotation_modifying_decorator(
|
|
694
|
+
*args: Any,
|
|
695
|
+
**kwargs: Any,
|
|
696
|
+
) -> Any:
|
|
697
|
+
return function(*args, **kwargs)
|
|
698
|
+
|
|
699
|
+
# Otherwise it will have the same signature as __wrapped__ due to how inspect module works
|
|
700
|
+
annotation_modifying_decorator.__alt_wrapped__ = ( # pyright: ignore[reportGeneralTypeIssues]
|
|
701
|
+
annotation_modifying_decorator.__wrapped__
|
|
702
|
+
)
|
|
703
|
+
del annotation_modifying_decorator.__wrapped__
|
|
704
|
+
|
|
705
|
+
return cast(_T, annotation_modifying_decorator)
|
|
@@ -8,8 +8,12 @@ from fastapi.params import Depends
|
|
|
8
8
|
from fastapi.routing import APIRoute
|
|
9
9
|
from starlette.routing import BaseRoute
|
|
10
10
|
|
|
11
|
+
from cadwyn.exceptions import LintingError
|
|
12
|
+
|
|
11
13
|
from .._utils import Sentinel
|
|
12
14
|
|
|
15
|
+
HTTP_METHODS = {"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"}
|
|
16
|
+
|
|
13
17
|
|
|
14
18
|
@dataclass(slots=True)
|
|
15
19
|
class EndpointAttributesPayload:
|
|
@@ -144,6 +148,14 @@ class EndpointInstructionFactory:
|
|
|
144
148
|
|
|
145
149
|
|
|
146
150
|
def endpoint(path: str, methods: list[str], /, *, func_name: str | None = None) -> EndpointInstructionFactory:
|
|
151
|
+
invalid_methods = set(methods) - HTTP_METHODS
|
|
152
|
+
if invalid_methods:
|
|
153
|
+
invalid_methods = ", ".join(sorted(invalid_methods))
|
|
154
|
+
raise LintingError(
|
|
155
|
+
f"The following HTTP methods are not valid: {invalid_methods}. "
|
|
156
|
+
"Please use valid HTTP methods such as GET, POST, PUT, PATCH, DELETE, OPTIONS, HEAD.",
|
|
157
|
+
)
|
|
158
|
+
|
|
147
159
|
return EndpointInstructionFactory(path, set(methods), func_name)
|
|
148
160
|
|
|
149
161
|
|
|
@@ -1,21 +1,24 @@
|
|
|
1
|
+
import email.message
|
|
1
2
|
import functools
|
|
2
3
|
import inspect
|
|
3
4
|
import json
|
|
4
5
|
from collections import defaultdict
|
|
5
6
|
from collections.abc import Callable, Sequence
|
|
7
|
+
from contextlib import AsyncExitStack
|
|
6
8
|
from contextvars import ContextVar
|
|
7
9
|
from enum import Enum
|
|
8
|
-
from typing import Any, ClassVar, ParamSpec, TypeAlias, TypeVar
|
|
10
|
+
from typing import Any, ClassVar, ParamSpec, TypeAlias, TypeVar, cast
|
|
9
11
|
|
|
12
|
+
from fastapi import HTTPException, params
|
|
10
13
|
from fastapi import Request as FastapiRequest
|
|
11
14
|
from fastapi import Response as FastapiResponse
|
|
12
|
-
from fastapi import params
|
|
13
15
|
from fastapi._compat import ModelField, _normalize_errors
|
|
14
16
|
from fastapi.dependencies.models import Dependant
|
|
15
17
|
from fastapi.dependencies.utils import solve_dependencies
|
|
16
18
|
from fastapi.exceptions import RequestValidationError
|
|
17
|
-
from fastapi.routing import _prepare_response_content
|
|
19
|
+
from fastapi.routing import APIRoute, _prepare_response_content
|
|
18
20
|
from pydantic import BaseModel
|
|
21
|
+
from pydantic.fields import Undefined
|
|
19
22
|
from typing_extensions import assert_never
|
|
20
23
|
|
|
21
24
|
from cadwyn.exceptions import CadwynError, CadwynStructureError
|
|
@@ -45,6 +48,7 @@ PossibleInstructions: TypeAlias = (
|
|
|
45
48
|
| AlterSchemaInstruction
|
|
46
49
|
| staticmethod
|
|
47
50
|
)
|
|
51
|
+
APIVersionVarType: TypeAlias = ContextVar[VersionDate | None] | ContextVar[VersionDate]
|
|
48
52
|
|
|
49
53
|
|
|
50
54
|
class VersionChange:
|
|
@@ -203,25 +207,27 @@ class Version:
|
|
|
203
207
|
class VersionBundle:
|
|
204
208
|
def __init__(
|
|
205
209
|
self,
|
|
206
|
-
|
|
207
|
-
|
|
210
|
+
latest_version: Version,
|
|
211
|
+
/,
|
|
212
|
+
*other_versions: Version,
|
|
213
|
+
api_version_var: APIVersionVarType,
|
|
208
214
|
) -> None:
|
|
209
215
|
super().__init__()
|
|
210
216
|
|
|
211
|
-
self.versions =
|
|
217
|
+
self.versions = (latest_version, *other_versions)
|
|
212
218
|
self.api_version_var = api_version_var
|
|
213
|
-
if sorted(versions, key=lambda v: v.value, reverse=True) != list(versions):
|
|
214
|
-
raise
|
|
219
|
+
if sorted(self.versions, key=lambda v: v.value, reverse=True) != list(self.versions):
|
|
220
|
+
raise CadwynStructureError(
|
|
215
221
|
"Versions are not sorted correctly. Please sort them in descending order.",
|
|
216
222
|
)
|
|
217
|
-
if versions[-1].version_changes:
|
|
223
|
+
if self.versions[-1].version_changes:
|
|
218
224
|
raise CadwynStructureError(
|
|
219
|
-
f'The first version "{versions[-1].value}" cannot have any version changes. '
|
|
225
|
+
f'The first version "{self.versions[-1].value}" cannot have any version changes. '
|
|
220
226
|
"Version changes are defined to migrate to/from a previous version so you "
|
|
221
227
|
"cannot define one for the very first version.",
|
|
222
228
|
)
|
|
223
229
|
version_values = set()
|
|
224
|
-
for version in versions:
|
|
230
|
+
for version in self.versions:
|
|
225
231
|
if version.value not in version_values:
|
|
226
232
|
version_values.add(version.value)
|
|
227
233
|
else:
|
|
@@ -293,6 +299,7 @@ class VersionBundle:
|
|
|
293
299
|
request.scope["headers"] = tuple((key.encode(), value.encode()) for key, value in request_info.headers.items())
|
|
294
300
|
del request._headers
|
|
295
301
|
# Remember this: if len(body_params) == 1, then route.body_schema == route.dependant.body_params[0]
|
|
302
|
+
|
|
296
303
|
new_kwargs, errors, _, _, _ = await solve_dependencies(
|
|
297
304
|
request=request,
|
|
298
305
|
response=response,
|
|
@@ -301,7 +308,6 @@ class VersionBundle:
|
|
|
301
308
|
# TODO: Take it from route
|
|
302
309
|
dependency_overrides_provider=None,
|
|
303
310
|
)
|
|
304
|
-
|
|
305
311
|
if errors:
|
|
306
312
|
raise RequestValidationError(_normalize_errors(errors), body=request_info.body)
|
|
307
313
|
return new_kwargs
|
|
@@ -346,7 +352,7 @@ class VersionBundle:
|
|
|
346
352
|
self,
|
|
347
353
|
template_module_body_field_for_request_migrations: type[BaseModel] | None,
|
|
348
354
|
module_body_field_name: str | None,
|
|
349
|
-
|
|
355
|
+
route: APIRoute,
|
|
350
356
|
dependant_for_request_migrations: Dependant,
|
|
351
357
|
latest_response_model: Any,
|
|
352
358
|
*,
|
|
@@ -368,7 +374,7 @@ class VersionBundle:
|
|
|
368
374
|
request_param_name,
|
|
369
375
|
kwargs,
|
|
370
376
|
response,
|
|
371
|
-
|
|
377
|
+
route,
|
|
372
378
|
)
|
|
373
379
|
|
|
374
380
|
return await self._convert_endpoint_response_to_version(
|
|
@@ -445,9 +451,8 @@ class VersionBundle:
|
|
|
445
451
|
request_param_name: str,
|
|
446
452
|
kwargs: dict[str, Any],
|
|
447
453
|
response: FastapiResponse,
|
|
448
|
-
|
|
454
|
+
route: APIRoute,
|
|
449
455
|
):
|
|
450
|
-
is_single_body_field = len(body_params) == 1
|
|
451
456
|
request: FastapiRequest = kwargs[request_param_name]
|
|
452
457
|
if request_param_name == _CADWYN_REQUEST_PARAM_NAME:
|
|
453
458
|
kwargs.pop(request_param_name)
|
|
@@ -456,14 +461,13 @@ class VersionBundle:
|
|
|
456
461
|
if api_version is None:
|
|
457
462
|
return kwargs
|
|
458
463
|
|
|
464
|
+
# TODO: What if the user never edits it? We just add a round of (de)serialization
|
|
459
465
|
if (
|
|
460
|
-
|
|
466
|
+
len(route.dependant.body_params) == 1
|
|
461
467
|
and template_module_body_field_for_request_migrations is not None
|
|
462
468
|
and body_field_alias is not None
|
|
463
469
|
and body_field_alias in kwargs
|
|
464
470
|
):
|
|
465
|
-
# TODO: What if the user never edits it? We just add a round of (de)serialization
|
|
466
|
-
|
|
467
471
|
raw_body = kwargs[body_field_alias]
|
|
468
472
|
if raw_body is None:
|
|
469
473
|
body = None
|
|
@@ -471,12 +475,8 @@ class VersionBundle:
|
|
|
471
475
|
body = raw_body.dict(by_alias=True, exclude_unset=True)
|
|
472
476
|
if kwargs[body_field_alias].__custom_root_type__:
|
|
473
477
|
body = body["__root__"]
|
|
474
|
-
# TODO: What if it's large? We need to also make ours a generator, then... But we can't because ours is
|
|
475
|
-
# synchronous. HMM... Or maybe just reading it later will solve the issue. Who knows...
|
|
476
|
-
elif any(isinstance(param.field_info, params.Form) for param in body_params):
|
|
477
|
-
body = await request.form()
|
|
478
478
|
else:
|
|
479
|
-
body = await request.
|
|
479
|
+
body = await _get_body(request, route.body_field)
|
|
480
480
|
request_info = RequestInfo(request, body)
|
|
481
481
|
new_kwargs = await self._migrate_request(
|
|
482
482
|
template_module_body_field_for_request_migrations,
|
|
@@ -493,6 +493,53 @@ class VersionBundle:
|
|
|
493
493
|
return new_kwargs
|
|
494
494
|
|
|
495
495
|
|
|
496
|
+
async def _get_body(request: FastapiRequest, body_field: ModelField | None): # pragma: no cover # This is from fastapi
|
|
497
|
+
is_body_form = body_field and isinstance(body_field.field_info, params.Form)
|
|
498
|
+
try:
|
|
499
|
+
body: Any = None
|
|
500
|
+
if body_field:
|
|
501
|
+
if is_body_form:
|
|
502
|
+
body = await request.form()
|
|
503
|
+
stack = cast(AsyncExitStack, request.scope.get("fastapi_astack"))
|
|
504
|
+
stack.push_async_callback(body.close)
|
|
505
|
+
else:
|
|
506
|
+
body_bytes = await request.body()
|
|
507
|
+
if body_bytes:
|
|
508
|
+
json_body: Any = Undefined
|
|
509
|
+
content_type_value = request.headers.get("content-type")
|
|
510
|
+
if not content_type_value:
|
|
511
|
+
json_body = await request.json()
|
|
512
|
+
else:
|
|
513
|
+
message = email.message.Message()
|
|
514
|
+
message["content-type"] = content_type_value
|
|
515
|
+
if message.get_content_maintype() == "application":
|
|
516
|
+
subtype = message.get_content_subtype()
|
|
517
|
+
if subtype == "json" or subtype.endswith("+json"):
|
|
518
|
+
json_body = await request.json()
|
|
519
|
+
if json_body != Undefined:
|
|
520
|
+
body = json_body
|
|
521
|
+
else:
|
|
522
|
+
body = body_bytes
|
|
523
|
+
except json.JSONDecodeError as e:
|
|
524
|
+
raise RequestValidationError(
|
|
525
|
+
[
|
|
526
|
+
{
|
|
527
|
+
"type": "json_invalid",
|
|
528
|
+
"loc": ("body", e.pos),
|
|
529
|
+
"msg": "JSON decode error",
|
|
530
|
+
"input": {},
|
|
531
|
+
"ctx": {"error": e.msg},
|
|
532
|
+
},
|
|
533
|
+
],
|
|
534
|
+
body=e.doc,
|
|
535
|
+
) from e
|
|
536
|
+
except HTTPException:
|
|
537
|
+
raise
|
|
538
|
+
except Exception as e: # noqa: BLE001
|
|
539
|
+
raise HTTPException(status_code=400, detail="There was an error parsing the body") from e
|
|
540
|
+
return body
|
|
541
|
+
|
|
542
|
+
|
|
496
543
|
def _add_keyword_only_parameter(
|
|
497
544
|
func: Callable,
|
|
498
545
|
param_name: str,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "cadwyn"
|
|
3
|
-
version = "2.
|
|
3
|
+
version = "2.3.0"
|
|
4
4
|
description = "Modern Stripe-like API versioning in FastAPI"
|
|
5
5
|
authors = ["Stanislav Zmiev <zmievsa@gmail.com>"]
|
|
6
6
|
license = "MIT"
|
|
@@ -35,8 +35,8 @@ python = "^3.10"
|
|
|
35
35
|
typing-extensions = "*"
|
|
36
36
|
fastapi = ">=0.96.1"
|
|
37
37
|
pydantic = "^1.10.0"
|
|
38
|
-
fastapi-header-versioning = ">=1.1.0"
|
|
39
38
|
typer = {version = ">=0.7.0", optional = true}
|
|
39
|
+
verselect-zmievsa = ">=0.0.5"
|
|
40
40
|
|
|
41
41
|
[tool.poetry.extras]
|
|
42
42
|
cli = ["typer"]
|
|
@@ -8,10 +8,10 @@ package_data = \
|
|
|
8
8
|
{'': ['*']}
|
|
9
9
|
|
|
10
10
|
install_requires = \
|
|
11
|
-
['fastapi
|
|
12
|
-
'fastapi>=0.96.1',
|
|
11
|
+
['fastapi>=0.96.1',
|
|
13
12
|
'pydantic>=1.10.0,<2.0.0',
|
|
14
|
-
'typing-extensions'
|
|
13
|
+
'typing-extensions',
|
|
14
|
+
'verselect-zmievsa>=0.0.5']
|
|
15
15
|
|
|
16
16
|
extras_require = \
|
|
17
17
|
{'cli': ['typer>=0.7.0']}
|
|
@@ -21,9 +21,9 @@ entry_points = \
|
|
|
21
21
|
|
|
22
22
|
setup_kwargs = {
|
|
23
23
|
'name': 'cadwyn',
|
|
24
|
-
'version': '2.
|
|
24
|
+
'version': '2.3.0',
|
|
25
25
|
'description': 'Modern Stripe-like API versioning in FastAPI',
|
|
26
|
-
'long_description': '# Cadwyn\n\nModern [Stripe-like](https://stripe.com/blog/api-versioning) API versioning in FastAPI\n\n---\n\n<p align="center">\n<a href="https://github.com/zmievsa/cadwyn/actions?query=workflow%3ATests+event%3Apush+branch%3Amain" target="_blank">\n <img src="https://github.com/zmievsa/cadwyn/actions/workflows/test.yaml/badge.svg?branch=main&event=push" alt="Test">\n</a>\n<a href="https://codecov.io/gh/ovsyanka83/cadwyn" target="_blank">\n <img src="https://img.shields.io/codecov/c/github/ovsyanka83/cadwyn?color=%2334D058" alt="Coverage">\n</a>\n<a href="https://pypi.org/project/cadwyn/" target="_blank">\n <img alt="PyPI" src="https://img.shields.io/pypi/v/cadwyn?color=%2334D058&label=pypi%20package" alt="Package version">\n</a>\n<a href="https://pypi.org/project/cadwyn/" target="_blank">\n <img src="https://img.shields.io/pypi/pyversions/cadwyn?color=%2334D058" alt="Supported Python versions">\n</a>\n</p>\n\n## Who is this for?\n\nCadwyn allows you to support a single version of your code, auto-generating the code/routes for older versions. You keep versioning encapsulated in small and independent "version change" modules while your business logic knows nothing about versioning.\n\nIts [approach](./docs/theory.md#ii-migration-based-response-building) will be useful if you want to:\n\n1. Support many (>2) API versions for a long time\n2. Effortlessly backport features and bugfixes to older API versions\n\n## Get started\n\nThe [documentation](https://docs.cadwyn.dev) has everything you need to get started. It is recommended to read it in the following order:\n\n1. [Tutorial](
|
|
26
|
+
'long_description': '# Cadwyn\n\nModern [Stripe-like](https://stripe.com/blog/api-versioning) API versioning in FastAPI\n\n---\n\n<p align="center">\n<a href="https://github.com/zmievsa/cadwyn/actions?query=workflow%3ATests+event%3Apush+branch%3Amain" target="_blank">\n <img src="https://github.com/zmievsa/cadwyn/actions/workflows/test.yaml/badge.svg?branch=main&event=push" alt="Test">\n</a>\n<a href="https://codecov.io/gh/ovsyanka83/cadwyn" target="_blank">\n <img src="https://img.shields.io/codecov/c/github/ovsyanka83/cadwyn?color=%2334D058" alt="Coverage">\n</a>\n<a href="https://pypi.org/project/cadwyn/" target="_blank">\n <img alt="PyPI" src="https://img.shields.io/pypi/v/cadwyn?color=%2334D058&label=pypi%20package" alt="Package version">\n</a>\n<a href="https://pypi.org/project/cadwyn/" target="_blank">\n <img src="https://img.shields.io/pypi/pyversions/cadwyn?color=%2334D058" alt="Supported Python versions">\n</a>\n</p>\n\n## Who is this for?\n\nCadwyn allows you to support a single version of your code, auto-generating the code/routes for older versions. You keep versioning encapsulated in small and independent "version change" modules while your business logic knows nothing about versioning.\n\nIts [approach](./docs/theory.md#ii-migration-based-response-building) will be useful if you want to:\n\n1. Support many (>2) API versions for a long time\n2. Effortlessly backport features and bugfixes to older API versions\n\n## Get started\n\nThe [documentation](https://docs.cadwyn.dev) has everything you need to get started. It is recommended to read it in the following order:\n\n1. [Tutorial](./tutorial.md)\n2. [Recipes](./recipes.md)\n3. [Reference](./reference.md)\n4. [Theory](./theory.md) <!-- TODO: Move section about cadwyn\'s approach to the beginning and move other approaches and "how we got here" to another article -->\n\n## Similar projects\n\nThe following projects are trying to accomplish similar results with a lot more simplistic functionality.\n\n- <https://github.com/sjkaliski/pinned>\n- <https://github.com/phillbaker/gates>\n- <https://github.com/lukepolo/laravel-api-migrations>\n- <https://github.com/tomschlick/request-migrations>\n- <https://github.com/keygen-sh/request_migrations>\n',
|
|
27
27
|
'author': 'Stanislav Zmiev',
|
|
28
28
|
'author_email': 'zmievsa@gmail.com',
|
|
29
29
|
'maintainer': 'None',
|
cadwyn-2.1.0rc1/cadwyn/header.py
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import datetime
|
|
2
|
-
import types
|
|
3
|
-
from collections.abc import Mapping
|
|
4
|
-
from contextvars import ContextVar
|
|
5
|
-
from typing import Any
|
|
6
|
-
|
|
7
|
-
from fastapi import Depends, Header
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
def get_cadwyn_dependency(
|
|
11
|
-
*,
|
|
12
|
-
version_header_name: str,
|
|
13
|
-
default_version: datetime.date | None = None,
|
|
14
|
-
extra_kwargs_to_header_constructor: Mapping[str, Any] = types.MappingProxyType({}),
|
|
15
|
-
api_version_var: ContextVar[datetime.date | None] | ContextVar[datetime.date],
|
|
16
|
-
) -> Any:
|
|
17
|
-
if default_version is None:
|
|
18
|
-
extra_kwargs: Mapping[str, Any] = extra_kwargs_to_header_constructor
|
|
19
|
-
else:
|
|
20
|
-
extra_kwargs = extra_kwargs_to_header_constructor | {"default": default_version}
|
|
21
|
-
|
|
22
|
-
async def dependency(
|
|
23
|
-
api_version: datetime.date = Header(alias=version_header_name, **extra_kwargs), # noqa: B008
|
|
24
|
-
):
|
|
25
|
-
api_version_var.set(api_version)
|
|
26
|
-
return api_version
|
|
27
|
-
|
|
28
|
-
return Depends(dependency)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|