feat: enforce backend endpoint classification
This commit is contained in:
@@ -46,6 +46,9 @@ jobs:
|
|||||||
- name: Install WebUI release dependencies with test scripts
|
- name: Install WebUI release dependencies with test scripts
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
run: bash tools/release/install-webui-release-dependencies.sh ../govoplan-core/webui
|
run: bash tools/release/install-webui-release-dependencies.sh ../govoplan-core/webui
|
||||||
|
- name: Validate platform endpoint surface declarations
|
||||||
|
working-directory: govoplan
|
||||||
|
run: .venv/bin/python tools/inventory/platform-interface-inventory.py --strict
|
||||||
- name: Validate Search against PostgreSQL
|
- name: Validate Search against PostgreSQL
|
||||||
working-directory: govoplan
|
working-directory: govoplan
|
||||||
env:
|
env:
|
||||||
|
|||||||
@@ -45,6 +45,28 @@ The command writes:
|
|||||||
- `audit-reports/platform-inventory/platform-interface-inventory.json`
|
- `audit-reports/platform-inventory/platform-interface-inventory.json`
|
||||||
- `audit-reports/platform-inventory/platform-interface-inventory.md`
|
- `audit-reports/platform-inventory/platform-interface-inventory.md`
|
||||||
|
|
||||||
|
Use `--strict` in CI. In addition to translation coverage, strict mode requires
|
||||||
|
every backend endpoint without a statically visible WebUI path to have an exact
|
||||||
|
entry in
|
||||||
|
`tools/inventory/endpoint-surface-declarations.json`. The registry is keyed by
|
||||||
|
repository, HTTP method, and canonical version-independent path. It accepts:
|
||||||
|
|
||||||
|
- `ui_reachable`: a mounted router, generic action, or provider path hides the
|
||||||
|
reference from static extraction;
|
||||||
|
- `intentionally_headless`: a capability/API is deliberately consumed without
|
||||||
|
its own UI;
|
||||||
|
- `public_integration`: a documented public or interoperability endpoint;
|
||||||
|
- `worker_internal`: a worker, scheduler, reconciliation, or monitoring path;
|
||||||
|
- `compatibility`: a retained transition endpoint with a current replacement;
|
||||||
|
- `missing_ui`: a real UI gap, which must include a Gitea tracking issue;
|
||||||
|
- `removable`: a reviewed dead endpoint pending removal.
|
||||||
|
|
||||||
|
Strict mode also rejects declarations that no longer match source. When an
|
||||||
|
endpoint is added, changed, or removed, update its declaration in the same
|
||||||
|
change. Do not classify an endpoint from a string mismatch alone: first check
|
||||||
|
mounted prefixes, dynamic action paths, public clients, worker use, and
|
||||||
|
capability consumers.
|
||||||
|
|
||||||
It combines:
|
It combines:
|
||||||
|
|
||||||
1. loaded module manifests
|
1. loaded module manifests
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import ast
|
import ast
|
||||||
import importlib.util
|
import importlib.util
|
||||||
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import tempfile
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
|
||||||
@@ -30,6 +32,92 @@ class PlatformInterfaceInventoryTests(unittest.TestCase):
|
|||||||
inventory.canonical_api_path("/api/v2/campaigns/{campaign_id}"),
|
inventory.canonical_api_path("/api/v2/campaigns/{campaign_id}"),
|
||||||
"/campaigns/{}",
|
"/campaigns/{}",
|
||||||
)
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
inventory.canonical_api_path("/api/v1/calendar/events/delta${querySuffix}"),
|
||||||
|
"/calendar/events/delta",
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
inventory.canonical_api_path("/api/v1/calendar/events/${eventId}"),
|
||||||
|
"/calendar/events/{}",
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_endpoint_declarations_are_exact_and_require_missing_ui_issue(self) -> None:
|
||||||
|
with tempfile.TemporaryDirectory() as directory:
|
||||||
|
path = Path(directory) / "endpoints.json"
|
||||||
|
path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"endpoints": [
|
||||||
|
{
|
||||||
|
"repository": "govoplan-example",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/example/items/{}",
|
||||||
|
"category": "public_integration",
|
||||||
|
"rationale": "Published integration API.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
),
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
declarations = inventory._load_endpoint_declarations(path)
|
||||||
|
|
||||||
|
self.assertIn(
|
||||||
|
("govoplan-example", "GET", "/example/items/{}"),
|
||||||
|
declarations,
|
||||||
|
)
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
payload["endpoints"][0]["category"] = "missing_ui"
|
||||||
|
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||||
|
with self.assertRaisesRegex(ValueError, "tracking_issue"):
|
||||||
|
inventory._load_endpoint_declarations(path)
|
||||||
|
|
||||||
|
def test_inventory_reports_unclassified_and_stale_endpoint_declarations(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
webui = {
|
||||||
|
"frontendApiReferences": [],
|
||||||
|
"translationUsages": [],
|
||||||
|
"translationCatalog": {"en": {}, "de": {}},
|
||||||
|
"fields": [],
|
||||||
|
"labels": [],
|
||||||
|
"visibleText": [],
|
||||||
|
"routes": [],
|
||||||
|
"navigation": [],
|
||||||
|
"uiCapabilities": [],
|
||||||
|
"dynamicTranslationUsages": [],
|
||||||
|
}
|
||||||
|
endpoint = {
|
||||||
|
"repository": "govoplan-example",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/api/v1/example/items",
|
||||||
|
"file": "src/example.py",
|
||||||
|
"line": 1,
|
||||||
|
"handler": "items",
|
||||||
|
"router": "router",
|
||||||
|
}
|
||||||
|
stale = {
|
||||||
|
"repository": "govoplan-example",
|
||||||
|
"method": "GET",
|
||||||
|
"path": "/example/removed",
|
||||||
|
"category": "removable",
|
||||||
|
"rationale": "Removal is pending.",
|
||||||
|
}
|
||||||
|
|
||||||
|
result = inventory._assemble_inventory(
|
||||||
|
webui=webui,
|
||||||
|
backend_endpoints=[endpoint],
|
||||||
|
manifests=[],
|
||||||
|
endpoint_declarations={
|
||||||
|
("govoplan-example", "GET", "/example/removed"): stale,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(1, result["summary"]["unclassified_backend_endpoints"])
|
||||||
|
self.assertEqual(1, result["summary"]["stale_endpoint_declarations"])
|
||||||
|
self.assertIsNone(result["api"]["backend_endpoints"][0]["surface"])
|
||||||
|
|
||||||
def test_fastapi_route_scanner_includes_router_prefix(self) -> None:
|
def test_fastapi_route_scanner_includes_router_prefix(self) -> None:
|
||||||
tree = ast.parse(
|
tree = ast.parse(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,18 @@ from typing import Any
|
|||||||
META_ROOT = Path(__file__).resolve().parents[2]
|
META_ROOT = Path(__file__).resolve().parents[2]
|
||||||
HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put"}
|
HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put"}
|
||||||
PATH_PARAMETER = re.compile(r"\$\{[^{}]*\}|\{[^{}]+\}")
|
PATH_PARAMETER = re.compile(r"\$\{[^{}]*\}|\{[^{}]+\}")
|
||||||
|
ENDPOINT_SURFACE_CATEGORIES = {
|
||||||
|
"ui_reachable",
|
||||||
|
"intentionally_headless",
|
||||||
|
"public_integration",
|
||||||
|
"worker_internal",
|
||||||
|
"compatibility",
|
||||||
|
"missing_ui",
|
||||||
|
"removable",
|
||||||
|
}
|
||||||
|
DEFAULT_ENDPOINT_DECLARATIONS = (
|
||||||
|
META_ROOT / "tools" / "inventory" / "endpoint-surface-declarations.json"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def main() -> int:
|
def main() -> int:
|
||||||
@@ -31,21 +43,29 @@ def main() -> int:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--strict",
|
"--strict",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
help="Fail when a used translation key is absent from a generated locale catalog.",
|
help="Fail on missing translations or incomplete endpoint-surface declarations.",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--endpoint-declarations",
|
||||||
|
type=Path,
|
||||||
|
default=DEFAULT_ENDPOINT_DECLARATIONS,
|
||||||
|
help="Versioned endpoint-surface declaration registry.",
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
catalog = json.loads(
|
catalog = json.loads((META_ROOT / "repositories.json").read_text(encoding="utf-8"))
|
||||||
(META_ROOT / "repositories.json").read_text(encoding="utf-8")
|
|
||||||
)
|
|
||||||
workspace_root = Path(catalog["default_parent"]).resolve()
|
workspace_root = Path(catalog["default_parent"]).resolve()
|
||||||
webui = _extract_webui()
|
webui = _extract_webui()
|
||||||
backend_endpoints = _extract_backend_endpoints(catalog, workspace_root)
|
backend_endpoints = _extract_backend_endpoints(catalog, workspace_root)
|
||||||
manifests = _extract_manifests(catalog, workspace_root)
|
manifests = _extract_manifests(catalog, workspace_root)
|
||||||
|
endpoint_declarations = _load_endpoint_declarations(
|
||||||
|
args.endpoint_declarations.resolve()
|
||||||
|
)
|
||||||
inventory = _assemble_inventory(
|
inventory = _assemble_inventory(
|
||||||
webui=webui,
|
webui=webui,
|
||||||
backend_endpoints=backend_endpoints,
|
backend_endpoints=backend_endpoints,
|
||||||
manifests=manifests,
|
manifests=manifests,
|
||||||
|
endpoint_declarations=endpoint_declarations,
|
||||||
)
|
)
|
||||||
|
|
||||||
output_dir = args.output_dir.resolve()
|
output_dir = args.output_dir.resolve()
|
||||||
@@ -60,9 +80,23 @@ def main() -> int:
|
|||||||
print(f"Platform inventory JSON: {json_path}")
|
print(f"Platform inventory JSON: {json_path}")
|
||||||
print(f"Platform inventory summary: {markdown_path}")
|
print(f"Platform inventory summary: {markdown_path}")
|
||||||
|
|
||||||
if args.strict and inventory["translation_health"]["missing_catalog_entries"]:
|
if args.strict:
|
||||||
|
failures: list[str] = []
|
||||||
|
if inventory["translation_health"]["missing_catalog_entries"]:
|
||||||
|
failures.append("used translation keys are missing from generated catalogs")
|
||||||
|
if inventory["api"]["unclassified_endpoints"]:
|
||||||
|
failures.append(
|
||||||
|
f"{len(inventory['api']['unclassified_endpoints'])} backend "
|
||||||
|
"endpoints have no WebUI evidence or surface declaration"
|
||||||
|
)
|
||||||
|
if inventory["api"]["stale_endpoint_declarations"]:
|
||||||
|
failures.append(
|
||||||
|
f"{len(inventory['api']['stale_endpoint_declarations'])} "
|
||||||
|
"endpoint declarations do not match a backend endpoint"
|
||||||
|
)
|
||||||
|
if failures:
|
||||||
print(
|
print(
|
||||||
"Used translation keys are missing from generated catalogs.",
|
"Strict platform inventory failed: " + "; ".join(failures) + ".",
|
||||||
file=sys.stderr,
|
file=sys.stderr,
|
||||||
)
|
)
|
||||||
return 1
|
return 1
|
||||||
@@ -167,7 +201,9 @@ def _endpoint_from_decorator(
|
|||||||
route = _static_string(decorator.args[0])
|
route = _static_string(decorator.args[0])
|
||||||
if route is None:
|
if route is None:
|
||||||
return None
|
return None
|
||||||
owner = decorator.func.value.id if isinstance(decorator.func.value, ast.Name) else ""
|
owner = (
|
||||||
|
decorator.func.value.id if isinstance(decorator.func.value, ast.Name) else ""
|
||||||
|
)
|
||||||
prefix = prefixes.get(owner, "")
|
prefix = prefixes.get(owner, "")
|
||||||
return {
|
return {
|
||||||
"method": method.upper(),
|
"method": method.upper(),
|
||||||
@@ -247,6 +283,7 @@ def _assemble_inventory(
|
|||||||
webui: dict[str, Any],
|
webui: dict[str, Any],
|
||||||
backend_endpoints: list[dict[str, Any]],
|
backend_endpoints: list[dict[str, Any]],
|
||||||
manifests: list[dict[str, Any]],
|
manifests: list[dict[str, Any]],
|
||||||
|
endpoint_declarations: dict[tuple[str, str, str], dict[str, Any]],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
frontend_refs = webui["frontendApiReferences"]
|
frontend_refs = webui["frontendApiReferences"]
|
||||||
frontend_paths = {
|
frontend_paths = {
|
||||||
@@ -254,25 +291,61 @@ def _assemble_inventory(
|
|||||||
for reference in frontend_refs
|
for reference in frontend_refs
|
||||||
if canonical_api_path(reference["path"])
|
if canonical_api_path(reference["path"])
|
||||||
}
|
}
|
||||||
|
endpoint_keys = {endpoint_key(endpoint) for endpoint in backend_endpoints}
|
||||||
|
classified_endpoints: list[dict[str, Any]] = []
|
||||||
|
for endpoint in backend_endpoints:
|
||||||
|
key = endpoint_key(endpoint)
|
||||||
|
canonical_path = key[2]
|
||||||
|
static_webui_reference = canonical_path in frontend_paths
|
||||||
|
declaration = endpoint_declarations.get(key)
|
||||||
|
surface = (
|
||||||
|
{
|
||||||
|
"category": "ui_reachable",
|
||||||
|
"rationale": "A canonical API path reference exists in WebUI source.",
|
||||||
|
"source": "static_webui_scan",
|
||||||
|
}
|
||||||
|
if static_webui_reference
|
||||||
|
else (
|
||||||
|
{**declaration, "source": "declaration"}
|
||||||
|
if declaration is not None
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
)
|
||||||
|
classified_endpoints.append(
|
||||||
|
{
|
||||||
|
**endpoint,
|
||||||
|
"canonical_path": canonical_path,
|
||||||
|
"static_webui_reference": static_webui_reference,
|
||||||
|
"surface": surface,
|
||||||
|
}
|
||||||
|
)
|
||||||
unreferenced = [
|
unreferenced = [
|
||||||
endpoint
|
endpoint
|
||||||
for endpoint in backend_endpoints
|
for endpoint in classified_endpoints
|
||||||
if canonical_api_path(endpoint["path"]) not in frontend_paths
|
if not endpoint["static_webui_reference"]
|
||||||
]
|
]
|
||||||
|
unclassified = [
|
||||||
|
endpoint for endpoint in unreferenced if endpoint["surface"] is None
|
||||||
|
]
|
||||||
|
stale_declarations = [
|
||||||
|
declaration
|
||||||
|
for key, declaration in endpoint_declarations.items()
|
||||||
|
if key not in endpoint_keys
|
||||||
|
]
|
||||||
|
classification_counts = Counter(
|
||||||
|
endpoint["surface"]["category"]
|
||||||
|
for endpoint in classified_endpoints
|
||||||
|
if endpoint["surface"] is not None
|
||||||
|
)
|
||||||
usages = {item["key"] for item in webui["translationUsages"]}
|
usages = {item["key"] for item in webui["translationUsages"]}
|
||||||
catalogs = webui["translationCatalog"]
|
catalogs = webui["translationCatalog"]
|
||||||
catalog_keys = {
|
catalog_keys = {locale: set(entries) for locale, entries in catalogs.items()}
|
||||||
locale: set(entries)
|
|
||||||
for locale, entries in catalogs.items()
|
|
||||||
}
|
|
||||||
expected_locales = sorted(catalog_keys)
|
expected_locales = sorted(catalog_keys)
|
||||||
missing_catalog_entries = [
|
missing_catalog_entries = [
|
||||||
{
|
{
|
||||||
"key": key,
|
"key": key,
|
||||||
"missing_locales": [
|
"missing_locales": [
|
||||||
locale
|
locale for locale in expected_locales if key not in catalog_keys[locale]
|
||||||
for locale in expected_locales
|
|
||||||
if key not in catalog_keys[locale]
|
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
for key in sorted(usages)
|
for key in sorted(usages)
|
||||||
@@ -311,9 +384,12 @@ def _assemble_inventory(
|
|||||||
"missing_catalog_entries": missing_catalog_entries,
|
"missing_catalog_entries": missing_catalog_entries,
|
||||||
},
|
},
|
||||||
"api": {
|
"api": {
|
||||||
"backend_endpoints": backend_endpoints,
|
"backend_endpoints": classified_endpoints,
|
||||||
"frontend_references": frontend_refs,
|
"frontend_references": frontend_refs,
|
||||||
"unreferenced_by_static_webui_scan": unreferenced,
|
"unreferenced_by_static_webui_scan": unreferenced,
|
||||||
|
"unclassified_endpoints": unclassified,
|
||||||
|
"stale_endpoint_declarations": stale_declarations,
|
||||||
|
"classification_counts": dict(sorted(classification_counts.items())),
|
||||||
},
|
},
|
||||||
"summary": {
|
"summary": {
|
||||||
"modules": len(manifests),
|
"modules": len(manifests),
|
||||||
@@ -326,6 +402,8 @@ def _assemble_inventory(
|
|||||||
"backend_endpoints": len(backend_endpoints),
|
"backend_endpoints": len(backend_endpoints),
|
||||||
"frontend_api_references": len(frontend_refs),
|
"frontend_api_references": len(frontend_refs),
|
||||||
"backend_endpoints_without_static_webui_reference": len(unreferenced),
|
"backend_endpoints_without_static_webui_reference": len(unreferenced),
|
||||||
|
"unclassified_backend_endpoints": len(unclassified),
|
||||||
|
"stale_endpoint_declarations": len(stale_declarations),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,6 +418,7 @@ def _render_markdown(inventory: dict[str, Any]) -> str:
|
|||||||
item["repository"]
|
item["repository"]
|
||||||
for item in inventory["api"]["unreferenced_by_static_webui_scan"]
|
for item in inventory["api"]["unreferenced_by_static_webui_scan"]
|
||||||
)
|
)
|
||||||
|
classification_counts = inventory["api"]["classification_counts"]
|
||||||
lines = [
|
lines = [
|
||||||
"# GovOPlaN Platform Interface Inventory",
|
"# GovOPlaN Platform Interface Inventory",
|
||||||
"",
|
"",
|
||||||
@@ -360,6 +439,8 @@ def _render_markdown(inventory: dict[str, Any]) -> str:
|
|||||||
"- Backend endpoints without a static WebUI reference: "
|
"- Backend endpoints without a static WebUI reference: "
|
||||||
f"{summary['backend_endpoints_without_static_webui_reference']}"
|
f"{summary['backend_endpoints_without_static_webui_reference']}"
|
||||||
),
|
),
|
||||||
|
f"- Unclassified backend endpoints: {summary['unclassified_backend_endpoints']}",
|
||||||
|
f"- Stale endpoint declarations: {summary['stale_endpoint_declarations']}",
|
||||||
f"- Used translation keys missing from a locale catalog: {len(missing)}",
|
f"- Used translation keys missing from a locale catalog: {len(missing)}",
|
||||||
"",
|
"",
|
||||||
"## Help Review Candidates",
|
"## Help Review Candidates",
|
||||||
@@ -388,6 +469,19 @@ def _render_markdown(inventory: dict[str, Any]) -> str:
|
|||||||
f"| `{repository}` | {count} |"
|
f"| `{repository}` | {count} |"
|
||||||
for repository, count in sorted(endpoint_by_repository.items())
|
for repository, count in sorted(endpoint_by_repository.items())
|
||||||
)
|
)
|
||||||
|
lines.extend(
|
||||||
|
[
|
||||||
|
"",
|
||||||
|
"## Endpoint Surface Classifications",
|
||||||
|
"",
|
||||||
|
"| Classification | Endpoints |",
|
||||||
|
"| --- | ---: |",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
lines.extend(
|
||||||
|
f"| `{category}` | {count} |"
|
||||||
|
for category, count in sorted(classification_counts.items())
|
||||||
|
)
|
||||||
lines.extend(
|
lines.extend(
|
||||||
[
|
[
|
||||||
"",
|
"",
|
||||||
@@ -408,11 +502,89 @@ def canonical_api_path(value: str) -> str:
|
|||||||
if "/api/" in path:
|
if "/api/" in path:
|
||||||
path = path[path.index("/api/") :]
|
path = path[path.index("/api/") :]
|
||||||
path = re.sub(r"^/api/v\d+", "", path)
|
path = re.sub(r"^/api/v\d+", "", path)
|
||||||
|
path = re.sub(r"(?<=[^/])\$\{[^{}]*\}$", "", path)
|
||||||
path = PATH_PARAMETER.sub("{}", path)
|
path = PATH_PARAMETER.sub("{}", path)
|
||||||
path = re.sub(r"/+", "/", path)
|
path = re.sub(r"/+", "/", path)
|
||||||
return path.rstrip("/") or "/"
|
return path.rstrip("/") or "/"
|
||||||
|
|
||||||
|
|
||||||
|
def endpoint_key(endpoint: dict[str, Any]) -> tuple[str, str, str]:
|
||||||
|
return (
|
||||||
|
str(endpoint["repository"]),
|
||||||
|
str(endpoint["method"]).upper(),
|
||||||
|
canonical_api_path(str(endpoint["path"])),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_endpoint_declarations(
|
||||||
|
path: Path,
|
||||||
|
) -> dict[tuple[str, str, str], dict[str, Any]]:
|
||||||
|
try:
|
||||||
|
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Endpoint declaration registry does not exist: {path}"
|
||||||
|
) from exc
|
||||||
|
except json.JSONDecodeError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Endpoint declaration registry is invalid JSON: {exc}"
|
||||||
|
) from exc
|
||||||
|
if not isinstance(payload, dict) or payload.get("schema_version") != 1:
|
||||||
|
raise ValueError("Endpoint declaration registry must use schema_version 1.")
|
||||||
|
entries = payload.get("endpoints")
|
||||||
|
if not isinstance(entries, list):
|
||||||
|
raise ValueError(
|
||||||
|
"Endpoint declaration registry must contain an endpoints list."
|
||||||
|
)
|
||||||
|
declarations: dict[tuple[str, str, str], dict[str, Any]] = {}
|
||||||
|
for index, entry in enumerate(entries):
|
||||||
|
if not isinstance(entry, dict):
|
||||||
|
raise ValueError(f"Endpoint declaration {index} must be an object.")
|
||||||
|
repository = entry.get("repository")
|
||||||
|
method = entry.get("method")
|
||||||
|
raw_path = entry.get("path")
|
||||||
|
category = entry.get("category")
|
||||||
|
rationale = entry.get("rationale")
|
||||||
|
if not isinstance(repository, str) or not repository.strip():
|
||||||
|
raise ValueError(f"Endpoint declaration {index} has no repository.")
|
||||||
|
if not isinstance(method, str) or method.lower() not in HTTP_METHODS:
|
||||||
|
raise ValueError(f"Endpoint declaration {index} has an invalid method.")
|
||||||
|
if not isinstance(raw_path, str) or not raw_path.startswith("/"):
|
||||||
|
raise ValueError(f"Endpoint declaration {index} has an invalid path.")
|
||||||
|
canonical_path = canonical_api_path(raw_path)
|
||||||
|
if raw_path != canonical_path:
|
||||||
|
raise ValueError(
|
||||||
|
f"Endpoint declaration {index} path must be canonical: {canonical_path}"
|
||||||
|
)
|
||||||
|
if category not in ENDPOINT_SURFACE_CATEGORIES:
|
||||||
|
raise ValueError(f"Endpoint declaration {index} has an invalid category.")
|
||||||
|
if not isinstance(rationale, str) or not rationale.strip():
|
||||||
|
raise ValueError(f"Endpoint declaration {index} has no rationale.")
|
||||||
|
tracking_issue = entry.get("tracking_issue")
|
||||||
|
if category == "missing_ui" and (
|
||||||
|
not isinstance(tracking_issue, str) or not tracking_issue.strip()
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Endpoint declaration {index} requires a tracking_issue for missing_ui."
|
||||||
|
)
|
||||||
|
key = (repository, method.upper(), canonical_path)
|
||||||
|
if key in declarations:
|
||||||
|
raise ValueError(f"Duplicate endpoint declaration: {key!r}.")
|
||||||
|
declarations[key] = {
|
||||||
|
"repository": repository,
|
||||||
|
"method": method.upper(),
|
||||||
|
"path": canonical_path,
|
||||||
|
"category": category,
|
||||||
|
"rationale": rationale.strip(),
|
||||||
|
**(
|
||||||
|
{"tracking_issue": tracking_issue.strip()}
|
||||||
|
if isinstance(tracking_issue, str) and tracking_issue.strip()
|
||||||
|
else {}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
return declarations
|
||||||
|
|
||||||
|
|
||||||
def _join_route(prefix: str, route: str) -> str:
|
def _join_route(prefix: str, route: str) -> str:
|
||||||
return f"/{prefix.strip('/')}/{route.strip('/')}".replace("//", "/")
|
return f"/{prefix.strip('/')}/{route.strip('/')}".replace("//", "/")
|
||||||
|
|
||||||
@@ -444,17 +616,11 @@ def _call_name(node: ast.AST) -> str:
|
|||||||
|
|
||||||
def _plain_value(value: Any) -> Any:
|
def _plain_value(value: Any) -> Any:
|
||||||
if is_dataclass(value):
|
if is_dataclass(value):
|
||||||
return {
|
return {key: _plain_value(item) for key, item in asdict(value).items()}
|
||||||
key: _plain_value(item)
|
|
||||||
for key, item in asdict(value).items()
|
|
||||||
}
|
|
||||||
if isinstance(value, tuple):
|
if isinstance(value, tuple):
|
||||||
return [_plain_value(item) for item in value]
|
return [_plain_value(item) for item in value]
|
||||||
if isinstance(value, dict):
|
if isinstance(value, dict):
|
||||||
return {
|
return {str(key): _plain_value(item) for key, item in value.items()}
|
||||||
str(key): _plain_value(item)
|
|
||||||
for key, item in value.items()
|
|
||||||
}
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user