135 lines
5.5 KiB
Python
135 lines
5.5 KiB
Python
"""Фейковая Eva для тестов агента: JSON-RPC 2.2 (CmfDocument.get/list) и /files/... на http.server."""
|
||
import json
|
||
import threading
|
||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||
from urllib.parse import parse_qs, urlsplit
|
||
|
||
|
||
def _field(doc, path):
|
||
"""Значение поля по пути `project.code`: ссылка хранится как {"id", "code"}."""
|
||
cur = doc
|
||
for part in path.split("."):
|
||
if not isinstance(cur, dict):
|
||
return None
|
||
cur = cur.get(part)
|
||
return cur
|
||
|
||
|
||
class FakeEva:
|
||
token = "eva-secret"
|
||
|
||
def __init__(self):
|
||
self.docs = []
|
||
self.files = {} # путь → (байты, с Content-Length?)
|
||
self.requests = [] # {"method", "path", "body", "auth"}
|
||
self.failures = [] # [status, retry_after] — по одному на ответ
|
||
self.rpc_errors = []
|
||
self.lock = threading.Lock()
|
||
eva = self
|
||
|
||
class Handler(BaseHTTPRequestHandler):
|
||
def log_message(self, *a):
|
||
pass
|
||
|
||
def do_GET(self):
|
||
eva.handle(self, "GET")
|
||
|
||
def do_POST(self):
|
||
eva.handle(self, "POST")
|
||
|
||
self.server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
||
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
||
|
||
@property
|
||
def base_url(self):
|
||
return "http://127.0.0.1:%d" % self.server.server_port
|
||
|
||
def stop(self):
|
||
self.server.shutdown()
|
||
self.server.server_close()
|
||
|
||
def add_doc(self, id, code, name="", parent=None, tree_parent=None, project=None, orderno=None, text=""):
|
||
ref = lambda c: {"id": c, "code": c} if c is not None else None # noqa: E731
|
||
self.docs.append({"id": id, "code": code, "name": name, "parent": ref(parent),
|
||
"tree_parent": ref(tree_parent), "project": ref(project), "orderno": orderno,
|
||
"text": text})
|
||
|
||
def add_file(self, path, data, length=True):
|
||
self.files[path] = (data, length)
|
||
|
||
def fail_next(self, status, times=1, retry_after=None):
|
||
self.failures.extend([[status, retry_after]] * times)
|
||
|
||
def rpc_error_next(self, text):
|
||
self.rpc_errors.append(text)
|
||
|
||
@property
|
||
def api_calls(self):
|
||
return [r for r in self.requests if r["path"].startswith("/api/")]
|
||
|
||
def handle(self, h, verb):
|
||
u = urlsplit(h.path)
|
||
n = int(h.headers.get("Content-Length") or 0)
|
||
raw = h.rfile.read(n) if n else b""
|
||
auth = h.headers.get("Authorization")
|
||
with self.lock:
|
||
self.requests.append({"method": verb, "path": h.path,
|
||
"body": json.loads(raw) if raw else None, "auth": auth})
|
||
failure = self.failures.pop(0) if self.failures else None
|
||
rpc_error = self.rpc_errors.pop(0) if self.rpc_errors else None
|
||
if auth != "Bearer " + self.token:
|
||
return self._reply(h, 401, b'{"error": "unauthorized"}')
|
||
if failure:
|
||
extra = {"Retry-After": str(failure[1])} if failure[1] is not None else {}
|
||
return self._reply(h, failure[0], b'{"error": "busy"}', extra)
|
||
try:
|
||
if u.path == "/api/" and verb == "POST":
|
||
body = json.loads(raw)
|
||
if rpc_error is not None:
|
||
out = {"jsonrpc": "2.2", "callid": body.get("callid"), "error": rpc_error}
|
||
else:
|
||
out = {"jsonrpc": "2.2", "callid": body.get("callid"),
|
||
"result": self.rpc(body["method"], body.get("kwargs") or {})}
|
||
return self._reply(h, 200, json.dumps(out).encode("utf-8"))
|
||
if u.path.startswith("/files/") and verb == "GET":
|
||
if u.path not in self.files:
|
||
return self._reply(h, 404, b'{"error": "not found"}')
|
||
data, with_length = self.files[u.path]
|
||
h.send_response(200)
|
||
h.send_header("Content-Type", "application/octet-stream")
|
||
if with_length:
|
||
h.send_header("Content-Length", str(len(data)))
|
||
h.end_headers() # без длины HTTP/1.0 отдаёт тело до закрытия соединения
|
||
h.wfile.write(data)
|
||
return
|
||
self._reply(h, 404, b"{}")
|
||
except (BrokenPipeError, ConnectionResetError):
|
||
pass
|
||
|
||
def _reply(self, h, status, body, headers=None):
|
||
h.send_response(status)
|
||
h.send_header("Content-Type", "application/json")
|
||
h.send_header("Content-Length", str(len(body)))
|
||
for k, v in (headers or {}).items():
|
||
h.send_header(k, v)
|
||
h.end_headers()
|
||
h.wfile.write(body)
|
||
|
||
def rpc(self, method, kw):
|
||
found = [d for d in self.docs if self._match(d, kw.get("filter") or [])]
|
||
fields = kw.get("fields")
|
||
pick = (lambda d: {k: d.get(k) for k in fields}) if fields else dict # noqa: E731
|
||
if method == "CmfDocument.get":
|
||
return pick(found[0]) if found else None
|
||
if method == "CmfDocument.list":
|
||
lo, hi = kw.get("slice") or [0, len(found)]
|
||
return [pick(d) for d in found[lo:hi]]
|
||
raise ValueError("метод %s не поддержан" % method)
|
||
|
||
@staticmethod
|
||
def _match(doc, flt):
|
||
for field, op, value in flt:
|
||
if op != "==" or _field(doc, field) != value:
|
||
return False
|
||
return True
|