""" routes/openapi.py — OpenAPI 3.1.0 спецификация. Blueprint "openapi" с префиксом /api/v1/svc. GET /openapi.json — динамическая OpenAPI-спека, построенная из загруженных сервисов. Swagger UI загружает этот JSON через /swagger и рендерит интерактивную документацию. Спека генерируется при каждом запросе — всегда актуальна. """ import os from flask import Blueprint, jsonify import config.loader as _cfg bp = Blueprint("openapi", __name__, url_prefix="/api/v1/svc") # URL для сервера — из env или автоопределение _SERVER_URL = os.getenv("POLYGON_ENDPOINT", "https://polygon.pythonk8s.dev.nubes.ru") def _build_servers(): """Список серверов — по одному на каждый стенд.""" servers = [] for sid in _cfg.list_stands(): servers.append({ "url": f"{_SERVER_URL}/{sid}", "description": f"Стенд: {sid}", }) return servers @bp.route("/openapi.json", methods=["GET"]) def openapi_spec(): """GET /api/v1/svc/openapi.json — OpenAPI 3.1.0 спецификация. Возвращает полную спецификацию всех эндпоинтов полигона. Динамически добавляет схемы параметров для каждого загруженного сервиса. """ return jsonify(_build_spec()) def _build_spec(): """Собрать OpenAPI 3.1.0 спецификацию. Включает: - Все 17 эндпоинтов (REST API + _mock) - Bearer-авторизацию - Схемы: Service, Instance, Operation, CfsParam, Stage - Примеры запросов и ответов """ return { "openapi": "3.1.0", "info": { "title": "Polygon — Mock Nubes API", "version": _cfg.VERSION, "description": ( "Эмулятор REST API облачной платформы **Nubes** для интеграционных тестов.\n\n" "Polygon полностью повторяет контракты реального API: сервисы, инстансы, " "операции, параметры, валидацию — но работает **без реальной инфраструктуры**, " "в памяти, с мгновенным откликом.\n\n" "### Стенды\n" "**3 изолированных стенда** — переключайте в выпадающем списке серверов выше:\n" "- **dev** — 37 сервисов (разработка)\n" "- **test** — 37 сервисов (тестирование)\n" "- **prod** — 35 сервисов (продуктив)\n" "У каждого стенда **свои инстансы, свои операции, своё состояние**.\n\n" "### Особенности\n" "- Все операции синхронные (настраиваемая задержка через `/_mock/delay`)\n" "- Состояние в памяти (1 gunicorn-воркер)\n" "- Конфигурация сервисов из YAML (генерируется из terraform)\n" "- Управляемый сброс через `/_mock/reset`\n" "- Симуляция ошибок через `/_mock/fail-next`\n\n" "### Авторизация\n" "- Основные эндпоинты — без авторизации\n" "- `_mock/*` — заголовок `X-Mock-Auth: test-token-123` " "(настроен через `MOCK_AUTH_TOKEN`)" ), "contact": { "name": "Nubes Platform", }, }, "servers": _build_servers(), "security": [], "tags": [ {"name": "services", "description": "Сервисы (каталог)"}, {"name": "instances", "description": "Инстансы (CRUD)"}, {"name": "operations", "description": "Операции (create/modify/delete)"}, {"name": "mock", "description": "Служебные (сброс, отладка, задержка)"}, {"name": "health", "description": "Healthcheck"}, ], "paths": _build_paths(), "components": { "securitySchemes": { "mockAuth": { "type": "apiKey", "in": "header", "name": "X-Mock-Auth", "description": "Токен для _mock/* эндпоинтов (по умолчанию: test-token-123)", }, }, "schemas": { "Service": { "type": "object", "properties": { "svcId": {"type": "integer", "description": "Числовой ID сервиса"}, "svc": {"type": "string", "description": "Человекочитаемое имя (Болванка, PostgreSQL, …)"}, "svcShort": {"type": "string", "description": "Короткое имя (dummy, postgres, …)"}, }, }, "ServiceList": { "type": "object", "properties": { "results": { "type": "array", "items": {"$ref": "#/components/schemas/Service"}, }, }, }, "ServiceDetail": { "type": "object", "properties": { "svc": { "type": "object", "properties": { "svc": {"type": "string"}, "svcShort": {"type": "string"}, "operations": { "type": "array", "items": { "type": "object", "properties": { "svcOperationId": {"type": "integer", "description": "ID операции"}, "operation": {"type": "string", "description": "create | modify | delete | suspend | …"}, }, }, }, }, }, }, }, "InstanceListItem": { "type": "object", "properties": { "instanceUid": {"type": "string", "format": "uuid", "description": "UUID инстанса"}, "serviceId": {"type": "integer", "description": "ID сервиса"}, "displayName": {"type": "string", "description": "Отображаемое имя"}, "svc": {"type": "string", "description": "Название сервиса"}, "explainedStatus": {"type": "string", "description": "Статус (Создание, Работает, …)"}, }, }, "InstanceList": { "type": "object", "properties": { "results": {"type": "array", "items": {"$ref": "#/components/schemas/InstanceListItem"}}, "pageSize": {"type": "integer"}, "page": {"type": "integer"}, "total": {"type": "integer"}, }, }, "Instance": { "type": "object", "properties": { "instanceUid": {"type": "string", "format": "uuid"}, "serviceId": {"type": "integer"}, "displayName": {"type": "string"}, "descr": {"type": "string"}, "status": {"type": "string", "description": "creating | running | modifying | deleting | suspended"}, "explainedStatus": {"type": "string"}, "svc": {"type": "string"}, "dtCreate": {"type": "string", "format": "date-time"}, "state": { "type": "object", "properties": { "params": {"type": "object", "description": "Параметры инстанса"}, "out": {"type": "object", "description": "Output-параметры"}, }, }, }, }, "InstanceResponse": { "type": "object", "properties": { "instance": {"$ref": "#/components/schemas/Instance"}, }, }, "CreateInstanceRequest": { "type": "object", "required": ["serviceId"], "properties": { "serviceId": {"type": "integer", "description": "ID сервиса из каталога"}, "displayName": {"type": "string", "description": "Отображаемое имя (по умолчанию: unnamed)"}, }, }, "CreateInstanceResponse": { "type": "object", "properties": { "instanceUid": {"type": "string", "format": "uuid"}, }, }, "CfsParam": { "type": "object", "properties": { "svcOperationCfsParamId": {"type": "integer", "description": "ID параметра"}, "svcOperationCfsParam": {"type": "string", "description": "Название параметра"}, "dataType": {"type": "string", "description": "string | integer | map | map-fixed | boolean"}, "dataDescriptor": {"type": "string", "description": "JSON-описание подполей (для map-типов)"}, "isRequired": {"type": "boolean"}, "isModifiable": {"type": "boolean"}, "defaultValue": {"type": "string"}, "paramValue": {"type": "string", "description": "Текущее значение (если задано)"}, "valueList": { "type": "array", "items": { "type": "object", "properties": { "value": {"type": "string"}, "label": {"type": "string"}, }, }, }, }, }, "OperationDefault": { "type": "object", "properties": { "svcOperation": { "type": "object", "properties": { "svcOperationId": {"type": "integer"}, "operation": {"type": "string"}, "cfsParams": { "type": "array", "items": {"$ref": "#/components/schemas/CfsParam"}, }, }, }, }, }, "Operation": { "type": "object", "properties": { "instanceOperationUid": {"type": "string", "format": "uuid"}, "instanceUid": {"type": "string", "format": "uuid"}, "svcOperationId": {"type": "integer"}, "operation": {"type": "string"}, "kind": {"type": "string", "description": "instance | access"}, "action": {"type": "string", "description": "create | modify | delete | …"}, "dtStart": {"type": ["string", "null"], "format": "date-time"}, "dtFinish": {"type": ["string", "null"], "format": "date-time"}, "isSuccessful": {"type": ["boolean", "null"]}, "errorLog": {"type": "string"}, "svc": {"type": "string"}, "stages": {"type": "array", "items": {"$ref": "#/components/schemas/Stage"}}, "cfsParams": {"type": "array", "items": {"$ref": "#/components/schemas/CfsParam"}}, }, }, "OperationResponse": { "type": "object", "properties": { "instanceOperation": {"$ref": "#/components/schemas/Operation"}, }, }, "Stage": { "type": "object", "properties": { "stage": {"type": "string"}, "status": {"type": "string", "description": "running | done | failed"}, "error": {"type": "string"}, "dtStart": {"type": "string", "format": "date-time"}, "dtFinish": {"type": "string", "format": "date-time"}, }, }, "CreateOperationRequest": { "type": "object", "required": ["instanceUid", "operation"], "properties": { "instanceUid": {"type": "string", "format": "uuid"}, "operation": {"type": "string", "description": "create | modify | delete | …"}, "svcOperationId": {"type": "integer", "description": "ID операции (опционально при create)"}, }, }, "CreateOperationResponse": { "type": "object", "properties": { "instanceOperationUid": {"type": "string", "format": "uuid"}, }, }, "SetParamRequest": { "type": "object", "required": ["instanceOperationUid", "svcOperationCfsParamId"], "properties": { "instanceOperationUid": {"type": "string", "format": "uuid"}, "svcOperationCfsParamId": {"type": "integer"}, "paramValue": {"type": "string"}, }, }, "RunResponse": { "type": "object", "properties": { "ok": {"type": "boolean"}, "error": {"type": "string", "description": "Сообщение об ошибке (только при ok=false)"}, }, }, "MockState": { "type": "object", "properties": { "instances": {"type": "object", "description": "uid → {instanceUid, displayName, status, serviceId}"}, "operations": {"type": "object", "description": "uid → {instanceOperationUid, instanceUid, operation, dtFinish, isSuccessful}"}, }, }, "MockServices": { "type": "object", "properties": { "count": {"type": "integer"}, "services": {"type": "object", "description": "svcId → {name, displayName, operations, params}"}, }, }, "Error": { "type": "object", "properties": { "error": {"type": "string"}, }, }, }, }, } def _build_paths(): """Собрать все paths (эндпоинты) спецификации.""" return { # ── Health ── "/health": { "get": { "tags": ["health"], "summary": "Healthcheck", "description": "Проверка живучести сервиса. Nubes дёргает этот эндпоинт периодически.", "operationId": "health", "security": [], "responses": { "200": { "description": "OK", "content": {"text/plain": {"schema": {"type": "string", "example": "OK"}}}, }, }, }, }, # ── Services ── "/api/v1/svc/services": { "get": { "tags": ["services"], "summary": "Список сервисов", "description": "Возвращает все загруженные сервисы (каталог).", "operationId": "listServices", "responses": { "200": { "description": "Список сервисов", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ServiceList"}}}, }, }, }, }, "/api/v1/svc/services/{serviceId}": { "get": { "tags": ["services"], "summary": "Детали сервиса", "description": "Возвращает детали сервиса: название, список операций.", "operationId": "getService", "parameters": [ { "name": "serviceId", "in": "path", "required": True, "schema": {"type": "integer"}, "description": "ID сервиса", }, ], "responses": { "200": { "description": "Детали сервиса", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/ServiceDetail"}}}, }, "404": { "description": "Сервис не найден", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, }, }, }, }, # ── Instances ── "/api/v1/svc/instances": { "get": { "tags": ["instances"], "summary": "Список инстансов", "description": "Возвращает список инстансов с пагинацией.", "operationId": "listInstances", "parameters": [ { "name": "pageSize", "in": "query", "schema": {"type": "integer", "default": 200}, "description": "Размер страницы (значения > 200 обрезаются до 200)", }, { "name": "page", "in": "query", "schema": {"type": "integer", "default": 1}, "description": "Номер страницы", }, ], "responses": { "200": { "description": "Список инстансов", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceList"}}}, }, }, }, "post": { "tags": ["instances"], "summary": "Создать инстанс", "description": "Создаёт shell инстанса (статус: creating). Возвращает 201 + Location.", "operationId": "createInstance", "requestBody": { "required": True, "content": { "application/json": { "schema": {"$ref": "#/components/schemas/CreateInstanceRequest"}, "example": {"serviceId": 38, "displayName": "my-test-instance"}, }, }, }, "responses": { "201": { "description": "Инстанс создан", "headers": { "Location": { "description": "Относительный URL инстанса (./{instanceUid})", "schema": {"type": "string"}, }, }, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateInstanceResponse"}}}, }, "400": { "description": "serviceId обязателен", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, }, "404": { "description": "Сервис не найден", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, }, }, }, }, "/api/v1/svc/instances/{instanceUid}": { "get": { "tags": ["instances"], "summary": "Детали инстанса", "description": "Возвращает полные данные инстанса: статус, параметры, output.", "operationId": "getInstance", "parameters": [ { "name": "instanceUid", "in": "path", "required": True, "schema": {"type": "string", "format": "uuid"}, "description": "UUID инстанса", }, ], "responses": { "200": { "description": "Детали инстанса", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceResponse"}}}, }, "404": { "description": "Инстанс не найден", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, }, }, }, }, # ── Operations: default ── "/api/v1/svc/instanceOperations/default/{svcOperationId}": { "get": { "tags": ["operations"], "summary": "Шаблон операции", "description": ( "Возвращает шаблон операции: название, список cfsParams " "(с dataDescriptor, valueList, isModifiable, isRequired).\n\n" "**Ключевой эндпоинт** для app-autotest — отсюда executor берёт " "структуру параметров для рендеринга формы." ), "operationId": "getOperationDefault", "parameters": [ { "name": "svcOperationId", "in": "path", "required": True, "schema": {"type": "integer"}, "description": "ID операции (svcOperationId)", }, ], "responses": { "200": { "description": "Шаблон операции", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OperationDefault"}}}, }, "404": { "description": "Операция не найдена", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, }, }, }, }, # ── Operations: create ── "/api/v1/svc/instanceOperations": { "post": { "tags": ["operations"], "summary": "Создать операцию", "description": ( "Создаёт операцию для инстанса. Возвращает 201 + Location.\n\n" "При `operation=\"create\"` можно не передавать `svcOperationId` — " "полигон найдёт его сам по сервису инстанса." ), "operationId": "createOperation", "requestBody": { "required": True, "content": { "application/json": { "schema": {"$ref": "#/components/schemas/CreateOperationRequest"}, "example": { "instanceUid": "550e8400-e29b-41d4-a716-446655440000", "operation": "create", }, }, }, }, "responses": { "201": { "description": "Операция создана", "headers": { "Location": { "description": "Относительный URL операции (./{instanceOperationUid})", "schema": {"type": "string"}, }, }, "content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateOperationResponse"}}}, }, "400": { "description": "instanceUid обязателен", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, }, "404": { "description": "Инстанс или операция не найдены", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, }, }, }, }, # ── Operations: detail + status ── "/api/v1/svc/instanceOperations/{instanceOperationUid}": { "get": { "tags": ["operations"], "summary": "Статус операции", "description": ( "Возвращает статус операции. Поддерживает `?fields=dtFinish,isSuccessful` " "для поллинга (app-autotest ждёт пока dtFinish != null).\n\n" "С `?fields=cfsParams` добавляет текущие значения параметров." ), "operationId": "getOperation", "parameters": [ { "name": "instanceOperationUid", "in": "path", "required": True, "schema": {"type": "string", "format": "uuid"}, "description": "UUID операции", }, { "name": "fields", "in": "query", "schema": {"type": "string"}, "description": "cs-список полей: dtFinish,isSuccessful,cfsParams", }, ], "responses": { "200": { "description": "Статус операции", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/OperationResponse"}}}, }, "404": { "description": "Операция не найдена", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, }, }, }, }, # ── Operations: run ── "/api/v1/svc/instanceOperations/{instanceOperationUid}/run": { "post": { "tags": ["operations"], "summary": "Запустить операцию", "description": ( "Выполняет операцию синхронно:\n" "1. Фиксирует dtStart\n" "2. Ждёт DELAY секунд (настраивается через `/_mock/delay`)\n" "3. Применяет эффект (create → running, delete → удалить, modify → обновить параметры)\n" "4. Фиксирует dtFinish\n\n" "Повторный вызов → 409 (already completed)." ), "operationId": "runOperation", "parameters": [ { "name": "instanceOperationUid", "in": "path", "required": True, "schema": {"type": "string", "format": "uuid"}, "description": "UUID операции", }, ], "responses": { "200": { "description": "Операция выполнена", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/RunResponse"}}}, }, "404": { "description": "Операция не найдена", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, }, "409": { "description": "Операция уже выполнена", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, }, }, }, }, # ── Operations: validate ── "/api/v1/svc/instanceOperations/{instanceOperationUid}/validate-cfs": { "get": { "tags": ["operations"], "summary": "Валидация параметров", "description": "Проверяет параметры операции. Возвращает пустое тело (200).", "operationId": "validateCfs", "parameters": [ { "name": "instanceOperationUid", "in": "path", "required": True, "schema": {"type": "string", "format": "uuid"}, "description": "UUID операции", }, ], "responses": { "200": { "description": "Валидация пройдена", }, "404": { "description": "Операция не найдена", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, }, }, }, }, # ── Operations: set params ── "/api/v1/svc/instanceOperationCfsParams": { "post": { "tags": ["operations"], "summary": "Установить параметр операции", "description": ( "Устанавливает значение одного параметра операции. " "Вызывается N раз (по одному на каждый параметр) перед `/run`." ), "operationId": "setOperationParam", "requestBody": { "required": True, "content": { "application/json": { "schema": {"$ref": "#/components/schemas/SetParamRequest"}, "example": { "instanceOperationUid": "550e8400-e29b-41d4-a716-446655440000", "svcOperationCfsParamId": 1, "paramValue": "my-value", }, }, }, }, "responses": { "200": { "description": "Параметр установлен (пустое тело)", }, "400": { "description": "Не указан instanceOperationUid или svcOperationCfsParamId", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, }, "404": { "description": "Операция не найдена", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, }, }, }, }, # ── Mock ── "/api/v1/svc/_mock/reset": { "post": { "tags": ["mock"], "summary": "Сброс состояния", "description": "Удаляет **все** инстансы, операции и параметры. Используется в тестах для изоляции.", "operationId": "mockReset", "security": [{"mockAuth": []}], "responses": { "200": { "description": "Состояние сброшено", "content": {"application/json": {"schema": {"type": "object", "properties": {"reset": {"type": "string", "example": "ok"}}}}}, }, }, }, }, "/api/v1/svc/_mock/state": { "get": { "tags": ["mock"], "summary": "Дамп состояния", "description": "Возвращает все инстансы и операции для отладки.", "operationId": "mockState", "security": [{"mockAuth": []}], "responses": { "200": { "description": "Текущее состояние", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MockState"}}}, }, }, }, }, "/api/v1/svc/_mock/services": { "get": { "tags": ["mock"], "summary": "Список загруженных сервисов", "description": "Возвращает все загруженные сервисы с количеством операций и параметров.", "operationId": "mockServices", "security": [{"mockAuth": []}], "responses": { "200": { "description": "Список сервисов", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/MockServices"}}}, }, }, }, }, "/api/v1/svc/_mock/delay/{seconds}": { "post": { "tags": ["mock"], "summary": "Задать задержку операций", "description": ( "Меняет задержку выполнения операций «на лету» без перезапуска.\n" "Минимум: 0, максимум: 5 (защита /health)." ), "operationId": "mockSetDelay", "security": [{"mockAuth": []}], "parameters": [ { "name": "seconds", "in": "path", "required": True, "schema": {"type": "number", "minimum": 0, "maximum": 5}, "description": "Задержка в секундах (0…5)", }, ], "responses": { "200": { "description": "Задержка изменена", "content": {"application/json": {"schema": {"type": "object", "properties": {"delay": {"type": "number"}}}}}, }, "400": { "description": "Некорректное значение", "content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}, }, }, }, }, "/api/v1/svc/_mock/fail-next": { "post": { "tags": ["mock"], "summary": "Следующая операция упадёт", "description": ( "Устанавливает one-shot флаг. Следующий `POST /run` вернёт ошибку.\n" "Позволяет тестировать негативные сценарии." ), "operationId": "mockFailNext", "security": [{"mockAuth": []}], "responses": { "200": { "description": "Флаг установлен", "content": {"application/json": {"schema": {"type": "object", "properties": {"fail_next": {"type": "boolean", "example": True}}}}}, }, }, }, }, }