From 163645e8db30e17737ff91818fcccd043deb5507 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 16:48:02 +0900 Subject: [PATCH 1/5] test(api): require v1 compatibility gate --- .../test_openapi_v1_compatibility_contract.py | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) create mode 100644 scripts/test_openapi_v1_compatibility_contract.py diff --git a/scripts/test_openapi_v1_compatibility_contract.py b/scripts/test_openapi_v1_compatibility_contract.py new file mode 100644 index 00000000..ebdc83e8 --- /dev/null +++ b/scripts/test_openapi_v1_compatibility_contract.py @@ -0,0 +1,107 @@ +"""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_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) == [] From 61aa5b6d2a8cfa0250a8c00f2560fdfb89ca78ff Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 16:51:35 +0900 Subject: [PATCH 2/5] feat(api): add bounded v1 compatibility checker --- scripts/openapi_v1_compatibility_contract.py | 121 +++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 scripts/openapi_v1_compatibility_contract.py diff --git a/scripts/openapi_v1_compatibility_contract.py b/scripts/openapi_v1_compatibility_contract.py new file mode 100644 index 00000000..eafd01fe --- /dev/null +++ b/scripts/openapi_v1_compatibility_contract.py @@ -0,0 +1,121 @@ +"""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][0-9]|default)["\']?:\s*$') +_MAX_SPEC_BYTES = 1_000_000 + + +def collect_operations(source: str) -> dict[str, dict[str, Any]]: + """Collect stable client-visible operation identity from bounded OpenAPI YAML text. + + The repository intentionally avoids adding a YAML dependency for this narrow + gate. Only the indentation levels owned by the OpenAPI ``paths`` object are + interpreted: path keys, HTTP method keys, ``operationId``, and response + status keys. Nested schemas and path-level metadata are 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]] = {} + in_paths = False + current_path: str | None = None + current_operation: dict[str, Any] | None = None + in_responses = False + + for raw_line in source.splitlines(): + if raw_line == "paths:": + in_paths = True + current_path = None + current_operation = None + in_responses = False + continue + if not in_paths: + continue + if raw_line and not raw_line.startswith(" "): + break + + if raw_line.startswith(" /") and not raw_line.startswith(" ") and raw_line.endswith(":"): + current_path = raw_line.strip()[:-1] + current_operation = None + in_responses = False + continue + + if current_path is None: + continue + + if raw_line.startswith(" ") and not raw_line.startswith(" ") and raw_line.endswith(":"): + candidate_method = raw_line.strip()[:-1].lower() + in_responses = False + if candidate_method in _HTTP_METHODS: + key = f"{candidate_method.upper()} {current_path}" + current_operation = {"operationId": None, "responses": []} + operations[key] = current_operation + else: + current_operation = None + continue + + if current_operation is None: + continue + + if raw_line.startswith(" operationId:"): + value = raw_line.split(":", 1)[1].strip() + current_operation["operationId"] = _unquote(value) + continue + + if raw_line == " responses:": + in_responses = True + continue + + if in_responses: + if raw_line.startswith(" ") and not raw_line.startswith(" ") and raw_line.strip(): + in_responses = False + continue + match = _RESPONSE_KEY.match(raw_line) + if match: + current_operation["responses"].append(match.group(1)) + + 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 _unquote(value: str) -> str: + """Remove matching single or double quotes from one simple YAML scalar.""" + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value From e9f8b101642e29298f0da47f63c9e10908539dd6 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 16:52:06 +0900 Subject: [PATCH 3/5] feat(api): freeze current v1 compatibility baseline --- .../openapi_v1_compatibility_baseline.json | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100644 scripts/openapi_v1_compatibility_baseline.json 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"] + } +} From 8f06f3a2820daac59b1c74b35408224d591eb855 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 17:15:19 +0900 Subject: [PATCH 4/5] test(openapi): preserve YAML-equivalent v1 contracts --- .../test_openapi_v1_compatibility_contract.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/scripts/test_openapi_v1_compatibility_contract.py b/scripts/test_openapi_v1_compatibility_contract.py index ebdc83e8..b2b34766 100644 --- a/scripts/test_openapi_v1_compatibility_contract.py +++ b/scripts/test_openapi_v1_compatibility_contract.py @@ -46,6 +46,29 @@ def test_collects_operation_identity_and_response_contract() -> None: } +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 = { From dbd535bf83350eac10e471837a7026da46ed9858 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Tue, 11 Aug 2026 17:23:44 +0900 Subject: [PATCH 5/5] fix(openapi): parse compatibility mappings structurally --- scripts/openapi_v1_compatibility_contract.py | 125 ++++++++++++------- 1 file changed, 83 insertions(+), 42 deletions(-) diff --git a/scripts/openapi_v1_compatibility_contract.py b/scripts/openapi_v1_compatibility_contract.py index eafd01fe..24f0376f 100644 --- a/scripts/openapi_v1_compatibility_contract.py +++ b/scripts/openapi_v1_compatibility_contract.py @@ -8,78 +8,102 @@ _HTTP_METHODS = {"delete", "get", "head", "options", "patch", "post", "put", "trace"} -_RESPONSE_KEY = re.compile(r'^ ["\']?([1-5][0-9][0-9]|default)["\']?:\s*$') +_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 stable client-visible operation identity from bounded OpenAPI YAML text. + """Collect client-visible operation identity from bounded OpenAPI YAML text. - The repository intentionally avoids adding a YAML dependency for this narrow - gate. Only the indentation levels owned by the OpenAPI ``paths`` object are - interpreted: path keys, HTTP method keys, ``operationId``, and response - status keys. Nested schemas and path-level metadata are ignored. + 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]] = {} - in_paths = False + 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 - in_responses = False + 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(): - if raw_line == "paths:": - in_paths = True - current_path = None - current_operation = None - in_responses = False + entry = _mapping_entry(raw_line) + if entry is None: continue - if not in_paths: + indent, key, value = entry + + if paths_indent is None: + if indent == 0 and key == "paths": + paths_indent = indent continue - if raw_line and not raw_line.startswith(" "): + if indent <= paths_indent: break - if raw_line.startswith(" /") and not raw_line.startswith(" ") and raw_line.endswith(":"): - current_path = raw_line.strip()[:-1] + if path_indent is not None and indent <= path_indent: + current_path = None + path_indent = None + path_child_indent = None current_operation = None - in_responses = False - continue + 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: - continue - - if raw_line.startswith(" ") and not raw_line.startswith(" ") and raw_line.endswith(":"): - candidate_method = raw_line.strip()[:-1].lower() - in_responses = False - if candidate_method in _HTTP_METHODS: - key = f"{candidate_method.upper()} {current_path}" - current_operation = {"operationId": None, "responses": []} - operations[key] = current_operation - else: - current_operation = 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 raw_line.startswith(" operationId:"): - value = raw_line.split(":", 1)[1].strip() - current_operation["operationId"] = _unquote(value) + 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 raw_line == " responses:": - in_responses = True + 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 in_responses: - if raw_line.startswith(" ") and not raw_line.startswith(" ") and raw_line.strip(): - in_responses = False - continue - match = _RESPONSE_KEY.match(raw_line) - if match: - current_operation["responses"].append(match.group(1)) + 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"])) @@ -114,8 +138,25 @@ def find_breaking_changes( 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