Coverage for diagrams / api_specs / generate.py: 100.00%

774 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-10-28 16:19 +0000

1#!/usr/bin/env python3 

2"""Render the API spec sheets: one Markdown page per GCO HTTP service. 

3 

4The source of truth is what FastAPI itself generates. ``scripts/generate_openapi.py`` 

5asks each application for ``app.openapi()`` and commits the result under 

6``docs/openapi/<service>.json`` — the document behind the service's automatic 

7Swagger ``/docs`` page — and ``tests/test_api_docs_coverage.py`` fails when 

8those documents drift from the live routes. This catalogue turns each document 

9into two human-facing renderings: 

10 

11* ``diagrams/api_specs/<service>.md`` — a Markdown spec sheet: the endpoint 

12 table, every operation's parameters, request body and responses, and every 

13 component schema as a property table. It renders on GitHub and, staged into 

14 the wiki's source tree by ``scripts/build_wiki.py``, as a page of the 

15 Zensical-built wiki, so the API reference is embedded in the documentation 

16 without a hand-maintained copy. 

17* The Swagger UI console FastAPI serves at ``/docs`` — ``--swagger-ui-dir`` 

18 writes FastAPI's own ``get_swagger_ui_html`` page per service, pointing at a 

19 copy of the document and at a self-hosted ``swagger-ui-dist`` (the pinned npm 

20 dependency), so the Pages site keeps making zero third-party requests. That 

21 tree is built at deploy time by ``pages.yml`` and is not committed. 

22 

23Everything is a deterministic function of the committed documents, so 

24freshness is verified by regenerating in memory and comparing (``--check``); 

25unlike the code flowcharts there is no rasteriser and nothing to stamp. The 

26render path is stdlib-only, which keeps ``python -S diagrams/generate.py 

27--check`` working without site-packages; FastAPI is imported only to build the 

28Swagger tree. 

29 

30Usage:: 

31 

32 python diagrams/api_specs/generate.py # rewrite the sheets + README 

33 python diagrams/api_specs/generate.py --check # exit 1 if any committed sheet is stale 

34 python diagrams/api_specs/generate.py --swagger-ui-dir site/swagger \\ 

35 --swagger-assets node_modules/swagger-ui-dist # build the interactive consoles 

36""" 

37 

38from __future__ import annotations 

39 

40import argparse 

41import json 

42import re 

43import shutil 

44import sys 

45import tomllib 

46from pathlib import Path 

47from typing import Any 

48 

49#: The checkout this generator renders for; tests point the functions at a 

50#: temporary root instead, which is why every entry point takes one. 

51REPO_ROOT = Path(__file__).resolve().parents[2] 

52 

53#: Path prefix of the Swagger UI consoles on the Pages site (``/swagger/<service>/``). 

54#: Distinct from the wiki's ``api/`` prefix, where the Markdown sheets live. 

55SWAGGER_SITE_PREFIX = "swagger" 

56 

57#: The regeneration command every generated file names. 

58REGENERATION_COMMAND = "python diagrams/generate.py --api-only" 

59 

60#: Files ``--swagger-ui-dir`` requires from the ``swagger-ui-dist`` package. 

61SWAGGER_ASSET_FILES: tuple[str, ...] = ( 

62 "swagger-ui-bundle.js", 

63 "swagger-ui.css", 

64 "favicon-32x32.png", 

65) 

66#: Redistribution notices copied alongside the assets when the package ships them. 

67SWAGGER_NOTICE_FILES: tuple[str, ...] = ("LICENSE", "NOTICE", "swagger-ui-bundle.js.LICENSE.txt") 

68 

69#: HTTP methods in the order a reader expects, not alphabetically. 

70_METHOD_ORDER: tuple[str, ...] = ( 

71 "get", 

72 "post", 

73 "put", 

74 "patch", 

75 "delete", 

76 "head", 

77 "options", 

78 "trace", 

79) 

80 

81#: The wiki's configuration, the one place the site and repository URLs live. 

82SITE_CONFIG_FILE = "zensical.toml" 

83_REF_PREFIX = "#/components/schemas/" 

84 

85#: The interaction diagram rendered next to the sheets. 

86TOPOLOGY_FILE = "api-topology.svg" 

87 

88#: How each kind of document came to be, keyed by ``x-gco-source.kind`` (a 

89#: FastAPI export carries no source block). ``noun`` labels the sheet header, 

90#: ``origin`` finishes the sentence "the ... this sheet is rendered from". 

91_KINDS: dict[str, tuple[str, str]] = { 

92 "fastapi": ( 

93 "Service", 

94 "FastAPI `app.openapi()` export (`scripts/generate_openapi.py`) this sheet is rendered from", 

95 ), 

96 "aws-api-gateway": ( 

97 "API Gateway", 

98 "document `scripts/generate_api_gateway_openapi.py` reads out of the synthesized " 

99 "CDK stack; this sheet is rendered from it", 

100 ), 

101 "cluster-gateway": ( 

102 "Gateway", 

103 "document `scripts/generate_cluster_gateway_openapi.py` composes from the HTTPRoute " 

104 "manifest and the service documents; this sheet is rendered from it", 

105 ), 

106} 

107#: Reading order of the catalogue index and the diagram: front doors first. 

108_KIND_ORDER: tuple[str, ...] = ("aws-api-gateway", "cluster-gateway", "fastapi") 

109 

110 

111class SpecSheetError(RuntimeError): 

112 """A document or the site configuration cannot be rendered faithfully.""" 

113 

114 

115def _dict(value: Any) -> dict[str, Any]: 

116 """A JSON object as itself, anything else as an empty mapping.""" 

117 return value if isinstance(value, dict) else {} 

118 

119 

120# ─── inputs ────────────────────────────────────────────────────────────────── 

121 

122 

123def service_names(openapi_dir: Path) -> list[str]: 

124 """The services with a committed OpenAPI document, by filename stem.""" 

125 names = sorted(path.stem for path in openapi_dir.glob("*.json")) 

126 if not names: 

127 raise SpecSheetError(f"no OpenAPI documents under {openapi_dir}") 

128 return names 

129 

130 

131def load_document(service: str, openapi_dir: Path) -> dict[str, Any]: 

132 document = json.loads((openapi_dir / f"{service}.json").read_text(encoding="utf-8")) 

133 if not isinstance(document, dict) or not isinstance(document.get("paths"), dict): 

134 raise SpecSheetError(f"{service}: OpenAPI document has no paths object") 

135 return document 

136 

137 

138def document_kind(document: dict[str, Any]) -> str: 

139 """``fastapi``, ``aws-api-gateway`` or ``cluster-gateway`` — what produced the document.""" 

140 kind = _dict(document.get("x-gco-source")).get("kind") or "fastapi" 

141 if kind not in _KINDS: 

142 raise SpecSheetError(f"unknown x-gco-source.kind {kind!r}") 

143 return str(kind) 

144 

145 

146def catalogue_order(documents: dict[str, dict[str, Any]]) -> list[str]: 

147 """Document names, front doors first, then alphabetical within a kind.""" 

148 return sorted( 

149 documents, key=lambda name: (_KIND_ORDER.index(document_kind(documents[name])), name) 

150 ) 

151 

152 

153def read_site_config(zensical_toml: Path) -> tuple[str, str]: 

154 """``(site_url, repo_url)`` from zensical.toml — the one place both are declared. 

155 

156 Read with the stdlib ``tomllib`` so the render path needs nothing beyond the 

157 standard library, and so a fork's ``scripts/migrate_fork.py`` rewrite of 

158 zensical.toml is the only edit needed for the generated links to follow. 

159 """ 

160 try: 

161 with zensical_toml.open("rb") as handle: 

162 project = tomllib.load(handle).get("project") 

163 except tomllib.TOMLDecodeError as exc: 

164 raise SpecSheetError(f"{zensical_toml}: not valid TOML ({exc})") from exc 

165 site = project.get("site_url") if isinstance(project, dict) else None 

166 repo = project.get("repo_url") if isinstance(project, dict) else None 

167 if not isinstance(site, str) or not isinstance(repo, str) or not site or not repo: 

168 raise SpecSheetError( 

169 f"{zensical_toml}: [project] site_url and repo_url must both be declared" 

170 ) 

171 return site.rstrip("/") + "/", repo.rstrip("/") 

172 

173 

174# ─── Markdown primitives ───────────────────────────────────────────────────── 

175 

176 

177def slugify(heading: str) -> str: 

178 """GitHub-compatible heading anchor (python-markdown's toc agrees for ASCII). 

179 

180 Lower-case, code spans unwrapped, anything but letters, digits, spaces, 

181 hyphens and underscores dropped, spaces to hyphens. ``GET /api/v1/jobs/{id}`` 

182 becomes ``get-apiv1jobsid``. 

183 """ 

184 text = heading.replace("`", "").lower() 

185 text = re.sub(r"[^a-z0-9 _-]", "", text) 

186 return re.sub(r"\s+", "-", text.strip()) 

187 

188 

189def cell(text: object) -> str: 

190 """One table cell: whitespace collapsed, pipes escaped, empty shown as a dash.""" 

191 collapsed = " ".join(str(text).split()) if text is not None else "" 

192 return collapsed.replace("|", "\\|") or "—" 

193 

194 

195def code(text: str) -> str: 

196 return f"`{text}`" 

197 

198 

199def table(headers: list[str], rows: list[list[str]]) -> list[str]: 

200 """A Markdown table; every row is padded to the header width (MD056).""" 

201 width = len(headers) 

202 lines = ["| " + " | ".join(headers) + " |", "|" + "---|" * width] 

203 for row in rows: 

