Skip to content

Commit e57bf40

Browse files
committed
preview
1 parent 5149e6d commit e57bf40

19 files changed

+2333
-0
lines changed

.gitignore

+3
Original file line numberDiff line numberDiff line change
@@ -150,3 +150,6 @@ cython_debug/
150150
# and can be added to the global gitignore or merged into this file. For a more nuclear
151151
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
152152
#.idea/
153+
154+
/.vscode/*
155+
!.vscode/extensions.json

.pre-commit-config.yaml

+35
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# See https://pre-commit.com for more information
2+
# See https://pre-commit.com/hooks.html for more hooks
3+
4+
# # set `default_language_version` need this version of Python existed on the computer
5+
# default_language_version:
6+
# python: python3.10
7+
8+
repos:
9+
- repo: https://github.com/pre-commit/pre-commit-hooks
10+
rev: v4.5.0
11+
hooks:
12+
- id: no-commit-to-branch
13+
- id: check-added-large-files
14+
- id: check-toml
15+
- id: check-json
16+
- id: check-yaml
17+
args:
18+
- --unsafe
19+
- id: end-of-file-fixer
20+
- id: trailing-whitespace
21+
# ruff must before black
22+
- repo: https://github.com/charliermarsh/ruff-pre-commit
23+
rev: v0.1.1
24+
hooks:
25+
- id: ruff
26+
args: [--fix, --exit-non-zero-on-fix]
27+
- repo: https://github.com/psf/black-pre-commit-mirror
28+
rev: 23.10.0
29+
hooks:
30+
- id: black
31+
- repo: https://github.com/RobertCraigie/pyright-python
32+
rev: v1.1.332
33+
hooks:
34+
- id: pyright
35+
args: [-p, ".pre-commit-pyrightconfig.json"] # ignore `reportMissingImports`

.pre-commit-pyrightconfig.json

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"typeCheckingMode": "strict",
3+
"pythonVersion": "3.8",
4+
"reportUnusedImport": "warning",
5+
"reportUnusedFunction": "warning",
6+
"reportUnusedExpression": "warning",
7+
"reportUnusedVariable": "warning",
8+
"reportUnnecessaryTypeIgnoreComment": true,
9+
"reportPrivateUsage": "warning",
10+
"reportUnnecessaryIsInstance": "warning",
11+
"reportIncompatibleMethodOverride": "warning",
12+
"reportMissingTypeArgument": true,
13+
"reportMissingParameterType": true
14+
}

.vscode/extensions.json

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{
2+
"recommendations": [
3+
"njpwerner.autodocstring",
4+
"ms-python.black-formatter",
5+
"ms-python.vscode-pylance",
6+
"ms-python.python",
7+
"charliermarsh.ruff"
8+
]
9+
}

CONTRIBUTING.md

+55
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# Hi 感谢对本项目感兴趣
2+
3+
## 代码贡献
4+
5+
> **Note**
6+
>
7+
> 我们最低支持的版本是`python3.8`,你可以随意使用`python3.8`以上的版本。
8+
>
9+
> 但是请注意,您的代码要能通过`py >= 3.8`的所有版本的代码风格检查与测试。
10+
11+
我们推荐使用[虚拟环境](https://docs.python.org/3/library/venv.html#creating-virtual-environments)来进行开发
12+
13+
在激活虚拟环境后,运行[./scripts/init.sh](./scripts/init-dev.sh)来为你初始化:
14+
15+
- [requirements](./requirements-dev.txt)
16+
- [hatch](https://github.com/pypa/hatch)
17+
- [pre-commit](https://pre-commit.com/)
18+
- [mkdocs-material](https://squidfunk.github.io/mkdocs-material/)
19+
20+
## 代码风格
21+
22+
由于本项目主要是python语言,所以我们只强制要求python代码风格。
23+
24+
但是涉及到其他语言(例如 `bash`, `toml` 等)的部分,也请尽力保证样式正确。
25+
26+
> **Note**
27+
> 我们启用了大量而严格的`lint`规则,如果你使用`vscode`,我们推荐你安装[./.vscode/extensions.json](./.vscode/extensions.json)中的插件来帮助你实时检查错误
28+
>
29+
> 如果你打不过检查器,可以使用`# noqa``# type: ignore`来跳过检查
30+
>
31+
> 当然,这些规则可能过于严格,你可以发起一个`issue`或者`pull request`来讨论是否需要修改规则
32+
33+
### python代码风格(请查看[./pyproject.toml](./pyproject.toml)了解我们的风格)
34+
35+
- [Ruff](https://github.com/astral-sh/ruff): 代码质量检查
36+
- [Blcak](https://github.com/psf/black): 代码格式规范
37+
- [Pyright](https://github.com/Microsoft/pyright/): 静态类型检查
38+
39+
您需要通过以上检查,`pre-commit` 会确保这点。
40+
41+
你也可以[手动检查](./scripts/lint.sh)[自动修复](./scripts/format.sh)
42+
43+
> **Note**
44+
> `Pyright`检查将发生两次
45+
>
46+
> -`pre-commit`中,`Pyright`不会检查第三方依赖,并且`python`版本为支持的最低版本
47+
> - 而在`Github Actions`[手动检查](./scripts/lint.sh)中,`Pyright`将在激活的虚拟环境中检查所有依赖
48+
49+
## 代码测试
50+
51+
测试文件位于[./tests/](./tests/)
52+
53+
我们使用`pytest`来完成测试,测试将在`Github Actions`中进行。
54+
55+
你也可以[手动测试](./scripts/pytest.sh)

README.md

Whitespace-only changes.

fastapi_proxy/__init__.py

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
"""fastapi_proxy package."""
2+
3+
__version__ = "0.0.1"

fastapi_proxy/app/utils.py

+226
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
"""User-oriented helper functions."""
2+
3+
import asyncio
4+
import warnings
5+
from contextlib import asynccontextmanager
6+
from typing import (
7+
Any,
8+
AsyncContextManager,
9+
AsyncIterator,
10+
Awaitable,
11+
Callable,
12+
Iterable,
13+
Literal,
14+
Optional,
15+
Set,
16+
Tuple,
17+
TypeVar,
18+
Union,
19+
)
20+
21+
import httpx
22+
from fastapi import APIRouter
23+
from starlette.requests import Request
24+
from starlette.responses import Response
25+
from starlette.websockets import WebSocket
26+
from typing_extensions import overload
27+
28+
from fastapi_proxy.core.http import ForwardHttpProxy, ReverseHttpProxy
29+
from fastapi_proxy.core.websocket import ReverseWebSocketProxy
30+
31+
_HttpProxyTypes = Union[ForwardHttpProxy, ReverseHttpProxy]
32+
_WebSocketProxyTypes = ReverseWebSocketProxy
33+
34+
35+
_T = TypeVar("_T")
36+
_T_co = TypeVar("_T_co", covariant=True)
37+
_LifeEventTypes = Callable[[_T_co], Awaitable[None]]
38+
_APIRouterTypes = TypeVar("_APIRouterTypes", bound=APIRouter)
39+
40+
41+
_HttpMethodTypes = Tuple[
42+
Literal["get"],
43+
Literal["post"],
44+
Literal["put"],
45+
Literal["delete"],
46+
Literal["options"],
47+
Literal["head"],
48+
Literal["patch"],
49+
Literal["trace"],
50+
]
51+
HTTP_METHODS: _HttpMethodTypes = (
52+
"get",
53+
"post",
54+
"put",
55+
"delete",
56+
"options",
57+
"head",
58+
"patch",
59+
"trace",
60+
)
61+
62+
63+
# https://fastapi.tiangolo.com/zh/advanced/events/
64+
def lifespan_event_factory(
65+
*,
66+
startup_events: Optional[Iterable[_LifeEventTypes[_T]]] = None,
67+
shutdown_events: Optional[Iterable[_LifeEventTypes[_T]]] = None,
68+
) -> Callable[[_T], AsyncContextManager[None]]:
69+
"""Create lifespan event for app.
70+
71+
When the app startup, await all the startup events.
72+
When the app shutdown, await all the shutdown events.
73+
74+
The `app` will pass into the event as the first argument.
75+
76+
Args:
77+
startup_events:
78+
An iterative container,
79+
where each element is an asynchronous function
80+
that needs to accept first positional parameter for `app`.
81+
shutdown_events:
82+
The same as `startup_events`.
83+
84+
Returns:
85+
app lifespan event.
86+
"""
87+
88+
@asynccontextmanager
89+
async def lifespan(app: _T) -> AsyncIterator[None]:
90+
if startup_events is not None:
91+
await asyncio.gather(*[event(app) for event in startup_events])
92+
yield
93+
if shutdown_events is not None:
94+
await asyncio.gather(*[event(app) for event in shutdown_events])
95+
96+
return lifespan
97+
98+
99+
def _http_register_router(
100+
proxy: _HttpProxyTypes,
101+
router: APIRouter,
102+
**kwargs: Any,
103+
) -> None:
104+
kwargs.pop("path", None)
105+
106+
@router.get("/{path:path}", **kwargs)
107+
@router.post("/{path:path}", **kwargs)
108+
@router.put("/{path:path}", **kwargs)
109+
@router.delete("/{path:path}", **kwargs)
110+
@router.options("/{path:path}", **kwargs)
111+
@router.head("/{path:path}", **kwargs)
112+
@router.patch("/{path:path}", **kwargs)
113+
@router.trace("/{path:path}", **kwargs)
114+
async def http_proxy( # pyright: ignore[reportUnusedFunction]
115+
request: Request, path: str = ""
116+
) -> Response:
117+
"""HTTP proxy endpoint."""
118+
return await proxy.proxy(request=request, path=path)
119+
120+
121+
def _ws_register_router(
122+
proxy: _WebSocketProxyTypes,
123+
router: APIRouter,
124+
**kwargs: Any,
125+
) -> None:
126+
kwargs.pop("path", None)
127+
128+
@router.websocket("/{path:path}", **kwargs)
129+
async def ws_proxy( # pyright: ignore[reportUnusedFunction]
130+
websocket: WebSocket, path: str = ""
131+
) -> Union[Response, Literal[True]]:
132+
"""WebSocket proxy endpoint."""
133+
return await proxy.proxy(websocket=websocket, path=path)
134+
135+
136+
class RouterHelper:
137+
"""Helper class to register proxy to fastapi router."""
138+
139+
def __init__(self):
140+
"""Initialize RouterHelper."""
141+
self._registered_clients: Set[httpx.AsyncClient] = set()
142+
self._registered_router_id: Set[int] = set()
143+
144+
@property
145+
def registered_clients(self) -> Set[httpx.AsyncClient]:
146+
"""The httpx.AsyncClient that has been registered."""
147+
return self._registered_clients
148+
149+
@overload
150+
def register_router(
151+
self,
152+
proxy: Union[_HttpProxyTypes, _WebSocketProxyTypes],
153+
router: Optional[None] = None,
154+
**endpoint_kwargs: Any,
155+
) -> APIRouter:
156+
...
157+
158+
@overload
159+
def register_router(
160+
self,
161+
proxy: Union[_HttpProxyTypes, _WebSocketProxyTypes],
162+
router: _APIRouterTypes,
163+
**endpoint_kwargs: Any,
164+
) -> _APIRouterTypes:
165+
...
166+
167+
def register_router(
168+
self,
169+
proxy: Union[_HttpProxyTypes, _WebSocketProxyTypes],
170+
router: Optional[APIRouter] = None,
171+
**endpoint_kwargs: Any,
172+
) -> APIRouter:
173+
"""Register proxy to router.
174+
175+
Args:
176+
proxy: The http/websocket proxy to register.
177+
router: The fastapi router to register.
178+
If None, will create a new router.
179+
Usually, you don't need to set the argument, unless you want set some arguments to router.
180+
Note: the same router can only be registered once.
181+
endpoint_kwargs: The kwargs to pass to router endpoint(e.g `router.get()`).
182+
183+
Raises:
184+
TypeError: If pass a unknown proxy type.
185+
186+
Returns:
187+
A fastapi router.
188+
"""
189+
router = APIRouter() if router is None else router
190+
191+
# 检查传入的 router 是否已经被注册过,因为 router 不能hash,所以只能用id来判断
192+
# HACK: 如果之前记录的router已经被销毁了,新的router可能会有相同的id
193+
router_id = id(router)
194+
if id(router) in self._registered_router_id:
195+
msg = (
196+
f"The router {router} (id: {router_id}) has been registered, "
197+
f"\033[33myou should not use it to register again in any case\033[m."
198+
)
199+
warnings.warn(msg, stacklevel=2)
200+
else:
201+
self._registered_router_id.add(router_id)
202+
203+
if isinstance(proxy, (ForwardHttpProxy, ReverseHttpProxy)):
204+
_http_register_router(proxy, router, **endpoint_kwargs)
205+
elif isinstance(
206+
proxy, ReverseWebSocketProxy
207+
): # pyright: ignore[reportUnnecessaryIsInstance]
208+
_ws_register_router(proxy, router, **endpoint_kwargs)
209+
else:
210+
msg = (
211+
f"Unknown proxy type: {type(proxy)}, "
212+
f"only support: {_HttpProxyTypes} and {_WebSocketProxyTypes}"
213+
)
214+
raise TypeError(msg)
215+
self._registered_clients.add(proxy.client)
216+
return router
217+
218+
def get_lifespan(self) -> Callable[[Any], AsyncContextManager[None]]:
219+
"""The lifespan event for close registered clients."""
220+
221+
async def shutdown_clients(_: Any):
222+
await asyncio.gather(
223+
*[client.aclose() for client in self.registered_clients]
224+
)
225+
226+
return lifespan_event_factory(shutdown_events=[shutdown_clients])

0 commit comments

Comments
 (0)