diff --git a/package.json b/package.json index 3a701752..68b09bd8 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "generate:network-variable-tabs": "python3 scripts/generate_network_variable_tabs.py", "validate:network-variable-tabs": "python3 scripts/validate_network_variable_tabs.py", "validate:splice-mintlify-openapi-nav": "python3 scripts/validate_splice_mintlify_openapi_nav.py", + "validate:mintlify-openapi-slugs": "python3 scripts/validate_mintlify_openapi_slugs.py", "test:external-snippets": "python3 -m pytest tests/test_generate_external_snippets.py", "generate:typescript-bindings-reference": "python3 scripts/generate_typescript_bindings_reference.py", "dev": "cd docs-main && mintlify dev" diff --git a/scripts/generate_json_api_reference.py b/scripts/generate_json_api_reference.py index 1b23e442..0016fb4b 100755 --- a/scripts/generate_json_api_reference.py +++ b/scripts/generate_json_api_reference.py @@ -194,14 +194,46 @@ def generated_operation_summary(path: str, method: str) -> str: return f"{method.upper()} {mintlify_path}" +def path_only_operation_summary(path: str, method: str, summary: str) -> bool: + normalized = summary.strip() + if normalized == generated_operation_summary(path, method): + return False + return normalized in { + path, + re.sub(r"\{([^{}]+)\}", r":\1", path), + f"{method.upper()} {path}", + } + + +def operation_summary_rewrites(spec: dict[str, Any]) -> dict[tuple[str, str], str]: + paths = spec.get("paths") + if not isinstance(paths, dict): + return {} + + rewrites: dict[tuple[str, str], str] = {} + for path, path_item in paths.items(): + if not isinstance(path, str) or not isinstance(path_item, dict): + continue + for method, operation in path_item.items(): + if method.lower() not in HTTP_METHODS or not isinstance(operation, dict): + continue + summary = operation.get("summary") + if not isinstance(summary, str) or not summary.strip(): + rewrites[(path, method.lower())] = generated_operation_summary(path, method) + elif path_only_operation_summary(path, method, summary): + rewrites[(path, method.lower())] = generated_operation_summary(path, method) + return rewrites + + def add_missing_operation_summaries(text: str) -> str: spec = yaml.safe_load(text) if not isinstance(spec, dict): raise ValueError("Expected generated OpenAPI YAML to parse as an object") - missing = missing_operation_summaries(spec) - if not missing: + rewrites = operation_summary_rewrites(spec) + if not rewrites: return text + missing = missing_operation_summaries(spec) lines = text.splitlines() output_lines: list[str] = [] @@ -209,8 +241,19 @@ def add_missing_operation_summaries(text: str) -> str: paths_indent = "" current_path: str | None = None current_path_indent: str | None = None + current_method: str | None = None + current_method_indent: str | None = None for line in lines: + if current_path is not None and current_method is not None and current_method_indent is not None: + summary_match = re.fullmatch(rf"{re.escape(current_method_indent)}\s{{2}}summary:\s*.*", line) + if summary_match and (current_path, current_method) in rewrites: + summary_indent = f"{current_method_indent} " + output_lines.append(f'{summary_indent}summary: "{rewrites[(current_path, current_method)]}"') + current_method = None + current_method_indent = None + continue + output_lines.append(line) paths_match = re.fullmatch(r"(?P\s*)paths:\s*", line) @@ -219,6 +262,8 @@ def add_missing_operation_summaries(text: str) -> str: paths_indent = paths_match.group("indent") current_path = None current_path_indent = None + current_method = None + current_method_indent = None continue if not in_paths: @@ -228,12 +273,16 @@ def add_missing_operation_summaries(text: str) -> str: in_paths = False current_path = None current_path_indent = None + current_method = None + current_method_indent = None continue path_match = re.fullmatch(rf"(?P{re.escape(paths_indent)}\s{{2}})(?P/.*):\s*", line) if path_match: current_path = path_match.group("path") current_path_indent = path_match.group("indent") + current_method = None + current_method_indent = None continue if current_path is None or current_path_indent is None: @@ -247,18 +296,22 @@ def add_missing_operation_summaries(text: str) -> str: continue method = method_match.group("method") + current_method = method + current_method_indent = method_match.group("indent") if (current_path, method) in missing: summary_indent = f"{method_match.group('indent')} " - output_lines.append(f'{summary_indent}summary: "{generated_operation_summary(current_path, method)}"') + output_lines.append(f'{summary_indent}summary: "{rewrites[(current_path, method)]}"') + current_method = None + current_method_indent = None rendered = "\n".join(output_lines).rstrip() + "\n" parsed = yaml.safe_load(rendered) if not isinstance(parsed, dict): raise ValueError("Generated OpenAPI YAML stopped parsing after summary insertion") - remaining = missing_operation_summaries(parsed) + remaining = operation_summary_rewrites(parsed) if remaining: details = ", ".join(f"{method.upper()} {path}" for path, method in sorted(remaining)) - raise ValueError(f"Failed to insert generated summaries for OpenAPI operations: {details}") + raise ValueError(f"Failed to normalize generated summaries for OpenAPI operations: {details}") return rendered diff --git a/scripts/validate_mintlify_openapi_slugs.py b/scripts/validate_mintlify_openapi_slugs.py new file mode 100644 index 00000000..3c8d0e5e --- /dev/null +++ b/scripts/validate_mintlify_openapi_slugs.py @@ -0,0 +1,99 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import argparse +import re +from pathlib import Path +from typing import Any + +import yaml + + +REPO_ROOT = Path(__file__).resolve().parents[1] +DEFAULT_OPENAPI_ROOT = REPO_ROOT / "docs-main" / "openapi" +HTTP_METHODS = {"get", "put", "post", "delete", "options", "head", "patch", "trace"} + + +def mintlify_operation_slug(summary: str) -> str: + without_braced_params = re.sub(r"\{[^}]+}", "", summary) + return re.sub(r"[^A-Za-z0-9]+", "", without_braced_params).lower() + + +def operation_slug_collisions(openapi_path: Path) -> list[str]: + spec = yaml.safe_load(openapi_path.read_text(encoding="utf-8")) + if not isinstance(spec, dict): + raise ValueError(f"Expected OpenAPI spec to parse as an object: {openapi_path}") + + paths = spec.get("paths") + if not isinstance(paths, dict): + return [] + + slugs: dict[str, list[str]] = {} + for path, path_item in paths.items(): + if not isinstance(path, str) or not isinstance(path_item, dict): + continue + for method, operation in path_item.items(): + if method.lower() not in HTTP_METHODS or not isinstance(operation, dict): + continue + summary = operation.get("summary") + if not isinstance(summary, str) or not summary.strip(): + continue + slug = mintlify_operation_slug(summary) + slugs.setdefault(slug, []).append(f"{method.upper()} {path}") + + return [ + f"{openapi_path}: {slug}: {', '.join(operations)}" + for slug, operations in slugs.items() + if len(operations) > 1 + ] + + +def openapi_files(root: Path) -> list[Path]: + return sorted( + path + for pattern in ("*.yaml", "*.yml") + for path in root.rglob(pattern) + if path.is_file() + ) + + +def validate_roots(roots: list[Path]) -> None: + failures: list[str] = [] + checked = 0 + for root in roots: + paths = openapi_files(root) if root.is_dir() else [root] + for path in paths: + checked += 1 + failures.extend(operation_slug_collisions(path)) + + if failures: + details = "\n".join(f"- {failure}" for failure in failures) + raise ValueError( + "OpenAPI specs contain operations that collide under Mintlify operation slugging.\n" + f"{details}" + ) + print(f"Validated Mintlify OpenAPI operation slugs for {checked} specs.") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Validate OpenAPI operation summaries do not collide under Mintlify operation slugging." + ) + parser.add_argument( + "paths", + nargs="*", + default=[str(DEFAULT_OPENAPI_ROOT)], + help="OpenAPI files or directories to validate. Defaults to docs-main/openapi.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + validate_roots([Path(path).resolve() for path in args.paths]) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_json_api_openapi.py b/tests/test_json_api_openapi.py index bca535cb..c6085cbe 100644 --- a/tests/test_json_api_openapi.py +++ b/tests/test_json_api_openapi.py @@ -75,7 +75,7 @@ def test_add_missing_operation_summaries_disambiguates_methods_on_same_path() -> assert operations["patch"]["summary"] == "PATCH /v2/users/:user-id" -def test_add_missing_operation_summaries_preserves_specs_that_already_have_summaries() -> None: +def test_add_missing_operation_summaries_normalizes_path_only_summaries() -> None: module = load_script_module("generate_json_api_reference.py") source = """openapi: 3.0.3 paths: @@ -84,10 +84,24 @@ def test_add_missing_operation_summaries_preserves_specs_that_already_have_summa summary: /v2/version description: Read the version. operationId: getV2Version + /v2/users/{user-id}: + get: + summary: GET /v2/users/{user-id} + description: Get user. + operationId: getV2UsersUser-id + /v2/descriptive: + get: + summary: Descriptive operation label + description: Descriptive operation. + operationId: getV2Descriptive components: {} """ - assert module.add_missing_operation_summaries(source) == source + rendered = module.add_missing_operation_summaries(source) + + assert ' summary: "GET /v2/version"' in rendered + assert ' summary: "GET /v2/users/:user-id"' in rendered + assert " summary: Descriptive operation label" in rendered def test_openapi_operation_page_refs_lists_endpoint_refs_in_source_order() -> None: