http_contract_audit.py 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126
  1. # -*- coding: utf-8 -*-
  2. r"""HTTP 契约复核门(P11-A):把"重构前的接口行为"变成可自动复核的判据。
  3. 规则(比结构与可达性,不比数据值):
  4. · 方法一致:契约登记 GET/POST,实测必须 200;
  5. · 状态码一致:200 ⇒ 必须 200;
  6. · JSON 顶层键:契约登记的键**必须都在**(允许新增键 —— 但少一个就是回归);
  7. · HTML:`<title>` 与契约一致(页面呈现的锚点之一)。
  8. 用法:
  9. python scripts/http_contract_audit.py # 复核(要求服务在跑)
  10. python scripts/http_contract_audit.py --json # 机读输出
  11. 退出码:0 全一致 · 9 有不一致 · 2 契约文件缺失 · 3 服务不可达。
  12. """
  13. from __future__ import annotations
  14. import argparse
  15. import json
  16. import pathlib
  17. import re
  18. import sys
  19. import urllib.error
  20. import urllib.request
  21. try:
  22. from app_common.app_common_guanlan.api import install_root as _install_root
  23. except ImportError: # 包结构异常时按位置上跳
  24. from pathlib import Path as _P
  25. def _install_root(_f): return _P(_f).resolve().parents[4]
  26. ROOT = _install_root(__file__)
  27. CONTRACT = ROOT / 'app_backEnd/app_backEnd_guanlan/contract/http_api_v1.json'
  28. BASES = {'gateway': 'http://127.0.0.1:28084', 'frontend': 'http://127.0.0.1:28084',
  29. 'detail_api': 'http://127.0.0.1:18033', 'detail_page': 'http://127.0.0.1:18033',
  30. 'cms': 'http://127.0.0.1:28084', 'algorithm': 'http://127.0.0.1:18050'}
  31. SLOW = ('/api/curves', '/api/fleet', '/api/rpt_compose', '/api/snapshot')
  32. def probe(url: str, method: str, timeout: int) -> dict:
  33. data = b'{}' if method == 'POST' else None
  34. try:
  35. req = urllib.request.Request(url, data=data, method=method,
  36. headers={'Content-Type': 'application/json',
  37. 'User-Agent': 'guanlan-http-contract-audit'})
  38. with urllib.request.urlopen(req, timeout=timeout) as r:
  39. body = r.read(600_000)
  40. return dict(status=r.status, ctype=r.headers.get('content-type', ''), body=body)
  41. except urllib.error.HTTPError as e:
  42. return dict(status=e.code, ctype='', body=b'')
  43. except Exception as e: # noqa: BLE001
  44. return dict(status=0, ctype='', body=b'', note=type(e).__name__)
  45. def audit(as_json: bool = False) -> int:
  46. if not CONTRACT.is_file():
  47. print('[X] 缺契约正本', CONTRACT.relative_to(ROOT).as_posix())
  48. return 2
  49. c = json.loads(CONTRACT.read_text(encoding='utf-8'))
  50. rows, bad, unreachable = [], [], 0
  51. for e in c['endpoints']:
  52. url = BASES.get(e['kind'], '') + e['path']
  53. to = 120 if any(s in e['path'] for s in SLOW) else 60
  54. got = probe(url, e['method'], to)
  55. if got['status'] == 0:
  56. unreachable += 1
  57. rows.append(dict(path=e['path'], method=e['method'], want=e['status'], got=0,
  58. ok=False, why='不可达(%s)' % got.get('note', '')))
  59. continue
  60. ok, why = True, ''
  61. if got['status'] != e['status']:
  62. ok, why = False, '状态码 %s ≠ %s' % (got['status'], e['status'])
  63. elif e.get('json_keys_any'):
  64. try:
  65. o = json.loads(got['body'].decode('utf-8', 'replace'))
  66. except Exception: # noqa: BLE001
  67. o = None
  68. if not isinstance(o, dict):
  69. ok, why = False, '响应不是 JSON 对象'
  70. else:
  71. sets = [set(s) for s in e['json_keys_any']]
  72. if not any(s <= set(o.keys()) for s in sets):
  73. ok, why = False, ('响应键集不在契约允许的任一种里(实测: %s)'
  74. % ','.join(sorted(o.keys())[:8]))
  75. elif e.get('json_keys'):
  76. try:
  77. o = json.loads(got['body'].decode('utf-8', 'replace'))
  78. except Exception: # noqa: BLE001
  79. o = None
  80. if not isinstance(o, dict):
  81. ok, why = False, '响应不是 JSON 对象'
  82. else:
  83. miss = [k for k in e['json_keys'] if k not in o]
  84. if miss:
  85. ok, why = False, '缺顶层键: ' + ','.join(miss[:6])
  86. elif e.get('html_title'):
  87. m = re.search(r'<title>(.*?)</title>', got['body'].decode('utf-8', 'replace'), re.S)
  88. title = (m.group(1).strip() if m else '')
  89. if title != e['html_title']:
  90. ok, why = False, '标题 %r ≠ 契约 %r' % (title[:40], e['html_title'][:40])
  91. rows.append(dict(path=e['path'], method=e['method'], want=e['status'], got=got['status'],
  92. ok=ok, why=why))
  93. if not ok:
  94. bad.append(rows[-1])
  95. if as_json:
  96. print(json.dumps(dict(total=len(rows), bad=len(bad), unreachable=unreachable, rows=rows),
  97. ensure_ascii=False, indent=1))
  98. else:
  99. print('== HTTP 契约复核(%d 条端点)==' % len(rows))
  100. for r in rows:
  101. if not r['ok']:
  102. print(' [X] %-42s %-4s 实测 %s %s' % (r['path'], r['method'], r['got'], r['why']))
  103. print(' 一致 %d · 不一致 %d · 不可达 %d' % (len(rows) - len(bad), len(bad), unreachable))
  104. print('结论:', '全部与重构前一致' if not bad else '存在不一致(见上)')
  105. if bad and len(bad) == unreachable:
  106. return 3
  107. return 0 if not bad else 9
  108. def main() -> int:
  109. ap = argparse.ArgumentParser(description='HTTP 契约复核门(重构前行为对拍)')
  110. ap.add_argument('--json', action='store_true')
  111. a = ap.parse_args()
  112. return audit(a.json)
  113. if __name__ == '__main__':
  114. sys.exit(main())