#!/usr/bin/env python3 """Generate the browser catalog and OpenAPI documents from one endpoint list.""" from __future__ import annotations import json from pathlib import Path ROOT = Path(__file__).resolve().parents[1] PUBLIC = ROOT / "public" OPENAPI = PUBLIC / "openapi" def title(path: str, method: str) -> str: names = { "/register": "Регистрация аккаунта", "/login": "Вход по паролю", "/auth/email/start": "Запрос email-кода", "/auth/email/verify": "Подтверждение email-кода", "/.well-known/openid-configuration": "OIDC Discovery", "/.well-known/oauth-authorization-server": "OAuth Server Metadata", "/oauth/jwks.json": "Публичные ключи OIDC", "/oauth/device_authorization": "Запуск Device Flow", "/oauth/device": "Страница подтверждения Device Flow", "/oauth/token": "Обмен device code на токены", "/oauth/userinfo": "Профиль OIDC", "/oauth/device/request": "Данные запроса входа", "/oauth/device/decision": "Решение пользователя", "/me": "Текущий пользователь", "/account/delete": "Удаление аккаунта", "/username": "Установка username", "/name": "Изменение отображаемого имени", "/profiles/description": "Описание профиля, бота или чата", "/privacy": "Настройки приватности", "/contacts": "Список контактов", "/contacts/add": "Добавление контакта", "/contacts/delete": "Удаление контакта", "/groups": "Создание группы", "/channels": "Создание канала", "/chats/title": "Изменение названия чата", "/channels/username": "Username канала", "/channels/comments/settings": "Настройки комментариев канала", "/channels/comments/send": "Комментарий к публикации", "/channels/comments": "Комментарии к публикации", "/chats/members/add": "Добавление участника", "/chats/members/remove": "Удаление участника", "/cloud-password": "Облачный пароль", "/cloud-password/reset": "Сброс облачного пароля", "/sessions": "Активные сессии", "/sessions/revoke": "Завершение сессии", "/sessions/revoke-others": "Завершение других сессий", "/bots": "Создание бота", "/bots/token/reset": "Перевыпуск bot token", "/e2e/key": "Публичный E2E-ключ" if method == "GET" else "Публикация E2E-ключа", "/e2e/backup": "Резервная копия E2E" if method == "GET" else "Сохранение E2E-копии", "/e2e/reset": "Сброс E2E-ключей", "/wallet": "Баланс Dastars", "/wallet/send": "Перевод Dastars", "/wallet/history": "История Dastars", "/call": "Создание звонка", "/voice-ticket": "Билет голосовой сессии", "/voice/participants": "Участники голосовой сессии", "/voice": "Голосовой WebSocket", "/send": "Отправка текстового сообщения", "/edit": "Редактирование сообщения", "/callback": "Callback кнопки", "/reactions": "Emoji-реакция", "/reactions/paid": "Платная реакция Dastars", "/read": "Отметка о прочтении", "/delete": "Удаление сообщения", "/favorite": "Избранное сообщение", "/media/quote": "Расчет стоимости медиа", "/messages/prepare": "Подготовка сообщения с медиа", "/messages/commit": "Публикация сообщения с медиа", "/messages/cancel": "Отмена сообщения с медиа", "/forward": "Пересылка сообщения с медиа", "/file/ticket": "Билет скачивания файла", "/health/live": "Проверка процесса Messenger", "/health/ready": "Готовность Messenger", "/metrics": "Метрики Messenger", "/nodes/status": "Статус сервисных узлов", "/chats": "Список чатов", "/chats/delete": "Удаление чата", "/users/ban": "Блокировка пользователя", "/users/unban": "Разблокировка пользователя", "/history": "История сообщений", "/updates": "Очередь обновлений", "/updates/ack": "Подтверждение обновлений", "/file/{id}": "Скачивание файла", "/health": "Состояние Gateway", "/v1/assets": "Доступные активы", "/v1/invoices": "Список invoices" if method == "GET" else "Создание invoice", "/v1/invoices/{invoice_id}": "Получение invoice", "/v1/withdrawals": "Список withdrawals" if method == "GET" else "Создание withdrawal", "/v1/withdrawals/{withdrawal_id}": "Получение withdrawal", "/v1/balances": "Merchant-балансы", "/v1/api-keys": "Список API-ключей", "/admin/v1/status": "Операторский статус", "/admin/v1/callbacks": "Инциденты callbacks", "/admin/v1/consolidations": "Инциденты консолидации", "/admin/v1/callbacks/{callback_id}/retry": "Повтор callback", "/admin/v1/merchants": "Создание merchant", "/admin/v1/fee-policies": "Глобальная fee policy", "/admin/v1/merchants/{merchant_id}/api-keys": "Выпуск merchant API-ключа", "/admin/v1/merchants/{merchant_id}/api-keys/{api_key_id}": "Отзыв merchant API-ключа", "/admin/v1/merchants/{merchant_id}/policies": "Merchant policy", "/admin/v1/withdrawals/{withdrawal_id}/resolve": "Решение manual review", "/admin/v1/consolidations/{consolidation_id}/retry": "Повтор консолидации", "/pay/{token}": "Hosted checkout", "/pay/{token}/status": "Статус hosted checkout", "/api/assets": "Активы для донатов", "/api/auth/start": "Вход стримера через Messenger", "/api/auth/poll/{flow}": "Статус входа стримера", "/api/logout": "Выход из Donation Service", "/api/me": "Панель стримера", "/api/profile": "Настройки страницы и OBS", "/api/sound": "Удаление звука" if method == "DELETE" else "Загрузка звука", "/api/widget/rotate": "Ротация секретной OBS-ссылки", "/api/widget/test": "Тестовый OBS alert", "/api/widget/{token}/config": "Конфигурация OBS-виджета", "/api/widget/{token}/events": "SSE-события OBS-виджета", "/api/public/creators/{slug}": "Публичная страница стримера", "/api/public/donations": "Создание доната", "/api/public/donations/{token}/status": "Статус доната", "/api/withdrawals": "Запрос вывода", "/api/withdrawals/{flow}/poll": "Подтверждение вывода через Messenger", "/api/admin/status": "Статус Donation Service", "/api/admin/creators/{creator_id}/blocked": "Блокировка страницы стримера", } return names.get(path, f"{method} {path}") MESSENGER = [ ("POST", "/register", "Аккаунт", "public"), ("POST", "/login", "Аккаунт", "public"), ("POST", "/auth/email/start", "Аккаунт", "public"), ("POST", "/auth/email/verify", "Аккаунт", "public"), ("GET", "/.well-known/openid-configuration", "OAuth", "public"), ("GET", "/.well-known/oauth-authorization-server", "OAuth", "public"), ("GET", "/oauth/jwks.json", "OAuth", "public"), ("POST", "/oauth/device_authorization", "OAuth", "oauth_client"), ("GET", "/oauth/device", "OAuth", "public"), ("POST", "/oauth/token", "OAuth", "oauth_client"), ("GET", "/oauth/userinfo", "OAuth", "bearer"), ("GET", "/oauth/device/request", "OAuth", "bearer"), ("POST", "/oauth/device/decision", "OAuth", "bearer"), ("GET", "/me", "Аккаунт", "bearer"), ("POST", "/account/delete", "Аккаунт", "bearer"), ("POST", "/username", "Аккаунт", "bearer"), ("POST", "/name", "Аккаунт", "bearer"), ("POST", "/profiles/description", "Аккаунт", "bearer"), ("POST", "/privacy", "Аккаунт", "bearer"), ("GET", "/contacts", "Контакты", "bearer"), ("POST", "/contacts/add", "Контакты", "bearer"), ("POST", "/contacts/delete", "Контакты", "bearer"), ("POST", "/groups", "Чаты", "bearer"), ("POST", "/channels", "Чаты", "bearer"), ("POST", "/chats/title", "Чаты", "bearer"), ("POST", "/channels/username", "Чаты", "bearer"), ("POST", "/channels/comments/settings", "Чаты", "bearer"), ("POST", "/channels/comments/send", "Чаты", "bearer"), ("GET", "/channels/comments", "Чаты", "bearer"), ("POST", "/chats/members/add", "Чаты", "bearer"), ("POST", "/chats/members/remove", "Чаты", "bearer"), ("POST", "/cloud-password", "Безопасность", "bearer"), ("POST", "/cloud-password/reset", "Безопасность", "bearer"), ("GET", "/sessions", "Безопасность", "bearer"), ("POST", "/sessions/revoke", "Безопасность", "bearer"), ("POST", "/sessions/revoke-others", "Безопасность", "bearer"), ("POST", "/bots", "Боты", "bearer"), ("POST", "/bots/token/reset", "Боты", "bearer"), ("POST", "/e2e/key", "E2E", "bearer"), ("GET", "/e2e/key", "E2E", "bearer"), ("POST", "/e2e/backup", "E2E", "bearer"), ("GET", "/e2e/backup", "E2E", "bearer"), ("POST", "/e2e/reset", "E2E", "bearer"), ("GET", "/wallet", "Dastars", "bearer"), ("POST", "/wallet/send", "Dastars", "bearer"), ("GET", "/wallet/history", "Dastars", "bearer"), ("POST", "/call", "Звонки", "bearer"), ("POST", "/voice-ticket", "Звонки", "bearer"), ("GET", "/voice/participants", "Звонки", "bearer"), ("GET", "/voice", "Звонки", "bearer"), ("POST", "/send", "Сообщения", "bearer"), ("POST", "/edit", "Сообщения", "bearer"), ("POST", "/callback", "Боты", "bearer"), ("POST", "/reactions", "Реакции", "bearer"), ("POST", "/reactions/paid", "Реакции", "bearer"), ("POST", "/read", "Сообщения", "bearer"), ("POST", "/delete", "Сообщения", "bearer"), ("POST", "/favorite", "Сообщения", "bearer"), ("POST", "/media/quote", "Файлы", "bearer"), ("POST", "/messages/prepare", "Файлы", "bearer"), ("POST", "/messages/commit", "Файлы", "bearer"), ("POST", "/messages/cancel", "Файлы", "bearer"), ("POST", "/forward", "Файлы", "bearer"), ("GET", "/file/ticket", "Файлы", "bearer"), ("GET", "/nodes/status", "Сервис", "bearer"), ("GET", "/chats", "Чаты", "bearer"), ("POST", "/chats/delete", "Чаты", "bearer"), ("POST", "/users/ban", "Модерация", "bearer"), ("POST", "/users/unban", "Модерация", "bearer"), ("GET", "/history", "Сообщения", "bearer"), ("GET", "/updates", "Updates", "bearer"), ("POST", "/updates/ack", "Updates", "bearer"), ("GET", "/file/{id}", "Файлы", "bearer"), ("GET", "/health/live", "Сервис", "public"), ("GET", "/health/ready", "Сервис", "public"), ("GET", "/metrics", "Сервис", "public"), ] GATEWAY = [ ("GET", "/health", "Сервис", "public"), ("GET", "/v1/assets", "Сервис", "public"), ("GET", "/v1/invoices", "Invoices", "hmac"), ("POST", "/v1/invoices", "Invoices", "hmac"), ("GET", "/v1/invoices/{invoice_id}", "Invoices", "hmac"), ("GET", "/v1/withdrawals", "Withdrawals", "hmac"), ("POST", "/v1/withdrawals", "Withdrawals", "hmac"), ("GET", "/v1/withdrawals/{withdrawal_id}", "Withdrawals", "hmac"), ("GET", "/v1/balances", "Баланс", "hmac"), ("GET", "/v1/api-keys", "Ключи", "hmac"), ("GET", "/admin/v1/status", "Operator", "admin"), ("GET", "/admin/v1/callbacks", "Operator", "admin"), ("GET", "/admin/v1/consolidations", "Operator", "admin"), ("POST", "/admin/v1/callbacks/{callback_id}/retry", "Operator", "admin"), ("POST", "/admin/v1/merchants", "Operator", "admin"), ("POST", "/admin/v1/fee-policies", "Operator", "admin"), ("POST", "/admin/v1/merchants/{merchant_id}/api-keys", "Operator", "admin"), ("DELETE", "/admin/v1/merchants/{merchant_id}/api-keys/{api_key_id}", "Operator", "admin"), ("POST", "/admin/v1/merchants/{merchant_id}/policies", "Operator", "admin"), ("POST", "/admin/v1/withdrawals/{withdrawal_id}/resolve", "Operator", "admin"), ("POST", "/admin/v1/consolidations/{consolidation_id}/retry", "Operator", "admin"), ("GET", "/pay/{token}", "Checkout", "public"), ("GET", "/pay/{token}/status", "Checkout", "public"), ] DONATIONS = [ ("GET", "/health", "Сервис", "public"), ("GET", "/api/assets", "Сервис", "public"), ("POST", "/api/auth/start", "Авторизация", "public"), ("GET", "/api/auth/poll/{flow}", "Авторизация", "public"), ("POST", "/api/logout", "Авторизация", "session"), ("GET", "/api/me", "Стример", "session"), ("PATCH", "/api/profile", "Стример", "session"), ("PUT", "/api/sound", "OBS", "session"), ("DELETE", "/api/sound", "OBS", "session"), ("POST", "/api/widget/rotate", "OBS", "session"), ("POST", "/api/widget/test", "OBS", "session"), ("GET", "/api/widget/{token}/config", "OBS", "widget"), ("GET", "/api/widget/{token}/events", "OBS", "widget"), ("GET", "/api/public/creators/{slug}", "Донаты", "public"), ("POST", "/api/public/donations", "Донаты", "public"), ("GET", "/api/public/donations/{token}/status", "Донаты", "public"), ("POST", "/api/withdrawals", "Вывод", "session"), ("GET", "/api/withdrawals/{flow}/poll", "Вывод", "session"), ("GET", "/api/admin/status", "Оператор", "session"), ("PATCH", "/api/admin/creators/{creator_id}/blocked", "Оператор", "session"), ] AUTH_LABELS = { "public": "public", "bearer": "Bearer", "oauth_client": "OAuth client", "hmac": "HMAC", "admin": "Admin Bearer", "session": "Secure session", "widget": "Widget token", } BODIES = { ("messenger", "POST", "/register"): {"email": "you@example.com", "password": "correct horse battery staple"}, ("messenger", "POST", "/login"): {"email": "you@example.com", "password": "correct horse battery staple"}, ("messenger", "POST", "/auth/email/start"): {"email": "you@example.com"}, ("messenger", "POST", "/auth/email/verify"): {"email": "you@example.com", "code": "123456"}, ("messenger", "POST", "/send"): {"to": 42, "text": "Привет!", "client_message_id": "0190f6d4-example"}, ("messenger", "POST", "/media/quote"): {"media": [{"client_id": "attachment-1", "name": "photo.jpg", "mime": "image/jpeg", "size": 524288}]}, ("messenger", "POST", "/messages/prepare"): {"to": 42, "text": "Caption", "client_message_id": "0190f6d4-media", "media": [{"client_id": "attachment-1", "name": "photo.jpg", "mime": "image/jpeg", "size": 524288}]}, ("messenger", "POST", "/messages/commit"): {"operation_id": "message-op-1-0190f6d4-media"}, ("messenger", "POST", "/messages/cancel"): {"operation_id": "message-op-1-0190f6d4-media"}, ("messenger", "POST", "/forward"): {"message_id": 1001, "to": 42, "client_message_id": "0190f6d4-forward"}, ("messenger", "POST", "/edit"): {"chat_id": 42, "message_id": 1001, "text": "Исправленный текст"}, ("messenger", "POST", "/reactions"): {"chat_id": 42, "message_id": 1001, "emoji": "🔥"}, ("messenger", "POST", "/reactions/paid"): {"chat_id": 42, "message_id": 1001, "amount": 5}, ("messenger", "POST", "/updates/ack"): {"update_id": 2048}, ("messenger", "POST", "/bots"): {"username": "shop_helper_bot", "name": "Shop helper"}, ("gateway", "POST", "/v1/invoices"): { "merchant_order_id": "order-123", "asset": "USDT", "network": "tron", "amount_atomic": "25000000", "expires_in_seconds": 1800, "description": "Order 123", "success_url": "https://merchant.example/orders/123", "metadata": {"customer": "42"}, }, ("gateway", "POST", "/v1/withdrawals"): { "asset": "USDT", "network": "tron", "amount_atomic": "10000000", "address": "T...", "fee_mode": "additional", "metadata": {"payout": "p-123"}, }, ("gateway", "POST", "/admin/v1/merchants"): { "name": "Example shop", "callback_url": "https://merchant.example/payments", "callback_hosts": ["merchant.example"], }, ("gateway", "POST", "/admin/v1/fee-policies"): { "asset": "USDT", "network": "tron", "platform_fee_basis_points": 500, "platform_fee_cap_atomic": "10000000", }, ("donations", "POST", "/api/auth/start"): {"email": "streamer@example.com"}, ("donations", "PATCH", "/api/profile"): { "display_name": "My channel", "slug": "my-channel", "accent_color": "#b9f541", "duration_seconds": 8, "show_donor_name": True, "show_message": True, }, ("donations", "POST", "/api/public/donations"): { "slug": "my-channel", "donor_name": "Anonymous", "message": "Great stream!", "asset": "USDT", "network": "tron", "amount": "25.5", "cover_fee": True, }, ("donations", "POST", "/api/withdrawals"): { "asset": "USDT", "network": "tron", "amount": "10", "address": "TExampleRecipientAddress", }, ("donations", "PATCH", "/api/admin/creators/{creator_id}/blocked"): {"blocked": True}, } DESCRIPTIONS = { "/oauth/device_authorization": "Создает device_code, user_code и verification URI для универсального входа через Messenger.", "/oauth/token": "Обменивает подтвержденный device_code на OIDC access/id token. Во время ожидания возвращает authorization_pending.", "/send": "Отправляет сообщение без медиа. Для клиентской очереди используйте стабильный client_message_id.", "/media/quote": "Рассчитывает единую стоимость новых вложений: сумма байтов округляется вверх до MiB один раз на сообщение.", "/messages/prepare": "Атомарно резервирует оплату и выдает до 10 upload tickets. Текст и media[] опциональны по отдельности, но сообщение не может быть пустым.", "/messages/commit": "Проверяет все загруженные вложения, одним платежом списывает DSR и публикует или редактирует одно сообщение.", "/messages/cancel": "Удаляет незавершенные загрузки и отменяет резерв оплаты.", "/updates": "Long polling событий клиента или бота. Bot updates долговечны и остаются до явного ACK.", "/reactions/paid": "Переводит указанное количество Dastars автору сообщения; для бота — его владельцу. amount может быть больше 1.", "/voice": "Переключает соединение на WebSocket после получения voice ticket.", "/v1/invoices": "Создает invoice или возвращает историю с cursor pagination, в зависимости от HTTP-метода.", "/v1/withdrawals": "Создает withdrawal или возвращает историю. Для token assets разрешен только fee_mode=additional.", "/v1/assets": "Возвращает только пары asset/network, для которых wallet seed готов к работе.", "/pay/{token}": "Публичная HTML-страница оплаты с QR, серверно-синхронизированным таймером и live status.", "/pay/{token}/status": "JSON-состояние checkout для polling; включает серверное время и срок действия.", "/api/public/donations": "Создает Gateway invoice с комиссией 5%. cover_fee=false удерживает комиссию из суммы; cover_fee=true точно увеличивает платеж так, чтобы стример получил введенную сумму после комиссии. Все расчеты выполняются в atomic без float.", "/api/widget/{token}/events": "Долгоживущий SSE-поток для OBS Browser Source. Виджет сам ставит alerts в очередь.", "/api/withdrawals": "Резервирует баланс и запускает новый Messenger Device Flow с описанием суммы и адреса.", "/api/withdrawals/{flow}/poll": "После свайпа тем же Messenger-пользователем идемпотентно отправляет withdrawal в Gateway.", } def endpoint(service_id: str, row: tuple[str, str, str, str]) -> dict: method, path, group, auth = row default = f"{title(path, method)}. Возвращает JSON-ответ или стандартную JSON-ошибку." item = { "method": method, "path": path, "group": group, "auth": auth, "auth_label": AUTH_LABELS[auth], "title": title(path, method), "description": DESCRIPTIONS.get(path, default), } body = BODIES.get((service_id, method, path)) if body is not None: item["body"] = body return item def openapi(service: dict) -> dict: paths: dict[str, dict] = {} for item in service["endpoints"]: operation: dict = { "tags": [item["group"]], "summary": item["title"], "description": item["description"], "operationId": ( item["method"].lower() + "_" + item["path"].strip("/").replace("/", "_").replace("{", "").replace("}", "") ).replace("-", "_").replace(".", "_") or "root", "responses": { "200": {"description": "Успешный ответ"}, "400": {"description": "Ошибка запроса"}, "401": {"description": "Ошибка авторизации"}, }, } if item["auth"] == "bearer": operation["security"] = [{"bearerAuth": []}] elif item["auth"] == "admin": operation["security"] = [{"adminBearer": []}] elif item["auth"] == "hmac": operation["security"] = [{"merchantHmac": []}] operation["parameters"] = [ {"name": "X-Api-Timestamp", "in": "header", "required": True, "schema": {"type": "integer"}}, {"name": "X-Api-Nonce", "in": "header", "required": True, "schema": {"type": "string", "format": "uuid"}}, {"name": "X-Api-Signature", "in": "header", "required": True, "schema": {"type": "string"}}, ] elif item["auth"] == "oauth_client": operation["security"] = [{"oauthClient": []}] elif item["auth"] == "session": operation["security"] = [{"sessionCookie": []}] parameters = operation.setdefault("parameters", []) for parameter in (part[1:-1] for part in item["path"].split("/") if part.startswith("{")): parameters.append({ "name": parameter, "in": "path", "required": True, "schema": {"type": "string"}, }) if not parameters: operation.pop("parameters", None) if item.get("body") is not None: operation["requestBody"] = { "required": True, "content": {"application/json": {"schema": {"type": "object"}, "example": item["body"]}}, } paths.setdefault(item["path"], {})[item["method"].lower()] = operation schemes = { "bearerAuth": {"type": "http", "scheme": "bearer"}, "oauthClient": { "type": "http", "scheme": "basic", "description": ( "OAuth client_id/client_secret. Device Authorization Grant не имеет " "отдельного стандартного flow в OpenAPI 3.1; discovery доступен в " "/.well-known/oauth-authorization-server." ), }, "merchantHmac": {"type": "apiKey", "in": "header", "name": "X-Api-Key", "description": "Дополнительно обязательна HMAC-SHA256 подпись запроса."}, "adminBearer": {"type": "http", "scheme": "bearer", "description": "Operator admin token"}, "sessionCookie": {"type": "apiKey", "in": "cookie", "name": "ove_donations_session", "description": "HttpOnly session; changing requests also require X-CSRF-Token."}, } scheme_auth = { "bearerAuth": "bearer", "oauthClient": "oauth_client", "merchantHmac": "hmac", "adminBearer": "admin", "sessionCookie": "session", } active_auth = {item["auth"] for item in service["endpoints"]} return { "openapi": "3.1.0", "info": { "title": f"OVE {service['name']} API", "version": "1.0.0", "description": service["description"], }, "servers": [{"url": service["base_url"]}], "paths": paths, "components": { "securitySchemes": { key: value for key, value in schemes.items() if scheme_auth[key] in active_auth }, "schemas": { "Error": { "type": "object", "properties": {"error": {"type": "string"}}, "required": ["error"], } }, }, } def main() -> None: services = [ { "id": "messenger", "name": "Messenger", "base_url": "https://ms.ove.rs", "description": "OVE Messenger user, bot, OAuth, files, calls and realtime updates API.", "endpoints": [endpoint("messenger", row) for row in MESSENGER], }, { "id": "gateway", "name": "Crypto Gateway", "base_url": "https://cr.ove.rs", "description": "OVE multi-merchant cryptocurrency payments, checkout and operator API.", "endpoints": [endpoint("gateway", row) for row in GATEWAY], }, { "id": "donations", "name": "Donations", "base_url": "https://do.ove.rs", "description": "OVE donation pages, Messenger login, Crypto Gateway payments and OBS alerts.", "endpoints": [endpoint("donations", row) for row in DONATIONS], }, ] manifest = { "version": 1, "generated_from": [ "micro-chat/micromsg/src/app_routes.rs", "crypto-gateway/crates/gateway-api/src/main.rs", "crypto-gateway/crates/gateway-api/src/web.rs", "donations/src/main.rs", ], "total_endpoints": sum(len(service["endpoints"]) for service in services), "services": services, } OPENAPI.mkdir(parents=True, exist_ok=True) (PUBLIC / "api-manifest.json").write_text( json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) for service in services: (OPENAPI / f"{service['id']}.json").write_text( json.dumps(openapi(service), ensure_ascii=False, indent=2) + "\n", encoding="utf-8" ) gateway_spec = OPENAPI / "gateway.json" if not gateway_spec.exists(): (OPENAPI / "gateway.json").write_text( (OPENAPI / "crypto-gateway.json").read_text(encoding="utf-8"), encoding="utf-8" ) (OPENAPI / "crypto-gateway.json").unlink() print(f"generated {manifest['total_endpoints']} endpoints") if __name__ == "__main__": main()