Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
141 changes: 141 additions & 0 deletions scripts/openapi_operation_id_contract.py
Original file line number Diff line number Diff line change
@@ -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)
93 changes: 93 additions & 0 deletions scripts/test_openapi_operation_id_contract.py
Original file line number Diff line number Diff line change
@@ -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()
Loading