204 padded = [*row, *(["—"] * (width - len(row)))][:width] 

205 lines.append("| " + " | ".join(padded) + " |") 

206 return lines 

207 

208 

209def _aligned_commands(*entries: tuple[str, str]) -> list[str]: 

210 """Shell lines whose trailing ``# comment`` starts in one column. 

211 

212 The column is the longest command plus two spaces, so a renamed or added 

213 command re-aligns the whole block instead of overflowing a fixed width. 

214 """ 

215 width = max(len(command) for command, _comment in entries) 

216 return [f"{command:<{width}} # {comment}" for command, comment in entries] 

217 

218 

219# ─── schema rendering ──────────────────────────────────────────────────────── 

220 

221 

222def _ref_name(ref: str) -> str: 

223 if not ref.startswith(_REF_PREFIX): 

224 raise SpecSheetError(f"unsupported $ref outside components/schemas: {ref}") 

225 return ref[len(_REF_PREFIX) :] 

226 

227 

228def schema_link(name: str) -> str: 

229 return f"[{code(name)}](#{schema_anchor(name)})" 

230 

231 

232def schema_type(schema: Any) -> str: 

233 """A compact, human-readable type for one JSON Schema fragment. 

234 

235 Nullable unions (``anyOf`` with ``null``) read as ``string (nullable)``; 

236 references link to the schema's section; free-form objects and empty 

237 schemas are named as such rather than left blank. 

238 """ 

239 if not isinstance(schema, dict): 

240 return "any" 

241 if "$ref" in schema: 

242 return schema_link(_ref_name(str(schema["$ref"]))) 

243 for keyword, joiner in (("anyOf", " or "), ("oneOf", " or "), ("allOf", " and ")): 

244 variants = schema.get(keyword) 

245 if isinstance(variants, list) and variants: 

246 non_null = [ 

247 v for v in variants if not (isinstance(v, dict) and v.get("type") == "null") 

248 ] 

249 nullable = len(non_null) < len(variants) 

250 rendered = joiner.join(schema_type(v) for v in non_null) or "null" 

251 return f"{rendered} (nullable)" if nullable else rendered 

252 if "enum" in schema and isinstance(schema["enum"], list): 

253 values = ", ".join(code(json.dumps(v)) for v in schema["enum"]) 

254 return f"enum: {values}" 

255 if "const" in schema: 

256 return f"const {code(json.dumps(schema['const']))}" 

257 declared = schema.get("type") 

258 types = declared if isinstance(declared, list) else [declared] if declared else [] 

259 if not types: 

260 return ( 

261 "object (free-form)" 

262 if "additionalProperties" in schema or "properties" in schema 

263 else "any" 

264 ) 

265 parts: list[str] = [] 

266 for type_name in types: 

267 if type_name == "array": 

268 parts.append(f"array of {schema_type(schema.get('items', {}))}") 

269 elif type_name == "object": 

270 additional = schema.get("additionalProperties") 

271 if isinstance(additional, dict) and additional: 

272 parts.append(f"object of {schema_type(additional)}") 

273 elif additional is True or (additional == {} and "properties" not in schema): 

274 parts.append("object (free-form)") 

275 else: 

276 parts.append("object") 

277 elif type_name == "string" and schema.get("format"): 

278 parts.append(f"string ({schema['format']})") 

279 else: 

280 parts.append(str(type_name)) 

281 return " or ".join(parts) 

282 

283 

284_CONSTRAINT_KEYS: tuple[tuple[str, str], ...] = ( 

285 ("minimum", "≥ {}"), 

286 ("exclusiveMinimum", "> {}"), 

287 ("maximum", "≤ {}"), 

288 ("exclusiveMaximum", "< {}"), 

289 ("minLength", "min length {}"), 

290 ("maxLength", "max length {}"), 

291 ("minItems", "min items {}"), 

292 ("maxItems", "max items {}"), 

293 ("pattern", "pattern {}"), 

294 ("multipleOf", "multiple of {}"), 

295) 

296 

297 

298def _constraint_fragments(schema: Any) -> list[str]: 

299 if not isinstance(schema, dict): 

300 return [] 

301 found = [ 

302 template.format(code(str(schema[key])) if key == "pattern" else schema[key]) 

303 for key, template in _CONSTRAINT_KEYS 

304 if key in schema 

305 ] 

306 # FastAPI puts the bounds of an ``Optional[...]`` field on the non-null 

307 # variant of its ``anyOf``, so a nullable ``str`` with ``max_length`` is 

308 # only described if the variants are read too. 

309 for keyword in ("anyOf", "oneOf", "allOf"): 

310 variants = schema.get(keyword) 

311 if isinstance(variants, list): 

312 for variant in variants: 

313 for fragment in _constraint_fragments(variant): 

314 if fragment not in found: 

315 found.append(fragment) 

316 return found 

317 

318 

319def constraints(schema: Any) -> str: 

320 """Numeric/length/pattern bounds as ``≥ 1; ≤ 1000``, or a dash.""" 

321 return cell("; ".join(_constraint_fragments(schema))) 

322 

323 

324def default_of(schema: Any) -> str: 

325 if isinstance(schema, dict) and "default" in schema: 

326 return code(json.dumps(schema["default"])) 

327 return "—" 

328 

329 

330def _description(*candidates: Any) -> str: 

331 """The first non-empty description among the candidates, as one table cell.""" 

332 for candidate in candidates: 

333 if isinstance(candidate, str) and candidate.strip(): 

334 return cell(candidate) 

335 return "—" 

336 

337 

338def _prose(text: Any) -> list[str]: 

339 """A description as its own paragraph(s), trailing whitespace trimmed.""" 

340 if not isinstance(text, str) or not text.strip(): 

341 return [] 

342 return [line.rstrip() for line in text.strip().splitlines()] 

343 

344 

345# ─── operations ────────────────────────────────────────────────────────────── 

346 

347 

348def _operations(document: dict[str, Any]) -> list[tuple[str, str, dict[str, Any]]]: 

349 """``(method, path, operation)`` in path order, methods in reading order.""" 

350 found: list[tuple[str, str, dict[str, Any]]] = [] 

351 for path, item in document["paths"].items(): 

352 if not isinstance(item, dict): 

353 continue 

354 for method in _METHOD_ORDER: 

355 operation = item.get(method) 

356 if isinstance(operation, dict): 

357 found.append((method.upper(), str(path), operation)) 

358 return found 

359 

360 

361def operation_heading(method: str, path: str) -> str: 

362 return f"{method} {path}" 

363 

364 

365def operation_anchor(method: str, path: str) -> str: 

366 """``op-get-api-v1-jobs-namespace-name``: an explicit anchor for the operation. 

367 

368 Headings get an ``<a id>`` of our own rather than relying on the renderer's 

369 heading slug: GitHub and python-markdown agree on ``GET /api/v1/jobs`` but 

370 not on the root path ``GET /`` (GitHub keeps a trailing hyphen, Markdown 

371 strips it), and an anchor both honour is worth one extra element. 

372 """ 

373 tail = re.sub(r"[^a-z0-9]+", "-", path.lower()).strip("-") 

374 return f"op-{method.lower()}" + (f"-{tail}" if tail else "") 

375 

376 

377def schema_anchor(name: str) -> str: 

378 return f"schema-{slugify(name)}" 

379 

380 

381def anchored_heading(anchor: str, heading: str) -> list[str]: 

382 return [f'<a id="{anchor}"></a>', "", f"### {code(heading)}", ""] 

383 

384 

385def _content_summary(content: Any) -> str: 

386 """``application/json: <type>`` for each media type of a body or response.""" 

387 if not isinstance(content, dict) or not content: 

388 return "—" 

389 return cell( 

390 "; ".join( 

391 f"{code(media_type)}: {schema_type(media.get('schema') if isinstance(media, dict) else None)}" 

392 for media_type, media in content.items() 

393 ) 

394 ) 

395 

396 

397def render_operation(method: str, path: str, operation: dict[str, Any]) -> list[str]: 

398 lines = anchored_heading(operation_anchor(method, path), operation_heading(method, path)) 

399 lines.extend(_prose(operation.get("description") or operation.get("summary"))) 

400 if lines[-1] != "": 

401 lines.append("") 

402 facts = [] 

403 if operation.get("operationId"): 

404 facts.append(f"- **Operation ID:** {code(str(operation['operationId']))}") 

405 tags = operation.get("tags") 

406 if isinstance(tags, list) and tags: 

407 facts.append(f"- **Tags:** {', '.join(str(tag) for tag in tags)}") 

408 if operation.get("deprecated") is True: 

409 facts.append("- **Deprecated:** yes") 

410 facts.extend(_security_facts(operation.get("security"))) 

411 facts.extend(_integration_facts(operation.get("x-amazon-apigateway-integration"))) 

412 facts.extend(_route_facts(operation.get("x-gco-route"))) 

413 if facts: 

414 lines.extend([*facts, ""]) 

415 lines.extend(_backend_block(operation.get("x-gco-backend"))) 

416 

417 parameters = operation.get("parameters") 

418 if isinstance(parameters, list) and parameters: 

419 rows = [] 

420 for parameter in parameters: 

421 if not isinstance(parameter, dict): 

422 continue 

423 schema = parameter.get("schema") 

424 rows.append( 

425 [ 

426 code(str(parameter.get("name", ""))), 

427 cell(parameter.get("in")), 

428 schema_type(schema), 

429 "yes" if parameter.get("required") is True else "no", 

430 default_of(schema), 

431 constraints(schema), 

432 _description( 

433 parameter.get("description"), 

434 schema.get("description") if isinstance(schema, dict) else None, 

435 ), 

436 ] 

437 ) 

