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.
- fast_version-1.0.0/PKG-INFO +62 -0
- fast_version-1.0.0/README.md +44 -0
- fast_version-1.0.0/fast_version/__init__.py +8 -0
- fast_version-1.0.0/fast_version/app.py +141 -0
- fast_version-1.0.0/fast_version/helpers.py +26 -0
- fast_version-1.0.0/fast_version/py.typed +0 -0
- fast_version-1.0.0/fast_version/router.py +73 -0
- fast_version-1.0.0/pyproject.toml +49 -0
|
@@ -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,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"
|