diff --git a/scripts/openapi_v1_compatibility_baseline.json b/scripts/openapi_v1_compatibility_baseline.json new file mode 100644 index 00000000..f4c5c12d --- /dev/null +++ b/scripts/openapi_v1_compatibility_baseline.json @@ -0,0 +1,38 @@ +{ + "GET /api/v1/analytics/kpi-snapshot": { + "operationId": "getKpiSnapshot", + "responses": ["200", "401", "403"] + }, + "GET /api/v1/analytics/kpi-snapshot-exports": { + "operationId": "listKpiSnapshotExports", + "responses": ["200", "401", "403"] + }, + "GET /api/v1/convert/jobs/{jobId}": { + "operationId": "getConversionJobStatus", + "responses": ["200", "401", "403", "404"] + }, + "GET /api/v1/viewer/{docId}": { + "operationId": "getViewerBootstrap", + "responses": ["200", "401", "403", "404", "409"] + }, + "GET /api/v1/viewer/{docId}/artifact-read-events": { + "operationId": "listArtifactReadEvents", + "responses": ["200", "401", "403", "404"] + }, + "POST /api/v1/convert/jobs": { + "operationId": "submitConversionJob", + "responses": ["202", "400", "401", "403"] + }, + "POST /api/v1/convert/jobs/{jobId}/retry": { + "operationId": "retryDeadLetteredConversionJob", + "responses": ["202", "400", "401", "403", "404", "409"] + }, + "POST /api/v1/viewer/artifact-links/{tokenId}/revoke": { + "operationId": "revokeArtifactLink", + "responses": ["200", "401", "403"] + }, + "POST /api/v1/viewer/{docId}/artifact-links": { + "operationId": "createArtifactLink", + "responses": ["200", "401", "403", "404"] + } +} diff --git a/scripts/openapi_v1_compatibility_contract.py b/scripts/openapi_v1_compatibility_contract.py new file mode 100644 index 00000000..24f0376f --- /dev/null +++ b/scripts/openapi_v1_compatibility_contract.py @@ -0,0 +1,162 @@ +"""Detect removals from Clearfolio's checked-in OpenAPI v1 compatibility baseline.""" + +from __future__ import annotations + +from collections.abc import Mapping +import re +from typing import Any + + +_HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put", "trace"} +_RESPONSE_KEY = re.compile(r"(?:[1-5][0-9]{2}|default)\Z") +_MAX_SPEC_BYTES = 1_000_000 + + +def collect_operations(source: str) -> dict[str, dict[str, Any]]: + """Collect client-visible operation identity from bounded OpenAPI YAML text. + + This dependency-free scanner follows relative YAML mapping indentation under + the top-level ``paths`` mapping instead of requiring one exact whitespace + layout. It accepts simple quoted keys, inline comments, and mapping anchors + while extracting only direct path, HTTP method, ``operationId``, and response + mappings. Unrelated nested schema content is ignored. + """ + if len(source.encode("utf-8")) > _MAX_SPEC_BYTES: + raise ValueError("OpenAPI source exceeds compatibility-gate size limit") + + operations: dict[str, dict[str, Any]] = {} + paths_indent: int | None = None + current_path: str | None = None + path_indent: int | None = None + path_child_indent: int | None = None + current_operation: dict[str, Any] | None = None + method_indent: int | None = None + method_child_indent: int | None = None + responses_indent: int | None = None + response_child_indent: int | None = None + + for raw_line in source.splitlines(): + entry = _mapping_entry(raw_line) + if entry is None: + continue + indent, key, value = entry + + if paths_indent is None: + if indent == 0 and key == "paths": + paths_indent = indent + continue + if indent <= paths_indent: + break + + if path_indent is not None and indent <= path_indent: + current_path = None + path_indent = None + path_child_indent = None + current_operation = None + method_indent = None + method_child_indent = None + responses_indent = None + response_child_indent = None + elif method_indent is not None and indent <= method_indent: + current_operation = None + method_indent = None + method_child_indent = None + responses_indent = None + response_child_indent = None + elif responses_indent is not None and indent <= responses_indent: + responses_indent = None + response_child_indent = None + + if current_path is None: + if key.startswith("/"): + current_path = key + path_indent = indent + path_child_indent = None + continue + + if current_operation is None: + if path_child_indent is None and path_indent is not None and indent > path_indent: + path_child_indent = indent + if indent == path_child_indent and key.lower() in _HTTP_METHODS: + method = key.lower() + current_operation = {"operationId": None, "responses": []} + operations[f"{method.upper()} {current_path}"] = current_operation + method_indent = indent + method_child_indent = None + continue + + if responses_indent is not None and indent > responses_indent: + if response_child_indent is None: + response_child_indent = indent + if indent == response_child_indent: + response_key = _unquote(key) + if _RESPONSE_KEY.fullmatch(response_key): + current_operation["responses"].append(response_key) + continue + + if method_child_indent is None and method_indent is not None and indent > method_indent: + method_child_indent = indent + if indent != method_child_indent: + continue + + if key == "operationId": + current_operation["operationId"] = _unquote(value) + elif key == "responses": + responses_indent = indent + response_child_indent = None + + for operation in operations.values(): + operation["responses"] = sorted(set(operation["responses"])) + return operations + + +def find_breaking_changes( + baseline: Mapping[str, Mapping[str, Any]], + candidate: Mapping[str, Mapping[str, Any]]) -> list[str]: + """Return deterministic client-visible removals from ``baseline`` to ``candidate``.""" + findings: list[str] = [] + for operation_key in sorted(baseline): + expected = baseline[operation_key] + actual = candidate.get(operation_key) + if actual is None: + findings.append(f"{operation_key}: operation was removed") + continue + + expected_operation_id = expected.get("operationId") + actual_operation_id = actual.get("operationId") + if actual_operation_id != expected_operation_id: + findings.append( + f"{operation_key}: operationId changed from " + f"{expected_operation_id} to {actual_operation_id}" + ) + + expected_responses = {str(status) for status in expected.get("responses", [])} + actual_responses = {str(status) for status in actual.get("responses", [])} + for removed_status in sorted(expected_responses - actual_responses): + findings.append(f"{operation_key}: response {removed_status} was removed") + + return findings + + +def _mapping_entry(raw_line: str) -> tuple[int, str, str] | None: + """Return a simple YAML mapping entry and its leading-space indentation.""" + prefix = raw_line[: len(raw_line) - len(raw_line.lstrip(" \t"))] + if "\t" in prefix: + raise ValueError("OpenAPI compatibility source must use spaces for indentation") + + stripped = raw_line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith("-") or ":" not in stripped: + return None + + key, value = stripped.split(":", 1) + key = _unquote(key.strip()) + value = value.split(" #", 1)[0].strip() + return len(prefix), key, value + + +def _unquote(value: str) -> str: + """Remove matching single or double quotes from one simple YAML scalar.""" + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value diff --git a/scripts/test_openapi_v1_compatibility_contract.py b/scripts/test_openapi_v1_compatibility_contract.py new file mode 100644 index 00000000..b2b34766 --- /dev/null +++ b/scripts/test_openapi_v1_compatibility_contract.py @@ -0,0 +1,130 @@ +"""Regression tests for the versioned OpenAPI compatibility gate.""" + +from __future__ import annotations + +import json +from pathlib import Path +import sys + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +SCRIPTS_DIR = REPOSITORY_ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS_DIR)) + +from openapi_v1_compatibility_contract import ( # noqa: E402 + collect_operations, + find_breaking_changes, +) + + +CURRENT_SPEC = REPOSITORY_ROOT / "docs" / "deployment" / "clearfolio-buyer-connector.openapi.yaml" +BASELINE = SCRIPTS_DIR / "openapi_v1_compatibility_baseline.json" + + +def test_collects_operation_identity_and_response_contract() -> None: + """Extract path/method identity, operationId, and status codes from OpenAPI YAML.""" + source = """\ +openapi: 3.0.3 +paths: + /api/v1/widgets/{widgetId}: + parameters: + - name: widgetId + get: + operationId: getWidget + responses: + \"200\": + description: OK + \"404\": + description: Missing +""" + + assert collect_operations(source) == { + "GET /api/v1/widgets/{widgetId}": { + "operationId": "getWidget", + "responses": ["200", "404"], + } + } + + +def test_collects_equivalent_yaml_formatting_variants() -> None: + """Treat compatible quoted, commented, anchored YAML mappings as the same contract.""" + source = """\ +openapi: 3.0.3 +paths: # public API surface + \"/api/v1/widgets/{widgetId}\": + get: + operationId: getWidget # stable generated-client identity + responses: &widget_responses + '200': + description: OK + default: + description: Controlled failure +""" + + assert collect_operations(source) == { + "GET /api/v1/widgets/{widgetId}": { + "operationId": "getWidget", + "responses": ["200", "default"], + } + } + + +def test_detects_removed_operation_operation_id_change_and_removed_response() -> None: + """Reject client-visible v1 contract removals while allowing additive changes.""" + baseline = { + "GET /api/v1/widgets/{widgetId}": { + "operationId": "getWidget", + "responses": ["200", "404"], + }, + "POST /api/v1/widgets": { + "operationId": "createWidget", + "responses": ["201", "400"], + }, + } + candidate = { + "GET /api/v1/widgets/{widgetId}": { + "operationId": "fetchWidget", + "responses": ["200", "409"], + }, + "DELETE /api/v1/widgets/{widgetId}": { + "operationId": "deleteWidget", + "responses": ["204"], + }, + } + + assert find_breaking_changes(baseline, candidate) == [ + "GET /api/v1/widgets/{widgetId}: operationId changed from getWidget to fetchWidget", + "GET /api/v1/widgets/{widgetId}: response 404 was removed", + "POST /api/v1/widgets: operation was removed", + ] + + +def test_additive_operations_and_responses_are_compatible() -> None: + """Do not block an additive v1 operation or response status.""" + baseline = { + "GET /api/v1/widgets/{widgetId}": { + "operationId": "getWidget", + "responses": ["200"], + } + } + candidate = { + "GET /api/v1/widgets/{widgetId}": { + "operationId": "getWidget", + "responses": ["200", "404"], + }, + "POST /api/v1/widgets": { + "operationId": "createWidget", + "responses": ["201"], + }, + } + + assert find_breaking_changes(baseline, candidate) == [] + + +def test_checked_in_v1_baseline_accepts_current_buyer_contract() -> None: + """The approved v1 baseline must be a subset of the repository-owned current spec.""" + baseline = json.loads(BASELINE.read_text(encoding="utf-8")) + candidate = collect_operations(CURRENT_SPEC.read_text(encoding="utf-8")) + + assert baseline + assert find_breaking_changes(baseline, candidate) == []