fix: correct "bugs" to "issues" in routing table and README references across multiple documentation files

This commit is contained in:
2026-06-14 07:48:10 +00:00
parent f79a427f32
commit f2b910ea75
162 changed files with 7011 additions and 1134 deletions
+78
View File
@@ -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"]
+147
View File
@@ -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)
+120
View File
@@ -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)
+115
View File
@@ -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()