fast-version 1.0.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.
@@ -0,0 +1,62 @@
1
+ Metadata-Version: 2.1
2
+ Name: fast-version
3
+ Version: 1.0.0
4
+ Summary: Fastapi versioning package with accept header
5
+ Home-page: https://github.com/modern-python/fast-version
6
+ Author: Artur Shiriev
7
+ Author-email: me@shiriev.ru
8
+ Requires-Python: >=3.8,<=3.12
9
+ Classifier: Framework :: Pytest
10
+ Classifier: Programming Language :: Python :: 3
11
+ Classifier: Programming Language :: Python :: 3.8
12
+ Classifier: Programming Language :: Python :: 3.9
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Requires-Dist: fastapi (>=0.100)
17
+ Description-Content-Type: text/markdown
18
+
19
+ FastAPI versioning library
20
+ ==
21
+ This package adds versioning by Accept-header into FastAPI
22
+
23
+ ## Quickstart:
24
+ ### Defining app and routes
25
+ ```python
26
+ import fastapi
27
+
28
+ from fast_version import VersionedAPIRouter, init_fastapi_versioning
29
+
30
+
31
+ VERSION_HEADER: str = "application/vnd.some.name+json"
32
+ ROUTER_OBJ = VersionedAPIRouter()
33
+
34
+
35
+ @ROUTER_OBJ.get("/test/")
36
+ async def test_get() -> dict:
37
+ return {"version": (1, 0)}
38
+
39
+
40
+ @ROUTER_OBJ.get("/test/")
41
+ @ROUTER_OBJ.set_api_version((2, 0))
42
+ async def test_get_v2() -> dict:
43
+ return {"version": (2, 0)}
44
+
45
+
46
+ app = fastapi.FastAPI()
47
+ app.include_router(ROUTER_OBJ)
48
+ init_fastapi_versioning(app=app, vendor_media_type=VERSION_HEADER)
49
+ ```
50
+
51
+ ### Query Examples
52
+ ```bash
53
+ # call 1.0 version
54
+ curl -X 'GET' 'https://test.ru/test/' -H 'accept: application/vnd.some.name+json; version=1.0'
55
+
56
+ curl -X 'GET' 'https://test.ru/test/' -H 'accept: application/vnd.some.name+json'
57
+
58
+ curl -X 'GET' 'https://test.ru/test/'
59
+
60
+ # call 2.0 version
61
+ curl -X 'GET' 'https://test.ru/test/' -H 'accept: application/vnd.some.name+json; version=2.0'
62
+ ```
@@ -0,0 +1,44 @@
1
+ FastAPI versioning library
2
+ ==
3
+ This package adds versioning by Accept-header into FastAPI
4
+
5
+ ## Quickstart:
6
+ ### Defining app and routes
7
+ ```python
8
+ import fastapi
9
+
10
+ from fast_version import VersionedAPIRouter, init_fastapi_versioning
11
+
12
+
13
+ VERSION_HEADER: str = "application/vnd.some.name+json"
14
+ ROUTER_OBJ = VersionedAPIRouter()
15
+
16
+
17
+ @ROUTER_OBJ.get("/test/")
18
+ async def test_get() -> dict:
19
+ return {"version": (1, 0)}
20
+
21
+
22
+ @ROUTER_OBJ.get("/test/")
23
+ @ROUTER_OBJ.set_api_version((2, 0))
24
+ async def test_get_v2() -> dict:
25
+ return {"version": (2, 0)}
26
+
27
+
28
+ app = fastapi.FastAPI()
29
+ app.include_router(ROUTER_OBJ)
30
+ init_fastapi_versioning(app=app, vendor_media_type=VERSION_HEADER)
31
+ ```
32
+
33
+ ### Query Examples
34
+ ```bash
35
+ # call 1.0 version
36
+ curl -X 'GET' 'https://test.ru/test/' -H 'accept: application/vnd.some.name+json; version=1.0'
37
+
38
+ curl -X 'GET' 'https://test.ru/test/' -H 'accept: application/vnd.some.name+json'
39
+
40
+ curl -X 'GET' 'https://test.ru/test/'
41
+
42
+ # call 2.0 version
43
+ curl -X 'GET' 'https://test.ru/test/' -H 'accept: application/vnd.some.name+json; version=2.0'
44
+ ```
@@ -0,0 +1,8 @@
1
+ from fast_version.app import init_fastapi_versioning
2
+ from fast_version.router import VersionedAPIRouter
3
+
4
+
5
+ __all__ = [
6
+ "init_fastapi_versioning",
7
+ "VersionedAPIRouter",
8
+ ]
@@ -0,0 +1,141 @@
1
+ import contextlib
2
+ import copy
3
+ import re
4
+ import typing
5
+ from types import MethodType
6
+
7
+ import fastapi
8
+ from fastapi.openapi.utils import get_openapi
9
+ from starlette import types
10
+ from starlette.responses import JSONResponse
11
+
12
+ from fast_version import helpers
13
+ from fast_version.router import VersionedAPIRoute, VersionedAPIRouter
14
+
15
+
16
+ _VERSION_RE: typing.Final = re.compile(r"^\d\.\d$")
17
+
18
+
19
+ def _get_vendor_media_type() -> str:
20
+ return VersionedAPIRouter.VENDOR_MEDIA_TYPE
21
+
22
+
23
+ def init_fastapi_versioning(*, app: fastapi.FastAPI, vendor_media_type: str) -> None:
24
+ VersionedAPIRouter.VENDOR_MEDIA_TYPE = vendor_media_type
25
+ app.add_middleware(FastAPIVersioningMiddleware)
26
+ app.openapi = MethodType(_custom_openapi, app) # type: ignore[method-assign]
27
+
28
+
29
+ class FastAPIVersioningMiddleware:
30
+ def __init__(self, app: fastapi.FastAPI) -> None:
31
+ self.app = app
32
+
33
+ async def __call__(
34
+ self,
35
+ scope: types.Scope,
36
+ receive: types.Receive,
37
+ send: types.Send,
38
+ ) -> None:
39
+ error_response: JSONResponse | None = None
40
+ while True:
41
+ if scope["type"] != "http":
42
+ break
43
+
44
+ accept_header_from_request = helpers.get_accept_header_from_scope(scope)
45
+ if not accept_header_from_request or accept_header_from_request == "*/*":
46
+ break
47
+
48
+ media_type: str
49
+ version_str: str
50
+ try:
51
+ media_type, version_str = accept_header_from_request.split(";")
52
+ except ValueError:
53
+ break
54
+
55
+ if media_type.strip() != _get_vendor_media_type():
56
+ error_response = JSONResponse(
57
+ {"detail": "Wrong media type"},
58
+ status_code=406,
59
+ )
60
+ break
61
+
62
+ version = ""
63
+ version_key = ""
64
+ with contextlib.suppress(ValueError):
65
+ version_key, version = version_str.strip().split("=")
66
+
67
+ if version_key.lower().strip() != "version":
68
+ error_response = JSONResponse(
69
+ {"detail": "No version in Accept header"},
70
+ status_code=400,
71
+ )
72
+ break
73
+
74
+ if not _VERSION_RE.match(version):
75
+ error_response = JSONResponse(
76
+ {"detail": "Version should be in <major>.<minor> format"},
77
+ status_code=400,
78
+ )
79
+ break
80
+
81
+ scope["version"] = tuple(
82
+ int(version_part) for version_part in version.split(".")
83
+ )
84
+ break
85
+ if error_response:
86
+ return await error_response(scope, receive, send)
87
+ return await self.app(scope, receive, send)
88
+
89
+
90
+ def _custom_openapi(self: fastapi.FastAPI) -> dict[str, typing.Any]:
91
+ if self.openapi_schema:
92
+ return self.openapi_schema
93
+
94
+ routes = []
95
+ for route_item in self.routes:
96
+ if not isinstance(route_item, VersionedAPIRoute):
97
+ routes.append(route_item)
98
+ continue
99
+
100
+ # trick to avoid merging routes
101
+ route_copy: VersionedAPIRoute = copy.copy(route_item)
102
+ route_copy.path_format = f"{route_copy.path_format}:{route_copy.version_str}"
103
+ routes.append(route_copy)
104
+
105
+ self.openapi_schema = get_openapi(
106
+ title=self.title,
107
+ version=self.version,
108
+ openapi_version=self.openapi_version,
109
+ summary=self.summary,
110
+ description=self.description,
111
+ terms_of_service=self.terms_of_service,
112
+ contact=self.contact,
113
+ license_info=self.license_info,
114
+ routes=routes,
115
+ webhooks=self.webhooks.routes,
116
+ tags=self.openapi_tags,
117
+ servers=self.servers,
118
+ )
119
+ paths_dict = {}
120
+ raw_path: str
121
+ methods: dict[str, typing.Any]
122
+ for raw_path, methods in self.openapi_schema["paths"].items():
123
+ if ":" not in raw_path:
124
+ paths_dict[raw_path] = methods
125
+ continue
126
+ clean_path, version = raw_path.split(":")
127
+ for payload in methods.values():
128
+ if "requestBody" not in payload:
129
+ continue
130
+ payload["requestBody"]["content"] = {
131
+ f"{_get_vendor_media_type()}; version={version}": v
132
+ for k, v in payload["requestBody"]["content"].items()
133
+ }
134
+
135
+ if clean_path not in paths_dict:
136
+ paths_dict[clean_path] = methods
137
+ continue
138
+
139
+ helpers.dict_merge(paths_dict[clean_path], methods)
140
+ self.openapi_schema["paths"] = paths_dict
141
+ return self.openapi_schema
@@ -0,0 +1,26 @@
1
+ import typing
2
+
3
+ from starlette import datastructures, types
4
+
5
+
6
+ def dict_merge(dict1: dict[str, typing.Any], dict2: dict[str, typing.Any]) -> None:
7
+ for key in dict2:
8
+ if key in dict1:
9
+ if isinstance(dict1[key], dict) and isinstance(dict2[key], dict):
10
+ dict_merge(dict1[key], dict2[key])
11
+ else:
12
+ dict1[key] = dict2[key]
13
+
14
+
15
+ def get_accept_header_from_scope(scope: types.Scope) -> str:
16
+ headers = datastructures.Headers(scope=scope)
17
+ return headers.get("Accept", "").strip().lower()
18
+
19
+
20
+ class ClassProperty:
21
+ # ruff: noqa: ANN401
22
+ def __init__(self, function: typing.Any) -> None:
23
+ self.function: typing.Any = classmethod(function)
24
+
25
+ def __get__(self, *args: typing.Any) -> typing.Any:
26
+ return self.function.__get__(*args)()
File without changes
@@ -0,0 +1,73 @@
1
+ import typing
2
+
3
+ from fastapi.routing import APIRoute, APIRouter
4
+ from fastapi.types import DecoratedCallable
5
+ from starlette import types
6
+ from starlette.responses import JSONResponse
7
+ from starlette.routing import Match
8
+
9
+ from fast_version.helpers import ClassProperty
10
+
11
+
12
+ DEFAULT_VERSION: typing.Final = (1, 0)
13
+
14
+
15
+ # ruff: noqa: B009, B010
16
+ # allow getattr, setattr with const
17
+ class VersionedAPIRoute(APIRoute):
18
+ @property
19
+ def version(self) -> tuple[int, int]:
20
+ return typing.cast(tuple[int, int], getattr(self.endpoint, "version"))
21
+
22
+ @property
23
+ def version_str(self) -> str:
24
+ return ".".join(str(x) for x in self.version)
25
+
26
+ def matches(self, scope: types.Scope) -> tuple[Match, types.Scope]:
27
+ match, child_scope = super().matches(scope)
28
+ if match != Match.FULL:
29
+ return match, child_scope
30
+
31
+ request_version: tuple[int, int] = scope.get("version", DEFAULT_VERSION)
32
+ if request_version == self.version:
33
+ return Match.FULL, child_scope
34
+ return Match.NONE, {}
35
+
36
+
37
+ class VersionedAPIRouter(APIRouter):
38
+ VENDOR_MEDIA_TYPE = ""
39
+
40
+ def api_route(
41
+ self,
42
+ path: str,
43
+ **kwargs: typing.Any, # noqa: ANN401
44
+ ) -> typing.Callable[[DecoratedCallable], DecoratedCallable]:
45
+ def decorator(func: DecoratedCallable) -> DecoratedCallable:
46
+ if not hasattr(func, "version"):
47
+ setattr(func, "version", DEFAULT_VERSION)
48
+ version_str = ".".join([str(x) for x in getattr(func, "version")])
49
+
50
+ class VersionedJSONResponse(JSONResponse):
51
+ @ClassProperty
52
+ def media_type(self) -> str: # type: ignore[override]
53
+ """Media type for docs."""
54
+ return (
55
+ f"{VersionedAPIRouter.VENDOR_MEDIA_TYPE}; version={version_str}"
56
+ )
57
+
58
+ kwargs["response_class"] = VersionedJSONResponse
59
+ kwargs["route_class_override"] = VersionedAPIRoute
60
+ self.add_api_route(path, func, **kwargs)
61
+ return func
62
+
63
+ return decorator
64
+
65
+ @staticmethod
66
+ def set_api_version(
67
+ version: tuple[int, int],
68
+ ) -> typing.Callable[[DecoratedCallable], DecoratedCallable]:
69
+ def decorator(func: DecoratedCallable) -> DecoratedCallable:
70
+ setattr(func, "version", version)
71
+ return func
72
+
73
+ return decorator
@@ -0,0 +1,49 @@
1
+ [tool.poetry]
2
+ name = "fast-version"
3
+ version = "1.0.0"
4
+ description = "Fastapi versioning package with accept header"
5
+ authors = ["Artur Shiriev <me@shiriev.ru>"]
6
+ readme = "README.md"
7
+ classifiers = ["Programming Language :: Python :: 3.11", "Framework :: Pytest"]
8
+ homepage = "https://github.com/modern-python/fast-version"
9
+ packages = [
10
+ { include = "fast_version" },
11
+ ]
12
+
13
+ [tool.poetry.dependencies]
14
+ python = ">=3.8,<=3.12"
15
+ fastapi = ">=0.100"
16
+
17
+ [tool.poetry.dev-dependencies]
18
+ pytest = "*"
19
+ pytest-asyncio = "*"
20
+ pytest-cov = "*"
21
+ httpx = "*"
22
+
23
+ [tool.mypy]
24
+ python_version = "3.11"
25
+ strict = true
26
+
27
+ [tool.ruff]
28
+ line-length = 120
29
+ target-version = "py311"
30
+ select = ["ALL"]
31
+ ignore = [
32
+ "D1", # allow missing docstrings
33
+ "S101", # allow asserts
34
+ "TCH", # ignore flake8-type-checking
35
+ "FBT", # allow boolean args
36
+ "ANN101", # missing-type-self
37
+ ]
38
+
39
+ [tool.ruff.isort]
40
+ lines-after-imports = 2
41
+ no-lines-before = ["standard-library", "local-folder"]
42
+
43
+ [tool.pytest.ini_options]
44
+ addopts = "--cov=. --cov-report term-missing"
45
+ asyncio_mode = "auto"
46
+
47
+ [build-system]
48
+ requires = ["poetry-core>=1.0.0"]
49
+ build-backend = "poetry.core.masonry.api"