Remnawave_python-sdk/remnawave_api/rapid/client.py
Artem f47cda9378
feat: Add models for subscriptions, users, and system statistics
- 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.
2025-04-21 20:44:27 +02:00

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