diff --git a/.github/workflows/openapi-lint.yml b/.github/workflows/openapi-lint.yml new file mode 100644 index 00000000..b8d5d55b --- /dev/null +++ b/.github/workflows/openapi-lint.yml @@ -0,0 +1,28 @@ +name: Lint OpenAPI specs + +# Quality gate for the public API reference. Fails on undocumented schema +# properties, epoch time fields without Unix/timestamp wording, string enums +# whose values are not explained in the description, and operations without +# request/response examples. See scripts/lint_openapi.py for the rules. + +on: + pull_request: + paths: + - 'api-reference/*.json' + - 'scripts/lint_openapi.py' + - '.github/workflows/openapi-lint.yml' + push: + branches: [main] + paths: + - 'api-reference/*.json' + - 'scripts/lint_openapi.py' + - '.github/workflows/openapi-lint.yml' + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v5 + - name: Lint specs + run: python3 scripts/lint_openapi.py diff --git a/api-reference/monitors.openapi.en.json b/api-reference/monitors.openapi.en.json index f4ee9a77..e7a18195 100644 --- a/api-reference/monitors.openapi.en.json +++ b/api-reference/monitors.openapi.en.json @@ -5399,7 +5399,7 @@ "request_id": { "type": "string", "example": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4", - "description": "Server-side trace ID for this request; quote it when reporting problems." + "description": "Unique trace ID of this request; include it when reporting issues so logs can be located." }, "error": { "$ref": "#/components/schemas/DutyError" diff --git a/api-reference/monitors.openapi.zh.json b/api-reference/monitors.openapi.zh.json index 80f6450d..796da38a 100644 --- a/api-reference/monitors.openapi.zh.json +++ b/api-reference/monitors.openapi.zh.json @@ -5399,7 +5399,7 @@ "request_id": { "type": "string", "example": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4", - "description": "服务端本次请求的追踪 ID,排障时请提供该值。" + "description": "本次请求的唯一追踪 ID(trace ID),反馈问题时请提供该值以便检索日志。" }, "error": { "$ref": "#/components/schemas/DutyError" diff --git a/api-reference/on-call.openapi.en.json b/api-reference/on-call.openapi.en.json index 7cf71d33..270ee7d1 100644 --- a/api-reference/on-call.openapi.en.json +++ b/api-reference/on-call.openapi.en.json @@ -21382,7 +21382,7 @@ "request_id": { "type": "string", "example": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4", - "description": "Trace ID of this request, identical to the `Flashcat-Request-Id` response header." + "description": "Unique trace ID of this request; include it when reporting issues so logs can be located." }, "error": { "$ref": "#/components/schemas/DutyError" diff --git a/api-reference/on-call.openapi.zh.json b/api-reference/on-call.openapi.zh.json index 5a95608e..9fabfc14 100644 --- a/api-reference/on-call.openapi.zh.json +++ b/api-reference/on-call.openapi.zh.json @@ -21374,7 +21374,7 @@ "request_id": { "type": "string", "example": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4", - "description": "本次请求的追踪 ID,与 `Flashcat-Request-Id` 响应头一致。" + "description": "本次请求的唯一追踪 ID(trace ID),反馈问题时请提供该值以便检索日志。" }, "error": { "$ref": "#/components/schemas/DutyError" diff --git a/api-reference/rum.openapi.en.json b/api-reference/rum.openapi.en.json index 010823ef..d0d36464 100644 --- a/api-reference/rum.openapi.en.json +++ b/api-reference/rum.openapi.en.json @@ -3681,7 +3681,7 @@ "request_id": { "type": "string", "example": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4", - "description": "Server-side trace ID of this request, used to correlate server logs when troubleshooting." + "description": "Unique trace ID of this request; include it when reporting issues so logs can be located." }, "error": { "$ref": "#/components/schemas/DutyError" diff --git a/api-reference/rum.openapi.zh.json b/api-reference/rum.openapi.zh.json index e93d3899..6f48f3d3 100644 --- a/api-reference/rum.openapi.zh.json +++ b/api-reference/rum.openapi.zh.json @@ -3681,7 +3681,7 @@ "request_id": { "type": "string", "example": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4", - "description": "本次请求的服务端 Trace ID,用于问题排查时关联服务端日志。" + "description": "本次请求的唯一追踪 ID(trace ID),反馈问题时请提供该值以便检索日志。" }, "error": { "$ref": "#/components/schemas/DutyError" diff --git a/scripts/lint_openapi.py b/scripts/lint_openapi.py new file mode 100644 index 00000000..d45c25b8 --- /dev/null +++ b/scripts/lint_openapi.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Lint Flashduty public OpenAPI specs for documentation quality. + +Stdlib-only. Lints every api-reference/*.openapi.*.json file (per-module and +consolidated) and fails on: + + missing-description every schema property must carry a non-empty description + (recursively, including array item objects and inline + allOf members; pure $ref nodes are exempt) + epoch-wording integer fields named ts / timestamp / *_at / *_time must + mention Unix/epoch/timestamp/时间戳 in the description — + the go-flashduty SDK maps these fields by description text + enum-undocumented every value of a string enum must be mentioned in the + property description (backticked or bare), so readers + learn what each value means + missing-example every operation needs a request example (when it has a + request body) and a 200 response example + +Usage: + python3 scripts/lint_openapi.py # lint all spec files, exit 1 on violations + python3 scripts/lint_openapi.py --warn-only +""" +import json +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent +SPEC_GLOB = "api-reference/*.json" +# the legacy hand-maintained Apifox export is exempt — it is read-only reference +SPEC_EXCLUDE = ("openapi.legacy",) + +EPOCH_RE = re.compile(r"\b(unix|epoch|timestamp)\b|时间戳", re.I) +EPOCH_NAME_RE = re.compile(r"^(ts|timestamp)$|_at$|_time$") +# duration-ish names that only look like timestamps +DURATION_HINT_RE = re.compile(r"timeout|interval|duration|delay|elapsed") + +# enum value sets whose meaning is self-evident everywhere they appear; +# anything else must explain each value in the property description +WELL_KNOWN_ENUMS = [ + {"Critical", "Warning", "Info"}, + {"Critical", "Warning", "Info", "Ok"}, + {"enabled", "disabled"}, + {"success", "failed"}, +] + +# field-name exemptions: order-by enums list sortable field names, whose +# meaning lives on the referenced fields themselves +ENUM_EXEMPT_NAMES = re.compile(r"^(orderby|order_by)$") + + +def enum_is_well_known(values) -> bool: + vals = {v for v in values if isinstance(v, str) and v} + return any(vals <= s for s in WELL_KNOWN_ENUMS) + + +class Reporter: + def __init__(self): + self.violations = [] + + def add(self, file, rule, where, detail): + self.violations.append((file, rule, where, detail)) + + def dump(self): + by_rule = {} + for f, rule, where, detail in self.violations: + by_rule.setdefault(rule, []).append((f, where, detail)) + for rule, items in sorted(by_rule.items()): + print(f"\n## {rule} ({len(items)})") + for f, where, detail in items[:50]: + print(f" {f}: {where} — {detail}") + if len(items) > 50: + print(f" ... and {len(items) - 50} more") + print(f"\nTotal: {len(self.violations)} violation(s)") + + +def walk_properties(node, where, file, rep, seen): + """Recursively check every property under a schema node.""" + if not isinstance(node, dict): + return + oid = id(node) + if oid in seen: + return + seen.add(oid) + + for member in node.get("allOf", []): + if isinstance(member, dict) and "$ref" not in member: + walk_properties(member, where, file, rep, seen) + + for name, prop in node.get("properties", {}).items(): + if not isinstance(prop, dict): + continue + pwhere = f"{where}.{name}" + if "$ref" in prop: + continue + desc = prop.get("description") + if not desc or not desc.strip(): + rep.add(file, "missing-description", pwhere, f"type={prop.get('type')}") + desc = "" + # epoch wording + if prop.get("type") == "integer" and EPOCH_NAME_RE.search(name) \ + and not DURATION_HINT_RE.search(name): + if not EPOCH_RE.search(desc): + rep.add(file, "epoch-wording", pwhere, + "integer time field without Unix/epoch/timestamp wording") + # enum values documented + enum = prop.get("enum") + if enum and prop.get("type") == "string" and desc and not enum_is_well_known(enum) \ + and not ENUM_EXEMPT_NAMES.match(name): + missing = [v for v in enum if isinstance(v, str) and v and v not in desc] + if missing: + rep.add(file, "enum-undocumented", pwhere, + f"enum values not explained in description: {missing}") + # recurse + if prop.get("type") == "object" or "properties" in prop: + walk_properties(prop, pwhere, file, rep, seen) + items = prop.get("items") + if isinstance(items, dict) and "$ref" not in items: + walk_properties(items, pwhere + "[]", file, rep, seen) + + +def lint_operations(spec, file, rep): + for path, pi in spec.get("paths", {}).items(): + if not isinstance(pi, dict): + continue + for method, op in pi.items(): + if not isinstance(op, dict): + continue + where = f"{method.upper()} {path}" + body = op.get("requestBody", {}).get("content", {}).get("application/json", {}) + if body and "example" not in body and "examples" not in body: + rep.add(file, "missing-example", where, "request body has no example") + ok = op.get("responses", {}).get("200", {}) + if "$ref" not in ok: + content = ok.get("content", {}).get("application/json", {}) + # only JSON responses can carry a JSON example; binary/ndjson + # downloads and empty 200s are exempt + if content and "example" not in content and "examples" not in content: + rep.add(file, "missing-example", where, "200 response has no example") + + +def lint_file(path, rep): + spec = json.load(open(path)) + file = path.name + for name, schema in spec.get("components", {}).get("schemas", {}).items(): + walk_properties(schema, f"schema:{name}", file, rep, set()) + lint_operations(spec, file, rep) + + +def main(): + files = [f for f in sorted(ROOT.glob(SPEC_GLOB)) + if not any(x in f.name for x in SPEC_EXCLUDE)] + if not files: + print(f"no spec files matched {SPEC_GLOB} under {ROOT}", file=sys.stderr) + return 2 + rep = Reporter() + for f in files: + lint_file(f, rep) + if not rep.violations: + print(f"OK: {len(files)} spec files, no violations") + return 0 + rep.dump() + if "--warn-only" in sys.argv: + return 0 + return 1 + + +if __name__ == "__main__": + sys.exit(main())