From bb3851346b5bad45d3c4623278d84fecb3dcc1eb Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 21:14:35 +0900 Subject: [PATCH 1/2] test(openapi): add RED operationId contract --- scripts/test_openapi_operation_id_contract.py | 93 +++++++++++++++++++ 1 file changed, 93 insertions(+) create mode 100644 scripts/test_openapi_operation_id_contract.py diff --git a/scripts/test_openapi_operation_id_contract.py b/scripts/test_openapi_operation_id_contract.py new file mode 100644 index 00000000..0b2fe417 --- /dev/null +++ b/scripts/test_openapi_operation_id_contract.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Contract tests for stable, unique buyer OpenAPI operation identifiers.""" + +from __future__ import annotations + +import unittest +from pathlib import Path + +from scripts.openapi_operation_id_contract import ContractViolation, inspect_operation_ids + + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +OPENAPI_PATH = REPOSITORY_ROOT / "docs/deployment/clearfolio-buyer-connector.openapi.yaml" + + +class OpenApiOperationIdContractTest(unittest.TestCase): + """Keep every shipped HTTP operation addressable by one stable operationId.""" + + def test_current_buyer_contract_has_unique_operation_ids(self) -> None: + """The repository-owned buyer contract must contain no missing or duplicate IDs.""" + + contract = OPENAPI_PATH.read_text(encoding="utf-8") + result = inspect_operation_ids(contract) + + self.assertGreater(len(result.operations), 0) + self.assertEqual([], result.violations) + + def test_duplicate_operation_id_is_rejected(self) -> None: + """Two HTTP operations must never share the same generated-client identity.""" + + contract = """openapi: 3.0.3 +paths: + /api/v1/jobs: + get: + operationId: readJob + /api/v1/items: + post: + operationId: readJob +components: {} +""" + + result = inspect_operation_ids(contract) + + self.assertIn( + ContractViolation( + code="duplicate_operation_id", + detail="operationId 'readJob' is used by GET /api/v1/jobs and POST /api/v1/items", + ), + result.violations, + ) + + def test_missing_operation_id_is_rejected(self) -> None: + """Every path-level HTTP method must declare an explicit operationId.""" + + contract = """openapi: 3.0.3 +paths: + /api/v1/jobs: + parameters: [] + get: + summary: Read a job +components: {} +""" + + result = inspect_operation_ids(contract) + + self.assertIn( + ContractViolation( + code="missing_operation_id", + detail="GET /api/v1/jobs does not declare operationId", + ), + result.violations, + ) + + def test_non_http_path_keys_do_not_create_operations(self) -> None: + """OpenAPI path-level metadata is not mistaken for an HTTP operation.""" + + contract = """openapi: 3.0.3 +paths: + /api/v1/jobs/{jobId}: + parameters: [] + get: + operationId: readJob +components: {} +""" + + result = inspect_operation_ids(contract) + + self.assertEqual([("GET", "/api/v1/jobs/{jobId}", "readJob")], result.operations) + self.assertEqual([], result.violations) + + +if __name__ == "__main__": + unittest.main() From f4b08dc83f972f7ea98027b0a1d9209e33496873 Mon Sep 17 00:00:00 2001 From: Seongho Bae Date: Mon, 10 Aug 2026 21:18:34 +0900 Subject: [PATCH 2/2] feat(openapi): validate stable unique operationIds --- scripts/openapi_operation_id_contract.py | 141 +++++++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 scripts/openapi_operation_id_contract.py diff --git a/scripts/openapi_operation_id_contract.py b/scripts/openapi_operation_id_contract.py new file mode 100644 index 00000000..700ed07d --- /dev/null +++ b/scripts/openapi_operation_id_contract.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +"""Inspect the buyer OpenAPI path table for stable, unique operation identifiers. + +This checker intentionally uses only the Python standard library so the repository's +buyer-readiness gate does not need a second YAML runtime. It recognizes the narrowly +formatted top-level ``paths`` table owned by this repository and treats only standard +HTTP method keys as operations. It does not attempt to be a general-purpose YAML parser. +""" + +from __future__ import annotations + +from dataclasses import dataclass + + +HTTP_METHODS = frozenset({"get", "put", "post", "delete", "options", "head", "patch", "trace"}) + + +@dataclass(frozen=True) +class ContractViolation: + """One deterministic operationId contract violation.""" + + code: str + detail: str + + +@dataclass(frozen=True) +class InspectionResult: + """Operations found in the path table and any contract violations.""" + + operations: list[tuple[str, str, str | None]] + violations: list[ContractViolation] + + +def _indent_width(line: str) -> int: + """Return leading-space indentation and reject tab-indented structure.""" + + prefix = line[: len(line) - len(line.lstrip(" \t"))] + if "\t" in prefix: + raise ValueError("OpenAPI contract must use spaces for structural indentation") + return len(prefix) + + +def _yaml_scalar(value: str) -> str: + """Return the simple scalar form used by repository-owned operationId values.""" + + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in {"'", '"'}: + return value[1:-1] + return value + + +def inspect_operation_ids(contract: str) -> InspectionResult: + """Inspect standard HTTP methods under the top-level OpenAPI ``paths`` mapping. + + The repository-owned contract keeps path keys at two spaces, method keys at four + spaces, and method properties below that level. Path-level metadata such as + ``parameters`` or ``$ref`` is ignored because it is not an HTTP operation. + """ + + operations: list[tuple[str, str, str | None]] = [] + violations: list[ContractViolation] = [] + first_use: dict[str, tuple[str, str]] = {} + + in_paths = False + current_path: str | None = None + current_method: str | None = None + current_operation_id: str | None = None + + def finish_operation() -> None: + nonlocal current_method, current_operation_id + if current_path is None or current_method is None: + return + + method = current_method.upper() + operation_id = current_operation_id + operations.append((method, current_path, operation_id)) + + if not operation_id: + violations.append(ContractViolation( + code="missing_operation_id", + detail=f"{method} {current_path} does not declare operationId", + )) + else: + previous = first_use.get(operation_id) + if previous is None: + first_use[operation_id] = (method, current_path) + else: + previous_method, previous_path = previous + violations.append(ContractViolation( + code="duplicate_operation_id", + detail=( + f"operationId '{operation_id}' is used by " + f"{previous_method} {previous_path} and {method} {current_path}" + ), + )) + + current_method = None + current_operation_id = None + + for raw_line in contract.splitlines(): + stripped = raw_line.strip() + if not stripped or stripped.startswith("#"): + continue + + indent = _indent_width(raw_line) + + if not in_paths: + if indent == 0 and stripped == "paths:": + in_paths = True + continue + + if indent == 0: + finish_operation() + break + + if indent == 2 and stripped.endswith(":"): + finish_operation() + key = stripped[:-1].strip() + if key.startswith("/"): + current_path = key + else: + current_path = None + continue + + if current_path is None: + continue + + if indent == 4 and stripped.endswith(":"): + finish_operation() + key = stripped[:-1].strip().lower() + if key in HTTP_METHODS: + current_method = key + continue + + if current_method is not None and indent > 4 and stripped.startswith("operationId:"): + _, value = stripped.split(":", 1) + candidate = _yaml_scalar(value) + current_operation_id = candidate or None + + finish_operation() + return InspectionResult(operations=operations, violations=violations)