mirror of
https://github.com/AstrBotDevs/AstrBot
synced 2026-07-15 17:30:13 +08:00
* refactor: migrate to fastapi * structure refactor * fix: pyright fix * refactor: improve error handling and public messages in plugin services * feat(api): refactor API client integration and enhance request handling - Updated API client configuration to use a dedicated HTTP client. - Introduced utility functions for generating options, queries, and form data for API requests. - Refactored multiple API methods to utilize the new utility functions for improved consistency and readability. - Renamed types for clarity and updated import statements accordingly. feat(docs): add script to update OpenAPI JSON from YAML spec - Created a Python script to convert OpenAPI YAML specification to JSON format. - The script supports customizable input and output paths. - Ensured the script handles directory creation for output paths and validates the YAML structure. * fix * feat(auth): implement rate limiting for v1 login endpoint and enhance request handling * Refactor dashboard API routers to use legacy_router for backward compatibility - Changed all instances of dashboard_router to legacy_router across multiple API modules including platform, plugins, providers, sessions, skills, stats, subagents, t2i, tools, updates, and asgi_runtime. - Updated route definitions to ensure existing endpoints remain functional under the new router structure. - Introduced support for Quart request context in asgi_runtime to enhance compatibility with existing Quart-based plugins. - Added a test case to validate the functionality of the new Quart request context handling in plugin extensions. * chore: remove cli test * fix: update dashboard tests for fastapi migration * chore: satisfy ruff checks * fix: update openapi api key scopes * fix: sync config scope chip selection * fix: restore quart dependency * docs: clarify quart plugin api compatibility * docs: update openapi scope documentation * fix: use singular skill openapi scope * fix: hide update service exception details * fix: address fastapi review comments * fix: address dashboard review findings * docs: revert unrelated package deployment changes * docs: update agent api generation guidance * feat: add plugin page web api helpers * docs: add plugin page bridge demo * fix: type plugin upload files * fix: stabilize plugin page uploads * fix: type plugin web request proxy * docs: remove plugin page docs example * fix: authenticate plugin page SSE bridge
712 lines
22 KiB
Python
712 lines
22 KiB
Python
from __future__ import annotations
|
|
|
|
import contextvars
|
|
import inspect
|
|
import re
|
|
from collections.abc import Callable, Iterable
|
|
from contextlib import asynccontextmanager, contextmanager
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import httpx
|
|
from fastapi import FastAPI, HTTPException, Request, WebSocket
|
|
from fastapi.encoders import jsonable_encoder
|
|
from fastapi.responses import FileResponse, JSONResponse, Response
|
|
from starlette.datastructures import UploadFile as StarletteUploadFile
|
|
from starlette.responses import StreamingResponse
|
|
|
|
_request_var: contextvars.ContextVar[DashboardRequest] = contextvars.ContextVar(
|
|
"dashboard_request"
|
|
)
|
|
_websocket_var: contextvars.ContextVar[DashboardWebSocket] = contextvars.ContextVar(
|
|
"dashboard_websocket"
|
|
)
|
|
_g_var: contextvars.ContextVar[DashboardRequestState] = contextvars.ContextVar(
|
|
"dashboard_g"
|
|
)
|
|
_app_var: contextvars.ContextVar[FastAPIAppAdapter] = contextvars.ContextVar(
|
|
"dashboard_app"
|
|
)
|
|
|
|
|
|
class RequestArgs:
|
|
def __init__(self, values) -> None:
|
|
self._values = values
|
|
|
|
def get(self, key: str, default: Any = None, type: Callable | None = None):
|
|
value = self._values.get(key, default)
|
|
if value is default or type is None:
|
|
return value
|
|
try:
|
|
return type(value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
|
|
|
|
class RequestMultiDict:
|
|
def __init__(self, pairs: list[tuple[str, Any]]) -> None:
|
|
self._pairs = pairs
|
|
|
|
def get(self, key: str, default: Any = None, type: Callable | None = None):
|
|
for item_key, item_value in reversed(self._pairs):
|
|
if item_key != key:
|
|
continue
|
|
if type is None:
|
|
return item_value
|
|
try:
|
|
return type(item_value)
|
|
except (TypeError, ValueError):
|
|
return default
|
|
return default
|
|
|
|
def getlist(self, key: str) -> list[Any]:
|
|
return [item_value for item_key, item_value in self._pairs if item_key == key]
|
|
|
|
def keys(self):
|
|
return dict.fromkeys(item_key for item_key, _ in self._pairs).keys()
|
|
|
|
def values(self):
|
|
return [self[key] for key in self.keys()]
|
|
|
|
def items(self):
|
|
return [(key, self[key]) for key in self.keys()]
|
|
|
|
def __contains__(self, key: str) -> bool:
|
|
return any(item_key == key for item_key, _ in self._pairs)
|
|
|
|
def __getitem__(self, key: str):
|
|
value = self.get(key)
|
|
if value is None and key not in self:
|
|
raise KeyError(key)
|
|
return value
|
|
|
|
def __bool__(self) -> bool:
|
|
return bool(self._pairs)
|
|
|
|
|
|
class RequestUploadFile:
|
|
def __init__(self, upload_file: StarletteUploadFile) -> None:
|
|
self._upload_file = upload_file
|
|
self.filename = upload_file.filename
|
|
self.content_type = upload_file.content_type
|
|
self.headers = upload_file.headers
|
|
self.content_length = self._resolve_content_length()
|
|
|
|
def _resolve_content_length(self) -> int | None:
|
|
try:
|
|
raw = self.headers.get("content-length")
|
|
return int(raw) if raw else None
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
async def save(self, destination: str | Path) -> None:
|
|
path = Path(destination)
|
|
try:
|
|
await self._upload_file.seek(0)
|
|
except Exception:
|
|
pass
|
|
with path.open("wb") as output:
|
|
while True:
|
|
chunk = await self._upload_file.read(1024 * 1024)
|
|
if not chunk:
|
|
break
|
|
output.write(chunk)
|
|
|
|
def __getattr__(self, key: str):
|
|
return getattr(self._upload_file, key)
|
|
|
|
|
|
class DashboardRequestState:
|
|
def __init__(self) -> None:
|
|
self._values: dict[str, Any] = {}
|
|
|
|
def get(self, key: str, default: Any = None):
|
|
return self._values.get(key, default)
|
|
|
|
def __getattr__(self, key: str):
|
|
try:
|
|
return self._values[key]
|
|
except KeyError as exc:
|
|
raise AttributeError(key) from exc
|
|
|
|
def __setattr__(self, key: str, value: Any) -> None:
|
|
if key == "_values":
|
|
super().__setattr__(key, value)
|
|
return
|
|
self._values[key] = value
|
|
|
|
|
|
class DashboardRequest:
|
|
def __init__(self, request: Request) -> None:
|
|
self._request = request
|
|
self.args = RequestArgs(request.query_params)
|
|
self.headers = request.headers
|
|
self.cookies = request.cookies
|
|
self.method = request.method
|
|
self.path = request.url.path
|
|
self.content_type = request.headers.get("content-type")
|
|
self.remote_addr = request.client.host if request.client else None
|
|
self._form_cache: RequestMultiDict | None = None
|
|
self._files_cache: RequestMultiDict | None = None
|
|
|
|
@property
|
|
def json(self):
|
|
return self.get_json()
|
|
|
|
@property
|
|
def files(self):
|
|
return self._load_files()
|
|
|
|
@property
|
|
def form(self):
|
|
return self._load_form()
|
|
|
|
async def get_json(self, silent: bool = False):
|
|
try:
|
|
return await self._request.json()
|
|
except Exception:
|
|
if silent:
|
|
return None
|
|
raise
|
|
|
|
async def _load_form_parts(self) -> None:
|
|
if self._form_cache is not None and self._files_cache is not None:
|
|
return
|
|
form = await self._request.form()
|
|
form_pairs: list[tuple[str, Any]] = []
|
|
file_pairs: list[tuple[str, Any]] = []
|
|
for key, value in form.multi_items():
|
|
if isinstance(value, StarletteUploadFile):
|
|
file_pairs.append((key, RequestUploadFile(value)))
|
|
else:
|
|
form_pairs.append((key, value))
|
|
self._form_cache = RequestMultiDict(form_pairs)
|
|
self._files_cache = RequestMultiDict(file_pairs)
|
|
|
|
async def _load_form(self) -> RequestMultiDict:
|
|
await self._load_form_parts()
|
|
assert self._form_cache is not None
|
|
return self._form_cache
|
|
|
|
async def _load_files(self) -> RequestMultiDict:
|
|
await self._load_form_parts()
|
|
assert self._files_cache is not None
|
|
return self._files_cache
|
|
|
|
|
|
class DashboardWebSocket:
|
|
def __init__(self, websocket: WebSocket) -> None:
|
|
self._websocket = websocket
|
|
self.args = RequestArgs(websocket.query_params)
|
|
self.headers = websocket.headers
|
|
|
|
async def accept(self) -> None:
|
|
await self._websocket.accept()
|
|
|
|
async def receive_json(self):
|
|
return await self._websocket.receive_json()
|
|
|
|
async def send_json(self, payload: Any) -> None:
|
|
await self._websocket.send_json(payload)
|
|
|
|
async def close(self, code: int = 1000, reason: str | None = None) -> None:
|
|
await self._websocket.close(code=code, reason=reason or "")
|
|
|
|
|
|
class AdapterTestHeaders:
|
|
def __init__(self, headers: httpx.Headers) -> None:
|
|
self._headers = headers
|
|
|
|
def getlist(self, key: str) -> list[str]:
|
|
values = self._headers.get_list(key)
|
|
if key.lower() == "set-cookie":
|
|
return [value.replace('=""', "=") for value in values]
|
|
return values
|
|
|
|
def get(self, key: str, default: Any = None):
|
|
value = self._headers.get(key, default)
|
|
if isinstance(value, str) and key.lower() == "set-cookie":
|
|
return value.replace('=""', "=")
|
|
return value
|
|
|
|
def __getitem__(self, key: str):
|
|
return self._headers[key]
|
|
|
|
def __contains__(self, key: str) -> bool:
|
|
return key in self._headers
|
|
|
|
|
|
class AdapterTestResponse:
|
|
def __init__(self, response: httpx.Response) -> None:
|
|
self._response = response
|
|
self.status_code = response.status_code
|
|
self.headers = AdapterTestHeaders(response.headers)
|
|
self.data = response.content
|
|
self.content = response.content
|
|
self.text = response.text
|
|
|
|
async def get_json(self):
|
|
return self._response.json()
|
|
|
|
async def get_data(self):
|
|
return self._response.content
|
|
|
|
|
|
class AdapterTestClient:
|
|
def __init__(self, app: FastAPI) -> None:
|
|
self._client = httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app),
|
|
base_url="http://testserver",
|
|
)
|
|
|
|
@staticmethod
|
|
def _is_file_storage(value: Any) -> bool:
|
|
return hasattr(value, "stream") and hasattr(value, "filename")
|
|
|
|
@classmethod
|
|
def _file_tuple(cls, value: Any):
|
|
stream = value.stream
|
|
if hasattr(stream, "seek"):
|
|
stream.seek(0)
|
|
content = stream.read()
|
|
filename = getattr(value, "filename", "upload.bin")
|
|
content_type = getattr(value, "content_type", None)
|
|
return filename, content, content_type
|
|
|
|
@classmethod
|
|
def _normalize_data(cls, data: Any):
|
|
if not isinstance(data, dict):
|
|
return data, None
|
|
|
|
form: dict[str, Any] = {}
|
|
files: list[tuple[str, tuple]] = []
|
|
for key, value in data.items():
|
|
if cls._is_file_storage(value):
|
|
files.append((key, cls._file_tuple(value)))
|
|
continue
|
|
if isinstance(value, Iterable) and not isinstance(
|
|
value, str | bytes | dict
|
|
):
|
|
values = list(value)
|
|
if values and all(cls._is_file_storage(item) for item in values):
|
|
files.extend((key, cls._file_tuple(item)) for item in values)
|
|
continue
|
|
form[key] = value
|
|
return form, files or None
|
|
|
|
@classmethod
|
|
def _normalize_files(cls, files: Any):
|
|
if isinstance(files, dict):
|
|
items = files.items()
|
|
elif isinstance(files, Iterable) and not isinstance(files, str | bytes):
|
|
items = files
|
|
else:
|
|
return files
|
|
|
|
normalized_files: list[tuple[str, Any]] = []
|
|
for key, value in items:
|
|
if cls._is_file_storage(value):
|
|
normalized_files.append((key, cls._file_tuple(value)))
|
|
continue
|
|
if isinstance(value, Iterable) and not isinstance(
|
|
value, str | bytes | dict
|
|
):
|
|
values = list(value)
|
|
if values and all(cls._is_file_storage(item) for item in values):
|
|
normalized_files.extend(
|
|
(key, cls._file_tuple(item)) for item in values
|
|
)
|
|
continue
|
|
normalized_files.append((key, value))
|
|
return normalized_files
|
|
|
|
async def request(self, method: str, url: str, **kwargs):
|
|
data = kwargs.pop("data", None)
|
|
if data is not None and "files" not in kwargs:
|
|
normalized_data, files = self._normalize_data(data)
|
|
kwargs["data"] = normalized_data
|
|
if files:
|
|
kwargs["files"] = files
|
|
elif data is not None:
|
|
kwargs["data"] = data
|
|
if "files" in kwargs:
|
|
kwargs["files"] = self._normalize_files(kwargs["files"])
|
|
response = await self._client.request(method, url, **kwargs)
|
|
return AdapterTestResponse(response)
|
|
|
|
async def get(self, url: str, **kwargs):
|
|
return await self.request("GET", url, **kwargs)
|
|
|
|
async def post(self, url: str, **kwargs):
|
|
return await self.request("POST", url, **kwargs)
|
|
|
|
async def put(self, url: str, **kwargs):
|
|
return await self.request("PUT", url, **kwargs)
|
|
|
|
async def patch(self, url: str, **kwargs):
|
|
return await self.request("PATCH", url, **kwargs)
|
|
|
|
async def delete(self, url: str, **kwargs):
|
|
return await self.request("DELETE", url, **kwargs)
|
|
|
|
|
|
class _ContextProxy:
|
|
def __init__(self, var) -> None:
|
|
self._var = var
|
|
|
|
def __getattr__(self, key: str):
|
|
return getattr(self._var.get(), key)
|
|
|
|
def __setattr__(self, key: str, value: Any) -> None:
|
|
if key == "_var":
|
|
super().__setattr__(key, value)
|
|
return
|
|
setattr(self._var.get(), key, value)
|
|
|
|
|
|
request = _ContextProxy(_request_var)
|
|
websocket = _ContextProxy(_websocket_var)
|
|
g = _ContextProxy(_g_var)
|
|
current_app = _ContextProxy(_app_var)
|
|
|
|
|
|
@contextmanager
|
|
def bind_request_context(
|
|
request_: Request,
|
|
app: FastAPIAppAdapter,
|
|
g_obj: DashboardRequestState | None = None,
|
|
):
|
|
token_request = _request_var.set(DashboardRequest(request_))
|
|
token_g = _g_var.set(
|
|
g_obj or getattr(request_.state, "dashboard_g", DashboardRequestState())
|
|
)
|
|
token_app = _app_var.set(app)
|
|
try:
|
|
yield _g_var.get()
|
|
finally:
|
|
_app_var.reset(token_app)
|
|
_g_var.reset(token_g)
|
|
_request_var.reset(token_request)
|
|
|
|
|
|
@contextmanager
|
|
def bind_websocket_context(
|
|
websocket_: WebSocket,
|
|
app: FastAPIAppAdapter,
|
|
g_obj: DashboardRequestState | None = None,
|
|
):
|
|
token_websocket = _websocket_var.set(DashboardWebSocket(websocket_))
|
|
token_g = _g_var.set(
|
|
g_obj or getattr(websocket_.state, "dashboard_g", DashboardRequestState())
|
|
)
|
|
token_app = _app_var.set(app)
|
|
try:
|
|
yield
|
|
finally:
|
|
_app_var.reset(token_app)
|
|
_g_var.reset(token_g)
|
|
_websocket_var.reset(token_websocket)
|
|
|
|
|
|
def jsonify(payload: Any = None):
|
|
return JSONResponse(payload if payload is not None else {})
|
|
|
|
|
|
async def make_response(*args):
|
|
if not args:
|
|
return Response()
|
|
content = args[0]
|
|
status_code = args[1] if len(args) > 1 and isinstance(args[1], int) else None
|
|
headers = args[1] if len(args) > 1 and isinstance(args[1], dict) else None
|
|
if len(args) > 2 and isinstance(args[2], dict):
|
|
headers = args[2]
|
|
if isinstance(content, Response):
|
|
if status_code is not None:
|
|
content.status_code = status_code
|
|
if headers:
|
|
content.headers.update(headers)
|
|
return content
|
|
if hasattr(content, "__aiter__"):
|
|
return StreamingResponse(
|
|
content,
|
|
status_code=status_code or 200,
|
|
headers=headers,
|
|
)
|
|
return Response(
|
|
content=content,
|
|
status_code=status_code or 200,
|
|
headers=headers,
|
|
)
|
|
|
|
|
|
async def send_file(path: str | Path, mimetype: str | None = None, **kwargs):
|
|
filename = kwargs.get("attachment_filename") or kwargs.get("download_name")
|
|
as_attachment = bool(kwargs.get("as_attachment"))
|
|
return FileResponse(
|
|
path,
|
|
media_type=mimetype,
|
|
filename=filename if as_attachment else None,
|
|
)
|
|
|
|
|
|
def abort(status_code: int):
|
|
raise HTTPException(status_code=status_code)
|
|
|
|
|
|
def _convert_rule(path: str) -> str:
|
|
converted = re.sub(r"<path:([A-Za-z_][A-Za-z0-9_]*)>", r"{\1:path}", path)
|
|
converted = re.sub(r"<([A-Za-z_][A-Za-z0-9_]*)>", r"{\1}", converted)
|
|
return converted
|
|
|
|
|
|
async def _call_view(view_func: Callable, path_params: dict[str, Any]):
|
|
result = view_func(**path_params)
|
|
if inspect.isawaitable(result):
|
|
result = await result
|
|
return await _coerce_view_result(result)
|
|
|
|
|
|
async def _coerce_view_result(result: Any):
|
|
if isinstance(result, Response):
|
|
return result
|
|
if _is_quart_response(result):
|
|
return await _quart_response_to_starlette(result)
|
|
|
|
if isinstance(result, tuple):
|
|
content = result[0] if result else None
|
|
status_code = next((item for item in result[1:] if isinstance(item, int)), 200)
|
|
headers = next(
|
|
(item for item in result[1:] if isinstance(item, dict)),
|
|
None,
|
|
)
|
|
if content is not None and isinstance(content, Response):
|
|
content.status_code = status_code
|
|
if headers:
|
|
content.headers.update(headers)
|
|
return content
|
|
if _is_quart_response(content):
|
|
return await _quart_response_to_starlette(
|
|
content,
|
|
status_code=status_code,
|
|
extra_headers=headers,
|
|
)
|
|
return _response_from_content(content, status_code=status_code, headers=headers)
|
|
|
|
if isinstance(result, dict | list):
|
|
return JSONResponse(jsonable_encoder(result))
|
|
return result
|
|
|
|
|
|
def _response_from_content(
|
|
content: Any,
|
|
*,
|
|
status_code: int,
|
|
headers: dict[str, str] | None = None,
|
|
):
|
|
if isinstance(content, dict | list):
|
|
return JSONResponse(
|
|
jsonable_encoder(content),
|
|
status_code=status_code,
|
|
headers=headers,
|
|
)
|
|
return Response(
|
|
content=content,
|
|
status_code=status_code,
|
|
headers=headers,
|
|
)
|
|
|
|
|
|
def _is_quart_response(value: Any) -> bool:
|
|
return (
|
|
hasattr(value, "get_data")
|
|
and inspect.iscoroutinefunction(value.get_data)
|
|
and hasattr(value, "headers")
|
|
and hasattr(value, "status_code")
|
|
)
|
|
|
|
|
|
def _response_header_pairs(headers: Any) -> list[tuple[str, str]]:
|
|
if headers is None:
|
|
return []
|
|
if hasattr(headers, "to_wsgi_list"):
|
|
return [(str(key), str(value)) for key, value in headers.to_wsgi_list()]
|
|
if hasattr(headers, "items"):
|
|
return [(str(key), str(value)) for key, value in headers.items()]
|
|
return [(str(key), str(value)) for key, value in headers]
|
|
|
|
|
|
async def _quart_response_to_starlette(
|
|
quart_response: Any,
|
|
*,
|
|
status_code: int | None = None,
|
|
extra_headers: dict[str, str] | None = None,
|
|
) -> Response:
|
|
content = await quart_response.get_data()
|
|
response = Response(
|
|
content=content,
|
|
status_code=status_code or int(quart_response.status_code),
|
|
)
|
|
pairs = _response_header_pairs(quart_response.headers)
|
|
if extra_headers:
|
|
pairs.extend((str(key), str(value)) for key, value in extra_headers.items())
|
|
response.raw_headers = [
|
|
(key.lower().encode("latin-1"), value.encode("latin-1")) for key, value in pairs
|
|
]
|
|
return response
|
|
|
|
|
|
@asynccontextmanager
|
|
async def bind_quart_request_context(
|
|
request_: Request,
|
|
app: FastAPIAppAdapter,
|
|
*,
|
|
path: str | None = None,
|
|
g_obj: DashboardRequestState | None = None,
|
|
):
|
|
try:
|
|
from quart import g as quart_g
|
|
except ImportError:
|
|
yield
|
|
return
|
|
|
|
quart_app = app.get_quart_compat_app()
|
|
headers = {
|
|
key.decode("latin-1"): value.decode("latin-1")
|
|
for key, value in request_.scope.get("headers", [])
|
|
}
|
|
body = await request_.body()
|
|
request_path = path or str(request_.url.path)
|
|
if "?" not in request_path and request_.url.query:
|
|
request_path = f"{request_path}?{request_.url.query}"
|
|
|
|
async with quart_app.test_request_context(
|
|
request_path,
|
|
method=request_.method,
|
|
headers=headers,
|
|
data=body,
|
|
scheme=request_.url.scheme,
|
|
root_path=request_.scope.get("root_path", ""),
|
|
scope_base={
|
|
"client": request_.scope.get("client"),
|
|
"server": request_.scope.get("server"),
|
|
},
|
|
):
|
|
if g_obj is not None:
|
|
for key, value in getattr(g_obj, "_values", {}).items():
|
|
setattr(quart_g, key, value)
|
|
yield
|
|
|
|
|
|
async def call_request_view(
|
|
request_: Request,
|
|
app: FastAPIAppAdapter,
|
|
view_func: Callable,
|
|
path_params: dict[str, Any] | None = None,
|
|
g_obj: DashboardRequestState | None = None,
|
|
quart_compat_path: str | None = None,
|
|
):
|
|
with bind_request_context(request_, app, g_obj):
|
|
async with bind_quart_request_context(
|
|
request_,
|
|
app,
|
|
path=quart_compat_path,
|
|
g_obj=g_obj,
|
|
):
|
|
return await _call_view(view_func, path_params or {})
|
|
|
|
|
|
async def call_websocket_view(
|
|
websocket_: WebSocket,
|
|
app: FastAPIAppAdapter,
|
|
view_func: Callable,
|
|
path_params: dict[str, Any] | None = None,
|
|
*,
|
|
accept: bool = True,
|
|
):
|
|
if accept:
|
|
await websocket_.accept()
|
|
with bind_websocket_context(websocket_, app):
|
|
return await _call_view(view_func, path_params or {})
|
|
|
|
|
|
class FastAPIAppAdapter:
|
|
def __init__(self, app: FastAPI, static_folder: str | None = None) -> None:
|
|
self._app = app
|
|
app.state.dashboard_app_adapter = self
|
|
self.static_folder = static_folder
|
|
self._dashboard_server: Any | None = None
|
|
self.config: dict[str, Any] = {}
|
|
self.debug = False
|
|
self.testing = False
|
|
self.name = "dashboard"
|
|
self._quart_compat_app: Any | None = None
|
|
|
|
def get_quart_compat_app(self):
|
|
if self._quart_compat_app is None:
|
|
from quart import Quart
|
|
|
|
self._quart_compat_app = Quart("astrbot_dashboard_plugin_compat")
|
|
self._quart_compat_app.json.sort_keys = False
|
|
return self._quart_compat_app
|
|
|
|
def add_url_rule(
|
|
self,
|
|
path: str,
|
|
view_func: Callable,
|
|
methods: list[str] | None = None,
|
|
endpoint: str | None = None,
|
|
) -> None:
|
|
route_path = _convert_rule(path)
|
|
methods = methods or ["GET"]
|
|
|
|
async def endpoint_func(request_: Request):
|
|
with bind_request_context(request_, self):
|
|
return await _call_view(view_func, dict(request_.path_params))
|
|
|
|
self._app.add_api_route(
|
|
route_path,
|
|
endpoint_func,
|
|
methods=methods,
|
|
name=endpoint,
|
|
include_in_schema=False,
|
|
)
|
|
|
|
def websocket(self, path: str):
|
|
route_path = _convert_rule(path)
|
|
|
|
def decorator(view_func: Callable):
|
|
async def endpoint_func(websocket_: WebSocket):
|
|
return await call_websocket_view(
|
|
websocket_,
|
|
self,
|
|
view_func,
|
|
dict(websocket_.path_params),
|
|
)
|
|
|
|
self._app.add_api_websocket_route(
|
|
route_path,
|
|
endpoint_func,
|
|
name=getattr(view_func, "__name__", None),
|
|
)
|
|
return view_func
|
|
|
|
return decorator
|
|
|
|
def errorhandler(self, _status_code: int):
|
|
def decorator(func: Callable):
|
|
return func
|
|
|
|
return decorator
|
|
|
|
async def send_static_file(self, filename: str):
|
|
if not self.static_folder:
|
|
raise HTTPException(status_code=404)
|
|
return FileResponse(Path(self.static_folder) / filename)
|
|
|
|
def test_client(self):
|
|
self.testing = True
|
|
return AdapterTestClient(self._app)
|
|
|
|
|
|
AdapterResponse = Response
|