159 lines
6.9 KiB
Python
159 lines
6.9 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`: ссылка project хранится как {"id", "code"}, parent/tree_parent — кодом."""
|
|
if path in ("parent.code", "tree_parent.code"):
|
|
return doc.get(path.split(".")[0])
|
|
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 = []
|
|
# форма ссылок parent/tree_parent в ответах: "full" — {"id": "<id документа>", "code"} (по умолчанию, как в Eva),
|
|
# "id_only" — {"id"}, "string" — голая строка-id, "code_id" — {"id": код, "code": код} (прежняя форма фейка)
|
|
self.ref_form = "full"
|
|
self.files = {} # путь → (байты, Content-Length: True/False/заявленное число)
|
|
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=""):
|
|
# parent/tree_parent хранятся кодами родителей; в ответе они превращаются в ссылку по self.ref_form
|
|
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": parent,
|
|
"tree_parent": tree_parent, "project": ref(project), "orderno": orderno,
|
|
"text": text})
|
|
|
|
def _ref(self, code):
|
|
if code is None:
|
|
return None
|
|
target = next((d for d in self.docs if d["code"] == code), None)
|
|
rid = str(target["id"]) if target else code
|
|
form = self.ref_form
|
|
if form == "id_only":
|
|
return {"id": rid}
|
|
if form == "string":
|
|
return rid
|
|
if form == "code_id":
|
|
return {"id": code, "code": code}
|
|
return {"id": rid, "code": code}
|
|
|
|
def _out(self, d, fields):
|
|
d = dict(d, parent=self._ref(d["parent"]), tree_parent=self._ref(d["tree_parent"]))
|
|
return {k: d.get(k) for k in fields} if fields else d
|
|
|
|
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: # True — настоящая длина; число — заявленная (ложь, для проверки заголовка)
|
|
h.send_header("Content-Length", str(len(data) if with_length is True else with_length))
|
|
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: self._out(d, fields) # 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
|