You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

249 lines
13 KiB

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""本批 JAR 的运行时 /v3/api-docs 与静态契约 docs/openapi-rc.json 的**严格**对照。
对照范围(整改项 2):
* 具体路径 + 方法 + 200 响应 Schema 引用(逐 operation 核对,不只看路径数量)。
* 展示字段的 类型 / 数组元素类型 / 范围 / 长度 / 数量 / 可空性,在静态与运行时之间逐项比较;
任一必需项不一致即失败(不再把「约束缺失」判为通过,取消 “static-only 即通过”)。
* Map 型岗位响应的**文档模型**(ApiResponseJobDetailViewDoc / ApiResponseJobListViewDoc /
ApiResponseJobPublicViewDoc 及其 data)结构与静态一致。
* 公开企业摘要严格 7 字段白名单;多出敏感字段即失败。
用法:
python b01_openapi_compare.py [base-url] [--static PATH] [--runtime PATH] [--out PATH]
退出码:0=全部通过;1=存在失败。
"""
import argparse
import json
import sys
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
DEFAULT_STATIC = ROOT / "docs" / "openapi-rc.json"
ACCEPT_DIR = Path(__file__).resolve().parent
DEFAULT_RUNTIME_OUT = ACCEPT_DIR / "runtime-openapi.json"
DEFAULT_COMPARE_OUT = ACCEPT_DIR / "openapi-compare.json"
# 逐 operation 期望的响应 Schema 引用(要求为各操作专属文档模型,不得共用 MapStringObject)。
EXPECTED_REFS = {
("/jobs", "get"): "ApiResponseJobListViewDoc",
("/jobs", "post"): "ApiResponseJobDetailViewDoc",
("/jobs/{jobId}", "get"): "ApiResponseJobDetailViewDoc",
("/jobs/{jobId}", "put"): "ApiResponseJobDetailViewDoc",
("/jobs/public/{jobId}", "get"): "ApiResponseJobPublicViewDoc",
}
PUBLIC_COMPANY_FIELDS = ["companySize", "description", "id", "industry", "logoUrl", "name", "verified"]
FORBIDDEN_PUBLIC_FIELDS = ["ownerUserId", "address", "website", "phone", "email", "createdAt",
"updatedAt", "materialsJson", "decisionReason", "reviewerId"]
DOC_SCHEMAS = ["JobDocCompanySummary", "JobDetailViewDoc", "JobPublicViewDoc", "JobListViewDoc",
"ApiResponseJobDetailViewDoc", "ApiResponseJobPublicViewDoc", "ApiResponseJobListViewDoc"]
JOB_DETAIL_FIELDS = ["id", "companyId", "title", "description", "requirements", "city",
"employmentType", "salaryMin", "salaryMax", "experience", "education",
"benefits", "salaryMonths", "workLocation", "status", "publishedAt",
"createdAt", "updatedAt"]
JOB_PUBLIC_FIELDS = ["id", "companyId", "title", "description", "requirements", "city",
"employmentType", "salaryMin", "salaryMax", "experience", "education",
"benefits", "salaryMonths", "workLocation", "status", "publishedAt",
"createdAt", "company"]
JOB_LIST_FIELDS = ["items", "total", "page", "size", "totalPages"]
rows = []
def record(case, expected, actual, ok):
rows.append({"case": case, "expected": str(expected)[:200], "actual": str(actual)[:200],
"ok": bool(ok)})
print("[%s] %-62s exp=%-20s act=%s" % ("PASS" if ok else "FAIL", case,
str(expected)[:20], str(actual)[:70]))
def props(node):
return (node or {}).get("properties", {})
def canon_type(node):
value = (node or {}).get("type")
if value is None:
return None
return tuple(sorted(value)) if isinstance(value, list) else value
def is_nullable(node):
value = (node or {}).get("type")
if isinstance(value, list) and "null" in value:
return True
return bool((node or {}).get("nullable"))
def get(node, path):
cur = node
for token in path.split("."):
if not isinstance(cur, dict):
return None
cur = cur.get(token)
return cur
def cmp_field(case_prefix, s_node, r_node, aspects):
"""逐 aspect 比较静态/运行时字段节点;缺失即失败。"""
if s_node is None or r_node is None:
record("%s 存在(静态/运行时)" % case_prefix, "present", "s=%s r=%s" % (s_node is not None, r_node is not None),
s_node is not None and r_node is not None)
return
for path, kind in aspects:
sv, rv = get(s_node, path), get(r_node, path)
if kind == "type":
sv, rv = canon_type({"type": sv}), canon_type({"type": rv})
elif kind == "nullable":
sv, rv = is_nullable(s_node), is_nullable(r_node)
record("%s %s 一致" % (case_prefix, path), sv, rv, sv == rv and sv is not None)
def cmp_ref(op_path, method, static, runtime):
def ref(doc):
return doc["paths"].get(op_path, {}).get(method, {}).get(
"responses", {}).get("200", {}).get("content", {}).get("*/*", {}).get(
"schema", {}).get("$ref", "")
s_ref, r_ref = ref(static), ref(runtime)
expected = EXPECTED_REFS[(op_path, method)]
record("%s %s 运行时 200 引用 = %s" % (method.upper(), op_path, expected),
"#/components/schemas/" + expected, r_ref, r_ref.endswith(expected))
record("%s %s 静态/运行时 200 引用一致" % (method.upper(), op_path), s_ref, r_ref,
s_ref == r_ref and s_ref != "")
record("%s %s 未共用 ApiResponseMapStringObject" % (method.upper(), op_path),
"专属模型", r_ref, "MapStringObject" not in r_ref)
name = expected
in_static = name in static["components"]["schemas"]
in_runtime = name in runtime["components"]["schemas"]
record("%s %s 引用组件双方存在" % (method.upper(), op_path), True,
"static=%s runtime=%s" % (in_static, in_runtime), in_static and in_runtime)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("base", nargs="?", default="http://127.0.0.1:18080")
parser.add_argument("--static", default=str(DEFAULT_STATIC))
parser.add_argument("--runtime", default=None)
parser.add_argument("--out", default=str(DEFAULT_COMPARE_OUT))
parser.add_argument("--runtime-out", default=str(DEFAULT_RUNTIME_OUT))
args = parser.parse_args()
static = json.load(open(args.static, encoding="utf-8"))
if args.runtime:
runtime = json.load(open(args.runtime, encoding="utf-8"))
else:
url = args.base.rstrip("/") + "/api/v1/v3/api-docs"
with urllib.request.urlopen(url, timeout=90) as resp:
runtime = json.loads(resp.read().decode("utf-8"))
Path(args.runtime_out).write_text(json.dumps(runtime, ensure_ascii=False, indent=2),
encoding="utf-8")
s_schemas, r_schemas = static["components"]["schemas"], runtime["components"]["schemas"]
s_paths, r_paths = static.get("paths", {}), runtime.get("paths", {})
# 1) 基线:路径数与 operation 数一致
record("运行时路径数 == 静态契约", len(s_paths), len(r_paths), len(s_paths) == len(r_paths))
record("运行时 operation 数 == 静态契约",
sum(len(v) for v in s_paths.values()), sum(len(v) for v in r_paths.values()),
sum(len(v) for v in s_paths.values()) == sum(len(v) for v in r_paths.values()))
# 2) 逐 operation 响应引用
for (path, method) in EXPECTED_REFS:
cmp_ref(path, method, static, runtime)
# 3) 请求 DTO 展示字段约束
text_aspects = [("type", "type"), ("maxLength", "raw"), ("nullable", "nullable")]
for name in ("JobCreateRequest", "JobUpdateRequest"):
sp, rp = props(s_schemas.get(name)), props(r_schemas.get(name))
for field in ("experience", "education"):
cmp_field("%s.%s" % (name, field), sp.get(field), rp.get(field), text_aspects)
cmp_field("%s.workLocation" % name, sp.get("workLocation"), rp.get("workLocation"),
[("type", "type"), ("maxLength", "raw"), ("nullable", "nullable")])
cmp_field("%s.benefits" % name, sp.get("benefits"), rp.get("benefits"),
[("type", "type"), ("nullable", "nullable"), ("items.type", "raw"),
("x-normalization", "raw")])
for label, node in (("静态", sp.get("benefits") or {}), ("运行时", rp.get("benefits") or {})):
expected = dict(trimItems=True, deduplicate=True, rejectBlankItems=True, maxItems=20, itemMaxLength=40)
record("%s %s 福利归一化语义" % (label, name), expected, node.get("x-normalization"),
node.get("x-normalization") == expected)
record("%s %s 不限制原始数组数量或未trim长度" % (label, name), "absent",
(node.get("maxItems"), node.get("items", {}).get("maxLength")),
"maxItems" not in node and "maxLength" not in node.get("items", {}))
record("%s %s 福利可空" % (label, name), True, is_nullable(node), is_nullable(node))
cmp_field("%s.salaryMonths" % name, sp.get("salaryMonths"), rp.get("salaryMonths"),
[("type", "type"), ("minimum", "raw"), ("maximum", "raw"), ("nullable", "nullable")])
for field in ("salaryMin", "salaryMax"):
record("%s.%s 双方存在且为 integer" % (name, field), "integer",
canon_type(rp.get(field)), canon_type(sp.get(field)) == "integer"
and canon_type(rp.get(field)) == "integer")
# 4) 企业请求 / 公开摘要的 companySize
for name in ("CompanyCreateRequest", "CompanyUpdateRequest", "CompanyPublicView"):
cmp_field("%s.companySize" % name,
props(s_schemas.get(name)).get("companySize"),
props(r_schemas.get(name)).get("companySize"),
[("type", "type"), ("maxLength", "raw"), ("nullable", "nullable")])
# 5) 企业自身岗位管理摘要
for field in ("experience", "education", "workLocation"):
cmp_field("CompanyManagedJobItem.%s" % field,
props(s_schemas.get("CompanyManagedJobItem")).get(field),
props(r_schemas.get("CompanyManagedJobItem")).get(field),
[("type", "type"), ("maxLength", "raw")])
cmp_field("CompanyManagedJobItem.benefits",
props(s_schemas.get("CompanyManagedJobItem")).get("benefits"),
props(r_schemas.get("CompanyManagedJobItem")).get("benefits"),
[("type", "type"), ("maxItems", "raw"), ("items.type", "raw"), ("items.maxLength", "raw")])
cmp_field("CompanyManagedJobItem.salaryMonths",
props(s_schemas.get("CompanyManagedJobItem")).get("salaryMonths"),
props(r_schemas.get("CompanyManagedJobItem")).get("salaryMonths"),
[("type", "type"), ("minimum", "raw"), ("maximum", "raw")])
# 6) 公开企业摘要严格 7 字段白名单(静态与运行时)
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)):
node = props(schemas.get("CompanyPublicView"))
record("CompanyPublicView %s 严格 7 字段" % label, PUBLIC_COMPANY_FIELDS,
sorted(node.keys()), sorted(node.keys()) == PUBLIC_COMPANY_FIELDS)
leaked = [f for f in FORBIDDEN_PUBLIC_FIELDS if f in node]
record("CompanyPublicView %s 无敏感字段" % label, "无", leaked, not leaked)
# 7) 文档模型:双方存在、属性集合与类型一致
for name in DOC_SCHEMAS:
s_node, r_node = s_schemas.get(name), r_schemas.get(name)
record("文档模型 %s 双方存在" % name, True, "static=%s runtime=%s" % (s_node is not None, r_node is not None),
s_node is not None and r_node is not None)
if s_node is None or r_node is None:
continue
record("文档模型 %s 属性集合一致" % name, sorted(props(s_node).keys()),
sorted(props(r_node).keys()), sorted(props(s_node).keys()) == sorted(props(r_node).keys()))
mismatched = [k for k in props(s_node)
if canon_type(props(s_node)[k]) != canon_type(props(r_node).get(k))]
record("文档模型 %s 逐属性类型一致" % name, "无差异", mismatched, not mismatched)
# 8) 文档模型语义:必要 data 字段
for schema_name, fields in (("JobDetailViewDoc", JOB_DETAIL_FIELDS),
("JobPublicViewDoc", JOB_PUBLIC_FIELDS),
("JobListViewDoc", JOB_LIST_FIELDS)):
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)):
missing = [f for f in fields if f not in props(schemas.get(schema_name))]
record("%s %s 必需字段齐全" % (label, schema_name), fields, missing, not missing)
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)):
node = props(schemas.get("JobDocCompanySummary"))
record("%s JobDocCompanySummary 严格 7 字段" % label, PUBLIC_COMPANY_FIELDS,
sorted(node.keys()), sorted(node.keys()) == PUBLIC_COMPANY_FIELDS)
for label, schemas in (("静态", s_schemas), ("运行时", r_schemas)):
b = props(schemas.get("JobPublicViewDoc")).get("benefits") or {}
record("%s 响应端 benefits 恒为数组(非可空)" % label, "array", b.get("type"),
b.get("type") == "array")
bad = [r for r in rows if not r["ok"]]
print("\n== OPENAPI COMPARE SUMMARY: total=%d fail=%d ==" % (len(rows), len(bad)))
Path(args.out).write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())