Skip to content

Lint_openapi

FieldValue
TypeSkill Resource
Source~/.copilot/skills/backend/scripts/lint_openapi.sh
DescriptionNot specified

Source Content

#!/usr/bin/env bash
# Lint an openapi.yaml against the backend house rules: schema validity,
# RFC 9457 Problem Details shape, cursor pagination, and Idempotency-Key on POST.
#
# Usage:
# lint_openapi.sh [openapi.yaml]
#
# Example:
# 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
# in a bare CI shell.
set -uo pipefail
SPEC="${1:-./openapi.yaml}"
if [[ ! -f "$SPEC" ]]; then
echo "Usage: $0 [openapi.yaml]" >&2
echo "File not found: $SPEC" >&2
exit 2
fi
fail=0
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
ok "spectral lint clean"
else
error "spectral lint reported errors/warnings"
fi
else
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'
import sys, yaml
path = sys.argv[1]
with open(path) as f:
doc = yaml.safe_load(f)
missing = [k for k in ("openapi", "info", "paths") if not isinstance(doc, dict) or k not in doc]
if missing:
print("missing top-level key(s): " + ", ".join(missing))
sys.exit(1)
sys.exit(0)
PYEOF
then
ok "PyYAML structural check: openapi/info/paths present"
else
error "PyYAML structural check failed — see missing key(s) above"
fi
else
warn "PyYAML not importable — falling back to grep-based line check"
missing=()
for key in openapi info paths; do
if ! grep -qE "^${key}:" "$SPEC"; then
missing+=("$key")
fi
done
if [[ ${#missing[@]} -eq 0 ]]; then
ok "grep structural check: openapi/info/paths present"
else
error "grep structural check failed — missing top-level key(s): ${missing[*]}"
fi
fi
fi
echo
echo "== RFC 9457 Problem Details shape =="
problem_check=$(python3 - "$SPEC" <<'PYEOF'
import re, sys
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.
window = 40
found = False
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):
found = True
break
print("OK" if found else "MISSING")
PYEOF
)
if [[ "$problem_check" == "OK" ]]; then
ok "found a schema block with title/status/detail/type within proximity (RFC 9457 shape)"
else
error "no schema block found with title, status, detail, and type keys together — RFC 9457 Problem Details errors are required"
fi
echo
echo "== cursor pagination on list endpoints =="
pagination_check=$(python3 - "$SPEC" <<'PYEOF'
import re, sys
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*$')
list_paths = []
for i, line in enumerate(lines):
m = path_re.match(line)
if not m:
continue
path = m.group(2).strip('"')
if path.rstrip('/').split('/')[-1].startswith('{'):
continue # ends in a path param — item endpoint, not a list
indent = len(m.group(1))
# does this path block contain a `get:` operation?
has_get = False
for j in range(i + 1, len(lines)):
nxt = lines[j]
if nxt.strip() == "":
continue
nxt_indent = len(nxt) - len(nxt.lstrip(' '))
if nxt_indent <= indent:
break
if re.match(r'^\s*get:\s*$', nxt):
has_get = True
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):
continue
if has_get:
list_paths.append(path)
has_cursor = bool(re.search(r'\bcursor\b', text))
if not list_paths:
print("NOLIST")
elif has_cursor:
print("OK:" + ",".join(list_paths))
else:
print("MISSING:" + ",".join(list_paths))
PYEOF
)
case "$pagination_check" in
NOLIST)
ok "no list GET endpoints found (no path lacking a {param} segment) — nothing to check"
;;
OK:*)
ok "cursor pagination param found for list endpoint(s): ${pagination_check#OK:}"
;;
MISSING:*)
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"
;;
esac
echo
echo "== Idempotency-Key on POST =="
idempotency_check=$(python3 - "$SPEC" <<'PYEOF'
import re, sys
text = open(sys.argv[1]).read()
has_post = bool(re.search(r'^\s*post:\s*$', text, re.MULTILINE))
has_header = "Idempotency-Key" in text
if not has_post:
print("NOPOST")
elif has_header:
print("OK")
else:
print("MISSING")
PYEOF
)
case "$idempotency_check" in
NOPOST)
ok "no POST operations found — nothing to check"
;;
OK)
ok "Idempotency-Key present in the doc"
;;
MISSING)
error "POST operation(s) exist but 'Idempotency-Key' never appears in the file"
;;
*)
warn "idempotency check produced an unexpected result: $idempotency_check"
;;
esac
echo
if [[ "$fail" -eq 0 ]]; then
echo "All checks passed."
else
echo "One or more checks failed — see ❌ lines above."
fi
exit "$fail"