fastabc 0.2.2__tar.gz → 4.6.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.
- {fastabc-0.2.2 → fastabc-4.6.0}/PKG-INFO +6 -4
- fastabc-4.6.0/fastabc/__init__.py +16 -0
- fastabc-4.6.0/fastabc/baseclasses.py +219 -0
- fastabc-4.6.0/fastabc/constants.py +21 -0
- fastabc-4.6.0/fastabc/exceptions.py +3 -0
- fastabc-4.6.0/fastabc/middlewares/__init__.py +18 -0
- fastabc-4.6.0/fastabc/middlewares/auth.py +60 -0
- fastabc-4.6.0/fastabc/middlewares/headers.py +50 -0
- fastabc-4.6.0/fastabc/middlewares/logging.py +83 -0
- fastabc-4.6.0/fastabc/middlewares/proxy.py +118 -0
- fastabc-4.6.0/fastabc/middlewares/utils.py +38 -0
- {fastabc-0.2.2 → fastabc-4.6.0}/fastabc/structures.py +2 -2
- fastabc-4.6.0/pyproject.toml +25 -0
- fastabc-0.2.2/fastabc/__init__.py +0 -11
- fastabc-0.2.2/fastabc/baseclasses.py +0 -46
- fastabc-0.2.2/pyproject.toml +0 -21
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: fastabc
|
|
3
|
-
Version:
|
|
4
|
-
Summary:
|
|
3
|
+
Version: 4.6.0
|
|
4
|
+
Summary: Base classes for FastAPI applications with Sanic compatibility layer
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Aleksandr Yurlov
|
|
7
7
|
Author-email: sasha.yur@mail.ru
|
|
@@ -12,5 +12,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
12
12
|
Classifier: Programming Language :: Python :: 3.11
|
|
13
13
|
Classifier: Programming Language :: Python :: 3.12
|
|
14
14
|
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
-
|
|
16
|
-
Requires-Dist:
|
|
15
|
+
Provides-Extra: proxy
|
|
16
|
+
Requires-Dist: fastapi (>=0.100.0)
|
|
17
|
+
Requires-Dist: httpx (>=0.24.0) ; extra == "proxy"
|
|
18
|
+
Requires-Dist: uvicorn (>=0.15.0)
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing as t
|
|
4
|
+
|
|
5
|
+
from .baseclasses import Api, App
|
|
6
|
+
|
|
7
|
+
__version__ = "4.6.0"
|
|
8
|
+
__all__ = ["Api", "App"]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def start_server(app: App, create_fn: t.Callable[[], App], **config) -> None:
|
|
12
|
+
if config.get("single_process", True):
|
|
13
|
+
return app.run_single(**config)
|
|
14
|
+
|
|
15
|
+
app.loader(create_fn, **config)
|
|
16
|
+
return app.run_single(**config)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import asyncio
|
|
4
|
+
import inspect
|
|
5
|
+
import typing as t
|
|
6
|
+
from logging.config import dictConfig
|
|
7
|
+
|
|
8
|
+
from fastapi import APIRouter, FastAPI
|
|
9
|
+
|
|
10
|
+
from .structures import FastAPIConfig
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AppConfig:
|
|
14
|
+
"""Sanic-совместимая обёртка для app.config.
|
|
15
|
+
|
|
16
|
+
Хранит конфигурацию в upper-case как в Sanic и предоставляет метод update_config().
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
def __init__(self):
|
|
20
|
+
self._data: dict = {}
|
|
21
|
+
|
|
22
|
+
def update_config(self, config) -> None:
|
|
23
|
+
if hasattr(config, "to_dict"):
|
|
24
|
+
data = config.to_dict(uppercase=True) if self._supports_uppercase(config) else config.to_dict()
|
|
25
|
+
data = {k.upper(): v for k, v in data.items()}
|
|
26
|
+
elif isinstance(config, dict):
|
|
27
|
+
data = {k.upper(): v for k, v in config.items()}
|
|
28
|
+
else:
|
|
29
|
+
return
|
|
30
|
+
self._data.update(data)
|
|
31
|
+
|
|
32
|
+
@staticmethod
|
|
33
|
+
def _supports_uppercase(config) -> bool:
|
|
34
|
+
try:
|
|
35
|
+
return "uppercase" in inspect.signature(config.to_dict).parameters
|
|
36
|
+
except (ValueError, TypeError):
|
|
37
|
+
return False
|
|
38
|
+
|
|
39
|
+
def get(self, key: str, default=None):
|
|
40
|
+
return self._data.get(key.upper(), default)
|
|
41
|
+
|
|
42
|
+
def __getitem__(self, key: str):
|
|
43
|
+
return self._data[key.upper()]
|
|
44
|
+
|
|
45
|
+
def __setitem__(self, key: str, value):
|
|
46
|
+
self._data[key.upper()] = value
|
|
47
|
+
|
|
48
|
+
def __contains__(self, key: str) -> bool:
|
|
49
|
+
return key.upper() in self._data
|
|
50
|
+
|
|
51
|
+
def __iter__(self):
|
|
52
|
+
return iter(self._data)
|
|
53
|
+
|
|
54
|
+
def __repr__(self):
|
|
55
|
+
return repr(self._data)
|
|
56
|
+
|
|
57
|
+
def items(self):
|
|
58
|
+
return self._data.items()
|
|
59
|
+
|
|
60
|
+
def keys(self):
|
|
61
|
+
return self._data.keys()
|
|
62
|
+
|
|
63
|
+
def values(self):
|
|
64
|
+
return self._data.values()
|
|
65
|
+
|
|
66
|
+
def to_dict(self) -> dict:
|
|
67
|
+
return dict(self._data)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class App(FastAPI):
|
|
71
|
+
|
|
72
|
+
def __init__(
|
|
73
|
+
self,
|
|
74
|
+
*args,
|
|
75
|
+
name: str | None = None,
|
|
76
|
+
log_config=None,
|
|
77
|
+
**kwargs,
|
|
78
|
+
):
|
|
79
|
+
if name is not None and "title" not in kwargs:
|
|
80
|
+
kwargs["title"] = name
|
|
81
|
+
super().__init__(*args, **kwargs)
|
|
82
|
+
self._name = name or kwargs.get("title") or self.__class__.__name__
|
|
83
|
+
self.state.extensions = {}
|
|
84
|
+
self.state._config = AppConfig()
|
|
85
|
+
|
|
86
|
+
if log_config:
|
|
87
|
+
log_dict = log_config.to_dict() if hasattr(log_config, "to_dict") else log_config
|
|
88
|
+
if log_dict:
|
|
89
|
+
dictConfig(log_dict)
|
|
90
|
+
|
|
91
|
+
@property
|
|
92
|
+
def name(self) -> str:
|
|
93
|
+
return self._name
|
|
94
|
+
|
|
95
|
+
def loader(self, create_fn: t.Callable, **kwargs):
|
|
96
|
+
"""Аналог Sanic AppLoader — сохраняет фабрику для multi-worker.
|
|
97
|
+
В single_process режиме не используется (start_server вызывает run_single напрямую).
|
|
98
|
+
"""
|
|
99
|
+
self._create_fn = create_fn
|
|
100
|
+
self._create_kwargs = kwargs
|
|
101
|
+
return self
|
|
102
|
+
|
|
103
|
+
def run_single(self, **kwargs):
|
|
104
|
+
import uvicorn
|
|
105
|
+
|
|
106
|
+
config = self.make_startup_config(**kwargs)
|
|
107
|
+
uvicorn_config = {
|
|
108
|
+
"host": config.host,
|
|
109
|
+
"port": config.port,
|
|
110
|
+
"log_level": "debug" if config.debug else "info",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for key in ["workers", "reload", "access_log"]:
|
|
114
|
+
if key in config.extra:
|
|
115
|
+
uvicorn_config[key] = config.extra[key]
|
|
116
|
+
|
|
117
|
+
uvicorn.run(self, log_config=None, **uvicorn_config)
|
|
118
|
+
|
|
119
|
+
def make_startup_config(self, **kwargs) -> FastAPIConfig:
|
|
120
|
+
return FastAPIConfig.create(
|
|
121
|
+
{
|
|
122
|
+
"host": kwargs.pop("host", "0.0.0.0"),
|
|
123
|
+
"port": kwargs.pop("port", 8000),
|
|
124
|
+
"debug": kwargs.pop("debug", False),
|
|
125
|
+
**kwargs,
|
|
126
|
+
}
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
def exception(self, *exceptions: type[BaseException]):
|
|
130
|
+
"""Регистрация handler-а исключений.
|
|
131
|
+
|
|
132
|
+
Принимает BaseException (как Sanic), но внутри маппит на Exception —
|
|
133
|
+
starlette не позволяет хэндлить не-Exception классы.
|
|
134
|
+
"""
|
|
135
|
+
def decorator(handler: t.Callable):
|
|
136
|
+
for exc in exceptions:
|
|
137
|
+
target = exc if isinstance(exc, type) and issubclass(exc, Exception) else Exception
|
|
138
|
+
self.add_exception_handler(target, handler)
|
|
139
|
+
return handler
|
|
140
|
+
return decorator
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def extensions(self):
|
|
144
|
+
return self.state.extensions
|
|
145
|
+
|
|
146
|
+
@property
|
|
147
|
+
def config(self) -> AppConfig:
|
|
148
|
+
return self.state._config
|
|
149
|
+
|
|
150
|
+
def update_config(self, config) -> None:
|
|
151
|
+
"""Устаревший метод — используйте app.config.update_config()."""
|
|
152
|
+
self.config.update_config(config)
|
|
153
|
+
|
|
154
|
+
def signal(self, event: str):
|
|
155
|
+
"""Аналог Sanic app.signal() — для совместимости.
|
|
156
|
+
|
|
157
|
+
Маппит события:
|
|
158
|
+
- 'server.init.*', 'server.startup.*' → on_startup (FastAPI)
|
|
159
|
+
- 'server.shutdown.*' → on_shutdown (FastAPI)
|
|
160
|
+
|
|
161
|
+
Хендлер может иметь Sanic сигнатуру async def handler(app, loop) —
|
|
162
|
+
обёртка вызовет его с нашим app и текущим event loop.
|
|
163
|
+
"""
|
|
164
|
+
def decorator(handler: t.Callable):
|
|
165
|
+
wrapped = self._wrap_signal_handler(handler)
|
|
166
|
+
if "shutdown" in event:
|
|
167
|
+
self.router.on_shutdown.append(wrapped)
|
|
168
|
+
elif "startup" in event or "init" in event:
|
|
169
|
+
self.router.on_startup.append(wrapped)
|
|
170
|
+
return handler
|
|
171
|
+
return decorator
|
|
172
|
+
|
|
173
|
+
def _wrap_signal_handler(self, handler: t.Callable) -> t.Callable:
|
|
174
|
+
try:
|
|
175
|
+
sig = inspect.signature(handler)
|
|
176
|
+
nargs = len([p for p in sig.parameters.values() if p.kind in (p.POSITIONAL_ONLY, p.POSITIONAL_OR_KEYWORD)])
|
|
177
|
+
except (ValueError, TypeError):
|
|
178
|
+
nargs = 0
|
|
179
|
+
|
|
180
|
+
async def wrapper():
|
|
181
|
+
if nargs >= 2:
|
|
182
|
+
loop = asyncio.get_running_loop()
|
|
183
|
+
result = handler(self, loop)
|
|
184
|
+
elif nargs == 1:
|
|
185
|
+
result = handler(self)
|
|
186
|
+
else:
|
|
187
|
+
result = handler()
|
|
188
|
+
if inspect.isawaitable(result):
|
|
189
|
+
await result
|
|
190
|
+
|
|
191
|
+
return wrapper
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class Api(APIRouter):
|
|
195
|
+
def __init__(self, name: str = None, url_prefix: str = ""):
|
|
196
|
+
super().__init__(prefix=url_prefix)
|
|
197
|
+
self.name = name
|
|
198
|
+
|
|
199
|
+
def new_routes(self, routes: dict[str, type], strict: bool = True):
|
|
200
|
+
cache: dict[str, int] = {}
|
|
201
|
+
|
|
202
|
+
for url, resource in routes.items():
|
|
203
|
+
name = resource.__name__.lower()
|
|
204
|
+
cache[name] = cache.get(name, 0) + 1
|
|
205
|
+
resource_instance = resource()
|
|
206
|
+
|
|
207
|
+
route_name = name if cache.get(name, 0) <= 1 else f"{name}_{cache[name]}"
|
|
208
|
+
|
|
209
|
+
for method_name in ["get", "post", "put", "delete", "patch", "head", "options"]:
|
|
210
|
+
if hasattr(resource_instance, method_name):
|
|
211
|
+
method = getattr(resource_instance, method_name)
|
|
212
|
+
self.add_api_route(
|
|
213
|
+
url, method, methods=[method_name.upper()], name=f"{route_name}_{method_name}"
|
|
214
|
+
)
|
|
215
|
+
return self
|
|
216
|
+
|
|
217
|
+
def init_app(self, app: FastAPI):
|
|
218
|
+
app.include_router(self)
|
|
219
|
+
return self
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
HTTP_ERROR_CODE_MIN = 400
|
|
4
|
+
HTTP_ERROR_CODE_MAX = 599
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
class HTTP_CODE(IntEnum): # noqa: N801
|
|
8
|
+
OK = 200
|
|
9
|
+
CREATED = 201
|
|
10
|
+
NO_CONTENT = 204
|
|
11
|
+
MOVED_PERMANENTLY = 301
|
|
12
|
+
BAD_REQUEST = 400
|
|
13
|
+
UNAUTHORIZED = 401
|
|
14
|
+
FORBIDDEN = 403
|
|
15
|
+
NOT_FOUND = 404
|
|
16
|
+
METHOD_NOT_ALLOWED = 405
|
|
17
|
+
CONFLICT = 409
|
|
18
|
+
INTERNAL_SERVER_ERROR = 500
|
|
19
|
+
BAD_GATEWAY = 502
|
|
20
|
+
SERVICE_UNAVAILABLE = 503
|
|
21
|
+
GATEWAY_TIMEOUT = 504
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from .auth import basic_auth, jwt_protection
|
|
4
|
+
from .headers import add_request_id, set_request_id
|
|
5
|
+
from .logging import log_request, log_response
|
|
6
|
+
from .proxy import redirect_trailing_slash, reverse_proxy
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"basic_auth",
|
|
11
|
+
"jwt_protection",
|
|
12
|
+
"add_request_id",
|
|
13
|
+
"set_request_id",
|
|
14
|
+
"log_request",
|
|
15
|
+
"log_response",
|
|
16
|
+
"redirect_trailing_slash",
|
|
17
|
+
"reverse_proxy",
|
|
18
|
+
]
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from base64 import b64decode
|
|
4
|
+
from functools import partial
|
|
5
|
+
from typing import Callable
|
|
6
|
+
|
|
7
|
+
import jwt
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
from starlette.responses import Response
|
|
11
|
+
|
|
12
|
+
from fastabc.middlewares.utils import check_secrets, unpack_basic_auth
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def basic_auth(secrets: dict | None = None):
|
|
16
|
+
"""
|
|
17
|
+
Middleware factory for Basic Auth.
|
|
18
|
+
Parses Authorization header and stores credential in request.state.basic
|
|
19
|
+
"""
|
|
20
|
+
get_credential = partial(check_secrets, secrets=secrets)
|
|
21
|
+
|
|
22
|
+
class BasicAuthMiddleware(BaseHTTPMiddleware):
|
|
23
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
24
|
+
auth_header = request.headers.get("authorization", "")
|
|
25
|
+
|
|
26
|
+
if auth_header and auth_header.lower().startswith("basic "):
|
|
27
|
+
token = auth_header[6:]
|
|
28
|
+
if (data := unpack_basic_auth(token)) is not None:
|
|
29
|
+
try:
|
|
30
|
+
credential = get_credential(*str(b64decode(data), "utf-8").split(":"))
|
|
31
|
+
if credential is not None:
|
|
32
|
+
request.state.basic = credential
|
|
33
|
+
except (ValueError, UnicodeDecodeError):
|
|
34
|
+
pass
|
|
35
|
+
|
|
36
|
+
return await call_next(request)
|
|
37
|
+
|
|
38
|
+
return BasicAuthMiddleware
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def jwt_protection(secret: str, algorithm: str = "HS256"):
|
|
42
|
+
"""
|
|
43
|
+
Middleware factory for JWT protection.
|
|
44
|
+
Parses Bearer token from Authorization header and stores decoded JWT in request.state.jwt
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
class JWTMiddleware(BaseHTTPMiddleware):
|
|
48
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
49
|
+
auth_header = request.headers.get("authorization", "")
|
|
50
|
+
|
|
51
|
+
if auth_header and auth_header.lower().startswith("bearer "):
|
|
52
|
+
token = auth_header[7:]
|
|
53
|
+
try:
|
|
54
|
+
request.state.jwt = jwt.decode(token, secret, algorithms=[algorithm])
|
|
55
|
+
except jwt.PyJWTError:
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
return await call_next(request)
|
|
59
|
+
|
|
60
|
+
return JWTMiddleware
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
from uuid import uuid4
|
|
5
|
+
|
|
6
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
7
|
+
from starlette.requests import Request
|
|
8
|
+
from starlette.responses import Response
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def add_request_id(generator: Callable[[], str] | None = None, override: bool = False):
|
|
12
|
+
"""
|
|
13
|
+
Middleware factory for adding request ID to request.state.
|
|
14
|
+
If X-Request-ID header exists and override=False, uses header value.
|
|
15
|
+
Otherwise generates new ID using generator function.
|
|
16
|
+
"""
|
|
17
|
+
id_generator = generator if generator is not None else lambda: str(uuid4())
|
|
18
|
+
|
|
19
|
+
class RequestIdMiddleware(BaseHTTPMiddleware):
|
|
20
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
21
|
+
request_id = request.headers.get("x-request-id")
|
|
22
|
+
|
|
23
|
+
if request_id and not override:
|
|
24
|
+
request.state.request_id = request_id
|
|
25
|
+
else:
|
|
26
|
+
request.state.request_id = id_generator()
|
|
27
|
+
|
|
28
|
+
return await call_next(request)
|
|
29
|
+
|
|
30
|
+
return RequestIdMiddleware
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def set_request_id():
|
|
34
|
+
"""
|
|
35
|
+
Middleware factory for setting X-Request-ID header in response.
|
|
36
|
+
Copies request_id from request.state to response headers.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
class SetRequestIdMiddleware(BaseHTTPMiddleware):
|
|
40
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
41
|
+
response = await call_next(request)
|
|
42
|
+
|
|
43
|
+
if hasattr(request.state, "request_id"):
|
|
44
|
+
response.headers["x-request-id"] = request.state.request_id
|
|
45
|
+
elif "x-request-id" in request.headers:
|
|
46
|
+
response.headers["x-request-id"] = request.headers["x-request-id"]
|
|
47
|
+
|
|
48
|
+
return response
|
|
49
|
+
|
|
50
|
+
return SetRequestIdMiddleware
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import typing as t
|
|
4
|
+
from json import dumps
|
|
5
|
+
from logging import Logger
|
|
6
|
+
from time import monotonic
|
|
7
|
+
|
|
8
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
9
|
+
from starlette.requests import Request
|
|
10
|
+
from starlette.responses import Response
|
|
11
|
+
|
|
12
|
+
from fastabc.middlewares.utils import parse_user
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def log_request(logger: Logger, get_user: t.Callable[[t.Optional[str]], t.Optional[str]] | None = None):
|
|
16
|
+
"""
|
|
17
|
+
Middleware factory for logging incoming requests.
|
|
18
|
+
Logs IP, method, URI, user-agent, user, and request-id.
|
|
19
|
+
"""
|
|
20
|
+
get_user_callback = get_user if get_user is not None else parse_user
|
|
21
|
+
|
|
22
|
+
class LogRequestMiddleware(BaseHTTPMiddleware):
|
|
23
|
+
async def dispatch(self, request: Request, call_next: t.Callable) -> Response:
|
|
24
|
+
request.state.start_time = monotonic()
|
|
25
|
+
|
|
26
|
+
# Extract token from Authorization header
|
|
27
|
+
auth_header = request.headers.get("authorization", "")
|
|
28
|
+
token = None
|
|
29
|
+
if auth_header.lower().startswith("bearer "):
|
|
30
|
+
token = auth_header[7:]
|
|
31
|
+
|
|
32
|
+
request.state.user = get_user_callback(token)
|
|
33
|
+
|
|
34
|
+
result = {
|
|
35
|
+
"ip": request.client.host if request.client else None,
|
|
36
|
+
"method": request.method,
|
|
37
|
+
"uri": request.url.path,
|
|
38
|
+
"user_agent": request.headers.get("user-agent"),
|
|
39
|
+
"user": getattr(request.state, "user", None),
|
|
40
|
+
"request-id": getattr(request.state, "request_id", None),
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
logger.info(dumps(result))
|
|
44
|
+
|
|
45
|
+
return await call_next(request)
|
|
46
|
+
|
|
47
|
+
return LogRequestMiddleware
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def log_response(logger: Logger):
|
|
51
|
+
"""
|
|
52
|
+
Middleware factory for logging responses.
|
|
53
|
+
Logs IP, method, URI, user, status, time, and request-id.
|
|
54
|
+
"""
|
|
55
|
+
|
|
56
|
+
class LogResponseMiddleware(BaseHTTPMiddleware):
|
|
57
|
+
async def dispatch(self, request: Request, call_next: t.Callable) -> Response:
|
|
58
|
+
response = await call_next(request)
|
|
59
|
+
|
|
60
|
+
end_time = None
|
|
61
|
+
if hasattr(request.state, "start_time"):
|
|
62
|
+
end_time = round((monotonic() - request.state.start_time) * 1000)
|
|
63
|
+
|
|
64
|
+
result = {
|
|
65
|
+
"ip": request.client.host if request.client else None,
|
|
66
|
+
"method": request.method,
|
|
67
|
+
"uri": request.url.path,
|
|
68
|
+
"user": getattr(request.state, "user", None),
|
|
69
|
+
"status": response.status_code,
|
|
70
|
+
"time": f"{end_time}ms" if end_time is not None else "",
|
|
71
|
+
"request-id": getattr(request.state, "request_id", None),
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if 400 <= response.status_code <= 599:
|
|
75
|
+
# Note: Can't easily get response body in BaseHTTPMiddleware
|
|
76
|
+
# Would need to use raw ASGI middleware for that
|
|
77
|
+
result["error"] = True
|
|
78
|
+
|
|
79
|
+
logger.info(dumps(result))
|
|
80
|
+
|
|
81
|
+
return response
|
|
82
|
+
|
|
83
|
+
return LogResponseMiddleware
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from typing import Callable
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
7
|
+
from starlette.requests import Request
|
|
8
|
+
from starlette.responses import RedirectResponse, Response
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def redirect_trailing_slash(path: str):
|
|
12
|
+
"""
|
|
13
|
+
Middleware factory for redirecting path without trailing slash to path with slash.
|
|
14
|
+
|
|
15
|
+
Args:
|
|
16
|
+
path: Exact path to match (e.g., "/webapp")
|
|
17
|
+
|
|
18
|
+
Example:
|
|
19
|
+
app.add_middleware(redirect_trailing_slash("/webapp"))
|
|
20
|
+
|
|
21
|
+
Request: GET /webapp?startapp=xxx
|
|
22
|
+
Redirect: GET /webapp/?startapp=xxx (307 redirect)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
class RedirectTrailingSlashMiddleware(BaseHTTPMiddleware):
|
|
26
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
27
|
+
if request.url.path == path:
|
|
28
|
+
url = path + "/"
|
|
29
|
+
if request.url.query:
|
|
30
|
+
url = f"{url}?{request.url.query}"
|
|
31
|
+
return RedirectResponse(url=url, status_code=307)
|
|
32
|
+
return await call_next(request)
|
|
33
|
+
|
|
34
|
+
return RedirectTrailingSlashMiddleware
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def reverse_proxy(path_prefix: str, target_url: str, strip_prefix: bool = False):
|
|
38
|
+
"""
|
|
39
|
+
Middleware factory for reverse proxying requests to another service.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
path_prefix: URL prefix to intercept (e.g., "/webapp/")
|
|
43
|
+
target_url: Target service URL (e.g., "http://localhost:3000")
|
|
44
|
+
strip_prefix: If True, removes path_prefix from proxied URL
|
|
45
|
+
|
|
46
|
+
Example:
|
|
47
|
+
app.add_middleware(reverse_proxy("/webapp/", "http://localhost:3000"))
|
|
48
|
+
|
|
49
|
+
Request: GET /webapp/index.html
|
|
50
|
+
Proxied: GET http://localhost:3000/webapp/index.html (strip_prefix=False)
|
|
51
|
+
Proxied: GET http://localhost:3000/index.html (strip_prefix=True)
|
|
52
|
+
"""
|
|
53
|
+
target = target_url.rstrip("/")
|
|
54
|
+
|
|
55
|
+
class ReverseProxyMiddleware(BaseHTTPMiddleware):
|
|
56
|
+
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
|
57
|
+
path = request.url.path
|
|
58
|
+
|
|
59
|
+
if not path.startswith(path_prefix):
|
|
60
|
+
return await call_next(request)
|
|
61
|
+
|
|
62
|
+
# Build target URL
|
|
63
|
+
if strip_prefix:
|
|
64
|
+
proxy_path = path[len(path_prefix.rstrip("/")):]
|
|
65
|
+
else:
|
|
66
|
+
proxy_path = path
|
|
67
|
+
|
|
68
|
+
proxy_url = f"{target}{proxy_path}"
|
|
69
|
+
if request.url.query:
|
|
70
|
+
proxy_url = f"{proxy_url}?{request.url.query}"
|
|
71
|
+
|
|
72
|
+
# Forward headers (excluding hop-by-hop)
|
|
73
|
+
headers = {
|
|
74
|
+
k: v for k, v in request.headers.items()
|
|
75
|
+
if k.lower() not in ("host", "connection", "keep-alive", "transfer-encoding")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
# Read body for non-GET requests
|
|
79
|
+
body = None
|
|
80
|
+
if request.method in ("POST", "PUT", "PATCH"):
|
|
81
|
+
body = await request.body()
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
85
|
+
response = await client.request(
|
|
86
|
+
method=request.method,
|
|
87
|
+
url=proxy_url,
|
|
88
|
+
headers=headers,
|
|
89
|
+
content=body,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
# Filter response headers
|
|
93
|
+
response_headers = {
|
|
94
|
+
k: v for k, v in response.headers.items()
|
|
95
|
+
if k.lower() not in ("content-encoding", "content-length", "transfer-encoding")
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return Response(
|
|
99
|
+
content=response.content,
|
|
100
|
+
status_code=response.status_code,
|
|
101
|
+
headers=response_headers,
|
|
102
|
+
media_type=response.headers.get("content-type"),
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
except httpx.ConnectError:
|
|
106
|
+
return Response(
|
|
107
|
+
content=b"Proxy target unavailable",
|
|
108
|
+
status_code=502,
|
|
109
|
+
media_type="text/plain",
|
|
110
|
+
)
|
|
111
|
+
except httpx.TimeoutException:
|
|
112
|
+
return Response(
|
|
113
|
+
content=b"Proxy target timeout",
|
|
114
|
+
status_code=504,
|
|
115
|
+
media_type="text/plain",
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
return ReverseProxyMiddleware
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import re
|
|
5
|
+
import typing as t
|
|
6
|
+
from types import MappingProxyType
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def unpack_basic_auth(token: str) -> str | None:
|
|
10
|
+
if not token:
|
|
11
|
+
return None
|
|
12
|
+
if token.startswith("Basic "):
|
|
13
|
+
token = token[6:]
|
|
14
|
+
try:
|
|
15
|
+
return base64.b64decode(token).decode("utf-8")
|
|
16
|
+
except Exception:
|
|
17
|
+
return None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def check_secrets(login: str, password: str, secrets: dict | None = None) -> MappingProxyType | None:
|
|
21
|
+
if secrets is None:
|
|
22
|
+
return None
|
|
23
|
+
if login in secrets and secrets[login] == password:
|
|
24
|
+
return MappingProxyType({"username": login, "password": password})
|
|
25
|
+
return None
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def parse_user(token: str | None) -> dict | None:
|
|
29
|
+
if token is None:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
try:
|
|
33
|
+
import jwt
|
|
34
|
+
payload = jwt.decode(token, options={"verify_signature": False})
|
|
35
|
+
uid = payload.get("sub") or payload.get("user_id") or payload.get("id")
|
|
36
|
+
return {"uid": uid, **payload} if uid else None
|
|
37
|
+
except Exception:
|
|
38
|
+
return None
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "fastabc"
|
|
3
|
+
version = "4.6.0"
|
|
4
|
+
description = "Base classes for FastAPI applications with Sanic compatibility layer"
|
|
5
|
+
authors = ["Aleksandr Yurlov <sasha.yur@mail.ru>"]
|
|
6
|
+
license = "MIT"
|
|
7
|
+
packages = [
|
|
8
|
+
{ include = "fastabc" }
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
[tool.poetry.dependencies]
|
|
12
|
+
python = "^3.10"
|
|
13
|
+
fastapi = ">=0.100.0"
|
|
14
|
+
uvicorn = ">=0.15.0"
|
|
15
|
+
httpx = { version = ">=0.24.0", optional = true }
|
|
16
|
+
|
|
17
|
+
[tool.poetry.extras]
|
|
18
|
+
proxy = ["httpx"]
|
|
19
|
+
|
|
20
|
+
[tool.poetry.group.dev.dependencies]
|
|
21
|
+
build = "^1.2.2.post1"
|
|
22
|
+
|
|
23
|
+
[build-system]
|
|
24
|
+
requires = ["poetry-core>=1.0.0"]
|
|
25
|
+
build-backend = "poetry.core.masonry.api"
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
from .baseclasses import App, Api
|
|
2
|
-
|
|
3
|
-
__version__ = "4.2.0"
|
|
4
|
-
__all__ = ["Api", "App"]
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
def start_server(create_fn, **config):
|
|
8
|
-
app_instance = App()
|
|
9
|
-
app = app_instance.loader(create_fn, **config)
|
|
10
|
-
import uvicorn
|
|
11
|
-
uvicorn.run(app, host=config.get("host", "0.0.0.0"), port=config.get("port", 8000))
|
|
@@ -1,46 +0,0 @@
|
|
|
1
|
-
import typing as t
|
|
2
|
-
from fastapi import FastAPI, APIRouter
|
|
3
|
-
from .structures import FastAPIConfig
|
|
4
|
-
|
|
5
|
-
class App:
|
|
6
|
-
def __init__(self, *args, lifespan: t.Optional[t.Callable] = None, **kwargs):
|
|
7
|
-
if lifespan:
|
|
8
|
-
kwargs["lifespan"] = lifespan
|
|
9
|
-
self.app = FastAPI(*args, **kwargs)
|
|
10
|
-
self.extensions = {}
|
|
11
|
-
|
|
12
|
-
def loader(self, create_fn: t.Callable[[FastAPI], FastAPI], **kwargs):
|
|
13
|
-
self.app = create_fn(self.app, **kwargs)
|
|
14
|
-
return self.app
|
|
15
|
-
|
|
16
|
-
def run_single(self, **kwargs):
|
|
17
|
-
import uvicorn
|
|
18
|
-
config = self.make_startup_config(**kwargs)
|
|
19
|
-
uvicorn.run(self.app, host=config.host, port=config.port, debug=config.debug)
|
|
20
|
-
|
|
21
|
-
def make_startup_config(self, **kwargs) -> FastAPIConfig:
|
|
22
|
-
return FastAPIConfig.create({
|
|
23
|
-
"host": kwargs.pop("host", "0.0.0.0"),
|
|
24
|
-
"port": kwargs.pop("port", 8000),
|
|
25
|
-
"debug": kwargs.pop("debug", False),
|
|
26
|
-
**kwargs,
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
class Api:
|
|
31
|
-
def __init__(self, name: str, url_prefix: str = ""):
|
|
32
|
-
self.router = APIRouter(prefix=url_prefix)
|
|
33
|
-
self.name = name
|
|
34
|
-
|
|
35
|
-
def new_routes(self, routes: t.Dict[str, t.Type]):
|
|
36
|
-
for url, resource in routes.items():
|
|
37
|
-
resource_instance = resource()
|
|
38
|
-
for method_name in ["get", "post", "put", "delete"]:
|
|
39
|
-
if hasattr(resource_instance, method_name):
|
|
40
|
-
method = getattr(resource_instance, method_name)
|
|
41
|
-
self.router.add_api_route(url, method, methods=[method_name.upper()])
|
|
42
|
-
return self
|
|
43
|
-
|
|
44
|
-
def init_app(self, app: FastAPI):
|
|
45
|
-
app.include_router(self.router)
|
|
46
|
-
return self
|
fastabc-0.2.2/pyproject.toml
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
[tool.poetry]
|
|
2
|
-
name = "fastabc"
|
|
3
|
-
version = "0.2.2"
|
|
4
|
-
description = "Initialize and create app|server"
|
|
5
|
-
authors = ["Aleksandr Yurlov <sasha.yur@mail.ru>"]
|
|
6
|
-
license = "MIT"
|
|
7
|
-
|
|
8
|
-
[tool.poetry.dependencies]
|
|
9
|
-
python = "^3.10"
|
|
10
|
-
fastapi = "^0.70.0"
|
|
11
|
-
uvicorn = "^0.15.0"
|
|
12
|
-
|
|
13
|
-
[tool.poetry.scripts]
|
|
14
|
-
fastapi_abc = "fastapi_abc.__init__:start_server"
|
|
15
|
-
|
|
16
|
-
[tool.poetry.group.dev.dependencies]
|
|
17
|
-
build = "^1.2.2.post1"
|
|
18
|
-
|
|
19
|
-
[build-system]
|
|
20
|
-
requires = ["poetry-core>=1.0.0"]
|
|
21
|
-
build-backend = "poetry.core.masonry.api"
|