438 lines.append("**Parameters**") 

439 lines.append("") 

440 lines.extend( 

441 table(["Name", "In", "Type", "Required", "Default", "Constraints", "Description"], rows) 

442 ) 

443 lines.append("") 

444 

445 body = operation.get("requestBody") 

446 if isinstance(body, dict): 

447 required = "required" if body.get("required") is True else "optional" 

448 lines.append(f"**Request body** ({required}): {_content_summary(body.get('content'))}") 

449 lines.append("") 

450 

451 responses = operation.get("responses") 

452 if isinstance(responses, dict) and responses: 

453 rows = [ 

454 [ 

455 code(str(status)), 

456 _description(response.get("description") if isinstance(response, dict) else None), 

457 _content_summary(response.get("content") if isinstance(response, dict) else None), 

458 ] 

459 for status, response in sorted(responses.items(), key=lambda item: str(item[0])) 

460 ] 

461 lines.append("**Responses**") 

462 lines.append("") 

463 lines.extend(table(["Status", "Description", "Content"], rows)) 

464 lines.append("") 

465 return lines 

466 

467 

468def _security_facts(security: Any) -> list[str]: 

469 """``- **Security:** ...`` when the operation declares its requirements. 

470 

471 FastAPI exports omit the key (authentication is middleware, not schema); 

472 the gateway documents write it explicitly, ``[]`` meaning unauthenticated. 

473 """ 

474 if not isinstance(security, list): 

475 return [] 

476 if not security: 

477 return ["- **Security:** none (unauthenticated)"] 

478 names = sorted( 

479 { 

480 str(name) 

481 for requirement in security 

482 if isinstance(requirement, dict) 

483 for name in requirement 

484 } 

485 ) 

486 return [f"- **Security:** {', '.join(code(name) for name in names)}"] 

487 

488 

489def _integration_facts(integration: Any) -> list[str]: 

490 """API Gateway's integration type, timeout and transfer mode as one line.""" 

491 if not isinstance(integration, dict): 

492 return [] 

493 parts = [code(str(integration.get("type", "?")))] 

494 timeout = integration.get("timeoutInMillis") 

495 if isinstance(timeout, int): 

496 parts.append(f"{timeout / 1000:g} s integration timeout") 

497 if integration.get("responseTransferMode") == "STREAM": 

498 parts.append("response streaming") 

499 return [f"- **Integration:** {', '.join(parts)}"] 

500 

501 

502def _route_facts(route: Any) -> list[str]: 

503 """The HTTPRoute rule that delivers an operation of the cluster gateway.""" 

504 if not isinstance(route, dict): 

505 return [] 

506 backend = f"{route.get('service')}:{route.get('port')}" 

507 return [ 

508 f"- **Delivered by rule:** {code(str(route.get('rule')))} → Service " 

509 f"{code(backend)} in {code(str(route.get('namespace')))}" 

510 ] 

511 

512 

513def _backend_block(backend: Any) -> list[str]: 

514 """The Lambda behind an API Gateway operation and the hops that follow it.""" 

515 if not isinstance(backend, dict): 

516 return [] 

517 lines = ["**Backend**", ""] 

518 function = backend.get("lambda") 

519 if isinstance(function, dict): 

520 detail = ", ".join( 

521 str(function[key]) for key in ("runtime", "handler") if function.get(key) is not None 

522 ) 

523 timeout = function.get("timeoutSeconds") 

524 if isinstance(timeout, int): 

525 detail += f", {timeout} s timeout" 

526 lines.append( 

527 f"- **Lambda:** {code(str(function.get('name')))} " 

528 f"({code(str(function.get('source')))}; {detail})" 

529 ) 

530 else: 

531 lines.append(f"- **Integration:** {code(str(backend.get('integration')))}") 

532 description = backend.get("description") 

533 if isinstance(description, str) and description.strip(): 

534 lines.append(f"- **Behaviour:** {cell(description)}") 

535 hops = backend.get("hops") 

536 valid_hops = [hop for hop in hops if isinstance(hop, dict)] if isinstance(hops, list) else [] 

537 if valid_hops: 

538 lines.append("- **Then:**") 

539 for index, hop in enumerate(valid_hops, start=1): 

540 documents = hop.get("documents") 

541 links = ( 

542 " — " + ", ".join(f"[{code(str(name))}]({name}.md)" for name in documents) 

543 if isinstance(documents, list) and documents 

544 else "" 

545 ) 

546 lines.append(f" {index}. {cell(hop.get('label'))}{links}") 

547 lines.append("") 

548 return lines 

549 

550 

551# ─── document-level extensions ─────────────────────────────────────────────── 

552 

553 

554def _json_block(value: Any) -> list[str]: 

555 return ["```json", json.dumps(value, indent=2, sort_keys=True), "```", ""] 

556 

557 

558def render_servers(servers: Any) -> list[str]: 

559 """``## Servers``: each URL template and its variables.""" 

560 if not isinstance(servers, list) or not servers: 

561 return [] 

562 lines = ["## Servers", ""] 

563 for server in servers: 

564 if not isinstance(server, dict): 

565 continue 

566 lines.append(f"- {code(str(server.get('url')))}") 

567 if server.get("description"): 

568 lines.extend(["", *(f" {line}" for line in _prose(server.get("description")))]) 

569 variables = server.get("variables") 

570 if isinstance(variables, dict) and variables: 

571 lines.append("") 

572 rows = [ 

573 [ 

574 code(str(name)), 

575 code(json.dumps(spec.get("default"))) 

576 if isinstance(spec, dict) and "default" in spec 

577 else "—", 

578 _description(spec.get("description") if isinstance(spec, dict) else None), 

579 ] 

580 for name, spec in variables.items() 

581 ] 

582 lines.extend(f" {row}" for row in table(["Variable", "Default", "Description"], rows)) 

583 lines.append("") 

584 return lines 

585 

586 

587def render_security_schemes(schemes: Any) -> list[str]: 

588 """``## Security schemes``: how a caller authenticates to this surface.""" 

589 if not isinstance(schemes, dict) or not schemes: 

590 return [] 

591 lines = ["## Security schemes", ""] 

592 for name, scheme in schemes.items(): 

593 if not isinstance(scheme, dict): 

594 continue 

595 where = "" 

596 if scheme.get("in") and scheme.get("name"): 

597 where = f" — {scheme['in']} {code(str(scheme['name']))}" 

598 authtype = scheme.get("x-amazon-apigateway-authtype") 

599 if authtype: 

600 where += f" ({authtype})" 

601 lines.append(f"### {code(str(name))}") 

602 lines.append("") 

603 lines.append(f"- **Type:** {cell(scheme.get('type'))}{where}") 

604 if scheme.get("description"): 

605 lines.extend(["", *_prose(scheme.get("description"))]) 

606 lines.append("") 

607 return lines 

608 

609 

610def render_gateway_details(document: dict[str, Any]) -> list[str]: 

611 """``## Deployment``: the API Gateway facts the synthesized template carried.""" 

612 stage = document.get("x-gco-stage") 

613 policy = document.get("x-gco-resource-policy") 

614 if not isinstance(stage, dict) and not isinstance(policy, list): 

615 return [] 

616 lines = ["## Deployment", ""] 

617 endpoint_type = document.get("x-gco-endpoint-type") 

618 if endpoint_type: 

619 lines.append(f"- **Endpoint type:** {code(str(endpoint_type))}") 

620 if isinstance(stage, dict): 

621 throttling = _dict(stage.get("throttling")) 

622 lines.append( 

623 f"- **Stage:** {code(str(stage.get('name')))} — throttling " 

624 f"{throttling.get('rateLimit')} req/s (burst {throttling.get('burstLimit')}), " 

625 f"execution logging {code(str(stage.get('loggingLevel')))}, data trace " 

626 f"{'on' if stage.get('dataTraceEnabled') else 'off'}, metrics " 

627 f"{'on' if stage.get('metricsEnabled') else 'off'}, X-Ray tracing " 

628 f"{'on' if stage.get('tracingEnabled') else 'off'}, access logs " 

629 f"{'on' if stage.get('accessLogging') else 'off'}" 

630 ) 

631 conditions = document.get("x-gco-conditions") 

632 if isinstance(conditions, list) and conditions: 

633 lines.append("- **Conditional routes:** operations marked *Only when* exist under:") 

634 for condition in conditions: 

635 if isinstance(condition, dict): 

636 lines.append( 

637 f" - {code(str(condition.get('id')))}: {cell(condition.get('description'))}" 

638 ) 

639 lines.append("") 

640 if isinstance(policy, list): 

641 lines.extend(["### Resource policy", ""]) 

642 lines.append( 

643 "Statements deployed on the REST API (`${AWS::AccountId}` is the deploying account):" 

644 ) 

645 lines.append("") 

646 lines.extend(_json_block(policy)) 

647 direct = document.get("x-gco-resource-policy-direct-access") 

648 if isinstance(direct, list) and direct: 

649 lines.append( 

650 "Added when `api_gateway.regional_api_enabled` is true (and always outside " 

651 "the `aws` partition), admitting the deploying account's other principals:" 

652 ) 

653 lines.append("") 

654 lines.extend(_json_block(direct)) 

655 waf = document.get("x-gco-waf") 

656 if isinstance(waf, list) and waf: 

657 lines.extend(["### WAF", ""]) 

658 rows = [ 

659 [ 

660 str(rule.get("priority")), 

661 code(str(rule.get("name"))), 

662 cell(rule.get("action")), 

663 code(str(rule["managedRuleGroup"])) if rule.get("managedRuleGroup") else "—", 

664 ] 

665 for rule in waf 

666 if isinstance(rule, dict) 

667 ] 

