# Lint an openapi.yaml against the backend house rules: schema validity,
# RFC 9457 Problem Details shape, cursor pagination, and Idempotency-Key on POST.
# lint_openapi.sh [openapi.yaml]
# scripts/lint_openapi.sh ./openapi.yaml
# Exit code is non-zero if any real check fails. Missing `spectral` on PATH
# degrades that check to a skipped warning rather than a hard failure (same
# degrade pattern as helm/scripts/lint_chart.sh with helm/kubeconform), so the
# house-rule checks (which need no external tools beyond python3) still run
SPEC="${1:-./openapi.yaml}"
if [[ ! -f "$SPEC" ]]; then
echo "Usage: $0 [openapi.yaml]" >&2
echo "File not found: $SPEC" >&2
warn() { printf '⚠️ %s\n' "$1"; }
error() { printf '❌ %s\n' "$1"; fail=1; }
ok() { printf '✅ %s\n' "$1"; }
echo "== schema validation =="
if command -v spectral >/dev/null 2>&1; then
if spectral lint "$SPEC"; then
error "spectral lint reported errors/warnings"
warn "spectral not on PATH — skipped (install: npm i -g @stoplight/spectral-cli)"
if python3 -c "import yaml" >/dev/null 2>&1; then
if python3 - "$SPEC" <<'PYEOF'
missing = [k for k in ("openapi", "info", "paths") if not isinstance(doc, dict) or k not in doc]
print("missing top-level key(s): " + ", ".join(missing))
ok "PyYAML structural check: openapi/info/paths present"
error "PyYAML structural check failed — see missing key(s) above"
warn "PyYAML not importable — falling back to grep-based line check"
for key in openapi info paths; do
if ! grep -qE "^${key}:" "$SPEC"; then
if [[ ${#missing[@]} -eq 0 ]]; then
ok "grep structural check: openapi/info/paths present"
error "grep structural check failed — missing top-level key(s): ${missing[*]}"
echo "== RFC 9457 Problem Details shape =="
problem_check=$(python3 - "$SPEC" <<'PYEOF'
text = open(sys.argv[1]).read()
lines = text.splitlines()
fields = ("title", "status", "detail", "type")
# Slide a window over the file looking for all 4 field names as YAML keys
# within a reasonably scoped region (a single schema block), pragmatic
# text-proximity check rather than a real YAML deep-parse.
for i in range(len(lines)):
chunk = "\n".join(lines[i:i + window])
if all(re.search(rf"(^|\s){f}\s*:", chunk, re.MULTILINE) for f in fields):
print("OK" if found else "MISSING")
if [[ "$problem_check" == "OK" ]]; then
ok "found a schema block with title/status/detail/type within proximity (RFC 9457 shape)"
error "no schema block found with title, status, detail, and type keys together — RFC 9457 Problem Details errors are required"
echo "== cursor pagination on list endpoints =="
pagination_check=$(python3 - "$SPEC" <<'PYEOF'
text = open(sys.argv[1]).read()
lines = text.splitlines()
# Find path keys under `paths:` that look like list endpoints: a GET on a
# path that does NOT end in a {param} segment, e.g. /things not /things/{id}.
path_re = re.compile(r'^(\s*)("?/[^:\s]*"?):\s*$')
for i, line in enumerate(lines):
path = m.group(2).strip('"')
if path.rstrip('/').split('/')[-1].startswith('{'):
continue # ends in a path param — item endpoint, not a list
# does this path block contain a `get:` operation?
for j in range(i + 1, len(lines)):
nxt_indent = len(nxt) - len(nxt.lstrip(' '))
if re.match(r'^\s*get:\s*$', nxt):
if nxt_indent <= indent + 2 and re.match(r'^\s*(post|put|patch|delete|get):\s*$', nxt) and not re.match(r'^\s*get:\s*$', nxt):
has_cursor = bool(re.search(r'\bcursor\b', text))
print("OK:" + ",".join(list_paths))
print("MISSING:" + ",".join(list_paths))
case "$pagination_check" in
ok "no list GET endpoints found (no path lacking a {param} segment) — nothing to check"
ok "cursor pagination param found for list endpoint(s): ${pagination_check#OK:}"
error "list GET endpoint(s) found with no 'cursor' pagination param anywhere in the doc: ${pagination_check#MISSING:}"
warn "pagination check produced an unexpected result: $pagination_check"
echo "== Idempotency-Key on POST =="
idempotency_check=$(python3 - "$SPEC" <<'PYEOF'
text = open(sys.argv[1]).read()
has_post = bool(re.search(r'^\s*post:\s*$', text, re.MULTILINE))
has_header = "Idempotency-Key" in text
case "$idempotency_check" in
ok "no POST operations found — nothing to check"
ok "Idempotency-Key present in the doc"
error "POST operation(s) exist but 'Idempotency-Key' never appears in the file"
warn "idempotency check produced an unexpected result: $idempotency_check"
if [[ "$fail" -eq 0 ]]; then
echo "All checks passed."
echo "One or more checks failed — see ❌ lines above."