mirror of
https://github.com/remnawave/python-sdk.git
synced 2026-08-30 13:23:02 +00:00
- Implemented TemplateResponseDto and UpdateTemplateRequestDto for subscription templates. - Created models for system statistics including CPU, memory, and bandwidth. - Developed user models for user creation, updates, and responses. - Added bulk actions for user updates and statistics tracking. - Introduced tests for authentication, bandwidth statistics, hosts, inbounds, key generation, nodes, subscriptions, and user management. - Enhanced utility functions for generating random strings, emails, and date ranges for testing.
210 lines
8.6 KiB
Python
210 lines
8.6 KiB
Python
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from inspect import BoundArguments, Signature
|
|
from typing import Any, Dict, Mapping, Self, Tuple, Type
|
|
|
|
import httpx
|
|
import orjson
|
|
from httpx import Request, Response
|
|
from pydantic import BaseModel, TypeAdapter
|
|
from rapid_api_client import (
|
|
Body,
|
|
FileBody,
|
|
FormBody,
|
|
PydanticBody,
|
|
PydanticXmlBody,
|
|
RapidApi,
|
|
)
|
|
from rapid_api_client.annotations import Header, JsonBody, Path, Query
|
|
from rapid_api_client.client import pydantic_xml, RapidParameter, RapidParameters
|
|
from rapid_api_client.typing import BM, T
|
|
from rapid_api_client.utils import filter_none_values, find_annotation
|
|
|
|
from remnawave_api.exceptions import ApiError, ApiErrorResponse, handle_api_error
|
|
from remnawave_api.rapid import AttributeBody
|
|
from remnawave_api.utils.serializer import orjson_default
|
|
|
|
|
|
class BaseController(RapidApi):
|
|
|
|
def _build_request(
|
|
self,
|
|
sig: Signature,
|
|
rapid_parameters: CustomRapidParameters,
|
|
method: str,
|
|
path: str,
|
|
args: Tuple[Any],
|
|
kwargs: Mapping[str, Any],
|
|
timeout: float | None,
|
|
) -> Request:
|
|
ba = sig.bind_partial(*args, **kwargs)
|
|
ba.apply_defaults()
|
|
|
|
path = rapid_parameters.get_resolved_path(path, ba)
|
|
|
|
build_kwargs: Dict[str, Any] = {
|
|
"headers": rapid_parameters.get_headers(ba),
|
|
"params": rapid_parameters.get_query(ba),
|
|
}
|
|
post_kw, post_data = rapid_parameters.get_body(ba)
|
|
if post_kw is not None:
|
|
build_kwargs[post_kw] = post_data
|
|
|
|
if timeout is not None:
|
|
build_kwargs["timeout"] = timeout
|
|
|
|
return self.client.build_request(method, path, **build_kwargs)
|
|
|
|
def _handle_response(
|
|
self,
|
|
response: Response,
|
|
response_class: Type[Response | str | bytes | BM] | TypeAdapter[T] = Response,
|
|
) -> Response | str | bytes | BM | T:
|
|
if response_class is Response:
|
|
return response
|
|
|
|
try:
|
|
response.raise_for_status()
|
|
except httpx.HTTPStatusError as e:
|
|
handle_api_error(e.response)
|
|
except httpx.RequestError as e:
|
|
now_time = datetime.now()
|
|
raise ApiError(
|
|
0,
|
|
ApiErrorResponse(
|
|
timestamp=now_time,
|
|
path="/api/users",
|
|
message=f"Request error: {str(e)}",
|
|
code="NETWORK_ERROR",
|
|
),
|
|
)
|
|
|
|
if response_class is str:
|
|
return response.text
|
|
if response_class is bytes:
|
|
return response.content
|
|
if isinstance(response_class, TypeAdapter):
|
|
return response_class.validate_json(response.content)
|
|
if pydantic_xml is not None and issubclass(
|
|
response_class, pydantic_xml.BaseXmlModel
|
|
):
|
|
return response_class.from_xml(response.content)
|
|
if issubclass(response_class, BaseModel):
|
|
data: dict = response.json()
|
|
api_response: dict | list = data.get("response")
|
|
if isinstance(api_response, list):
|
|
return response_class.model_validate(data)
|
|
return response_class.model_validate(api_response)
|
|
raise ValueError(f"Response class not supported: {response_class}")
|
|
|
|
|
|
class CustomRapidParameters(RapidParameters):
|
|
@classmethod
|
|
def from_sig(cls, sig: Signature) -> Self:
|
|
out = cls()
|
|
for parameter in sig.parameters.values():
|
|
if (annot := find_annotation(parameter, Path)) is not None:
|
|
out.path_parameters.append(RapidParameter(parameter, annot))
|
|
if (annot := find_annotation(parameter, Query)) is not None:
|
|
out.query_parameters.append(RapidParameter(parameter, annot))
|
|
if (annot := find_annotation(parameter, Header)) is not None:
|
|
out.header_parameters.append(RapidParameter(parameter, annot))
|
|
if (annot := find_annotation(parameter, Body)) is not None:
|
|
out.body_parameters.append(RapidParameter(parameter, annot))
|
|
|
|
if len(out.body_parameters) > 0:
|
|
first_body_param = out.body_parameters[0]
|
|
if isinstance(first_body_param.annot, FileBody):
|
|
assert all(
|
|
map(lambda p: isinstance(p.annot, FileBody), out.body_parameters)
|
|
), "All body parameters must be of type FileBody"
|
|
elif isinstance(first_body_param.annot, FormBody):
|
|
assert all(
|
|
map(lambda p: isinstance(p.annot, FormBody), out.body_parameters)
|
|
), "All body parameters must be of type FormBody"
|
|
elif isinstance(first_body_param.annot, JsonBody):
|
|
assert len(out.body_parameters) == 1, "Only one JsonBody allowed"
|
|
elif isinstance(first_body_param.annot, Body) and not isinstance(
|
|
first_body_param.annot,
|
|
AttributeBody, # don't check the AttributeBody because there can be more than one
|
|
):
|
|
assert (
|
|
len(out.body_parameters) == 1
|
|
), "Only one Body (JsonBody, FormBody, PydanticBody, FileBody, PydanticXmlBody) allowed"
|
|
|
|
return out
|
|
|
|
def get_body(self, ba: BoundArguments) -> Tuple[str | None, Any]:
|
|
"""
|
|
Prepares the body of an HTTP request based on annotated parameters.
|
|
|
|
For parameters annotated with `AttributeBody`, collects them into a dictionary,
|
|
serializes them into JSON with custom type processing via `orjson`, and returns
|
|
the result as `("json", dict)`. Supports other body types like `FileBody`,
|
|
`FormBody`, `PydanticBody`, etc., with appropriate serialization.
|
|
|
|
Args:
|
|
ba (BoundArguments): Bound arguments of the function containing parameter values.
|
|
|
|
Returns:
|
|
Tuple[str | None, Any]: A tuple of (body_type, body_data), where body_type
|
|
indicates the content type (e.g., "json", "files") and body_data is the
|
|
serialized content, or (None, None) if no body is constructed.
|
|
"""
|
|
|
|
if len(self.body_parameters) > 0:
|
|
first_body_param = self.body_parameters[0]
|
|
if isinstance(first_body_param.annot, FileBody):
|
|
values = filter_none_values(
|
|
{p.get_name(): p.get_value(ba) for p in self.body_parameters}
|
|
)
|
|
if len(values) > 0:
|
|
return "files", values
|
|
elif isinstance(first_body_param.annot, FormBody):
|
|
values = {}
|
|
|
|
def update_values(p: RapidParameter[Body]) -> None:
|
|
if (value := p.get_value(ba)) is not None:
|
|
if isinstance(value, dict):
|
|
values.update(value)
|
|
else:
|
|
values[p.get_name()] = value
|
|
|
|
for param in self.body_parameters:
|
|
update_values(param)
|
|
|
|
if len(values) > 0:
|
|
return "data", values
|
|
elif isinstance(first_body_param.annot, PydanticXmlBody):
|
|
assert (
|
|
pydantic_xml is not None
|
|
), "pydantic-xml must be installed to use PydanticXmlBody"
|
|
if (value := first_body_param.get_value(ba)) is not None:
|
|
assert isinstance(value, pydantic_xml.BaseXmlModel)
|
|
return "content", value.to_xml()
|
|
elif isinstance(first_body_param.annot, PydanticBody):
|
|
if (value := first_body_param.get_value(ba)) is not None:
|
|
assert isinstance(value, BaseModel)
|
|
return "json", value.model_dump(
|
|
exclude_none=True, by_alias=True, mode="json"
|
|
)
|
|
elif isinstance(first_body_param.annot, JsonBody):
|
|
if (value := first_body_param.get_value(ba)) is not None:
|
|
assert isinstance(value, dict)
|
|
return "json", value
|
|
elif isinstance(first_body_param.annot, AttributeBody):
|
|
body: dict[str, Any] = {}
|
|
for param in self.body_parameters:
|
|
if (value := param.get_value(ba)) is not None:
|
|
param_name: str = param.get_name()
|
|
body[param_name] = value
|
|
if body:
|
|
return "json", orjson.loads(
|
|
orjson.dumps(body, default=orjson_default)
|
|
)
|
|
else:
|
|
if (value := first_body_param.get_value(ba)) is not None:
|
|
return "content", value
|
|
|
|
return None, None
|