Skip to content

Commit 835faec

Browse files
authored
Merge pull request #296 from flashcatcloud/ci/openapi-lint-gate
ci: add OpenAPI spec lint gate
2 parents b984dea + d6dc21f commit 835faec

8 files changed

Lines changed: 203 additions & 6 deletions

File tree

.github/workflows/openapi-lint.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
name: Lint OpenAPI specs
2+
3+
# Quality gate for the public API reference. Fails on undocumented schema
4+
# properties, epoch time fields without Unix/timestamp wording, string enums
5+
# whose values are not explained in the description, and operations without
6+
# request/response examples. See scripts/lint_openapi.py for the rules.
7+
8+
on:
9+
pull_request:
10+
paths:
11+
- 'api-reference/*.json'
12+
- 'scripts/lint_openapi.py'
13+
- '.github/workflows/openapi-lint.yml'
14+
push:
15+
branches: [main]
16+
paths:
17+
- 'api-reference/*.json'
18+
- 'scripts/lint_openapi.py'
19+
- '.github/workflows/openapi-lint.yml'
20+
21+
jobs:
22+
lint:
23+
runs-on: ubuntu-latest
24+
steps:
25+
- uses: actions/checkout@v7
26+
- uses: actions/setup-python@v5
27+
- name: Lint specs
28+
run: python3 scripts/lint_openapi.py

