fix: correct "bugs" to "issues" in routing table and README references across multiple documentation files
This commit is contained in:
@@ -0,0 +1,78 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from devplacepy.config import XMLRPC_BIND, XMLRPC_PORT
|
||||
from devplacepy.services.base import BaseService
|
||||
|
||||
XMLRPC_INTERVAL_SECONDS = 15
|
||||
SERVER_MODULE = "devplacepy.services.xmlrpc.server"
|
||||
TERMINATE_TIMEOUT_SECONDS = 10
|
||||
|
||||
|
||||
class XmlrpcService(BaseService):
|
||||
title = "XML-RPC Bridge"
|
||||
description = (
|
||||
"Forking XML-RPC server that exposes every documented REST endpoint as an "
|
||||
"XML-RPC method. Reachable at /xmlrpc through the app and nginx."
|
||||
)
|
||||
default_enabled = True
|
||||
min_interval = 5
|
||||
|
||||
def __init__(self) -> None:
|
||||
super().__init__("xmlrpc", interval_seconds=XMLRPC_INTERVAL_SECONDS)
|
||||
self._process: subprocess.Popen | None = None
|
||||
|
||||
def _alive(self) -> bool:
|
||||
return self._process is not None and self._process.poll() is None
|
||||
|
||||
def _spawn(self) -> None:
|
||||
self._process = subprocess.Popen(
|
||||
[sys.executable, "-m", SERVER_MODULE],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
)
|
||||
self.log(
|
||||
f"Forking XML-RPC server started (pid {self._process.pid}) on "
|
||||
f"{XMLRPC_BIND}:{XMLRPC_PORT}"
|
||||
)
|
||||
|
||||
def _terminate(self) -> None:
|
||||
if not self._alive():
|
||||
self._process = None
|
||||
return
|
||||
self._process.terminate()
|
||||
try:
|
||||
self._process.wait(timeout=TERMINATE_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._process.kill()
|
||||
self._process.wait()
|
||||
self.log("Forking XML-RPC server stopped")
|
||||
self._process = None
|
||||
|
||||
async def on_enable(self) -> None:
|
||||
if not self._alive():
|
||||
self._spawn()
|
||||
|
||||
async def on_disable(self) -> None:
|
||||
self._terminate()
|
||||
|
||||
async def run_once(self) -> None:
|
||||
if self._alive():
|
||||
self.log(f"XML-RPC server healthy (pid {self._process.pid})")
|
||||
return
|
||||
self.log("XML-RPC server not running, starting it")
|
||||
self._spawn()
|
||||
|
||||
def collect_metrics(self) -> dict:
|
||||
return {
|
||||
"running": self._alive(),
|
||||
"pid": self._process.pid if self._alive() else None,
|
||||
"bind": f"{XMLRPC_BIND}:{XMLRPC_PORT}",
|
||||
}
|
||||
|
||||
|
||||
__all__ = ["XmlrpcService"]
|
||||
@@ -0,0 +1,147 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import xmlrpc.client
|
||||
from typing import Any
|
||||
|
||||
from devplacepy import stealth
|
||||
from devplacepy.config import INTERNAL_BASE_URL
|
||||
from .registry import Method
|
||||
|
||||
logger = logging.getLogger("xmlrpc.bridge")
|
||||
|
||||
FAULT_INVALID_PARAMS = -32602
|
||||
FAULT_MISSING_PARAM = -32001
|
||||
FAULT_TRANSPORT = -32300
|
||||
|
||||
WRITE_METHODS = {"POST", "PUT", "DELETE", "PATCH"}
|
||||
TIMEOUT_SECONDS = 60.0
|
||||
|
||||
|
||||
def _coerce_value(value: Any) -> str:
|
||||
if isinstance(value, bool):
|
||||
return "1" if value else "0"
|
||||
return str(value)
|
||||
|
||||
|
||||
def _build_path(method: Method, params: dict[str, Any]) -> str:
|
||||
path = method.path
|
||||
for param in method.path_params:
|
||||
if param.name not in params:
|
||||
raise xmlrpc.client.Fault(
|
||||
FAULT_MISSING_PARAM,
|
||||
f"Missing required path parameter '{param.name}' for {method.name}.",
|
||||
)
|
||||
path = path.replace("{" + param.name + "}", _coerce_value(params[param.name]))
|
||||
return path
|
||||
|
||||
|
||||
def _split_params(
|
||||
method: Method, params: dict[str, Any]
|
||||
) -> tuple[dict[str, str], dict[str, str]]:
|
||||
declared = {p.name: p for p in method.params}
|
||||
path_names = {p.name for p in method.path_params}
|
||||
query: dict[str, str] = {}
|
||||
form: dict[str, str] = {}
|
||||
for key, value in params.items():
|
||||
if key in path_names:
|
||||
continue
|
||||
spec = declared.get(key)
|
||||
location = spec.location if spec else None
|
||||
if location == "query":
|
||||
query[key] = _coerce_value(value)
|
||||
elif location == "form":
|
||||
form[key] = _coerce_value(value)
|
||||
elif method.http_method in WRITE_METHODS:
|
||||
form[key] = _coerce_value(value)
|
||||
else:
|
||||
query[key] = _coerce_value(value)
|
||||
return query, form
|
||||
|
||||
|
||||
def _check_required(method: Method, params: dict[str, Any]) -> None:
|
||||
for spec in method.params:
|
||||
if spec.required and spec.name not in params:
|
||||
raise xmlrpc.client.Fault(
|
||||
FAULT_MISSING_PARAM,
|
||||
f"Missing required parameter '{spec.name}' for {method.name}.",
|
||||
)
|
||||
|
||||
|
||||
def _decode(response: Any) -> Any:
|
||||
content_type = response.headers.get("content-type", "")
|
||||
if "application/json" in content_type:
|
||||
try:
|
||||
return response.json()
|
||||
except ValueError:
|
||||
return response.text
|
||||
return response.text
|
||||
|
||||
|
||||
def call(method: Method, params: dict[str, Any], auth: dict[str, str]) -> Any:
|
||||
if not isinstance(params, dict):
|
||||
raise xmlrpc.client.Fault(
|
||||
FAULT_INVALID_PARAMS,
|
||||
f"{method.name} expects a single struct of named parameters.",
|
||||
)
|
||||
params = dict(params)
|
||||
api_key = str(params.pop("api_key", "") or auth.get("api_key", ""))
|
||||
_check_required(method, params)
|
||||
path = _build_path(method, params)
|
||||
query, form = _split_params(method, params)
|
||||
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"X-Requested-With": "fetch",
|
||||
}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
headers["X-API-KEY"] = api_key
|
||||
elif auth.get("authorization"):
|
||||
headers["Authorization"] = auth["authorization"]
|
||||
if auth.get("real_ip"):
|
||||
headers["X-Real-IP"] = auth["real_ip"]
|
||||
if auth.get("forwarded_for"):
|
||||
headers["X-Forwarded-For"] = auth["forwarded_for"]
|
||||
|
||||
logger.debug("Bridge %s -> %s %s", method.name, method.http_method, path)
|
||||
try:
|
||||
with stealth.stealth_sync_client(
|
||||
base_url=INTERNAL_BASE_URL,
|
||||
timeout=TIMEOUT_SECONDS,
|
||||
follow_redirects=True,
|
||||
headers=headers,
|
||||
) as client:
|
||||
response = client.request(
|
||||
method.http_method,
|
||||
path,
|
||||
params=query or None,
|
||||
data=form or None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.warning("Bridge transport error for %s: %s", method.name, exc)
|
||||
raise xmlrpc.client.Fault(
|
||||
FAULT_TRANSPORT, f"Transport error calling {method.name}: {exc}"
|
||||
)
|
||||
|
||||
body = _decode(response)
|
||||
if response.status_code >= 400:
|
||||
message = body
|
||||
if isinstance(body, dict):
|
||||
error = body.get("error")
|
||||
if isinstance(error, dict):
|
||||
message = error.get("message", body)
|
||||
elif error:
|
||||
message = error
|
||||
else:
|
||||
message = body.get("detail", body)
|
||||
raise xmlrpc.client.Fault(response.status_code, _stringify(message))
|
||||
return body
|
||||
|
||||
|
||||
def _stringify(message: Any) -> str:
|
||||
if isinstance(message, str):
|
||||
return message
|
||||
return str(message)
|
||||
@@ -0,0 +1,120 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterator
|
||||
|
||||
from devplacepy import docs_api
|
||||
|
||||
|
||||
@dataclass
|
||||
class Param:
|
||||
name: str
|
||||
location: str
|
||||
type: str
|
||||
required: bool
|
||||
description: str
|
||||
options: list[str] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Method:
|
||||
name: str
|
||||
http_method: str
|
||||
path: str
|
||||
title: str
|
||||
summary: str
|
||||
auth: str
|
||||
group: str
|
||||
destructive: bool
|
||||
params: list[Param]
|
||||
|
||||
@property
|
||||
def path_params(self) -> list[Param]:
|
||||
return [p for p in self.params if p.location == "path"]
|
||||
|
||||
@property
|
||||
def query_params(self) -> list[Param]:
|
||||
return [p for p in self.params if p.location == "query"]
|
||||
|
||||
@property
|
||||
def body_params(self) -> list[Param]:
|
||||
return [p for p in self.params if p.location == "form"]
|
||||
|
||||
|
||||
def _method_name(endpoint_id: str) -> str:
|
||||
return endpoint_id.replace("-", ".")
|
||||
|
||||
|
||||
def _to_param(spec: dict) -> Param:
|
||||
return Param(
|
||||
name=spec["name"],
|
||||
location=spec.get("location", "form"),
|
||||
type=spec.get("type", "string"),
|
||||
required=bool(spec.get("required", False)),
|
||||
description=spec.get("description", ""),
|
||||
options=list(spec.get("options", []) or []),
|
||||
)
|
||||
|
||||
|
||||
def iter_methods() -> Iterator[Method]:
|
||||
seen: set[str] = set()
|
||||
for group in docs_api.API_GROUPS:
|
||||
group_title = group.get("title", group.get("slug", ""))
|
||||
for ep in group.get("endpoints", []) or []:
|
||||
name = _method_name(ep["id"])
|
||||
if name in seen:
|
||||
continue
|
||||
seen.add(name)
|
||||
yield Method(
|
||||
name=name,
|
||||
http_method=ep["method"].upper(),
|
||||
path=ep["path"],
|
||||
title=ep.get("title", name),
|
||||
summary=ep.get("summary", ""),
|
||||
auth=ep.get("auth", "user"),
|
||||
group=group_title,
|
||||
destructive=bool(ep.get("destructive", False)),
|
||||
params=[_to_param(p) for p in ep.get("params", []) or []],
|
||||
)
|
||||
|
||||
|
||||
def build_index() -> dict[str, Method]:
|
||||
return {method.name: method for method in iter_methods()}
|
||||
|
||||
|
||||
def method_help(method: Method) -> str:
|
||||
lines = [
|
||||
f"{method.title} - {method.http_method} {method.path}",
|
||||
f"Group: {method.group} | Auth: {method.auth}",
|
||||
"",
|
||||
method.summary,
|
||||
"",
|
||||
"Call with a single struct of named parameters, for example:",
|
||||
f" proxy.{method.name}({{{_example_struct(method)}}})",
|
||||
"",
|
||||
"Authenticate by passing 'api_key' inside the struct, or with an "
|
||||
"X-API-KEY / Authorization Bearer header on the HTTP transport.",
|
||||
]
|
||||
if method.params:
|
||||
lines.append("")
|
||||
lines.append("Parameters:")
|
||||
for p in method.params:
|
||||
flag = "required" if p.required else "optional"
|
||||
extra = f" (one of: {', '.join(p.options)})" if p.options else ""
|
||||
lines.append(
|
||||
f" - {p.name} [{p.location}, {p.type}, {flag}]: {p.description}{extra}"
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def method_signature(method: Method) -> list[list[str]]:
|
||||
return [["struct", "struct"]]
|
||||
|
||||
|
||||
def _example_struct(method: Method) -> str:
|
||||
pieces = [f"'{p.name}': ..." for p in method.params if p.required]
|
||||
if method.auth != "public":
|
||||
pieces.append("'api_key': ...")
|
||||
return ", ".join(pieces)
|
||||
@@ -0,0 +1,115 @@
|
||||
# retoor <retoor@molodetz.nl>
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from socketserver import ForkingMixIn
|
||||
from xmlrpc.server import SimpleXMLRPCDispatcher, SimpleXMLRPCRequestHandler, SimpleXMLRPCServer
|
||||
|
||||
from devplacepy.config import XMLRPC_BIND, XMLRPC_PORT
|
||||
from . import bridge
|
||||
from .registry import Method, build_index, method_help, method_signature
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("xmlrpc.server")
|
||||
|
||||
RPC_PATHS = ("/", "/RPC2", "/xmlrpc")
|
||||
|
||||
|
||||
def _auth_from_headers(headers) -> dict[str, str]:
|
||||
auth: dict[str, str] = {}
|
||||
api_key = headers.get("X-API-KEY") or headers.get("x-api-key")
|
||||
authorization = headers.get("Authorization") or headers.get("authorization") or ""
|
||||
if not api_key and authorization.lower().startswith("bearer "):
|
||||
api_key = authorization[7:].strip()
|
||||
if authorization.lower().startswith("basic "):
|
||||
auth["authorization"] = authorization.strip()
|
||||
if api_key:
|
||||
auth["api_key"] = api_key.strip()
|
||||
real_ip = headers.get("X-Real-IP") or headers.get("x-real-ip")
|
||||
if real_ip:
|
||||
auth["real_ip"] = real_ip
|
||||
forwarded = headers.get("X-Forwarded-For") or headers.get("x-forwarded-for")
|
||||
if forwarded:
|
||||
auth["forwarded_for"] = forwarded
|
||||
return auth
|
||||
|
||||
|
||||
class _RequestHandler(SimpleXMLRPCRequestHandler):
|
||||
rpc_paths = RPC_PATHS
|
||||
|
||||
def decode_request_content(self, data):
|
||||
self.server.current_auth = _auth_from_headers(self.headers)
|
||||
return super().decode_request_content(data)
|
||||
|
||||
def log_message(self, *args, **kwargs):
|
||||
return
|
||||
|
||||
|
||||
class BridgeDispatcher(SimpleXMLRPCDispatcher):
|
||||
def system_methodHelp(self, method_name):
|
||||
target = self.index.get(method_name)
|
||||
if target is not None:
|
||||
return method_help(target)
|
||||
return super().system_methodHelp(method_name)
|
||||
|
||||
def system_methodSignature(self, method_name):
|
||||
target = self.index.get(method_name)
|
||||
if target is not None:
|
||||
return method_signature(target)
|
||||
return super().system_methodSignature(method_name)
|
||||
|
||||
|
||||
class ForkingXMLRPCServer(ForkingMixIn, BridgeDispatcher, SimpleXMLRPCServer):
|
||||
allow_reuse_address = True
|
||||
daemon_threads = True
|
||||
|
||||
def __init__(self, addr, index):
|
||||
self.index = index
|
||||
self.current_auth: dict[str, str] = {}
|
||||
SimpleXMLRPCServer.__init__(
|
||||
self,
|
||||
addr,
|
||||
requestHandler=_RequestHandler,
|
||||
allow_none=True,
|
||||
logRequests=False,
|
||||
)
|
||||
|
||||
|
||||
def _make_handler(server: "ForkingXMLRPCServer", method: Method):
|
||||
def handler(*args):
|
||||
params = args[0] if args else {}
|
||||
return bridge.call(method, params, server.current_auth)
|
||||
|
||||
handler.__name__ = method.name
|
||||
handler.__doc__ = method_help(method)
|
||||
return handler
|
||||
|
||||
|
||||
def build_server(host: str, port: int) -> ForkingXMLRPCServer:
|
||||
index = build_index()
|
||||
server = ForkingXMLRPCServer((host, port), index)
|
||||
server.register_introspection_functions()
|
||||
server.register_multicall_functions()
|
||||
for name, method in index.items():
|
||||
server.register_function(_make_handler(server, method), name)
|
||||
logger.info("XML-RPC bridge registered %d methods", len(index))
|
||||
return server
|
||||
|
||||
|
||||
def main() -> None:
|
||||
server = build_server(XMLRPC_BIND, XMLRPC_PORT)
|
||||
logger.info("Forking XML-RPC server listening on %s:%d", XMLRPC_BIND, XMLRPC_PORT)
|
||||
try:
|
||||
server.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
logger.info("XML-RPC server interrupted")
|
||||
finally:
|
||||
server.server_close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user