Files
api/scripts/generate_writerside_openapi_docs.py
T
f4ba70623e feat(edge-broker): expose lastActivityAt on /api/health (AUT-2/TRU-6) (#373)
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>
2026-08-15 21:19:37 +02:00

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()