docs+test: 发布包对拍计划、pytest 双端 API 对拍与 run_elevator_parity 脚本

- docs/elevator-api-parity: 计划/报告模板/示例
- tools/elevator_api_parity: 端点目录、fixtures、对拍 client/compare、报告生成
- scripts/run_elevator_parity: JDK8 构建 + 单测/对拍(无服务时跳过对拍用例)

Made-with: Cursor

Former-commit-id: 3d54a40e1a7ae0b1724261d4f18910a6f415f853
This commit is contained in:
反编译工作区
2026-04-25 09:50:32 +08:00
parent 2cd9da61da
commit 038f846dad
24 changed files with 712 additions and 0 deletions
@@ -0,0 +1 @@
"""Parity: HTTP client, health probe, JSON compare for elevator JARs."""
@@ -0,0 +1,8 @@
import json
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
def load() -> dict:
return json.loads((_ROOT / "api_catalog.json").read_text(encoding="utf-8"))
@@ -0,0 +1,135 @@
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from typing import Any, Optional
import requests
from . import compare
from .health import probe_healthy
@dataclass
class ParityResult:
name: str
method: str
path: str
old_status: int
new_status: int
old_text: str
new_text: str
match: bool
message: str
def default_headers() -> dict[str, str]:
h: dict[str, str] = {
"Content-Type": "application/json",
}
m = {
"businessid": "ELEVATOR_HEADER_BUSINESSID",
"loginid": "ELEVATOR_HEADER_LOGINID",
"platformuserid": "ELEVATOR_HEADER_PLATFORMUSERID",
"authorization": "ELEVATOR_HEADER_AUTHORIZATION",
"applicationid": "ELEVATOR_HEADER_APPLICATIONID",
}
for hdr, evar in m.items():
v = os.environ.get(evar, "").strip()
if v:
h[hdr] = v
return h
def call_both(
name: str,
method: str,
path: str,
body: Any,
base_old: str,
base_new: str,
extra_headers: Optional[dict] = None,
compare_mode: str = "deep",
session: requests.Session | None = None,
) -> ParityResult:
s = session or requests.Session()
h = {**default_headers(), **(extra_headers or {})}
url_o = base_old.rstrip("/") + path
url_n = base_new.rstrip("/") + path
if body is None:
data = None
else:
data = body if isinstance(body, str) else json.dumps(body, ensure_ascii=False)
m = method.upper()
if m == "GET":
r_old = s.get(url_o, headers=h, timeout=60)
r_new = s.get(url_n, headers=h, timeout=60)
else:
r_old = s.post(url_o, headers=h, data=data, timeout=120)
r_new = s.post(url_n, headers=h, data=data, timeout=120)
ot, nt = r_old.text, r_new.text
st_o, st_n = r_old.status_code, r_new.status_code
msg = ""
if compare_mode == "status_only":
m_ok = st_o == st_n
if not m_ok:
msg = f"http {st_o} vs {st_n}"
elif compare_mode == "code_only":
msg = ""
oj, nj = _safe_json(ot), _safe_json(nt)
c1, c2 = compare.business_code(oj), compare.business_code(nj)
m_ok = st_o == st_n
if m_ok and c1 is not None and c2 is not None:
m_ok = c1 == c2
elif m_ok and c1 is None and c2 is None and st_o == st_n and 400 <= st_o < 600:
m_ok = True
msg = "http status only (no parseable JSON body)"
elif m_ok and (c1 is None) != (c2 is None):
m_ok = False
if not m_ok and not msg:
msg = compare.describe_diff(ot, nt, "code_only")
elif m_ok and not msg:
msg = ""
else:
m_ok = st_o == st_n
oj, nj = _safe_json(ot), _safe_json(nt)
if oj is not None and nj is not None and m_ok:
m_ok = compare.normalize(oj) == compare.normalize(nj)
elif m_ok and (ot or "") != (nt or ""):
m_ok = (ot or "").strip() == (nt or "").strip()
if not m_ok:
msg = compare.describe_diff(ot, nt, "deep") or f"http {st_o}/{st_n} or body mismatch"
return ParityResult(
name=name,
method=m,
path=path,
old_status=st_o,
new_status=st_n,
old_text=ot,
new_text=nt,
match=m_ok,
message=msg,
)
def _safe_json(text: str) -> Any:
if not (text or "").strip():
return None
try:
return compare.parse_json_loose(text)
except Exception:
return None
def can_reach_both(
base_old: str, base_new: str, s: requests.Session | None = None
) -> tuple[bool, str]:
s = s or requests.Session()
p1, ok1, _ = probe_healthy(base_old, s)
p2, ok2, _ = probe_healthy(base_new, s)
if ok1 and ok2:
return True, f"ok old health path~{p1} new probed"
return False, f"health old ok={ok1} new ok={ok2} (paths like {p1})"
@@ -0,0 +1,94 @@
"""
Compare two JSON response bodies. CloudwalkResult: compare top-level `code` when
both parse as objects; also deep-compare with optional key stripping.
"""
from __future__ import annotations
import copy
import json
from typing import Any, List, Set
# JSONPath-like: tuple of keys in order for nesting
DEFAULT_STRIP_PATHS: Set[tuple] = {
# ("data", "ts"),
}
def _strip(obj: Any, rules: Set[tuple], prefix: tuple) -> Any:
if not isinstance(obj, (dict, list)):
return obj
if isinstance(obj, list):
return [_strip(x, rules, prefix + (i,)) for i, x in enumerate(obj)]
out = copy.deepcopy(obj)
for k, v in list(obj.items()) if isinstance(obj, dict) else []:
p = prefix + (k,)
if p in rules:
if k in out:
del out[k]
continue
if isinstance(v, (dict, list)):
out[k] = _strip(v, rules, p)
return out
def parse_json_loose(text: str) -> Any:
if not (text or "").strip():
return None
return json.loads(text)
def normalize(
data: Any,
strip_paths: Set[tuple] | None = None,
) -> str:
rules = strip_paths if strip_paths is not None else DEFAULT_STRIP_PATHS
s = _strip(data, rules, tuple())
return json.dumps(s, ensure_ascii=False, sort_keys=True, default=str)
def business_code(data: Any) -> str | None:
if isinstance(data, dict) and "code" in data:
c = data.get("code")
return str(c) if c is not None else None
return None
def same_shape(old_text: str, new_text: str) -> bool:
try:
a = parse_json_loose(old_text)
b = parse_json_loose(new_text)
except json.JSONDecodeError:
return False
if type(a) != type(b):
return False
if isinstance(a, dict) and isinstance(b, dict) and a.keys() == b.keys():
return all(type(a[k]) is type(b[k]) for k in a)
if isinstance(a, list) and isinstance(b, list):
if len(a) == len(b) and len(a) == 0:
return True
return False
def describe_diff(
old_text: str, new_text: str, compare_norm: str = "deep"
) -> str:
try:
o = parse_json_loose(old_text)
n = parse_json_loose(new_text)
except json.JSONDecodeError as e:
return f"json parse: {e}"
if compare_norm == "code_only":
c1, c2 = business_code(o), business_code(n)
if c1 == c2:
return ""
return f"business code: old={c1!r} new={c2!r}"
no = normalize(o)
nn = normalize(n)
if no == nn:
return ""
if business_code(o) is not None and business_code(n) is not None and business_code(o) != business_code(
n
):
return f"normalized JSON differs; first code: old={business_code(o)} new={business_code(n)}"
return "normalized JSON differs (see full bodies in test log)"
@@ -0,0 +1,29 @@
"""Probes that work for both Spring Boot 1.5/2.x with common actuator settings."""
import requests
_CANDIDATES = (
"/actuator/health",
"/actuator/health/", # noqa: PIE
"/health",
"/application/health", # very old
)
def probe_healthy(base_url: str, session: requests.Session, timeout: float = 2.0) -> tuple[str, bool, int]:
base = base_url.rstrip("/")
last_status = 0
for path in _CANDIDATES:
try:
r = session.get(base + path, timeout=timeout, allow_redirects=True)
last_status = r.status_code
if r.status_code == 200 and _body_up(r.text):
return path, True, r.status_code
except requests.RequestException:
last_status = -1
return _CANDIDATES[0], False, last_status
def _body_up(text: str) -> bool:
t = (text or "").lower()
return "up" in t or '"status":"ok"' in t or '"status": "ok"' in t