api-reference/monitors.openapi.en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5399,7 +5399,7 @@
53995399
"request_id": {
54005400
"type": "string",
54015401
"example": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4",
5402-
"description": "Server-side trace ID for this request; quote it when reporting problems."
5402+
"description": "Unique trace ID of this request; include it when reporting issues so logs can be located."
54035403
},
54045404
"error": {
54055405
"$ref": "#/components/schemas/DutyError"

api-reference/monitors.openapi.zh.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5399,7 +5399,7 @@
53995399
"request_id": {
54005400
"type": "string",
54015401
"example": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4",
5402-
"description": "服务端本次请求的追踪 ID,排障时请提供该值"
5402+
"description": "本次请求的唯一追踪 ID(trace ID),反馈问题时请提供该值以便检索日志"
54035403
},
54045404
"error": {
54055405
"$ref": "#/components/schemas/DutyError"

api-reference/on-call.openapi.en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21382,7 +21382,7 @@
2138221382
"request_id": {
2138321383
"type": "string",
2138421384
"example": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4",
21385-
"description": "Trace ID of this request, identical to the `Flashcat-Request-Id` response header."
21385+
"description": "Unique trace ID of this request; include it when reporting issues so logs can be located."
2138621386
},
2138721387
"error": {
2138821388
"$ref": "#/components/schemas/DutyError"

api-reference/on-call.openapi.zh.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21374,7 +21374,7 @@
2137421374
"request_id": {
2137521375
"type": "string",
2137621376
"example": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4",
21377-
"description": "本次请求的追踪 ID,与 `Flashcat-Request-Id` 响应头一致。"
21377+
"description": "本次请求的唯一追踪 ID(trace ID),反馈问题时请提供该值以便检索日志。"
2137821378
},
2137921379
"error": {
2138021380
"$ref": "#/components/schemas/DutyError"

api-reference/rum.openapi.en.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3681,7 +3681,7 @@
36813681
"request_id": {
36823682
"type": "string",
36833683
"example": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4",
3684-
"description": "Server-side trace ID of this request, used to correlate server logs when troubleshooting."
3684+
"description": "Unique trace ID of this request; include it when reporting issues so logs can be located."
36853685
},
36863686
"error": {
36873687
"$ref": "#/components/schemas/DutyError"

api-reference/rum.openapi.zh.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3681,7 +3681,7 @@
36813681
"request_id": {
36823682
"type": "string",
36833683
"example": "01HK8XQE3Z7JM2NTFQ5YJ8P9R4",
3684-
"description": "本次请求的服务端 Trace ID,用于问题排查时关联服务端日志"
3684+
"description": "本次请求的唯一追踪 ID(trace ID),反馈问题时请提供该值以便检索日志"
36853685
},
36863686
"error": {
36873687
"$ref": "#/components/schemas/DutyError"

scripts/lint_openapi.py

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
#!/usr/bin/env python3
2+
"""Lint Flashduty public OpenAPI specs for documentation quality.
3+
4+
Stdlib-only. Lints every api-reference/*.openapi.*.json file (per-module and
5+
consolidated) and fails on:
6+
7+
missing-description every schema property must carry a non-empty description
8+
(recursively, including array item objects and inline
9+
allOf members; pure $ref nodes are exempt)
10+
epoch-wording integer fields named ts / timestamp / *_at / *_time must
11+
mention Unix/epoch/timestamp/时间戳 in the description —
12+
the go-flashduty SDK maps these fields by description text
13+
enum-undocumented every value of a string enum must be mentioned in the
14+
property description (backticked or bare), so readers
15+
learn what each value means
16+
missing-example every operation needs a request example (when it has a
17+
request body) and a 200 response example
18+
19+
Usage:
20+
python3 scripts/lint_openapi.py # lint all spec files, exit 1 on violations
21+
python3 scripts/lint_openapi.py --warn-only
22+
"""
23+
import json
24+
import re
25+
import sys
26+
from pathlib import Path
27+
28+
ROOT = Path(__file__).resolve().parent.parent
29+
SPEC_GLOB = "api-reference/*.json"
30+
# the legacy hand-maintained Apifox export is exempt — it is read-only reference
31+
SPEC_EXCLUDE = ("openapi.legacy",)
32+
33+
EPOCH_RE = re.compile(r"\b(unix|epoch|timestamp)\b|时间戳", re.I)
34+
EPOCH_NAME_RE = re.compile(r"^(ts|timestamp)$|_at$|_time$")
35+
# duration-ish names that only look like timestamps
36+
DURATION_HINT_RE = re.compile(r"timeout|interval|duration|delay|elapsed")
37+
38+
# enum value sets whose meaning is self-evident everywhere they appear;
39+
# anything else must explain each value in the property description
40+
WELL_KNOWN_ENUMS = [
41+
{"Critical", "Warning", "Info"},
42+
{"Critical", "Warning", "Info", "Ok"},
43+
{"enabled", "disabled"},
44+
{"success", "failed"},
45+
]
46+
47+
# field-name exemptions: order-by enums list sortable field names, whose
48+
# meaning lives on the referenced fields themselves
49+
ENUM_EXEMPT_NAMES = re.compile(r"^(orderby|order_by)$")
50+
51+
52+
def enum_is_well_known(values) -> bool:
53+
vals = {v for v in values if isinstance(v, str) and v}
54+
return any(vals <= s for s in WELL_KNOWN_ENUMS)
55+
56+
57+
class Reporter:
58+
def __init__(self):
59+
self.violations = []
60+
61+
def add(self, file, rule, where, detail):
62+
self.violations.append((file, rule, where, detail))
63+
64+
def dump(self):
65+
by_rule = {}
66+
for f, rule, where, detail in self.violations:
67+
by_rule.setdefault(rule, []).append((f, where, detail))
68+
for rule, items in sorted(by_rule.items()):
69+
print(f"\n## {rule} ({len(items)})")
70+
for f, where, detail in items[:50]:
71+
print(f" {f}: {where}{detail}")
72+
if len(items) > 50:
73+
print(f" ... and {len(items) - 50} more")
74+
print(f"\nTotal: {len(self.violations)} violation(s)")
75+
76+
77+
def walk_properties(node, where, file, rep, seen):
78+
"""Recursively check every property under a schema node."""
79+
if not isinstance(node, dict):
80+
return
81+
oid = id(node)
82+
if oid in seen:
83+
return
84+
seen.add(oid)
85+
86+
for member in node.get("allOf", []):
87+
if isinstance(member, dict) and "$ref" not in member:
88+
walk_properties(member, where, file, rep, seen)
89+
90+
for name, prop in node.get("properties", {}).items():
91+
if not isinstance(prop, dict):
92+
continue
93+
pwhere = f"{where}.{name}"
94+
if "$ref" in prop:
95+
continue
96+
desc = prop.get("description")
97+
if not desc or not desc.strip():
98+
rep.add(file, "missing-description", pwhere, f"type={prop.get('type')}")
99+
desc = ""
100+
# epoch wording
101+
if prop.get("type") == "integer" and EPOCH_NAME_RE.search(name) \
102+
and not DURATION_HINT_RE.search(name):
103+
if not EPOCH_RE.search(desc):
104+
rep.add(file, "epoch-wording", pwhere,
105+
"integer time field without Unix/epoch/timestamp wording")
106+
# enum values documented
107+
enum = prop.get("enum")
108+
if enum and prop.get("type") == "string" and desc and not enum_is_well_known(enum) \
109+
and not ENUM_EXEMPT_NAMES.match(name):
110+
missing = [v for v in enum if isinstance(v, str) and v and v not in desc]
111+
if missing:
112+
rep.add(file, "enum-undocumented", pwhere,
113+
f"enum values not explained in description: {missing}")
114+
# recurse
115+
if prop.get("type") == "object" or "properties" in prop:
116+
walk_properties(prop, pwhere, file, rep, seen)
117+
items = prop.get("items")
118+
if isinstance(items, dict) and "$ref" not in items:
119+
walk_properties(items, pwhere + "[]", file, rep, seen)
120+
121+
122+
def lint_operations(spec, file, rep):
123+
for path, pi in spec.get("paths", {}).items():
124+
if not isinstance(pi, dict):
125+
continue
126+
for method, op in pi.items():
127+
if not isinstance(op, dict):
128+
continue
129+
where = f"{method.upper()} {path}"
130+
body = op.get("requestBody", {}).get("content", {}).get("application/json", {})
131+
if body and "example" not in body and "examples" not in body:
132+
rep.add(file, "missing-example", where, "request body has no example")
133+
ok = op.get("responses", {}).get("200", {})
134+
if "$ref" not in ok:
135+
content = ok.get("content", {}).get("application/json", {})
136+
# only JSON responses can carry a JSON example; binary/ndjson
137+
# downloads and empty 200s are exempt
138+
if content and "example" not in content and "examples" not in content:
139+
rep.add(file, "missing-example", where, "200 response has no example")
140+
141+
142+
def lint_file(path, rep):
143+
spec = json.load(open(path))
144+
file = path.name
145+
for name, schema in spec.get("components", {}).get("schemas", {}).items():
146+
walk_properties(schema, f"schema:{name}", file, rep, set())
147+
lint_operations(spec, file, rep)
148+
149+
150+
def main():
151+
files = [f for f in sorted(ROOT.glob(SPEC_GLOB))
152+
if not any(x in f.name for x in SPEC_EXCLUDE)]
153+
if not files:
154+
print(f"no spec files matched {SPEC_GLOB} under {ROOT}", file=sys.stderr)
155+
return 2
156+
rep = Reporter()
157+
for f in files:
158+
lint_file(f, rep)
159+
if not rep.violations:
160+
print(f"OK: {len(files)} spec files, no violations")
161+
return 0
162+
rep.dump()
163+
if "--warn-only" in sys.argv:
164+
return 0
165+
return 1
166+
167+
168+
if __name__ == "__main__":
169+
sys.exit(main())

0 commit comments

Comments
 (0)