668 lines.extend(table(["Priority", "Rule", "Action", "Managed rule group"], rows)) 

669 lines.append("") 

670 return lines 

671 

672 

673def render_cluster_gateway_details(document: dict[str, Any]) -> list[str]: 

674 """``## Routing``: the Gateway, its HTTPRoute table and what it leaves out.""" 

675 gateway = document.get("x-gco-gateway") 

676 routes = document.get("x-gco-routes") 

677 if not isinstance(gateway, dict) or not isinstance(routes, list): 

678 return [] 

679 lines = ["## Routing", ""] 

680 listener = _dict(gateway.get("listener")) 

681 load_balancer = _dict(gateway.get("loadBalancer")) 

682 qualified = f"{gateway.get('namespace')}/{gateway.get('name')}" 

683 lines.append( 

684 f"- **Gateway:** {code(qualified)} " 

685 f"(GatewayClass {code(str(gateway.get('gatewayClassName')))}, controller " 

686 f"{code(str(gateway.get('controllerName')))})" 

687 ) 

688 lines.append( 

689 f"- **Listener:** {code(str(listener.get('name')))} " 

690 f"{listener.get('protocol')}:{listener.get('port')}; load balancer scheme " 

691 f"{code(str(load_balancer.get('scheme')))}" 

692 ) 

693 lines.append(f"- **HTTPRoute:** {code(str(gateway.get('httpRoute')))}") 

694 unauthenticated = document.get("x-gco-unauthenticated-paths") 

695 if isinstance(unauthenticated, list) and unauthenticated: 

696 lines.append( 

697 "- **Paths served without the HMAC envelope:** " 

698 + ", ".join(code(str(path)) for path in unauthenticated) 

699 ) 

700 lines.append("") 

701 lines.extend(["### HTTPRoute rules", ""]) 

702 lines.append( 

703 "In document order; the controller awards precedence by path specificity " 

704 "(longest prefix wins), so `/` is the catch-all." 

705 ) 

706 lines.append("") 

707 rows = [ 

708 [code(str(rule.get("prefix"))), code(str(rule.get("service"))), str(rule.get("port"))] 

709 for rule in routes 

710 if isinstance(rule, dict) 

711 ] 

712 lines.extend(table(["Path prefix", "Service", "Port"], rows)) 

713 lines.append("") 

714 for key, heading in ( 

715 ("loadBalancer", "Load balancer"), 

716 ("targetGroup", "Target group"), 

717 ): 

718 value = gateway.get(key) 

719 if isinstance(value, dict) and value: 

720 lines.extend([f"### {heading}", "", *_json_block(value)]) 

721 unreachable = document.get("x-gco-unreachable") 

722 if isinstance(unreachable, list) and unreachable: 

723 lines.extend(["### Paths a Service serves but never receives", ""]) 

724 lines.append( 

725 "A prefix cannot be split between Services, so these implementations are " 

726 "reachable only by addressing the Service inside the cluster." 

727 ) 

728 lines.append("") 

729 rows = [ 

730 [ 

731 code(str(item.get("path"))), 

732 code(str(item.get("service"))), 

733 code(str(item.get("rule"))), 

734 code(str(item.get("deliveredTo"))), 

735 ] 

736 for item in unreachable 

737 if isinstance(item, dict) 

738 ] 

739 lines.extend(table(["Path", "Implemented by", "Winning rule", "Delivered to"], rows)) 

740 lines.append("") 

741 not_exposed = document.get("x-gco-not-exposed") 

742 if isinstance(not_exposed, dict) and not_exposed: 

743 lines.extend(["### Services not on the ALB", ""]) 

744 for service, detail in not_exposed.items(): 

745 info = _dict(detail) 

746 lines.append( 

747 f"- [{code(str(service))}]({service}.md) — reached through " 

748 f"[{code(str(info.get('via')))}]({info.get('via')}.md) " 

749 f"({code(str(info.get('pathPrefix')) + '*')}). {cell(info.get('description'))}" 

750 ) 

751 lines.append("") 

752 downstream = document.get("x-gco-downstream") 

753 if isinstance(downstream, dict) and downstream: 

754 lines.extend(["### Downstream of the backends", ""]) 

755 for service, detail in downstream.items(): 

756 info = _dict(detail) 

757 lines.append( 

758 f"- [{code(str(service))}]({service}.md) → {cell(info.get('target'))}. " 

759 f"{cell(info.get('description'))}" 

760 ) 

761 lines.append("") 

762 return lines 

763 

764 

765# ─── schemas ───────────────────────────────────────────────────────────────── 

766 

767 

768def render_schema(name: str, schema: dict[str, Any]) -> list[str]: 

769 lines = anchored_heading(schema_anchor(name), name) 

770 lines.extend(_prose(schema.get("description"))) 

771 if lines[-1] != "": 

772 lines.append("") 

773 properties = schema.get("properties") 

774 required = schema.get("required") 

775 required_names = {str(item) for item in required} if isinstance(required, list) else set() 

776 if isinstance(properties, dict) and properties: 

777 rows = [ 

778 [ 

779 code(str(prop_name)), 

780 schema_type(prop), 

781 "yes" if prop_name in required_names else "no", 

782 default_of(prop), 

783 constraints(prop), 

784 _description(prop.get("description") if isinstance(prop, dict) else None), 

785 ] 

786 for prop_name, prop in properties.items() 

787 ] 

788 lines.extend( 

789 table(["Property", "Type", "Required", "Default", "Constraints", "Description"], rows) 

790 ) 

791 lines.append("") 

792 else: 

793 lines.append(f"- **Type:** {schema_type(schema)}") 

794 lines.append("") 

795 if "example" in schema: 

796 lines.extend( 

797 ["Example:", "", "```json", json.dumps(schema["example"], indent=2), "```", ""] 

798 ) 

799 return lines 

800 

801 

802# ─── the sheet ─────────────────────────────────────────────────────────────── 

803 

804 

805def banner(service: str) -> str: 

806 return ( 

807 f"<!-- Generated by diagrams/api_specs/generate.py from docs/openapi/{service}.json. " 

808 f"Do not edit by hand; regenerate with `{REGENERATION_COMMAND}`. -->" 

809 ) 

810 

811 

812def _assert_unique_anchors(anchors: list[tuple[str, str]]) -> None: 

813 """``(anchor, owner)`` pairs must not collide, or a link would land on the wrong section.""" 

814 seen: dict[str, str] = {} 

815 for anchor, owner in anchors: 

816 if anchor in seen: 

817 raise SpecSheetError( 

818 f"{seen[anchor]!r} and {owner!r} share the anchor #{anchor}; " 

819 "the summary table could not link to both" 

820 ) 

821 seen[anchor] = owner 

822 

823 

824def render_sheet(service: str, document: dict[str, Any], *, site_url: str, repo_url: str) -> str: 

825 info = _dict(document.get("info")) 

826 title = str(info.get("title") or service) 

827 operations = _operations(document) 

828 schemas = _dict(_dict(document.get("components")).get("schemas")) 

829 _assert_unique_anchors( 

830 [ 

831 (operation_anchor(method, path), operation_heading(method, path)) 

832 for method, path, _ in operations 

833 ] 

834 + [(schema_anchor(str(name)), str(name)) for name in schemas] 

835 ) 

836 

837 noun, origin = _KINDS[document_kind(document)] 

838 

839 lines = [f"# {title} — API spec sheet", "", banner(service), ""] 

840 lines.append( 

841 f"*{noun} {code(service)} · OpenAPI {document.get('openapi', '?')} · " 

842 f"API version {info.get('version', '?')} · {len(operations)} endpoints · {len(schemas)} schemas*" 

843 ) 

844 lines.append("") 

845 lines.extend(_prose(info.get("description"))) 

846 if lines[-1] != "": 

847 lines.append("") 

848 lines.extend( 

849 [ 

850 f"- **Machine-readable document:** [{code(f'docs/openapi/{service}.json')}]" 

851 f"({repo_url}/blob/main/docs/openapi/{service}.json) — the {origin}", 

852 f"- **Interactive console (Swagger UI):** <{site_url}{SWAGGER_SITE_PREFIX}/{service}/>", 

853 "- **Catalogue index:** [README.md](README.md) · " 

854 "[interaction diagram](README.md#how-the-surfaces-fit-together)", 

855 "", 

856 ] 

857 ) 

858 lines.extend(render_servers(document.get("servers"))) 

859 lines.extend(render_gateway_details(document)) 

860 lines.extend(render_cluster_gateway_details(document)) 

861 lines.extend(render_security_schemes(_dict(document.get("components")).get("securitySchemes"))) 

862 lines.extend(["## Endpoints", ""]) 

863 rows = [] 

864 for method, path, operation in operations: 

865 anchor = operation_anchor(method, path) 

866 tags = operation.get("tags") 

867 rows.append( 

868 [ 

869 f"[{code(method)}](#{anchor})", 

870 code(path), 

871 _description(operation.get("summary")), 

872 cell(", ".join(str(tag) for tag in tags)) 

873 if isinstance(tags, list) and tags 

874 else "—", 

875 ] 

876 ) 

877 lines.extend(table(["Method", "Path", "Summary", "Tags"], rows)) 

878 lines.extend(["", "## Endpoint details", ""]) 

879 for method, path, operation in operations: 

880 lines.extend(render_operation(method, path, operation)) 

881 lines.extend(["## Schemas", ""]) 

882 if schemas: 

883 for name, schema in schemas.items(): 

884 lines.extend(render_schema(str(name), schema if isinstance(schema, dict) else {})) 

