Adds the `lastActivityAt` field to the response body of the `/api/health` endpoint exposed by the edge broker. The field reports the most recent successful request timestamp from the container, defaulting to the container's start time when no requests have been served yet. The field is also surfaced on `broker.state` (alongside a new `containerStartedAt`) so callers can observe the activity timestamp without performing an HTTP round-trip. The Writerside `API-Reference.topic` and its generator are updated to document the new field. Resolves TRU-6 (AUT-2). ## Example request ```sh curl -s http://edge-broker:8080/api/health ``` ```json { "ok": true, "service": "edge-broker", "auth_mode": "manager", "manager_url_configured": true, "shared_secret_configured": true, "agents_connected": 0, "lastActivityAt": "2026-08-15T19:15:34.898Z" } ``` ## Documentation The diff for the writerside topic that documents the new field lives in this PR — see [`documentation/topics/API-Reference.topic`](https://github.com/copenhagentruckwash/api/blob/10f28d6/documentation/topics/API-Reference.topic) (vs. [the previous version at `origin/develop`](https://github.com/copenhagentruckwash/api/blob/cdf8541/documentation/topics/API-Reference.topic)) in the [PR "Files changed" view](https://github.com/copenhagentruckwash/api/pull/373/files). The same paragraph is reproduced by `scripts/generate_writerside_openapi_docs.py` so future regenerations preserve it. **What consumers need to re-read.** `API-Reference.topic` adds a paragraph documenting the new `lastActivityAt` ISO 8601 timestamp on the edge broker's `/api/health` response. Consumers that previously inferred broker activity from indirect signals (e.g. comparing `agents_connected` across polls or assuming a fresh process meant a fresh state) should now read `lastActivityAt` directly: it is the timestamp of the most recent successful HTTP request handled by the broker container, and defaults to `containerStartedAt` until the first request lands. No request or response shape changes; the field is purely additive. ## Tests `node --test services/edge-broker/test/broker.test.mjs` covers both the shape of the new field on `/api/health` and the fact that `lastActivityAt` advances on every successful request after `containerStartedAt`. All 21 broker tests pass locally. --- _This PR description was generated by an OpenHands AI agent on behalf of jepp9350._ 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()
|