Skip to content
Draft
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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
63 changes: 58 additions & 5 deletions scripts/generate_json_api_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,23 +194,66 @@ 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] = []
in_paths = False
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<indent>\s*)paths:\s*", line)
Expand All @@ -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:
Expand All @@ -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<indent>{re.escape(paths_indent)}\s{{2}})(?P<path>/.*):\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:
Expand All @@ -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


Expand Down
99 changes: 99 additions & 0 deletions scripts/validate_mintlify_openapi_slugs.py
Original file line number Diff line number Diff line change
@@ -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())
18 changes: 16 additions & 2 deletions tests/test_json_api_openapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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:
Expand Down