885 else: 

886 lines.extend([f"This {noun.lower()} declares no component schemas.", ""]) 

887 return "\n".join(lines).rstrip() + "\n" 

888 

889 

890def render_index(documents: dict[str, dict[str, Any]], *, site_url: str, repo_url: str) -> str: 

891 rows = [] 

892 for service in catalogue_order(documents): 

893 document = documents[service] 

894 info = _dict(document.get("info")) 

895 rows.append( 

896 [ 

897 f"[{code(service)}]({service}.md)", 

898 _KINDS[document_kind(document)][0], 

899 cell(info.get("title")), 

900 code(str(info.get("version", "?"))), 

901 str(len(_operations(document))), 

902 f"[{code('json')}]({repo_url}/blob/main/docs/openapi/{service}.json)", 

903 f"[{code('/docs')}]({site_url}{SWAGGER_SITE_PREFIX}/{service}/)", 

904 ] 

905 ) 

906 lines = [ 

907 "# GCO API Spec Sheets", 

908 "", 

909 "<!-- Generated by diagrams/api_specs/generate.py from docs/openapi/*.json. " 

910 f"Do not edit by hand; regenerate with `{REGENERATION_COMMAND}`. -->", 

911 "", 

912 "One spec sheet per HTTP surface of GCO, rendered from the OpenAPI document that", 

913 "describes it: for each FastAPI **service** the document the application", 

914 "generates itself (the one behind its automatic Swagger `/docs` page); for each", 

915 "AWS **API Gateway** the document read out of the synthesized CDK stack; and for", 

916 "the in-cluster **Gateway** (the internal ALB) the document composed from its", 

917 "HTTPRoute and the service documents. The documents are committed under", 

918 "`docs/openapi/`, each guarded by a `--check` in CI, and these sheets are a", 

919 "deterministic rendering of them — so a route, parameter or model change shows up", 

920 "here in the same pull request as the code.", 

921 "", 

922 *table( 

923 ["Document", "Kind", "API", "Version", "Endpoints", "OpenAPI document", "Swagger UI"], 

924 rows, 

925 ), 

926 "", 

927 "The **Swagger UI** column is the Swagger console for each document (FastAPI's own", 

928 f"`/docs` page for the services), served from the project site under `/{SWAGGER_SITE_PREFIX}/`", 

929 "with a self-hosted copy of `swagger-ui-dist` (the site makes no third-party", 

930 "requests). It is built at deploy time by `pages.yml`; the sheets in this directory", 

931 "are also staged into the wiki's source tree under `/api/` by `scripts/build_wiki.py`,", 

932 "so both renderings come from one source.", 

933 "", 

934 "## How the surfaces fit together", 

935 "", 

936 f"![How the API Gateways, the cluster Gateway and the services interact]({TOPOLOGY_FILE})", 

937 "", 

938 "Drawn from the same documents: a client signs a request with SigV4 to one of the", 

939 "two API Gateways; a Lambda proxy adds the request-bound HMAC envelope and forwards", 

940 "it — over Global Accelerator from the global API, inside the VPC from the regional", 

941 "bridge — to the region's internal ALB, the Kubernetes Gateway whose HTTPRoute picks", 

942 "the Service by longest path prefix. The aggregator answers `/api/v1/global/*` by", 

943 "fanning out to the regional API Gateways. Dashed arrows are opt-in or dynamic", 

944 "paths; `†` marks routes that exist only under a deployment condition. Every box", 

945 "is a document in the table above, and each is a link to its sheet on the site.", 

946 "", 

947 "## Regenerating", 

948 "", 

949 "```bash", 

950 *_aligned_commands( 

951 ("python scripts/generate_openapi.py", "refresh the service documents from the apps"), 

952 ( 

953 "python scripts/generate_api_gateway_openapi.py", 

954 "re-synthesize the API Gateway documents", 

955 ), 

956 ( 

957 "python scripts/generate_cluster_gateway_openapi.py", 

958 "recompose the cluster gateway document", 

959 ), 

960 (REGENERATION_COMMAND, "rewrite these sheets, this index and the diagram"), 

961 ("python diagrams/generate.py --check", "fail if anything here is stale"), 

962 ), 

963 "```", 

964 "", 

965 "To browse the consoles locally, install the locked npm tooling and serve the", 

966 "generated tree:", 

967 "", 

968 "```bash", 

969 "npm ci --ignore-scripts --no-audit --no-fund", 

970 f"python diagrams/api_specs/generate.py --swagger-ui-dir /tmp/gco-{SWAGGER_SITE_PREFIX} \\", 

971 " --swagger-assets node_modules/swagger-ui-dist", 

972 f"python -m http.server --directory /tmp/gco-{SWAGGER_SITE_PREFIX} 8080", 

973 "```", 

974 "", 

975 "Swagger UI loads the document with a browser request, so the tree has to be", 

976 "served over HTTP rather than opened from the filesystem.", 

977 ] 

978 return "\n".join(lines) + "\n" 

979 

980 

981# ─── the interaction diagram ───────────────────────────────────────────────── 

982# 

983# ``api-topology.svg`` is drawn from the documents alone: the API Gateway 

984# documents' ``x-gco-backend`` hop chains, the cluster gateway's ``x-gco-routes`` 

985# (plus what it declares not exposed or downstream) and the service documents. 

986# The layout is a fixed grid of columns — clients, API Gateways, Lambda 

987# proxies, transit, the cluster Gateway, Services, what sits behind them — so 

988# the picture is a deterministic function of the JSON and ``--check`` can 

989# compare it byte for byte. Every box that is a document links to its sheet. 

990 

991_SVG_FONT = "ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif" 

992_SVG_MONO = "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" 

993#: ``(fill, stroke)`` per node style. 

994_NODE_STYLES: dict[str, tuple[str, str]] = { 

995 "clients": ("#ffffff", "#334155"), 

996 "api-gateway": ("#dbeafe", "#1d4ed8"), 

997 "lambda": ("#fef3c7", "#b45309"), 

998 "transit": ("#ede9fe", "#6d28d9"), 

999 "cluster-gateway": ("#dcfce7", "#15803d"), 

1000 "service": ("#f1f5f9", "#334155"), 

1001 "dynamic": ("#ffffff", "#64748b"), 

1002} 

1003_LEGEND: tuple[tuple[str, str], ...] = ( 

1004 ("api-gateway", "AWS API Gateway (SigV4)"), 

1005 ("lambda", "Lambda proxy (HMAC envelope)"), 

1006 ("transit", "AWS transit"), 

1007 ("cluster-gateway", "Kubernetes Gateway (internal ALB)"), 

1008 ("service", "FastAPI service"), 

1009 ("dynamic", "dynamic or internal-only"), 

1010) 

1011_COLUMN_WIDTHS: tuple[int, ...] = (150, 250, 225, 180, 265, 205, 215) 

1012#: Space after each column; wider where edge labels (route prefixes) sit. 

1013_COLUMN_GAPS: tuple[int, ...] = (130, 175, 60, 60, 140, 130, 0) 

1014_MARGIN = 20 

1015_NODE_GAP = 26 

1016_TITLE_HEIGHT = 30 

1017_LINE_HEIGHT = 15 

1018_CHAR_WIDTH = 6.3 

1019_EDGE_COLOUR = "#475569" 

1020_FANOUT_COLOUR = "#6d28d9" 

1021 

1022 

1023class _Node: 

1024 def __init__( 

1025 self, 

1026 node_id: str, 

1027 column: int, 

1028 title: str, 

1029 lines: list[str], 

1030 style: str, 

1031 href: str | None = None, 

1032 ) -> None: 

1033 self.id = node_id 

1034 self.column = column 

1035 self.title = title 

1036 self.lines = lines 

1037 self.style = style 

1038 self.href = href 

1039 self.x = 0 

1040 self.y = 0 

1041 self.width = _COLUMN_WIDTHS[column] 

1042 self.height = _TITLE_HEIGHT + _LINE_HEIGHT * len(lines) + 10 

1043 

1044 

1045class _Edge: 

1046 def __init__( 

1047 self, 

1048 source: str, 

1049 target: str, 

1050 label: str = "", 

1051 dashed: bool = False, 

1052 colour: str = _EDGE_COLOUR, 

1053 ) -> None: 

1054 self.source = source 

1055 self.target = target 

1056 self.labels = [label] if label else [] 

1057 self.dashed = dashed 

1058 self.colour = colour 

1059 

1060 

1061def _wrap(text: str, width: int, *, indent: str = "") -> list[str]: 

1062 """Break ``text`` into lines that fit a node of ``width`` pixels in the mono font.""" 

1063 import textwrap 

1064 

1065 chars = max(12, int((width - 18) / _CHAR_WIDTH)) 

1066 return textwrap.wrap(str(text), width=chars, subsequent_indent=indent) or [""] 

1067 

1068 

1069def _route_lines(document: dict[str, Any], width: int) -> list[str]: 

1070 """One entry per path of an API Gateway document: its methods, then ``†`` if conditional.""" 

1071 lines: list[str] = [] 

1072 for path, item in document["paths"].items(): 

1073 if not isinstance(item, dict): 

1074 continue 

1075 methods = [m.upper() for m in _METHOD_ORDER if isinstance(item.get(m), dict)] 

1076 conditional = any("x-gco-condition" in item[m.lower()] for m in methods) 

1077 text = f"{' '.join(methods)} {path}{' †' if conditional else ''}" 

1078 lines.extend(_wrap(text, width, indent=" ")) 

1079 return lines 

1080 

1081 

1082def _short_title(hop_id: str) -> str: 

