Brings all of the develop branch's commits into master. ## What this contains The 2 commits on develop that landed during the XL Vask integration dispatch: - **PR #373** (TRU-6 / AUT-2) — feat(edge-broker): expose lastActivityAt on /api/health (AUT-2/TRU-6) - **PR #375** (TRU-49 / AUT-49) — fix(api): include wash_id in xlvask_missing_order_link flag text (AUT-49/TRU-49) ## Why The XL Vask integration dispatch via the OpenSymphony orchestrator (MiniMax M3) produced 2 api-side fixes: - **PR #373** — adds `lastActivityAt` to the api health endpoint so operators can see if the edge-broker has processed any requests recently. - **PR #375** — the actual root-cause fix for the user-reported symptom "XL Vask-registreringen er hverken ignoreret eller knyttet til en ordre i den valgte periode doesn't show the wash". The bug was in `messageParts()` for the `xlvask_missing_order_link` arm — the link text was hard-coded to 'XL Vask wash' instead of using the actual wash_id. This PR makes the link identify the wash it points to. ## Verification Both source PRs passed: - Required CI (PHP unit, PHP integration, PHP api, PHP legacy, edge broker, edge agent, edge gateway backend) - The api ruleset allows squash merges ## Notes - The pleno-vue repo has its own equivalent develop→master PR (#312) with the 9 UI fixes (component, i18n, and a Playwright E2E). --------- Co-authored-by: Jeppe <jeppe@copenhagentruckwash.io> Co-authored-by: openhands <openhands@all-hands.dev>
844 lines
37 KiB
Python
844 lines
37 KiB
Python
#!/usr/bin/env python3
|
|
import argparse
|
|
import hashlib
|
|
import html
|
|
import json
|
|
import re
|
|
import sys
|
|
from dataclasses import dataclass
|
|
from pathlib import Path
|
|
from typing import Dict, List, Tuple
|
|
|
|
import yaml
|
|
|
|
|
|
HTTP_METHODS = ["get", "post", "put", "patch", "delete", "options", "head", "trace"]
|
|
AUTOGEN_NOTE = "AUTO-GENERATED, DO NOT EDIT"
|
|
TOC_START_MARKER = "<!-- AUTO-GENERATED API TOC START -->"
|
|
TOC_END_MARKER = "<!-- AUTO-GENERATED API TOC END -->"
|
|
OPS_PER_PAGE = 20
|
|
MODULE_DESCRIPTIONS: Dict[str, Tuple[str, str]] = {
|
|
"action-logs": ("Action Logs", "Audit and operational logs for integration module activity."),
|
|
"backup": ("Backup", "Backup integrations and backup module orchestration."),
|
|
"cvr": ("CVR", "Danish company registry (CVR) lookup and search integration."),
|
|
"economic": ("e-conomic", "Accounting and invoicing integration with e-conomic."),
|
|
"entra": ("Entra", "Microsoft Entra directory integration endpoints."),
|
|
"fxratesapi": ("FXRatesAPI", "Currency exchange-rate lookup integration."),
|
|
"motorapi": ("MotorAPI", "Vehicle lookup integration via MotorAPI."),
|
|
"self-serve": ("Self-Serve", "Self-serve lane control and machine command endpoints."),
|
|
"stripe": ("Stripe", "Stripe payments, invoices, terminals, products, and customers."),
|
|
"virkdata": ("VirkData", "VirkData company information integration."),
|
|
"washcertificates": ("Wash Certificates", "Wash certificate retrieval and listing integration."),
|
|
"weatherapi": ("WeatherAPI", "Weather provider integration for current, forecast, and search."),
|
|
"xlvask": ("XLVask", "XLVask synchronization, usage logs, vehicles, and customers."),
|
|
}
|
|
CONFIG_DESCRIPTIONS: Dict[str, Tuple[str, str]] = {
|
|
"backups": ("Backups", "Backup configuration for backup module behavior."),
|
|
"bird": ("Bird", "Bird communication integration configuration."),
|
|
"economic": ("e-conomic", "e-conomic accounting integration configuration."),
|
|
"email": ("Email", "Email provider and SMTP/MailerSend configuration."),
|
|
"entra": ("Entra", "Microsoft Entra identity integration configuration."),
|
|
"fxratesapi": ("FXRatesAPI", "FXRatesAPI exchange-rate integration configuration."),
|
|
"gatewayapi": ("GatewayAPI", "GatewayAPI integration configuration."),
|
|
"licenseplaterecognizer": ("LicensePlateRecognizer", "License plate recognizer integration configuration."),
|
|
"limble": ("Limble", "Limble integration configuration."),
|
|
"motorapi": ("MotorAPI", "MotorAPI vehicle lookup integration configuration."),
|
|
"ocrspace": ("OcrSpace", "OCR Space integration configuration."),
|
|
"openai": ("OpenAI", "OpenAI integration configuration."),
|
|
"reCAPTCHA": ("reCAPTCHA", "reCAPTCHA protection configuration."),
|
|
"selfserve": ("Self-Serve", "Self-serve module runtime configuration."),
|
|
"shelly": ("Shelly", "Shelly integration configuration."),
|
|
"stripe": ("Stripe", "Stripe integration configuration."),
|
|
"virkdata": ("VirkData", "VirkData integration configuration."),
|
|
"weatherapi": ("WeatherAPI", "WeatherAPI integration configuration."),
|
|
"xlvask": ("XLVask", "XLVask integration configuration."),
|
|
}
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Operation:
|
|
method: str
|
|
path: str
|
|
title: str
|
|
topic_id: str
|
|
topic_file: str
|
|
primary_tag: str
|
|
operation_id: str
|
|
description: str
|
|
parameters: List[dict]
|
|
request_body: dict
|
|
responses: dict
|
|
security: List[dict]
|
|
module_key: str
|
|
module_name: str
|
|
module_description: str
|
|
config_key: str
|
|
config_name: str
|
|
config_description: str
|
|
|
|
|
|
def slugify(value: str) -> str:
|
|
slug = re.sub(r"[^A-Za-z0-9_]+", "_", value.strip())
|
|
slug = re.sub(r"_+", "_", slug).strip("_")
|
|
if not slug:
|
|
slug = "unnamed"
|
|
if not re.match(r"^[A-Za-z_]", slug):
|
|
slug = f"id_{slug}"
|
|
return slug
|
|
|
|
|
|
def safe_token(value: str) -> str:
|
|
return slugify(value).lower()
|
|
|
|
|
|
def build_operation_topic_id(operation_id: str, method: str, path: str) -> str:
|
|
base = slugify(operation_id)
|
|
if len(base) > 110:
|
|
digest = hashlib.sha1(f"{method}:{path}:{operation_id}".encode("utf-8")).hexdigest()[:8]
|
|
base = f"{base[:100]}_{digest}"
|
|
return base
|
|
|
|
|
|
def schema_type_name(schema: dict) -> str:
|
|
if not isinstance(schema, dict):
|
|
return "unknown"
|
|
if "$ref" in schema:
|
|
ref = str(schema["$ref"])
|
|
return ref.rsplit("/", 1)[-1]
|
|
if "type" in schema:
|
|
t = str(schema["type"])
|
|
if t == "array" and isinstance(schema.get("items"), dict):
|
|
return f"array<{schema_type_name(schema['items'])}>"
|
|
return t
|
|
if "oneOf" in schema:
|
|
return "oneOf"
|
|
if "anyOf" in schema:
|
|
return "anyOf"
|
|
if "allOf" in schema:
|
|
return "allOf"
|
|
return "object"
|
|
|
|
|
|
def xml_escape(value: str) -> str:
|
|
return html.escape(value or "", quote=True)
|
|
|
|
|
|
def pretty_json(value: dict) -> str:
|
|
return html.escape(json.dumps(value, indent=2, ensure_ascii=False, sort_keys=True))
|
|
|
|
|
|
def render_parameters_table(parameters: List[dict]) -> str:
|
|
if not parameters:
|
|
return ""
|
|
rows = [
|
|
" <chapter title=\"Parameters\" id=\"parameters\">",
|
|
" <table>",
|
|
" <tr><td>Name</td><td>In</td><td>Required</td><td>Type</td><td>Description</td></tr>",
|
|
]
|
|
for param in parameters:
|
|
name = xml_escape(str(param.get("name", "")))
|
|
loc = xml_escape(str(param.get("in", "")))
|
|
required = "yes" if param.get("required") else "no"
|
|
ptype = xml_escape(schema_type_name(param.get("schema", {})))
|
|
desc = xml_escape(str(param.get("description", "")))
|
|
rows.append(
|
|
f" <tr><td>{name}</td><td>{loc}</td><td>{required}</td><td>{ptype}</td><td>{desc}</td></tr>"
|
|
)
|
|
rows.append(" </table>")
|
|
rows.append(" </chapter>")
|
|
return "\n".join(rows) + "\n"
|
|
|
|
|
|
def render_request_body(request_body: dict) -> str:
|
|
if not isinstance(request_body, dict) or not request_body:
|
|
return ""
|
|
lines = [" <chapter title=\"Request Body\" id=\"request-body\">"]
|
|
lines.append(f" <p>Required: {'yes' if request_body.get('required') else 'no'}.</p>")
|
|
content = request_body.get("content", {})
|
|
if isinstance(content, dict) and content:
|
|
for content_type, media in content.items():
|
|
lines.append(f" <p>Content type: <code>{xml_escape(str(content_type))}</code></p>")
|
|
schema = media.get("schema") if isinstance(media, dict) else None
|
|
if isinstance(schema, dict):
|
|
lines.append(" <code-block lang=\"json\">")
|
|
lines.append(pretty_json(schema))
|
|
lines.append(" </code-block>")
|
|
lines.append(" </chapter>")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def render_responses(responses: dict) -> str:
|
|
if not isinstance(responses, dict) or not responses:
|
|
return ""
|
|
lines = [
|
|
" <chapter title=\"Responses\" id=\"responses\">",
|
|
" <table>",
|
|
" <tr><td>Status</td><td>Description</td><td>Content Types</td></tr>",
|
|
]
|
|
for status_code, response in responses.items():
|
|
if not isinstance(response, dict):
|
|
continue
|
|
description = xml_escape(str(response.get("description", "")))
|
|
content = response.get("content", {})
|
|
if isinstance(content, dict):
|
|
content_types = ", ".join(sorted(str(k) for k in content.keys()))
|
|
else:
|
|
content_types = ""
|
|
lines.append(
|
|
f" <tr><td>{xml_escape(str(status_code))}</td><td>{description}</td><td>{xml_escape(content_types)}</td></tr>"
|
|
)
|
|
lines.append(" </table>")
|
|
|
|
for status_code, response in responses.items():
|
|
if not isinstance(response, dict):
|
|
continue
|
|
content = response.get("content", {})
|
|
if not isinstance(content, dict):
|
|
continue
|
|
for content_type, media in content.items():
|
|
schema = media.get("schema") if isinstance(media, dict) else None
|
|
if not isinstance(schema, dict):
|
|
continue
|
|
lines.append(
|
|
f" <p>Schema for response <code>{xml_escape(str(status_code))}</code> (<code>{xml_escape(str(content_type))}</code>):</p>"
|
|
)
|
|
lines.append(" <code-block lang=\"json\">")
|
|
lines.append(pretty_json(schema))
|
|
lines.append(" </code-block>")
|
|
lines.append(" </chapter>")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def render_security(security: List[dict]) -> str:
|
|
if not security:
|
|
return " <chapter title=\"Authentication\" id=\"authentication\"><p>No authentication required.</p></chapter>\n"
|
|
lines = [
|
|
" <chapter title=\"Authentication\" id=\"authentication\">",
|
|
" <p>Security requirements:</p>",
|
|
" <table>",
|
|
" <tr><td>Scheme</td><td>Scopes</td></tr>",
|
|
]
|
|
for req in security:
|
|
if not isinstance(req, dict):
|
|
continue
|
|
for scheme, scopes in req.items():
|
|
if isinstance(scopes, list):
|
|
scope_text = ", ".join(str(s) for s in scopes) if scopes else "-"
|
|
else:
|
|
scope_text = "-"
|
|
lines.append(
|
|
f" <tr><td>{xml_escape(str(scheme))}</td><td>{xml_escape(scope_text)}</td></tr>"
|
|
)
|
|
lines.extend([" </table>", " </chapter>"])
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def render_operation_topic(operation: Operation) -> str:
|
|
title = html.escape(operation.title, quote=True)
|
|
endpoint = xml_escape(operation.path)
|
|
method = operation.method.upper()
|
|
topic_id = html.escape(operation.topic_id, quote=True)
|
|
description = xml_escape(operation.description)
|
|
operation_id = xml_escape(operation.operation_id)
|
|
|
|
parameter_block = render_parameters_table(operation.parameters)
|
|
request_block = render_request_body(operation.request_body)
|
|
response_block = render_responses(operation.responses)
|
|
security_block = render_security(operation.security)
|
|
|
|
return (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<!DOCTYPE topic\n'
|
|
' SYSTEM "https://resources.jetbrains.com/writerside/1.0/xhtml-entities.dtd">\n'
|
|
'<topic xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n'
|
|
' xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd"\n'
|
|
f' title="{title}" id="{topic_id}">\n'
|
|
f"\n <!-- {AUTOGEN_NOTE} -->\n"
|
|
" <p>This endpoint documentation is generated directly from <code>openapi.yaml</code>.</p>\n"
|
|
" <chapter title=\"Endpoint\" id=\"endpoint\">\n"
|
|
f" <code-block lang=\"http\">{method} {endpoint}</code-block>\n"
|
|
" </chapter>\n"
|
|
" <chapter title=\"Operation\" id=\"operation\">\n"
|
|
f" <p>Operation ID: <code>{operation_id}</code></p>\n"
|
|
f" <p>{description}</p>\n"
|
|
" </chapter>\n"
|
|
f"{security_block}"
|
|
f"{parameter_block}"
|
|
f"{request_block}"
|
|
f"{response_block}"
|
|
"</topic>\n"
|
|
)
|
|
|
|
|
|
def render_tag_topic(tag: str, topic_id: str) -> str:
|
|
title = html.escape(tag, quote=True)
|
|
safe_id = html.escape(topic_id, quote=True)
|
|
return (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<!DOCTYPE topic\n'
|
|
' SYSTEM "https://resources.jetbrains.com/writerside/1.0/xhtml-entities.dtd">\n'
|
|
'<topic xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n'
|
|
' xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd"\n'
|
|
f' title="{title}" id="{safe_id}">\n'
|
|
f"\n <!-- {AUTOGEN_NOTE} -->\n"
|
|
" <p>Endpoints in this section are generated from <code>openapi.yaml</code>.</p>\n"
|
|
"</topic>\n"
|
|
)
|
|
|
|
|
|
def render_tag_page_topic(tag: str, topic_id: str, page_number: int, total_pages: int) -> str:
|
|
title = html.escape(f"{tag} - Page {page_number} of {total_pages}", quote=True)
|
|
safe_id = html.escape(topic_id, quote=True)
|
|
return (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<!DOCTYPE topic\n'
|
|
' SYSTEM "https://resources.jetbrains.com/writerside/1.0/xhtml-entities.dtd">\n'
|
|
'<topic xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n'
|
|
' xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd"\n'
|
|
f' title="{title}" id="{safe_id}">\n'
|
|
f"\n <!-- {AUTOGEN_NOTE} -->\n"
|
|
" <p>This page groups endpoint topics for this object type.</p>\n"
|
|
"</topic>\n"
|
|
)
|
|
|
|
|
|
def render_module_topic(module_name: str, module_description: str, topic_id: str) -> str:
|
|
title = html.escape(module_name, quote=True)
|
|
safe_id = html.escape(topic_id, quote=True)
|
|
description = xml_escape(module_description)
|
|
return (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<!DOCTYPE topic\n'
|
|
' SYSTEM "https://resources.jetbrains.com/writerside/1.0/xhtml-entities.dtd">\n'
|
|
'<topic xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n'
|
|
' xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd"\n'
|
|
f' title="{title}" id="{safe_id}">\n'
|
|
f"\n <!-- {AUTOGEN_NOTE} -->\n"
|
|
f" <p>{description}</p>\n"
|
|
"</topic>\n"
|
|
)
|
|
|
|
|
|
def render_modules_index_topic(topic_id: str, modules: List[Tuple[str, str]]) -> str:
|
|
safe_id = html.escape(topic_id, quote=True)
|
|
rows = [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
"<!DOCTYPE topic",
|
|
' SYSTEM "https://resources.jetbrains.com/writerside/1.0/xhtml-entities.dtd">',
|
|
'<topic xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
|
|
' xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd"',
|
|
f' title="Modules" id="{safe_id}">',
|
|
"",
|
|
f" <!-- {AUTOGEN_NOTE} -->",
|
|
" <p>Module integrations sorted by module name.</p>",
|
|
" <chapter title=\"Module Catalog\" id=\"module-catalog\">",
|
|
" <table>",
|
|
" <tr><td>Module</td><td>Description</td></tr>",
|
|
]
|
|
for module_name, module_description in modules:
|
|
rows.append(
|
|
f" <tr><td>{xml_escape(module_name)}</td><td>{xml_escape(module_description)}</td></tr>"
|
|
)
|
|
rows.extend([" </table>", " </chapter>", "</topic>", ""])
|
|
return "\n".join(rows)
|
|
|
|
|
|
def render_config_index_topic(topic_id: str, modules: List[Tuple[str, str]]) -> str:
|
|
safe_id = html.escape(topic_id, quote=True)
|
|
rows = [
|
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
|
"<!DOCTYPE topic",
|
|
' SYSTEM "https://resources.jetbrains.com/writerside/1.0/xhtml-entities.dtd">',
|
|
'<topic xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"',
|
|
' xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd"',
|
|
f' title="Config" id="{safe_id}">',
|
|
"",
|
|
f" <!-- {AUTOGEN_NOTE} -->",
|
|
" <p>Configuration endpoints grouped by module name.</p>",
|
|
" <chapter title=\"Configuration Module Catalog\" id=\"config-module-catalog\">",
|
|
" <table>",
|
|
" <tr><td>Module</td><td>Description</td></tr>",
|
|
]
|
|
for module_name, module_description in modules:
|
|
rows.append(
|
|
f" <tr><td>{xml_escape(module_name)}</td><td>{xml_escape(module_description)}</td></tr>"
|
|
)
|
|
rows.extend([" </table>", " </chapter>", "</topic>", ""])
|
|
return "\n".join(rows)
|
|
|
|
|
|
def infer_module(path: str) -> Tuple[str, str, str]:
|
|
segments = [segment for segment in path.split("/") if segment]
|
|
module_key = "misc"
|
|
if segments:
|
|
if segments[0] == "modules" and len(segments) > 1:
|
|
module_key = segments[1]
|
|
elif segments[0] in {"economic", "cvr"}:
|
|
module_key = segments[0]
|
|
|
|
if module_key in MODULE_DESCRIPTIONS:
|
|
module_name, module_description = MODULE_DESCRIPTIONS[module_key]
|
|
else:
|
|
module_name = module_key.replace("-", " ").title()
|
|
module_description = "Integration module endpoints."
|
|
return module_key, module_name, module_description
|
|
|
|
|
|
def infer_config_module(path: str) -> Tuple[str, str, str]:
|
|
segments = [segment for segment in path.split("/") if segment]
|
|
config_key = segments[0] if segments else "misc"
|
|
if config_key in CONFIG_DESCRIPTIONS:
|
|
config_name, config_description = CONFIG_DESCRIPTIONS[config_key]
|
|
else:
|
|
config_name = config_key.replace("-", " ").title()
|
|
config_description = "Configuration endpoints for this module."
|
|
return config_key, config_name, config_description
|
|
|
|
|
|
def render_api_reference_topic() -> str:
|
|
return (
|
|
'<?xml version="1.0" encoding="UTF-8"?>\n'
|
|
'<!DOCTYPE topic\n'
|
|
' SYSTEM "https://resources.jetbrains.com/writerside/1.0/xhtml-entities.dtd">\n'
|
|
'<topic xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"\n'
|
|
' xsi:noNamespaceSchemaLocation="https://resources.jetbrains.com/writerside/1.0/topic.v2.xsd"\n'
|
|
' title="API Reference" id="API-Reference">\n'
|
|
f"\n <!-- {AUTOGEN_NOTE} -->\n"
|
|
" <p>Comprehensive API reference generated from the repository root <code>openapi.yaml</code>.</p>\n"
|
|
" <p>The edge broker's <code>/api/health</code> response additionally exposes a "
|
|
"<code>lastActivityAt</code> field (ISO 8601 timestamp). It reports the most recent "
|
|
"successful HTTP request handled by the broker container and defaults to the container's "
|
|
"start time when no request has been processed yet.</p>\n"
|
|
"</topic>\n"
|
|
)
|
|
|
|
|
|
def write_if_changed(path: Path, content: str) -> bool:
|
|
if path.exists() and path.read_text(encoding="utf-8") == content:
|
|
return False
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(content, encoding="utf-8")
|
|
return True
|
|
|
|
|
|
def parse_openapi(openapi_path: Path) -> Tuple[List[str], List[Operation], Dict[str, List[Operation]]]:
|
|
doc = yaml.safe_load(openapi_path.read_text(encoding="utf-8"))
|
|
global_security = doc.get("security", [])
|
|
tags_section = doc.get("tags", [])
|
|
declared_tags = []
|
|
for tag_item in tags_section:
|
|
if isinstance(tag_item, dict) and isinstance(tag_item.get("name"), str):
|
|
declared_tags.append(tag_item["name"])
|
|
|
|
declared_tag_set = set(declared_tags)
|
|
paths = doc.get("paths", {})
|
|
if not isinstance(paths, dict):
|
|
raise ValueError("Invalid OpenAPI: top-level 'paths' must be an object.")
|
|
|
|
operations: List[Operation] = []
|
|
grouped: Dict[str, List[Operation]] = {}
|
|
seen_topic_ids: Dict[str, str] = {}
|
|
unknown_tags: List[str] = []
|
|
|
|
for api_path, path_item in paths.items():
|
|
if not isinstance(path_item, dict):
|
|
continue
|
|
path_parameters = path_item.get("parameters", [])
|
|
for method in HTTP_METHODS:
|
|
operation = path_item.get(method)
|
|
if not isinstance(operation, dict):
|
|
continue
|
|
|
|
operation_tags = operation.get("tags")
|
|
if isinstance(operation_tags, list) and operation_tags:
|
|
tags = [str(tag) for tag in operation_tags]
|
|
else:
|
|
tags = ["Misc"]
|
|
|
|
for tag in tags:
|
|
if tag != "Misc" and tag not in declared_tag_set:
|
|
unknown_tags.append(f"{method.upper()} {api_path} => '{tag}'")
|
|
|
|
primary_tag = tags[0]
|
|
operation_id = operation.get("operationId")
|
|
if not isinstance(operation_id, str) or not operation_id.strip():
|
|
operation_id = f"{method}_{api_path}"
|
|
|
|
summary = operation.get("summary")
|
|
if not isinstance(summary, str) or not summary.strip():
|
|
summary = f"{method.upper()} {api_path}"
|
|
description = operation.get("description")
|
|
if not isinstance(description, str) or not description.strip():
|
|
description = summary
|
|
|
|
topic_id = build_operation_topic_id(operation_id, method, api_path)
|
|
existing = seen_topic_ids.get(topic_id)
|
|
operation_ref = f"{method.upper()} {api_path}"
|
|
if existing and existing != operation_ref:
|
|
raise ValueError(
|
|
f"Duplicate topic id '{topic_id}' generated for '{existing}' and '{operation_ref}'."
|
|
)
|
|
seen_topic_ids[topic_id] = operation_ref
|
|
|
|
topic_file = f"{topic_id}.topic"
|
|
operation_parameters = operation.get("parameters", [])
|
|
merged_parameters: List[dict] = []
|
|
seen_params = set()
|
|
for param_source in [path_parameters, operation_parameters]:
|
|
if not isinstance(param_source, list):
|
|
continue
|
|
for param in param_source:
|
|
if not isinstance(param, dict):
|
|
continue
|
|
key = (str(param.get("in", "")), str(param.get("name", "")))
|
|
if key in seen_params:
|
|
merged_parameters = [
|
|
p
|
|
for p in merged_parameters
|
|
if (str(p.get("in", "")), str(p.get("name", ""))) != key
|
|
]
|
|
seen_params.add(key)
|
|
merged_parameters.append(param)
|
|
|
|
module_key, module_name, module_description = infer_module(api_path)
|
|
config_key, config_name, config_description = infer_config_module(api_path)
|
|
op = Operation(
|
|
method=method,
|
|
path=api_path,
|
|
title=summary,
|
|
topic_id=topic_id,
|
|
topic_file=topic_file,
|
|
primary_tag=primary_tag,
|
|
operation_id=operation_id,
|
|
description=description,
|
|
parameters=merged_parameters,
|
|
request_body=operation.get("requestBody", {}),
|
|
responses=operation.get("responses", {}),
|
|
security=operation.get("security", global_security),
|
|
module_key=module_key,
|
|
module_name=module_name,
|
|
module_description=module_description,
|
|
config_key=config_key,
|
|
config_name=config_name,
|
|
config_description=config_description,
|
|
)
|
|
operations.append(op)
|
|
grouped.setdefault(primary_tag, []).append(op)
|
|
|
|
if unknown_tags:
|
|
raise ValueError(
|
|
"Invalid tag mapping: operation tags not declared in top-level OpenAPI tags:\n"
|
|
+ "\n".join(sorted(unknown_tags))
|
|
)
|
|
|
|
for tag_ops in grouped.values():
|
|
tag_ops.sort(key=lambda op: (op.path, op.method))
|
|
|
|
ordered_tags = [tag for tag in declared_tags if tag in grouped]
|
|
if "Misc" in grouped and "Misc" not in ordered_tags:
|
|
ordered_tags.append("Misc")
|
|
for extra in sorted(tag for tag in grouped if tag not in ordered_tags):
|
|
ordered_tags.append(extra)
|
|
|
|
return ordered_tags, operations, grouped
|
|
|
|
|
|
def render_generated_toc(ordered_tags: List[str], grouped: Dict[str, List[Operation]]) -> str:
|
|
lines: List[str] = []
|
|
lines.append(' <toc-element topic="API-Reference.topic">')
|
|
for tag in ordered_tags:
|
|
tag_topic_file = f"Tag_{slugify(tag)}.topic"
|
|
lines.append(f' <toc-element topic="{tag_topic_file}">')
|
|
tag_ops = grouped.get(tag, [])
|
|
if tag == "Modules":
|
|
module_groups: Dict[str, List[Operation]] = {}
|
|
module_meta: Dict[str, Tuple[str, str]] = {}
|
|
for op in tag_ops:
|
|
module_groups.setdefault(op.module_key, []).append(op)
|
|
module_meta[op.module_key] = (op.module_name, op.module_description)
|
|
|
|
for module_key in sorted(module_groups, key=lambda key: module_meta[key][0].lower()):
|
|
module_name, _ = module_meta[module_key]
|
|
module_slug = safe_token(module_name)
|
|
module_topic = f"modules_module_{module_slug}.topic"
|
|
lines.append(f' <toc-element topic="{module_topic}">')
|
|
module_ops = sorted(module_groups[module_key], key=lambda op: (op.module_name.lower(), op.title.lower(), op.path, op.method))
|
|
total_pages = max(1, (len(module_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE)
|
|
for page_index in range(total_pages):
|
|
start = page_index * OPS_PER_PAGE
|
|
end = start + OPS_PER_PAGE
|
|
page_ops = module_ops[start:end]
|
|
page_number = page_index + 1
|
|
page_topic = f"modules_module_{module_slug}_page_{page_number}.topic"
|
|
lines.append(f' <toc-element topic="{page_topic}">')
|
|
for op in page_ops:
|
|
lines.append(f' <toc-element topic="{op.topic_file}"/>')
|
|
lines.append(" </toc-element>")
|
|
lines.append(" </toc-element>")
|
|
lines.append(" </toc-element>")
|
|
continue
|
|
|
|
if tag == "Config":
|
|
config_groups: Dict[str, List[Operation]] = {}
|
|
config_meta: Dict[str, Tuple[str, str]] = {}
|
|
for op in tag_ops:
|
|
config_groups.setdefault(op.config_key, []).append(op)
|
|
config_meta[op.config_key] = (op.config_name, op.config_description)
|
|
|
|
for config_key in sorted(config_groups, key=lambda key: config_meta[key][0].lower()):
|
|
config_name, _ = config_meta[config_key]
|
|
config_slug = safe_token(config_name)
|
|
config_topic = f"config_module_{config_slug}.topic"
|
|
lines.append(f' <toc-element topic="{config_topic}">')
|
|
config_ops = sorted(config_groups[config_key], key=lambda op: (op.title.lower(), op.path, op.method))
|
|
total_pages = max(1, (len(config_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE)
|
|
for page_index in range(total_pages):
|
|
start = page_index * OPS_PER_PAGE
|
|
end = start + OPS_PER_PAGE
|
|
page_ops = config_ops[start:end]
|
|
page_number = page_index + 1
|
|
page_topic = f"config_module_{config_slug}_page_{page_number}.topic"
|
|
lines.append(f' <toc-element topic="{page_topic}">')
|
|
for op in page_ops:
|
|
lines.append(f' <toc-element topic="{op.topic_file}"/>')
|
|
lines.append(" </toc-element>")
|
|
lines.append(" </toc-element>")
|
|
lines.append(" </toc-element>")
|
|
continue
|
|
|
|
total_pages = max(1, (len(tag_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE)
|
|
for page_index in range(total_pages):
|
|
start = page_index * OPS_PER_PAGE
|
|
end = start + OPS_PER_PAGE
|
|
page_ops = tag_ops[start:end]
|
|
page_number = page_index + 1
|
|
page_topic = f"Tag_{slugify(tag)}_Page_{page_number}.topic"
|
|
lines.append(f' <toc-element topic="{page_topic}">')
|
|
for op in page_ops:
|
|
lines.append(f' <toc-element topic="{op.topic_file}"/>')
|
|
lines.append(" </toc-element>")
|
|
lines.append(" </toc-element>")
|
|
lines.append(" </toc-element>")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def apply_toc_block(ctw_tree_content: str, generated_toc: str) -> str:
|
|
block = f" {TOC_START_MARKER}\n{generated_toc.rstrip()}\n {TOC_END_MARKER}\n"
|
|
marker_pattern = re.compile(
|
|
rf"(?ms)^[ \t]*{re.escape(TOC_START_MARKER)}\r?\n.*?^[ \t]*{re.escape(TOC_END_MARKER)}\r?\n?"
|
|
)
|
|
if marker_pattern.search(ctw_tree_content):
|
|
return marker_pattern.sub(block, ctw_tree_content, count=1)
|
|
|
|
close_tag = "</instance-profile>"
|
|
close_index = ctw_tree_content.rfind(close_tag)
|
|
if close_index == -1:
|
|
raise ValueError("Unable to update ctw.tree: missing </instance-profile>.")
|
|
left = ctw_tree_content[:close_index].rstrip() + "\n\n"
|
|
right = ctw_tree_content[close_index:]
|
|
return left + block + right
|
|
|
|
|
|
def build_expected_outputs(root: Path) -> Dict[Path, str]:
|
|
documentation_dir = root / "documentation"
|
|
openapi_path = root / "openapi.yaml"
|
|
ctw_tree_path = documentation_dir / "ctw.tree"
|
|
api_reference_topic_path = documentation_dir / "topics" / "API-Reference.topic"
|
|
generated_dir = documentation_dir / "topics" / "generated"
|
|
generated_toc_path = documentation_dir / "generated" / "api-reference.toc.xml"
|
|
|
|
openapi_doc = yaml.safe_load(openapi_path.read_text(encoding="utf-8"))
|
|
ordered_tags, operations, grouped = parse_openapi(openapi_path)
|
|
generated_toc = render_generated_toc(ordered_tags, grouped)
|
|
|
|
expected: Dict[Path, str] = {}
|
|
expected[generated_toc_path] = generated_toc
|
|
expected[documentation_dir / "generated" / "openapi.json"] = json.dumps(
|
|
openapi_doc, indent=2, ensure_ascii=False, sort_keys=False
|
|
) + "\n"
|
|
expected[api_reference_topic_path] = render_api_reference_topic()
|
|
|
|
existing_tree = ctw_tree_path.read_text(encoding="utf-8")
|
|
expected[ctw_tree_path] = apply_toc_block(existing_tree, generated_toc)
|
|
|
|
for tag in ordered_tags:
|
|
tag_slug = slugify(tag)
|
|
tag_topic_file = generated_dir / f"Tag_{tag_slug}.topic"
|
|
tag_ops = grouped[tag]
|
|
if tag == "Modules":
|
|
module_groups: Dict[str, List[Operation]] = {}
|
|
module_meta: Dict[str, Tuple[str, str]] = {}
|
|
for op in tag_ops:
|
|
module_groups.setdefault(op.module_key, []).append(op)
|
|
module_meta[op.module_key] = (op.module_name, op.module_description)
|
|
|
|
module_list = [
|
|
module_meta[module_key]
|
|
for module_key in sorted(module_groups, key=lambda key: module_meta[key][0].lower())
|
|
]
|
|
expected[tag_topic_file] = render_modules_index_topic(f"Tag_{tag_slug}", module_list)
|
|
|
|
for module_key in sorted(module_groups, key=lambda key: module_meta[key][0].lower()):
|
|
module_name, module_description = module_meta[module_key]
|
|
module_slug = safe_token(module_name)
|
|
module_topic_file = generated_dir / f"modules_module_{module_slug}.topic"
|
|
expected[module_topic_file] = render_module_topic(
|
|
module_name=module_name,
|
|
module_description=module_description,
|
|
topic_id=f"modules_module_{module_slug}",
|
|
)
|
|
|
|
module_ops = sorted(module_groups[module_key], key=lambda op: (op.module_name.lower(), op.title.lower(), op.path, op.method))
|
|
total_pages = max(1, (len(module_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE)
|
|
for page_index in range(total_pages):
|
|
page_number = page_index + 1
|
|
page_topic_file = generated_dir / f"modules_module_{module_slug}_page_{page_number}.topic"
|
|
expected[page_topic_file] = render_tag_page_topic(
|
|
tag=module_name,
|
|
topic_id=f"modules_module_{module_slug}_page_{page_number}",
|
|
page_number=page_number,
|
|
total_pages=total_pages,
|
|
)
|
|
|
|
for op in module_ops:
|
|
expected[generated_dir / op.topic_file] = render_operation_topic(op)
|
|
continue
|
|
|
|
if tag == "Config":
|
|
config_groups: Dict[str, List[Operation]] = {}
|
|
config_meta: Dict[str, Tuple[str, str]] = {}
|
|
for op in tag_ops:
|
|
config_groups.setdefault(op.config_key, []).append(op)
|
|
config_meta[op.config_key] = (op.config_name, op.config_description)
|
|
|
|
config_list = [
|
|
config_meta[config_key]
|
|
for config_key in sorted(config_groups, key=lambda key: config_meta[key][0].lower())
|
|
]
|
|
expected[tag_topic_file] = render_config_index_topic(f"Tag_{tag_slug}", config_list)
|
|
|
|
for config_key in sorted(config_groups, key=lambda key: config_meta[key][0].lower()):
|
|
config_name, config_description = config_meta[config_key]
|
|
config_slug = safe_token(config_name)
|
|
config_topic_file = generated_dir / f"config_module_{config_slug}.topic"
|
|
expected[config_topic_file] = render_module_topic(
|
|
module_name=config_name,
|
|
module_description=config_description,
|
|
topic_id=f"config_module_{config_slug}",
|
|
)
|
|
|
|
config_ops = sorted(config_groups[config_key], key=lambda op: (op.title.lower(), op.path, op.method))
|
|
total_pages = max(1, (len(config_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE)
|
|
for page_index in range(total_pages):
|
|
page_number = page_index + 1
|
|
page_topic_file = generated_dir / f"config_module_{config_slug}_page_{page_number}.topic"
|
|
expected[page_topic_file] = render_tag_page_topic(
|
|
tag=config_name,
|
|
topic_id=f"config_module_{config_slug}_page_{page_number}",
|
|
page_number=page_number,
|
|
total_pages=total_pages,
|
|
)
|
|
|
|
for op in config_ops:
|
|
expected[generated_dir / op.topic_file] = render_operation_topic(op)
|
|
continue
|
|
|
|
expected[tag_topic_file] = render_tag_topic(tag, f"Tag_{tag_slug}")
|
|
|
|
total_pages = max(1, (len(tag_ops) + OPS_PER_PAGE - 1) // OPS_PER_PAGE)
|
|
for page_index in range(total_pages):
|
|
page_number = page_index + 1
|
|
page_topic_file = generated_dir / f"Tag_{tag_slug}_Page_{page_number}.topic"
|
|
expected[page_topic_file] = render_tag_page_topic(
|
|
tag=tag,
|
|
topic_id=f"Tag_{tag_slug}_Page_{page_number}",
|
|
page_number=page_number,
|
|
total_pages=total_pages,
|
|
)
|
|
|
|
for op in tag_ops:
|
|
expected[generated_dir / op.topic_file] = render_operation_topic(op)
|
|
|
|
operation_topic_names = {f"{op.topic_id}.topic" for op in operations}
|
|
operation_topics = [
|
|
path for path in expected
|
|
if path.parent == generated_dir and path.name in operation_topic_names
|
|
]
|
|
if len(operation_topics) != len(operations):
|
|
raise ValueError(
|
|
f"Coverage mismatch: expected {len(operations)} operation topics, built {len(operation_topics)} files."
|
|
)
|
|
|
|
return expected
|
|
|
|
|
|
def generate(root: Path) -> None:
|
|
expected = build_expected_outputs(root)
|
|
generated_dir = root / "documentation" / "topics" / "generated"
|
|
generated_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
expected_generated_paths = {
|
|
path for path in expected if path.parent == generated_dir and path.suffix == ".topic"
|
|
}
|
|
for existing_file in generated_dir.glob("*.topic"):
|
|
if existing_file not in expected_generated_paths:
|
|
existing_file.unlink()
|
|
|
|
changed_files = 0
|
|
for path, content in expected.items():
|
|
if write_if_changed(path, content):
|
|
changed_files += 1
|
|
|
|
print(f"Generated documentation artifacts. Updated files: {changed_files}")
|
|
|
|
|
|
def check(root: Path) -> None:
|
|
expected = build_expected_outputs(root)
|
|
generated_dir = root / "documentation" / "topics" / "generated"
|
|
|
|
failures: List[str] = []
|
|
for path, content in expected.items():
|
|
if not path.exists():
|
|
failures.append(f"Missing file: {path}")
|
|
continue
|
|
current = path.read_text(encoding="utf-8")
|
|
if current != content:
|
|
failures.append(f"Outdated file: {path}")
|
|
|
|
if generated_dir.exists():
|
|
expected_generated_paths = {
|
|
path for path in expected if path.parent == generated_dir and path.suffix == ".topic"
|
|
}
|
|
for existing_file in generated_dir.glob("*.topic"):
|
|
if existing_file not in expected_generated_paths:
|
|
failures.append(f"Stale generated topic: {existing_file}")
|
|
|
|
if failures:
|
|
print("Documentation check failed:")
|
|
for failure in failures:
|
|
print(f"- {failure}")
|
|
print("Run: python scripts/generate_writerside_openapi_docs.py generate")
|
|
raise SystemExit(1)
|
|
|
|
print("Documentation check passed.")
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser(description="Generate Writerside API docs from openapi.yaml.")
|
|
parser.add_argument("command", choices=["generate", "check"], help="Generation mode")
|
|
args = parser.parse_args()
|
|
|
|
root = Path(__file__).resolve().parents[1]
|
|
try:
|
|
if args.command == "generate":
|
|
generate(root)
|
|
else:
|
|
check(root)
|
|
except ValueError as exc:
|
|
print(str(exc), file=sys.stderr)
|
|
raise SystemExit(1)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|