feat: add OpenAPI contract check script and integrate into pre-commit hooks

This commit is contained in:
Lloyd
2026-05-28 12:02:45 +01:00
parent 0f6a7dc053
commit d1dc57cc58
3 changed files with 1287 additions and 19 deletions
+8
View File
@@ -44,6 +44,14 @@ repos:
# Test suite gate
- repo: local
hooks:
- id: openapi-contract-check
name: OpenAPI contract check
entry: python3 scripts/check_openapi_contract.py
language: system
pass_filenames: false
always_run: true
files: ^(repeater/web/.*\.py|repeater/web/openapi\.yaml)$
- id: pytest
name: pytest
entry: ./scripts/precommit-pytest.sh
+939 -19
View File
@@ -1304,20 +1304,20 @@ paths:
type: object
/advert:
get:
delete:
tags: [Adverts]
summary: Get specific advert
description: Retrieve details of a specific advertisement
summary: Delete specific advert
description: Delete a specific advertisement by ID
parameters:
- name: advert_id
in: query
required: true
schema:
type: integer
description: Advert ID to retrieve
description: Advert ID to delete
responses:
'200':
description: Advert details
description: Advert deleted
content:
application/json:
schema:
@@ -1421,20 +1421,6 @@ paths:
# Network Policy
# ============================================================================
/unscoped_flood_policy:
get:
tags: [Network Policy]
summary: Get unscoped flood policy
description: Retrieve current network flood policy configuration
security:
- BearerAuth: []
- ApiKeyAuth: []
responses:
'200':
description: Current policy
content:
application/json:
schema:
type: object
post:
tags: [Network Policy]
summary: Update unscoped flood policy
@@ -2457,6 +2443,940 @@ paths:
deleted_count:
type: integer
/needs_setup:
get:
tags: [System]
summary: Check setup wizard status
responses:
'200':
description: Setup status
content:
application/json:
schema:
type: object
/site_info:
get:
tags: [System]
summary: Get site and host info
responses:
'200':
description: Site info
content:
application/json:
schema:
type: object
/hardware_options:
get:
tags: [System]
summary: Get supported hardware options
responses:
'200':
description: Hardware options
content:
application/json:
schema:
type: object
/radio_presets:
get:
tags: [System]
summary: Get radio presets
responses:
'200':
description: Preset list
content:
application/json:
schema:
type: object
/serial_ports:
get:
tags: [System]
summary: List available serial ports
responses:
'200':
description: Serial ports
content:
application/json:
schema:
type: object
/setup_wizard:
post:
tags: [System]
summary: Submit setup wizard payload
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Setup applied
content:
application/json:
schema:
type: object
/check_pymc_console:
get:
tags: [System]
summary: Check pyMC console availability
responses:
'200':
description: Console status
content:
application/json:
schema:
type: object
/mqtt_status:
get:
tags: [System]
summary: Get MQTT runtime status
responses:
'200':
description: MQTT status
content:
application/json:
schema:
type: object
/broker_presets:
get:
tags: [System]
summary: List MQTT broker presets
responses:
'200':
description: Broker presets
content:
application/json:
schema:
type: object
/update_web_config:
post:
tags: [System]
summary: Update web configuration
security:
- BearerAuth: []
- ApiKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Web config updated
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
/update_mqtt_config:
post:
tags: [System]
summary: Update MQTT configuration
security:
- BearerAuth: []
- ApiKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: MQTT config updated
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
/update_advert_rate_limit_config:
post:
tags: [Adverts]
summary: Update advert rate limit configuration
security:
- BearerAuth: []
- ApiKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Advert rate limit config updated
content:
application/json:
schema:
$ref: '#/components/schemas/SuccessResponse'
/bulk_packets:
get:
tags: [Packets]
summary: Fetch packets in bulk
parameters:
- name: limit
in: query
schema:
type: integer
default: 1000
- name: offset
in: query
schema:
type: integer
default: 0
- name: start_timestamp
in: query
schema:
type: number
- name: end_timestamp
in: query
schema:
type: number
responses:
'200':
description: Bulk packet result
content:
application/json:
schema:
type: object
/airtime_data:
get:
tags: [Packets]
summary: Get lightweight airtime packet rows
parameters:
- name: start_timestamp
in: query
schema:
type: number
- name: end_timestamp
in: query
schema:
type: number
- name: limit
in: query
schema:
type: integer
default: 50000
responses:
'200':
description: Airtime data rows
content:
application/json:
schema:
type: object
/airtime_chart_data:
get:
tags: [Charts]
summary: Get server-aggregated airtime chart buckets
parameters:
- name: start_timestamp
in: query
schema:
type: number
- name: end_timestamp
in: query
schema:
type: number
- name: bucket_seconds
in: query
schema:
type: integer
default: 60
- name: sf
in: query
schema:
type: integer
default: 9
- name: bw_hz
in: query
schema:
type: integer
default: 62500
- name: cr
in: query
schema:
type: integer
default: 5
- name: preamble
in: query
schema:
type: integer
default: 17
responses:
'200':
description: Airtime buckets
content:
application/json:
schema:
type: object
/adverts_count_by_contact_type:
get:
tags: [Adverts]
summary: Get advert count for contact type
parameters:
- name: contact_type
in: query
required: true
schema:
type: string
- name: hours
in: query
schema:
type: integer
responses:
'200':
description: Advert count
content:
application/json:
schema:
type: object
/advert_rate_limit_stats:
get:
tags: [Adverts]
summary: Get advert rate-limit runtime stats
responses:
'200':
description: Rate-limit stats
content:
application/json:
schema:
type: object
/crc_error_count:
get:
tags: [System]
summary: Get CRC error count
parameters:
- name: hours
in: query
schema:
type: integer
default: 24
responses:
'200':
description: CRC error count
content:
application/json:
schema:
type: object
/crc_error_history:
get:
tags: [System]
summary: Get CRC error history
parameters:
- name: hours
in: query
schema:
type: integer
default: 24
- name: limit
in: query
schema:
type: integer
responses:
'200':
description: CRC error records
content:
application/json:
schema:
type: object
/memory_debug:
get:
tags: [System]
summary: Get memory diagnostics
responses:
'200':
description: Memory diagnostics
content:
application/json:
schema:
type: object
post:
tags: [System]
summary: Start/stop memory diagnostics tracing
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
action:
type: string
enum: [start, stop]
responses:
'200':
description: Tracing state changed
content:
application/json:
schema:
type: object
/config_export:
get:
tags: [System]
summary: Export configuration
parameters:
- name: include_secrets
in: query
schema:
type: boolean
responses:
'200':
description: Exported config payload
content:
application/json:
schema:
type: object
/config_import:
post:
tags: [System]
summary: Import configuration
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Import result
content:
application/json:
schema:
type: object
/identity_export:
get:
tags: [Identities]
summary: Export repeater identity key
responses:
'200':
description: Identity export
content:
application/json:
schema:
type: object
/generate_vanity_key:
post:
tags: [Identities]
summary: Generate vanity identity key
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [prefix]
properties:
prefix:
type: string
apply:
type: boolean
responses:
'200':
description: Vanity key generation result
content:
application/json:
schema:
type: object
/db_stats:
get:
tags: [System]
summary: Get database statistics
responses:
'200':
description: Database stats
content:
application/json:
schema:
type: object
/db_purge:
post:
tags: [System]
summary: Purge database tables
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Table purge result
content:
application/json:
schema:
type: object
/db_vacuum:
post:
tags: [System]
summary: Vacuum SQLite database
responses:
'200':
description: Vacuum result
content:
application/json:
schema:
type: object
/docs:
get:
tags: [System]
summary: Serve Swagger UI docs page
responses:
'200':
description: HTML docs page
/api/auth/tokens:
get:
tags: [Authentication]
summary: List API tokens (alias path)
responses:
'200':
description: Token list
content:
application/json:
schema:
type: object
post:
tags: [Authentication]
summary: Create API token (alias path)
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Token created
content:
application/json:
schema:
type: object
/api/auth/tokens/{token_id}:
delete:
tags: [Authentication]
summary: Revoke API token (alias path)
parameters:
- name: token_id
in: path
required: true
schema:
type: integer
responses:
'200':
description: Token revoked
content:
application/json:
schema:
type: object
/companion:
get:
tags: [System]
summary: List companion bridge instances
responses:
'200':
description: Companion instances
content:
application/json:
schema:
type: object
/companion/self_info:
get:
tags: [System]
summary: Get local companion identity info
responses:
'200':
description: Companion identity
content:
application/json:
schema:
type: object
/companion/contacts:
get:
tags: [System]
summary: List companion contacts
responses:
'200':
description: Contacts
content:
application/json:
schema:
type: object
/companion/contact:
get:
tags: [System]
summary: Get one companion contact
parameters:
- name: pub_key
in: query
required: true
schema:
type: string
responses:
'200':
description: Contact detail
content:
application/json:
schema:
type: object
/companion/import_repeater_contacts:
post:
tags: [System]
summary: Import repeater adverts into companion contacts
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Import result
content:
application/json:
schema:
type: object
/companion/channels:
get:
tags: [System]
summary: List companion channels
responses:
'200':
description: Channels
content:
application/json:
schema:
type: object
/companion/stats:
get:
tags: [System]
summary: Get companion stats
responses:
'200':
description: Companion stats
content:
application/json:
schema:
type: object
/companion/send_text:
post:
tags: [System]
summary: Send direct text message via companion
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Send result
content:
application/json:
schema:
type: object
/companion/send_channel_message:
post:
tags: [System]
summary: Send channel message via companion
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Send result
content:
application/json:
schema:
type: object
/companion/login:
post:
tags: [System]
summary: Initiate companion login flow
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Login result
content:
application/json:
schema:
type: object
/companion/request_status:
post:
tags: [System]
summary: Request companion status frame
responses:
'200':
description: Request accepted
content:
application/json:
schema:
type: object
/companion/request_telemetry:
post:
tags: [System]
summary: Request companion telemetry frame
responses:
'200':
description: Request accepted
content:
application/json:
schema:
type: object
/companion/send_command:
post:
tags: [System]
summary: Send command to companion
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Command result
content:
application/json:
schema:
type: object
/companion/reset_path:
post:
tags: [System]
summary: Reset companion route/path state
responses:
'200':
description: Path reset
content:
application/json:
schema:
type: object
/companion/set_advert_name:
post:
tags: [System]
summary: Set companion advert name
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Name updated
content:
application/json:
schema:
type: object
/companion/set_advert_location:
post:
tags: [System]
summary: Set companion advert location
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Location updated
content:
application/json:
schema:
type: object
/companion/events:
get:
tags: [System]
summary: Stream companion events (SSE)
responses:
'200':
description: Event stream
/update/status:
get:
tags: [System]
summary: Get update service status
responses:
'200':
description: Update status
content:
application/json:
schema:
type: object
/update/check:
get:
tags: [System]
summary: Trigger or fetch update check
responses:
'200':
description: Check result
content:
application/json:
schema:
type: object
post:
tags: [System]
summary: Trigger update check
requestBody:
required: false
content:
application/json:
schema:
type: object
responses:
'200':
description: Check started/result
content:
application/json:
schema:
type: object
/update/install:
post:
tags: [System]
summary: Install available update
requestBody:
required: false
content:
application/json:
schema:
type: object
responses:
'200':
description: Install started
content:
application/json:
schema:
type: object
/update/progress:
get:
tags: [System]
summary: Stream update progress (SSE)
responses:
'200':
description: Progress event stream
/update/channels:
get:
tags: [System]
summary: List update channels
responses:
'200':
description: Available channels
content:
application/json:
schema:
type: object
/update/set_channel:
post:
tags: [System]
summary: Set update channel
requestBody:
required: true
content:
application/json:
schema:
type: object
responses:
'200':
description: Channel changed
content:
application/json:
schema:
type: object
/update/changelog:
get:
tags: [System]
summary: Get update changelog
responses:
'200':
description: Changelog content
content:
application/json:
schema:
type: object
/cli:
post:
tags: [System]
summary: Execute repeater CLI command
security:
- BearerAuth: []
- ApiKeyAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [command]
properties:
command:
type: string
responses:
'200':
description: CLI command result
content:
application/json:
schema:
type: object
components:
schemas:
SuccessResponse:
+340
View File
@@ -0,0 +1,340 @@
#!/usr/bin/env python3
"""Check OpenAPI contract coverage against CherryPy exposed endpoints.
This check enforces both directions:
- every OpenAPI path must exist in code
- every API endpoint in code must exist in OpenAPI OR be explicitly allowlisted
Method checks are warning-only by default and can be made strict.
"""
from __future__ import annotations
import ast
import argparse
import re
import sys
from dataclasses import dataclass
from pathlib import Path
import yaml
ROOT = Path(__file__).resolve().parents[1]
WEB_DIR = ROOT / "repeater" / "web"
OPENAPI_PATH = WEB_DIR / "openapi.yaml"
ALLOWLIST_PATH = ROOT / "scripts" / "openapi_contract_allowlist.yaml"
@dataclass
class RouteInfo:
methods: set[str]
confident: bool
@dataclass
class Allowlist:
exact: set[str]
prefixes: tuple[str, ...]
def _normalize_path(path: str) -> str:
path = path.strip()
if not path:
return "/"
if not path.startswith("/"):
path = "/" + path
path = re.sub(r"/{2,}", "/", path)
path = re.sub(r"\{[^/}]+\}", "{}", path)
if len(path) > 1 and path.endswith("/"):
path = path[:-1]
return path
def _load_openapi() -> dict[str, set[str]]:
with OPENAPI_PATH.open("r", encoding="utf-8") as f:
doc = yaml.safe_load(f) or {}
paths = doc.get("paths", {})
out: dict[str, set[str]] = {}
for raw_path, ops in paths.items():
if not isinstance(ops, dict):
continue
methods = {
m.lower()
for m in ops.keys()
if m.lower() in {"get", "post", "put", "delete", "patch", "options", "head"}
}
# We do not enforce OPTIONS/HEAD in this checker.
methods.discard("options")
methods.discard("head")
out[_normalize_path(str(raw_path))] = methods
return out
def _load_allowlist() -> Allowlist:
if not ALLOWLIST_PATH.exists():
return Allowlist(exact=set(), prefixes=tuple())
with ALLOWLIST_PATH.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
exact_raw = data.get("exact_paths", [])
prefix_raw = data.get("path_prefixes", [])
exact = {_normalize_path(str(p)) for p in exact_raw if str(p).strip()}
prefixes = tuple(_normalize_path(str(p)) for p in prefix_raw if str(p).strip())
return Allowlist(exact=exact, prefixes=prefixes)
def _is_allowlisted(path: str, allowlist: Allowlist) -> bool:
if path in allowlist.exact:
return True
for prefix in allowlist.prefixes:
if path == prefix or path.startswith(prefix + "/"):
return True
return False
def _is_cherrypy_request_method_expr(node: ast.AST) -> bool:
# cherrypy.request.method
return (
isinstance(node, ast.Attribute)
and node.attr == "method"
and isinstance(node.value, ast.Attribute)
and node.value.attr == "request"
and isinstance(node.value.value, ast.Name)
and node.value.value.id == "cherrypy"
)
def _extract_method_strings(node: ast.AST) -> set[str]:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return {node.value.upper()}
if isinstance(node, (ast.Tuple, ast.List, ast.Set)):
vals: set[str] = set()
for e in node.elts:
vals |= _extract_method_strings(e)
return vals
return set()
def _infer_methods(fn: ast.FunctionDef) -> tuple[set[str], bool]:
methods: set[str] = set()
confidence = False
has_require_post = False
saw_method_compare = False
for node in ast.walk(fn):
if isinstance(node, ast.Call):
# self._require_post()
if (
isinstance(node.func, ast.Attribute)
and node.func.attr == "_require_post"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "self"
):
has_require_post = True
methods.add("POST")
confidence = True
if not isinstance(node, ast.Compare):
continue
if not _is_cherrypy_request_method_expr(node.left):
continue
saw_method_compare = True
if not node.ops or not node.comparators:
continue
op = node.ops[0]
rhs_vals = _extract_method_strings(node.comparators[0])
if not rhs_vals:
continue
# Treat equality and inequality guards as declared allowed methods.
if isinstance(op, (ast.Eq, ast.In, ast.NotEq, ast.NotIn)):
methods |= rhs_vals
confidence = True
methods.discard("OPTIONS")
methods.discard("HEAD")
# If a handler branches on request.method but is not explicitly POST-only,
# CherryPy's default method for uncovered branches is typically GET.
if saw_method_compare and not has_require_post and methods and "POST" in methods:
methods.add("GET")
if not methods:
return {"GET"}, False
return methods, confidence
def _has_expose_decorator(fn: ast.FunctionDef) -> bool:
for d in fn.decorator_list:
# @cherrypy.expose
if isinstance(d, ast.Attribute):
if (
d.attr == "expose"
and isinstance(d.value, ast.Name)
and d.value.id == "cherrypy"
):
return True
return False
def _fn_params(fn: ast.FunctionDef) -> list[str]:
params = [a.arg for a in fn.args.args if a.arg != "self"]
return [p for p in params if p not in {"kwargs", "args"}]
def _candidate_suffixes(fn: ast.FunctionDef) -> list[str]:
name = fn.name
params = _fn_params(fn)
if name == "index":
return [""]
if name == "default":
if params:
return ["/{}"]
return ["/{path}"]
base = f"/{name}"
# Keep named endpoints canonical. Parameters are often query parameters,
# so adding path-segment variants here produces false positives.
return [base]
def _collect_class_routes(module_path: Path, class_name: str, prefixes: list[str]) -> dict[str, RouteInfo]:
tree = ast.parse(module_path.read_text(encoding="utf-8"), filename=str(module_path))
cls = next(
(
n
for n in tree.body
if isinstance(n, ast.ClassDef) and n.name == class_name
),
None,
)
if cls is None:
return {}
routes: dict[str, RouteInfo] = {}
for node in cls.body:
if not isinstance(node, ast.FunctionDef):
continue
if class_name == "APIEndpoints" and node.name == "default":
# Catch-all handlers are not part of API contract surface.
continue
if not _has_expose_decorator(node):
continue
methods, confident = _infer_methods(node)
suffixes = _candidate_suffixes(node)
for prefix in prefixes:
for suffix in suffixes:
path = _normalize_path(prefix + suffix)
cur = routes.get(path)
if cur is None:
routes[path] = RouteInfo(methods=set(methods), confident=confident)
else:
cur.methods |= methods
cur.confident = cur.confident or confident
return routes
def _collect_routes() -> dict[str, RouteInfo]:
route_map: dict[str, RouteInfo] = {}
class_specs = [
# /api/* methods are described in OpenAPI as /<endpoint>
(WEB_DIR / "api_endpoints.py", "APIEndpoints", [""]),
# Nested /api/companion/* endpoints are described as /companion/*.
(WEB_DIR / "companion_endpoints.py", "CompanionAPIEndpoints", ["/companion"]),
# Nested /api/update/* endpoints are described as /update/* when documented.
(WEB_DIR / "update_endpoints.py", "UpdateAPIEndpoints", ["/update"]),
# Auth top-level endpoints are mounted at /auth/*
(WEB_DIR / "auth_endpoints.py", "AuthEndpoints", ["/auth"]),
# Token sub-resource is exposed both under /auth and /api/auth in current routing.
(WEB_DIR / "auth_endpoints.py", "TokensAPIEndpoint", ["/auth/tokens", "/api/auth/tokens"]),
]
for file_path, class_name, prefixes in class_specs:
class_routes = _collect_class_routes(file_path, class_name, prefixes)
for path, info in class_routes.items():
cur = route_map.get(path)
if cur is None:
route_map[path] = info
else:
cur.methods |= info.methods
cur.confident = cur.confident or info.confident
return route_map
def main() -> int:
parser = argparse.ArgumentParser(description="Check OpenAPI contract coverage.")
parser.add_argument(
"--strict-methods",
action="store_true",
help="Fail when inferred HTTP methods differ from OpenAPI methods.",
)
args = parser.parse_args()
if not OPENAPI_PATH.exists():
print(f"ERROR: OpenAPI spec not found at {OPENAPI_PATH}")
return 1
spec = _load_openapi()
allowlist = _load_allowlist()
code_routes = _collect_routes()
errors: list[str] = []
warnings: list[str] = []
for path, spec_methods in sorted(spec.items()):
code = code_routes.get(path)
if code is None:
errors.append(f"Missing endpoint in code for OpenAPI path: {path}")
continue
if spec_methods and code.confident:
code_methods = {m.lower() for m in code.methods}
missing_methods = sorted(m for m in spec_methods if m not in code_methods)
if missing_methods:
msg = (
f"Method mismatch for {path}: OpenAPI has {sorted(spec_methods)}, "
f"code inference has {sorted(code_methods)}"
)
if args.strict_methods:
errors.append(msg)
else:
warnings.append(msg)
# Enforce code -> OpenAPI (unless allowlisted)
for path in sorted(code_routes.keys()):
if path in spec:
continue
if _is_allowlisted(path, allowlist):
continue
errors.append(
f"Undocumented endpoint in code (not in OpenAPI and not allowlisted): {path}"
)
if warnings:
print("OpenAPI contract warnings:")
for w in warnings:
print(f"- {w}")
if errors:
print("OpenAPI contract check failed:")
for e in errors:
print(f"- {e}")
return 1
print("OpenAPI contract check passed.")
return 0
if __name__ == "__main__":
raise SystemExit(main())