1083 return " ".join(word.capitalize() for word in hop_id.split("-")) 

1084 

1085 

1086def topology_model( 

1087 documents: dict[str, dict[str, Any]], *, site_url: str 

1088) -> tuple[list[_Node], list[_Edge]]: 

1089 """Nodes and edges of the interaction diagram, derived from the documents.""" 

1090 kinds = {name: document_kind(document) for name, document in documents.items()} 

1091 gateways = [n for n in catalogue_order(documents) if kinds[n] == "aws-api-gateway"] 

1092 clusters = [n for n in catalogue_order(documents) if kinds[n] == "cluster-gateway"] 

1093 services = [n for n in catalogue_order(documents) if kinds[n] == "fastapi"] 

1094 nodes: dict[str, _Node] = {} 

1095 edges: dict[tuple[str, str], _Edge] = {} 

1096 

1097 def link(name: str) -> str: 

1098 return f"{site_url}api/{name}/" 

1099 

1100 def connect( 

1101 source: str, 

1102 target: str, 

1103 label: str = "", 

1104 *, 

1105 dashed: bool = False, 

1106 colour: str = _EDGE_COLOUR, 

1107 ) -> None: 

1108 for node_id in (source, target): 

1109 if node_id not in nodes: 

1110 raise SpecSheetError(f"the diagram references an unknown node {node_id!r}") 

1111 edge = edges.setdefault( 

1112 (source, target), _Edge(source, target, dashed=dashed, colour=colour) 

1113 ) 

1114 if label and label not in edge.labels: 

1115 edge.labels.append(label) 

1116 

1117 nodes["clients"] = _Node( 

1118 "clients", 

1119 0, 

1120 "Clients", 

1121 ["AWS SigV4 (IAM)", "gco CLI, awscurl,", "boto3, curl --aws-sigv4"], 

1122 "clients", 

1123 ) 

1124 not_exposed: dict[str, dict[str, Any]] = {} 

1125 for name in clusters: 

1126 for service, info in _dict(documents[name].get("x-gco-not-exposed")).items(): 

1127 not_exposed[str(service)] = _dict(info) 

1128 for name in services: 

1129 document = documents[name] 

1130 column = 6 if name in not_exposed else 5 

1131 lines = [f"{len(_operations(document))} endpoints", f"Service {name}"] 

1132 nodes[f"doc:{name}"] = _Node( 

1133 f"doc:{name}", 

1134 column, 

1135 str(_dict(document.get("info")).get("title") or name), 

1136 lines, 

1137 "dynamic" if name in not_exposed else "service", 

1138 link(name), 

1139 ) 

1140 for name in gateways: 

1141 document = documents[name] 

1142 nodes[f"doc:{name}"] = _Node( 

1143 f"doc:{name}", 

1144 1, 

1145 str(_dict(document.get("info")).get("title") or name), 

1146 _route_lines(document, _COLUMN_WIDTHS[1]), 

1147 "api-gateway", 

1148 link(name), 

1149 ) 

1150 for name in clusters: 

1151 document = documents[name] 

1152 gateway = _dict(document.get("x-gco-gateway")) 

1153 routes = [r for r in document.get("x-gco-routes", []) if isinstance(r, dict)] 

1154 lines = [ 

1155 f"Gateway {gateway.get('namespace')}/{gateway.get('name')}", 

1156 f"HTTPRoute {gateway.get('httpRoute')} (prefix → Service)", 

1157 *[f"{r.get('prefix')} → {r.get('service')}" for r in routes], 

1158 ] 

1159 nodes[f"doc:{name}"] = _Node( 

1160 f"doc:{name}", 

1161 4, 

1162 str(_dict(document.get("info")).get("title") or name), 

1163 [line for text in lines for line in _wrap(text, _COLUMN_WIDTHS[4], indent=" ")], 

1164 "cluster-gateway", 

1165 link(name), 

1166 ) 

1167 

1168 # What the backends send on to (declared by the cluster gateway) is drawn 

1169 # once, under the hop id the API Gateway chains use for the same place. 

1170 for name in clusters: 

1171 for service, detail in _dict(documents[name].get("x-gco-downstream")).items(): 

1172 info = _dict(detail) 

1173 node_id = f"hop:{info.get('hop') or service}" 

1174 nodes[node_id] = _Node( 

1175 node_id, 

1176 6, 

1177 _short_title(str(info.get("hop") or service)) + "s", 

1178 _wrap(str(info.get("target") or ""), _COLUMN_WIDTHS[6])[:4], 

1179 "dynamic", 

1180 ) 

1181 

1182 # Lambda nodes first, so a hop that names another API's proxy Lambda 

1183 # (the aggregator's fan-out ends at the regional bridge's proxy) resolves. 

1184 lambdas_by_name: dict[str, str] = {} 

1185 for name in gateways: 

1186 for _method, _path, operation in _operations(documents[name]): 

1187 function = _dict(_dict(operation.get("x-gco-backend")).get("lambda")) 

1188 if not function: 

1189 continue 

1190 node_id = f"lambda:{name}:{function.get('name')}" 

1191 if node_id not in nodes: 

1192 source = str(function.get("source") or "") 

1193 nodes[node_id] = _Node( 

1194 node_id, 

1195 2, 

1196 str(function.get("name")), 

1197 [f"Lambda · {source.rsplit('/', 1)[-1]}", str(function.get("runtime") or "")], 

1198 "lambda", 

1199 ) 

1200 lambdas_by_name.setdefault(str(function.get("name")), node_id) 

1201 

1202 def hop_targets(hop: dict[str, Any], previous: _Node) -> list[str]: 

1203 named = hop.get("documents") 

1204 if isinstance(named, list) and named: 

1205 return [f"doc:{document}" for document in named] 

1206 hop_id = str(hop.get("id")) 

1207 if hop_id in lambdas_by_name: 

1208 return [lambdas_by_name[hop_id]] 

1209 node_id = f"hop:{hop_id}" 

1210 if node_id not in nodes: 

1211 column = max(3, previous.column + 1) 

1212 nodes[node_id] = _Node( 

1213 node_id, 

1214 column, 

1215 _short_title(hop_id), 

1216 _wrap(str(hop.get("label") or ""), _COLUMN_WIDTHS[column])[:4], 

1217 "dynamic" if column >= 5 else "transit", 

1218 ) 

1219 return [node_id] 

1220 

1221 for name in gateways: 

1222 document = documents[name] 

1223 direct = bool(document.get("x-gco-resource-policy-direct-access")) 

1224 connect("clients", f"doc:{name}", "SigV4 · opt-in" if direct else "SigV4", dashed=direct) 

1225 # One label per path into a Lambda; a path is marked conditional when 

1226 # any of its methods is. 

1227 conditional_paths = { 

1228 path for _m, path, operation in _operations(document) if "x-gco-condition" in operation 

1229 } 

1230 for _method, path, operation in _operations(document): 

1231 backend = _dict(operation.get("x-gco-backend")) 

1232 function = _dict(backend.get("lambda")) 

1233 if not function: 

1234 continue 

1235 previous_id = f"lambda:{name}:{function.get('name')}" 

1236 marker = " †" if path in conditional_paths else "" 

1237 connect(f"doc:{name}", previous_id, f"{path}{marker}") 

1238 for hop in backend.get("hops", []): 

1239 if not isinstance(hop, dict): 

1240 continue 

1241 targets = hop_targets(hop, nodes[previous_id]) 

1242 fan_out = str(hop.get("id")) in {n.removeprefix("doc:") for n in targets} and any( 

1243 kinds.get(t.removeprefix("doc:")) == "aws-api-gateway" for t in targets 

1244 ) 

1245 for target in targets: 

1246 connect( 

1247 previous_id, 

1248 target, 

1249 "SigV4 fan-out" if fan_out else "", 

1250 dashed=fan_out, 

1251 colour=_FANOUT_COLOUR if fan_out else _EDGE_COLOUR, 

1252 ) 

1253 if len(targets) != 1: 

1254 break 

1255 previous_id = targets[0] 

1256 

1257 for name in clusters: 

1258 document = documents[name] 

1259 by_service: dict[str, list[str]] = {} 

1260 for rule in document.get("x-gco-routes", []): 

1261 if isinstance(rule, dict): 

1262 by_service.setdefault(str(rule.get("service")), []).append(str(rule.get("prefix"))) 

1263 for service, prefixes in by_service.items(): 

1264 for prefix in prefixes: 

1265 connect(f"doc:{name}", f"doc:{service}", prefix) 

1266 for service, info in not_exposed.items(): 

1267 connect( 

1268 f"doc:{info.get('via')}", 

1269 f"doc:{service}", 

1270 f"{info.get('pathPrefix')}*", 

1271 dashed=True, 

1272 ) 

1273 for service, detail in _dict(document.get("x-gco-downstream")).items(): 

1274 info = _dict(detail) 

1275 connect( 

1276 f"doc:{service}", 

1277 f"hop:{info.get('hop') or service}", 

1278 "by endpoint name", 

1279 dashed=True, 

1280 ) 

1281 

1282 return list(nodes.values()), list(edges.values()) 

1283 

1284 

1285def _label_lines(labels: list[str]) -> list[str]: 

1286 """Edge labels as lines: paths under one deep prefix collapse to ``prefix*``.""" 

1287 if len(labels) > 1 and all(label.startswith("/") for label in labels): 

1288 import os.path 

1289 

1290 common = os.path.commonprefix(labels) 

1291 prefix = common[: common.rfind("/") + 1] 

1292 if prefix.count("/") >= 3: 

1293 return [f"{prefix}*"] 

1294 return labels 

1295 

1296 

