Skip to content
157 changes: 157 additions & 0 deletions scripts/openapi_operation_id_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#!/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.

YAML plain scalars may contain ``#`` as data when it is not preceded by
separation whitespace. A separated ``#`` starts a comment and is therefore
excluded from the operation identifier. Quoted scalar support remains narrow:
the complete trimmed value must be enclosed by one matching quote pair.
"""

value = value.strip()
if not value:
return ""

if value[0] in {"'", '"'}:
if len(value) < 2 or value[-1] != value[0]:
raise ValueError("unsupported quoted operationId scalar")
return value[1:-1]

for index, character in enumerate(value):
if character == "#" and (index == 0 or value[index - 1].isspace()):
return value[:index].rstrip()
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 direct method properties at six spaces. Path-level metadata such as
``parameters`` or ``$ref`` is ignored because it is not an HTTP operation, and
nested extension metadata cannot satisfy the direct ``operationId`` contract.
"""

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 == 6 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Inline YAML comments on path keys silently disable the operationId check for that path (fail-open)

  • Location: scripts/openapi_operation_id_contract.py:157
  • Problem: A path key with a trailing YAML comment (e.g. ' /api/v1/jobs: # legacy') fails the indent-2 branch condition stripped.endswith(':') because the stripped line ends in 't', so current_path stays None and every following method/operationId line is discarded by 'if current_path is None: continue'. When at least one other path exists, test_current_buyer_contract_has_unique_operation_ids still passes (len(result.operations) > 0 and violations == []) and a duplicate or missing operationId on the commented path reaches the generated client undetected - exactly the failure this gate exists to prevent. The parser already strips and tests inline comments on operationId values, so the path-key asymmetry is a real gap, not an intended limitation.
  • Root cause: The indent-2 path-key branch requires the stripped line to end with ':' and never strips a trailing '# ...' comment, so YAML-legal commented path keys fall through to the silent current_path-is-None skip instead of failing closed.
  • Fix: In the indent-2 branch, strip a trailing ' #' comment before the colon check and emit a fail-closed malformed_path_key violation for any indent-2 line that is not a parseable path key; apply the same comment handling to the top-level 'paths:' detection. Prefer failing loud over silently dropping operations from the uniqueness check.
  • Regression test: Add a unit test to scripts/test_openapi_operation_id_contract.py whose fixture puts '# comment' after a path key and asserts the duplicate_operation_id (or missing_operation_id) violation is still reported; run python -m pytest -q scripts or python -m unittest scripts.test_openapi_operation_id_contract to verify.

Suggested diff

diff --git a/scripts/openapi_operation_id_contract.py b/scripts/openapi_operation_id_contract.py
--- a/scripts/openapi_operation_id_contract.py
+++ b/scripts/openapi_operation_id_contract.py
@@ -129,15 +129,23 @@
         if indent == 2 and stripped.endswith(":"):
-            finish_operation()
-            key = stripped[:-1].strip()
-            if key.startswith("/"):
-                current_path = key
-            else:
-                current_path = None
-            continue
+        if indent == 2:
+            path_key = stripped.split(" #", 1)[0].rstrip()
+            if not path_key.endswith(":"):
+                violations.append(ContractViolation(
+                    code="malformed_path_key",
+                    detail=f"unparseable path key line: {stripped!r}",
+                ))
+                continue
+            finish_operation()
+            key = path_key[:-1].strip()
+            if key.startswith("/"):
+                current_path = key
+            else:
+                current_path = None
+            continue

169 changes: 169 additions & 0 deletions scripts/test_openapi_operation_id_contract.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
#!/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"
CI_WORKFLOW = REPOSITORY_ROOT / ".github/workflows/ci.yml"


class OpenApiOperationIdContractTest(unittest.TestCase):
"""Keep every shipped HTTP operation addressable by one stable operationId."""

def test_ci_executes_buyer_readiness_script_contracts(self) -> None:
"""The operationId contract must remain reachable from exact-head CI."""

workflow = CI_WORKFLOW.read_text(encoding="utf-8")

self.assertIn("python -m pytest -q scripts", workflow)

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_inline_comments_do_not_change_duplicate_operation_identity(self) -> None:
"""YAML comments must not make the same operationId look distinct."""

contract = """openapi: 3.0.3
paths:
/api/v1/jobs:
get:
operationId: readJob # first operation
/api/v1/items:
post:
operationId: readJob # second operation
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_comment_only_operation_id_is_missing(self) -> None:
"""A comment after an empty scalar must not satisfy the operationId contract."""

contract = """openapi: 3.0.3
paths:
/api/v1/jobs:
get:
operationId: # required for generated clients
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_nested_extension_operation_id_does_not_satisfy_operation(self) -> None:
"""Only a method's direct operationId property may satisfy the contract."""

contract = """openapi: 3.0.3
paths:
/api/v1/jobs:
get:
x-metadata:
operationId: nestedOnly
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,
)

Comment thread
seonghobae marked this conversation as resolved.
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()
Comment thread
seonghobae marked this conversation as resolved.
Loading