1297def _layout(nodes: list[_Node]) -> tuple[int, int]: 

1298 """Place nodes on the column grid, columns vertically centred; return the canvas size.""" 

1299 columns: dict[int, list[_Node]] = {} 

1300 for node in nodes: 

1301 columns.setdefault(node.column, []).append(node) 

1302 heights = { 

1303 column: sum(n.height for n in members) + _NODE_GAP * (len(members) - 1) 

1304 for column, members in columns.items() 

1305 } 

1306 total_height = max(heights.values()) if heights else 0 

1307 x = _MARGIN 

1308 for column, width in enumerate(_COLUMN_WIDTHS): 

1309 y = _MARGIN + (total_height - heights.get(column, 0)) // 2 

1310 for node in columns.get(column, []): 

1311 node.x, node.y = x, y 

1312 y += node.height + _NODE_GAP 

1313 x += width + _COLUMN_GAPS[column] 

1314 return x + _MARGIN, total_height + 2 * _MARGIN 

1315 

1316 

1317def _svg_text( 

1318 x: float, 

1319 y: float, 

1320 text: str, 

1321 *, 

1322 size: float, 

1323 mono: bool = False, 

1324 weight: str = "normal", 

1325 anchor: str = "start", 

1326 fill: str = "#0f172a", 

1327) -> str: 

1328 font = _SVG_MONO if mono else _SVG_FONT 

1329 return ( 

1330 f'<text x="{x:g}" y="{y:g}" font-family="{font}" font-size="{size:g}" ' 

1331 f'font-weight="{weight}" text-anchor="{anchor}" fill="{fill}">{_xml(text)}</text>' 

1332 ) 

1333 

1334 

1335def _xml(text: str) -> str: 

1336 """Escape what would end or alter an SVG text node or attribute value.""" 

1337 return ( 

1338 text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;").replace('"', "&quot;") 

1339 ) 

1340 

1341 

1342def _svg_node(node: _Node) -> str: 

1343 fill, stroke = _NODE_STYLES[node.style] 

1344 dash = ' stroke-dasharray="6 4"' if node.style == "dynamic" else "" 

1345 parts = [ 

1346 f'<rect x="{node.x}" y="{node.y}" width="{node.width}" height="{node.height}" rx="8" ' 

1347 f'fill="{fill}" stroke="{stroke}" stroke-width="1.5"{dash}/>', 

1348 _svg_text(node.x + 10, node.y + 19, node.title, size=12.5, weight="bold", fill=stroke), 

1349 ] 

1350 for index, line in enumerate(node.lines): 

1351 parts.append( 

1352 _svg_text( 

1353 node.x + 10, 

1354 node.y + _TITLE_HEIGHT + 8 + _LINE_HEIGHT * index, 

1355 line, 

1356 size=10.5, 

1357 mono=True, 

1358 fill="#1e293b", 

1359 ) 

1360 ) 

1361 body = "\n ".join(parts) 

1362 if node.href: 

1363 return f'<a href="{_xml(node.href)}"><title>{_xml(node.title)}</title>\n {body}\n </a>' 

1364 return body 

1365 

1366 

1367def _svg_edge(edge: _Edge, nodes: dict[str, _Node]) -> str: 

1368 source, target = nodes[edge.source], nodes[edge.target] 

1369 ty = target.y + target.height / 2 

1370 forward = target.column > source.column 

1371 if forward: 

1372 # Right edge of the source, mid-height, to the left edge of the target. 

1373 sx, sy, tx = source.x + source.width, source.y + source.height / 2, target.x 

1374 mx = sx + 24 

1375 else: 

1376 # A hop back to an earlier column leaves from the source's lower left 

1377 # so it never overprints the edge arriving at the source's midpoint. 

1378 sx, sy, tx = source.x, source.y + source.height - 12, target.x + target.width 

1379 mx = sx - 24 

1380 path = f"M {sx:g} {sy:g} H {mx:g} V {ty:g} H {tx:g}" 

1381 dash = ' stroke-dasharray="7 5"' if edge.dashed else "" 

1382 marker = "arrow-fanout" if edge.colour == _FANOUT_COLOUR else "arrow" 

1383 parts = [ 

1384 f'<path d="{path}" fill="none" stroke="{edge.colour}" stroke-width="1.4"{dash} ' 

1385 f'marker-end="url(#{marker})"/>' 

1386 ] 

1387 # Forward labels sit above the last segment, right-aligned at the target and 

1388 # stacked upward one line per label so several prefixes into one Service 

1389 # never overprint; a backward label hangs below its first segment. 

1390 lx, base_y, step = (tx - 8, ty - 6, -12) if forward else (sx - 8, sy + 14, 12) 

1391 for index, label in enumerate(reversed(_label_lines(edge.labels))): 

1392 parts.append( 

1393 _svg_text( 

1394 lx, 

1395 base_y + step * index, 

1396 label, 

1397 size=9.5, 

1398 mono=True, 

1399 anchor="end", 

1400 fill=edge.colour, 

1401 ) 

1402 ) 

1403 return "\n ".join(parts) 

1404 

1405 

1406def render_topology(documents: dict[str, dict[str, Any]], *, site_url: str) -> str: 

1407 """The interaction diagram as a standalone SVG document.""" 

1408 nodes, edges = topology_model(documents, site_url=site_url) 

1409 by_id = {node.id: node for node in nodes} 

1410 width, height = _layout(nodes) 

1411 legend_y = height + 4 

1412 legend_parts = [] 

1413 x = _MARGIN 

1414 for style, label in _LEGEND: 

1415 fill, stroke = _NODE_STYLES[style] 

1416 dash = ' stroke-dasharray="4 3"' if style == "dynamic" else "" 

1417 legend_parts.append( 

1418 f'<rect x="{x}" y="{legend_y}" width="16" height="12" rx="3" fill="{fill}" ' 

1419 f'stroke="{stroke}" stroke-width="1.2"{dash}/>' 

1420 ) 

1421 legend_parts.append(_svg_text(x + 22, legend_y + 10, label, size=10.5, fill="#334155")) 

1422 x += 22 + int(len(label) * 6.0) + 26 

1423 legend_parts.append( 

1424 _svg_text( 

1425 _MARGIN, 

1426 legend_y + 30, 

1427 "† route exists only under a deployment condition (see the sheet) · dashed arrow: " 

1428 "opt-in or dynamic path · every box links to its spec sheet", 

1429 size=10.5, 

1430 fill="#334155", 

1431 ) 

1432 ) 

1433 legend_parts.append( 

1434 _svg_text( 

1435 _MARGIN, 

1436 legend_y + 48, 

1437 f"Generated by diagrams/api_specs/generate.py from docs/openapi/*.json; " 

1438 f"regenerate with `{REGENERATION_COMMAND}`.", 

1439 size=9.5, 

1440 fill="#64748b", 

1441 ) 

1442 ) 

1443 total_height = legend_y + 60 

1444 lines = [ 

1445 '<?xml version="1.0" encoding="UTF-8"?>', 

1446 # The default SVG namespace only: links use SVG 2's plain ``href``, and 

1447 # .github/scripts/validate_svg_assets.py refuses ``xlink`` (or any other 

1448 # foreign namespace) in a tracked SVG. 

1449 f'<svg xmlns="http://www.w3.org/2000/svg" ' 

1450 f'width="{width}" height="{total_height}" viewBox="0 0 {width} {total_height}" ' 

1451 'role="img" aria-labelledby="title desc">', 

1452 ' <title id="title">GCO API topology: API Gateways, Lambda proxies, the cluster Gateway and the services</title>', 

1453 ' <desc id="desc">Clients sign requests with SigV4 to the global or regional API Gateway; Lambda ' 

1454 "proxies add the HMAC envelope and forward to the region's internal ALB, the Kubernetes " 

1455 "Gateway whose HTTPRoute routes by path prefix to the FastAPI services.</desc>", 

1456 " <defs>", 

1457 f' <marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="{_EDGE_COLOUR}"/></marker>', 

1458 f' <marker id="arrow-fanout" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="8" markerHeight="8" orient="auto-start-reverse"><path d="M 0 0 L 10 5 L 0 10 z" fill="{_FANOUT_COLOUR}"/></marker>', 

1459 " </defs>", 

1460 f' <rect width="{width}" height="{total_height}" fill="#ffffff"/>', 

1461 ] 

1462 lines.extend(f" {_svg_edge(edge, by_id)}" for edge in edges) 

1463 lines.extend(f" {_svg_node(node)}" for node in nodes) 

1464 lines.extend(f" {part}" for part in legend_parts) 

1465 lines.append("</svg>") 

1466 return "\n".join(lines) + "\n" 

1467 

1468 

1469# ─── the catalogue contract ────────────────────────────────────────────────── 

1470 

1471 

1472def expected_outputs(project_root: Path) -> dict[Path, str]: 

1473 """``{path: content}`` for every file this catalogue owns, rendered in memory.""" 

1474 openapi_dir = project_root / "docs" / "openapi" 

1475 output_dir = project_root / "diagrams" / "api_specs" 

1476 site_url, repo_url = read_site_config(project_root / SITE_CONFIG_FILE) 

1477 documents = { 

1478 service: load_document(service, openapi_dir) for service in service_names(openapi_dir) 

1479 } 

1480 outputs = { 

1481 output_dir / f"{service}.md": render_sheet( 

1482 service, document, site_url=site_url, repo_url=repo_url 

1483 ) 

1484 for service, document in documents.items() 

1485 } 

1486 outputs[output_dir / "README.md"] = render_index( 

1487 documents, site_url=site_url, repo_url=repo_url 

1488 ) 

1489 outputs[output_dir / TOPOLOGY_FILE] = render_topology(documents, site_url=site_url) 

1490 return outputs 

1491 

1492 

1493def api_contract_issues(project_root: Path) -> list[str]: 

1494 """Stale, missing and orphan sheets, by repository path; empty when current. 

1495 

1496 A checkout that cannot be rendered at all (no OpenAPI documents, no 

1497 ``zensical.toml``, a colliding anchor) is reported as an issue rather than 

1498 raised, so the aggregate ``--check`` prints one actionable line per 

1499 catalogue instead of a traceback. 

1500 """ 

1501 output_dir = project_root / "diagrams" / "api_specs" 

1502 try: 

1503 expected = expected_outputs(project_root) 

1504 except (SpecSheetError, OSError) as exc: 

1505 return [f"API spec sheets cannot be rendered: {exc}"] 

1506 issues: list[str] = [] 

1507 for path, content in sorted(expected.items()): 

1508 relative = path.relative_to(project_root).as_posix() 

1509 if not path.is_file(): 

1510 issues.append(f"missing API spec sheet: {relative}") 

1511 elif path.read_text(encoding="utf-8") != content: 

1512 issues.append(f"stale API spec sheet: {relative}") 

1513 for path in sorted([*output_dir.glob("*.md"), *output_dir.glob("*.svg")]): 

1514 if path not in expected: 

1515 issues.append(f"orphan API spec sheet: {path.relative_to(project_root).as_posix()}") 

1516 if issues: 

1517 issues.append(f"regenerate with `{REGENERATION_COMMAND}`") 

1518 return issues 

1519 

1520 

1521def write_outputs(project_root: Path) -> list[str]: 

1522 """Write every owned file; return the repository paths that changed.""" 

1523 changed: list[str] = [] 

1524 for path, content in sorted(expected_outputs(project_root).items()): 

1525 current = path.read_text(encoding="utf-8") if path.is_file() else None 

1526 if current != content: 

1527 path.parent.mkdir(parents=True, exist_ok=True) 

1528 path.write_text(content, encoding="utf-8") 

1529 changed.append(path.relative_to(project_root).as_posix()) 

1530 return changed 

1531 

1532 

1533# ─── Swagger UI consoles ───────────────────────────────────────────────────── 

1534 

1535 

1536def _console_index(services: dict[str, str], site_url: str) -> str: 

1537 items = "\n".join( 

1538 f' <li><a href="./{service}/">{title}</a> <code>{service}</code></li>' 

1539 for service, title in sorted(services.items()) 

1540 ) 

1541 return f"""<!DOCTYPE html> 

1542<html lang="en"> 

1543<head> 

1544 <meta charset="utf-8"> 

1545 <meta name="viewport" content="width=device-width, initial-scale=1"> 

1546 <title>GCO API consoles</title> 

1547 <link rel="icon" href="./assets/favicon-32x32.png"> 

1548 <style> 

1549 body {{ font-family: system-ui, sans-serif; margin: 2rem auto; max-width: 42rem; line-height: 1.5; }} 

1550 code {{ background: #f2f2f2; padding: 0 .3em; border-radius: 3px; }} 

1551 </style> 

1552</head> 

1553<body> 

1554 <h1>GCO API consoles</h1> 

1555 <p>FastAPI's Swagger UI for each HTTP service, rendered from the committed OpenAPI 

1556 documents. Read-only: these pages describe the API and do not send requests.</p> 

1557 <ul> 

1558{items} 

1559 </ul> 

1560 <p><a href="{site_url}">Back to the wiki</a></p> 

1561</body> 

1562</html> 

1563""" 

1564 

1565 

1566def build_swagger_site(site_dir: Path, assets_dir: Path, *, project_root: Path) -> list[Path]: 

1567 """Write FastAPI's Swagger UI console per service under ``site_dir``. 

1568 

1569 ``assets_dir`` is the ``swagger-ui-dist`` package directory; its bundle, 

1570 stylesheet and favicon (plus LICENSE/NOTICE when shipped) are copied to 

1571 ``site_dir/assets`` so the console loads everything from the site itself. 

1572 Every generated page is checked to reference no remote resource, so a 

1573 FastAPI default creeping back in fails the build instead of the site's 

1574 zero-third-party-requests rule. Returns the written files. 

1575 """ 

1576 from fastapi.openapi.docs import get_swagger_ui_html 

1577 

1578 missing = [name for name in SWAGGER_ASSET_FILES if not (assets_dir / name).is_file()] 

1579 if missing: 

1580 raise SpecSheetError( 

1581 f"swagger-ui-dist assets missing from {assets_dir}: {', '.join(missing)} " 

1582 "(run `npm ci --ignore-scripts --no-audit --no-fund`)" 

1583 ) 

1584 site_url, _repo_url = read_site_config(project_root / SITE_CONFIG_FILE) 

1585 openapi_dir = project_root / "docs" / "openapi" 

1586 written: list[Path] = [] 

1587 assets_out = site_dir / "assets" 

1588 assets_out.mkdir(parents=True, exist_ok=True) 

1589 for name in (*SWAGGER_ASSET_FILES, *SWAGGER_NOTICE_FILES): 

1590 source = assets_dir / name 

1591 if source.is_file(): 

1592 written.append(Path(shutil.copyfile(source, assets_out / name))) 

1593 

1594 titles: dict[str, str] = {} 

1595 for service in service_names(openapi_dir): 

1596 document = load_document(service, openapi_dir) 

1597 titles[service] = str(_dict(document.get("info")).get("title") or service) 

1598 console_dir = site_dir / service 

1599 console_dir.mkdir(parents=True, exist_ok=True) 

1600 spec_copy = console_dir / "openapi.json" 

1601 spec_copy.write_text( 

1602 (openapi_dir / f"{service}.json").read_text(encoding="utf-8"), encoding="utf-8" 

1603 ) 

1604 written.append(spec_copy) 

1605 page = get_swagger_ui_html( 

1606 openapi_url="openapi.json", 

1607 title=f"{titles[service]} — Swagger UI", 

1608 swagger_js_url="../assets/swagger-ui-bundle.js", 

1609 swagger_css_url="../assets/swagger-ui.css", 

1610 swagger_favicon_url="../assets/favicon-32x32.png", 

1611 # No validator badge (a request to validator.swagger.io) and no 

1612 # "Try it out": the site has no backend to send requests to. 

1613 swagger_ui_parameters={"validatorUrl": None, "supportedSubmitMethods": []}, 

1614 ) 

1615 html = bytes(page.body).decode("utf-8") 

1616 if re.search(r"(?:src|href)=\"https?://", html): 

1617 raise SpecSheetError(f"{service}: the Swagger UI page references a remote resource") 

1618 # FastAPI serves this page with a ``charset=utf-8`` header and so omits 

1619 # the meta tag; a static host may not. Without it a browser falls back 

1620 # to Latin-1 for the page *and* the bundle it loads, and the bundle's 

1621 # non-ASCII literals then fail to parse ("SwaggerUIBundle is not defined"). 

1622 html = html.replace("<head>", '<head>\n <meta charset="utf-8">', 1) 

1623 if '<meta charset="utf-8">' not in html: 

1624 raise SpecSheetError(f"{service}: FastAPI's Swagger UI template has no <head> to tag") 

1625 index = console_dir / "index.html" 

1626 index.write_text(html, encoding="utf-8") 

1627 written.append(index) 

1628 root_index = site_dir / "index.html" 

1629 root_index.write_text(_console_index(titles, site_url), encoding="utf-8") 

1630 written.append(root_index) 

1631 return written 

1632 

1633 

1634# ─── CLI ───────────────────────────────────────────────────────────────────── 

1635 

1636 

1637def main(argv: list[str] | None = None) -> int: 

1638 parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) 

1639 parser.add_argument( 

1640 "--check", 

1641 action="store_true", 

1642 help="Do not write; exit non-zero if any committed sheet is stale.", 

1643 ) 

1644 parser.add_argument( 

1645 "--swagger-ui-dir", 

1646 type=Path, 

1647 help="Also build the Swagger UI consoles into this directory (the Pages /swagger/ tree).", 

1648 ) 

1649 parser.add_argument( 

1650 "--swagger-assets", 

1651 type=Path, 

1652 default=None, 

1653 help="The swagger-ui-dist package directory to self-host from " 

1654 "(default: node_modules/swagger-ui-dist).", 

1655 ) 

1656 args = parser.parse_args(argv) 

1657 swagger_assets = args.swagger_assets or REPO_ROOT / "node_modules" / "swagger-ui-dist" 

1658 

1659 try: 

1660 if args.check: 

1661 issues = api_contract_issues(REPO_ROOT) 

1662 for issue in issues: 

1663 print(f"ERROR: {issue}", file=sys.stderr) 

1664 if issues: 

1665 return 1 

1666 print("API spec sheets are current") 

1667 else: 

1668 changed = write_outputs(REPO_ROOT) 

1669 for path in changed: 

1670 print(f"{path}: written") 

1671 if not changed: 

1672 print("API spec sheets unchanged") 

1673 if args.swagger_ui_dir is not None: 

1674 written = build_swagger_site( 

1675 args.swagger_ui_dir, swagger_assets, project_root=REPO_ROOT 

1676 ) 

1677 print(f"Swagger UI consoles: {len(written)} files under {args.swagger_ui_dir}") 

1678 except SpecSheetError as exc: 

1679 print(f"ERROR: {exc}", file=sys.stderr) 

1680 return 2 

1681 return 0 

1682 

1683 

1684if __name__ == "__main__": 

1685 raise SystemExit(main())