docs: clarify Polygon auth and API contracts
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
# polygon v0.5.17
|
# polygon v0.5.18
|
||||||
|
|
||||||
Эмулятор REST API облачной платформы Nubes для интеграционных тестов app-autotest.
|
Эмулятор REST API облачной платформы Nubes для интеграционных тестов app-autotest.
|
||||||
|
|
||||||
@@ -11,13 +11,14 @@
|
|||||||
polygon/
|
polygon/
|
||||||
├── requirements.txt # Flask>=3.0, gunicorn>=21.2, PyYAML>=6.0
|
├── requirements.txt # Flask>=3.0, gunicorn>=21.2, PyYAML>=6.0
|
||||||
├── tests/
|
├── tests/
|
||||||
│ ├── test_converter.py # тесты from_stands.py
|
│ ├── test_converter.py # тесты from_stands.py
|
||||||
│ ├── test_state_machine.py # тесты state_machine.py
|
│ ├── test_loader.py # тесты CWD-independent loader
|
||||||
|
│ ├── test_state_machine.py # тесты state_machine.py
|
||||||
│ └── test_route_regressions.py # локальные регрессионные тесты API
|
│ └── test_route_regressions.py # локальные регрессионные тесты API
|
||||||
└── site/
|
└── site/
|
||||||
├── app.py # Flask: только blueprint'ы + app.run() (57 строк)
|
├── app.py # Flask: только blueprint'ы + app.run() (57 строк)
|
||||||
├── routes/ # blueprint-файлы по доменам
|
├── routes/ # blueprint-файлы по доменам
|
||||||
│ ├── root.py # /health, /
|
│ ├── root.py # /health, /, /swagger
|
||||||
│ ├── services_routes.py# /api/v1/svc/services
|
│ ├── services_routes.py# /api/v1/svc/services
|
||||||
│ ├── instances_routes.py# /api/v1/svc/instances
|
│ ├── instances_routes.py# /api/v1/svc/instances
|
||||||
│ ├── operations_routes.py# /api/v1/svc/instanceOperations/*
|
│ ├── operations_routes.py# /api/v1/svc/instanceOperations/*
|
||||||
@@ -35,7 +36,8 @@ polygon/
|
|||||||
├── static/
|
├── static/
|
||||||
│ └── style.css # тёмная тема
|
│ └── style.css # тёмная тема
|
||||||
├── templates/
|
├── templates/
|
||||||
│ └── index.html # Jinja2-шаблон
|
│ ├── index.html # главная и инструкции API
|
||||||
|
│ └── swagger.html # Swagger UI и инструкция Authorize
|
||||||
└── services/ # fallback YAML, также генерируются from_stands.py
|
└── services/ # fallback YAML, также генерируются from_stands.py
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -141,6 +143,12 @@ curl https://polygon.pythonk8s.dev.nubes.ru/dev/api/v1/svc/services | python3 -m
|
|||||||
|
|
||||||
Основные API-маршруты не требуют авторизации. Если задан `MOCK_AUTH_TOKEN`, запросы к `/_mock/*` должны передавать заголовок `X-Mock-Auth`.
|
Основные API-маршруты не требуют авторизации. Если задан `MOCK_AUTH_TOKEN`, запросы к `/_mock/*` должны передавать заголовок `X-Mock-Auth`.
|
||||||
|
|
||||||
|
### Где взять и куда вводить токен
|
||||||
|
|
||||||
|
`MOCK_AUTH_TOKEN` задаёт администратор в переменных окружения запущенного сервиса Polygon. Приложение не генерирует и не показывает это значение. Если у вас нет доступа к настройкам сервиса, запросите токен у его администратора.
|
||||||
|
|
||||||
|
В Swagger нажмите **Authorize**, в поле `X-Mock-Auth` → **Value** вставьте выданный токен и нажмите **Authorize** в диалоге. После этого Swagger автоматически добавит заголовок к запросам `/_mock/*`. В curl передавайте его явно: `-H "X-Mock-Auth: <token>"`. Если `MOCK_AUTH_TOKEN` на сервере не задан, токен вводить не нужно.
|
||||||
|
|
||||||
## Переменные окружения
|
## Переменные окружения
|
||||||
|
|
||||||
| Переменная | По умолчанию | Описание |
|
| Переменная | По умолчанию | Описание |
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ SERVICES = _DF["SERVICES"]
|
|||||||
OPS_INDEX = _DF["OPS_INDEX"]
|
OPS_INDEX = _DF["OPS_INDEX"]
|
||||||
|
|
||||||
DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1"))
|
DELAY = float(os.getenv("MOCK_OP_DELAY", "0.1"))
|
||||||
VERSION = "0.5.17"
|
VERSION = "0.5.18"
|
||||||
|
|
||||||
|
|
||||||
def get_services(stand_id):
|
def get_services(stand_id):
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ def create_instance():
|
|||||||
Также возвращает {"instanceUid": uid} в теле — двойная защита
|
Также возвращает {"instanceUid": uid} в теле — двойная защита
|
||||||
на случай если заголовок не дойдёт.
|
на случай если заголовок не дойдёт.
|
||||||
"""
|
"""
|
||||||
body = request.get_json(silent=True) or {}
|
body = request.get_json(silent=True)
|
||||||
|
|
||||||
# ⛔ body должен быть dict, не массив и не примитив
|
# ⛔ body должен быть dict, не массив и не примитив
|
||||||
if not isinstance(body, dict):
|
if not isinstance(body, dict):
|
||||||
|
|||||||
+24
-4
@@ -82,7 +82,11 @@ def _build_spec():
|
|||||||
"### Авторизация\n"
|
"### Авторизация\n"
|
||||||
"- Основные эндпоинты — без авторизации\n"
|
"- Основные эндпоинты — без авторизации\n"
|
||||||
"- `_mock/*` требуют заголовок `X-Mock-Auth`, только если на сервере "
|
"- `_mock/*` требуют заголовок `X-Mock-Auth`, только если на сервере "
|
||||||
"задан `MOCK_AUTH_TOKEN`"
|
"задан `MOCK_AUTH_TOKEN`. Администратор задаёт его в переменных "
|
||||||
|
"окружения сервиса; Swagger не может получить это значение с сервера.\n"
|
||||||
|
"- В Swagger нажмите **Authorize**, вставьте токен в поле `X-Mock-Auth` "
|
||||||
|
"и нажмите **Authorize** в диалоге. Для curl передавайте "
|
||||||
|
"`-H \"X-Mock-Auth: <token>\"`. Если токен не настроен, заголовок не нужен."
|
||||||
),
|
),
|
||||||
"contact": {
|
"contact": {
|
||||||
"name": "Nubes Platform",
|
"name": "Nubes Platform",
|
||||||
@@ -104,7 +108,11 @@ def _build_spec():
|
|||||||
"type": "apiKey",
|
"type": "apiKey",
|
||||||
"in": "header",
|
"in": "header",
|
||||||
"name": "X-Mock-Auth",
|
"name": "X-Mock-Auth",
|
||||||
"description": "Нужен для _mock/* только если MOCK_AUTH_TOKEN задан на сервере.",
|
"description": (
|
||||||
|
"Если MOCK_AUTH_TOKEN задан администратором сервиса, введите его "
|
||||||
|
"в поле Value и нажмите Authorize. Значение берётся из конфигурации "
|
||||||
|
"окружения сервера; Swagger его не показывает."
|
||||||
|
),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"schemas": {
|
"schemas": {
|
||||||
@@ -553,7 +561,10 @@ def _build_paths():
|
|||||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateOperationResponse"}}},
|
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/CreateOperationResponse"}}},
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "instanceUid обязателен",
|
"description": (
|
||||||
|
"Некорректный JSON или тип поля, отсутствует обязательное поле, "
|
||||||
|
"либо operation не соответствует svcOperationId"
|
||||||
|
),
|
||||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}},
|
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}},
|
||||||
},
|
},
|
||||||
"404": {
|
"404": {
|
||||||
@@ -696,7 +707,12 @@ def _build_paths():
|
|||||||
},
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Параметр установлен (пустое тело)",
|
"description": "Параметр установлен",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {"type": "object", "example": {}},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Не указан instanceOperationUid или svcOperationCfsParamId",
|
"description": "Не указан instanceOperationUid или svcOperationCfsParamId",
|
||||||
@@ -706,6 +722,10 @@ def _build_paths():
|
|||||||
"description": "Операция не найдена",
|
"description": "Операция не найдена",
|
||||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}},
|
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}},
|
||||||
},
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Операция уже завершена",
|
||||||
|
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -169,12 +169,16 @@ def create_operation():
|
|||||||
)
|
)
|
||||||
if target_op is None:
|
if target_op is None:
|
||||||
return jsonify({"error": f"operation '{operation}' not found for service"}), 404
|
return jsonify({"error": f"operation '{operation}' not found for service"}), 404
|
||||||
|
if target_op.get("operation") != operation:
|
||||||
|
return jsonify({"error": "operation does not match svcOperationId"}), 400
|
||||||
|
|
||||||
kind = target_op.get("kind", "instance")
|
kind = target_op.get("kind", "instance")
|
||||||
action = target_op.get("action", operation)
|
action = target_op.get("action", operation)
|
||||||
|
|
||||||
# Создать операцию в MockState (dtFinish=None — ещё не выполнена)
|
# Создать операцию в MockState (dtFinish=None — ещё не выполнена)
|
||||||
op_uid = state.create_operation(instance_uid, svc_op_id, operation, kind, action)
|
op_uid = state.create_operation(
|
||||||
|
instance_uid, svc_op_id, target_op["operation"], kind, action
|
||||||
|
)
|
||||||
|
|
||||||
# ⛔ Location ОБЯЗАТЕЛЕН — без него executor не получит opUid
|
# ⛔ Location ОБЯЗАТЕЛЕН — без него executor не получит opUid
|
||||||
resp = make_response(jsonify({"instanceOperationUid": op_uid}), 201)
|
resp = make_response(jsonify({"instanceOperationUid": op_uid}), 201)
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ def index():
|
|||||||
svc_list=svc_list,
|
svc_list=svc_list,
|
||||||
stands_list=stands_list,
|
stands_list=stands_list,
|
||||||
current_stand=current_stand,
|
current_stand=current_stand,
|
||||||
|
swagger_url=(f"/{current_stand}/swagger" if len(stands_list) > 1 else "/swagger"),
|
||||||
endpoint=_POLYGON_ENDPOINT,
|
endpoint=_POLYGON_ENDPOINT,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -92,8 +93,10 @@ def swagger():
|
|||||||
from routes.openapi import _build_spec
|
from routes.openapi import _build_spec
|
||||||
import json as _json
|
import json as _json
|
||||||
spec_json = _json.dumps(_build_spec(), ensure_ascii=False)
|
spec_json = _json.dumps(_build_spec(), ensure_ascii=False)
|
||||||
|
home_url = f"/{g.stand_id}/" if len(_cfg.list_stands()) > 1 else "/"
|
||||||
return render_template(
|
return render_template(
|
||||||
"swagger.html",
|
"swagger.html",
|
||||||
version=_cfg.VERSION,
|
version=_cfg.VERSION,
|
||||||
spec_json=spec_json,
|
spec_json=spec_json,
|
||||||
|
home_url=home_url,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
<span class="sep">·</span>
|
<span class="sep">·</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="/swagger" class="swagger-link">📋 Swagger</a>
|
<a href="{{ swagger_url }}" class="swagger-link">📋 Swagger</a>
|
||||||
<span class="sep">·</span>
|
<span class="sep">·</span>
|
||||||
<span class="dot dot-green"></span>
|
<span class="dot dot-green"></span>
|
||||||
<span class="status-text">Работает</span>
|
<span class="status-text">Работает</span>
|
||||||
@@ -112,8 +112,12 @@ curl -s -X POST {{ endpoint }}/{% if stands_list|length > 1 %}{{ current_stand }
|
|||||||
<hr style="margin: 20px 0; border: none; border-top: 1px solid var(--border);">
|
<hr style="margin: 20px 0; border: none; border-top: 1px solid var(--border);">
|
||||||
|
|
||||||
<h3 class="h4">🛠 Управление полигоном (служебные эндпоинты)</h3>
|
<h3 class="h4">🛠 Управление полигоном (служебные эндпоинты)</h3>
|
||||||
<p>Если на сервере задан <code>MOCK_AUTH_TOKEN</code>, для запросов к <code>/_mock/*</code> добавьте
|
<p><strong>Авторизация служебных запросов:</strong> администратор задаёт <code>MOCK_AUTH_TOKEN</code>
|
||||||
заголовок <code>-H "X-Mock-Auth: <token>"</code>.</p>
|
в переменных окружения сервиса. Сам токен здесь не показывается. Если защита включена, в Swagger
|
||||||
|
нажмите <strong>Authorize</strong>, вставьте токен в поле <code>X-Mock-Auth</code> и подтвердите
|
||||||
|
кнопкой <strong>Authorize</strong>. Для curl добавьте к запросу
|
||||||
|
<code>-H "X-Mock-Auth: <token>"</code>. Если переменная не задана, токен не нужен.
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="feature-grid">
|
<div class="feature-grid">
|
||||||
<div class="feature-item">
|
<div class="feature-item">
|
||||||
|
|||||||
@@ -86,11 +86,30 @@
|
|||||||
.swagger-ui .info .title {
|
.swagger-ui .info .title {
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.auth-help {
|
||||||
|
max-width: 1460px;
|
||||||
|
margin: 16px auto;
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
color: #374151;
|
||||||
|
font: 14px/1.5 system-ui, sans-serif;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
<aside class="auth-help">
|
||||||
|
<strong>Токен для служебных запросов</strong>
|
||||||
|
<p>Если администратор настроил <code>MOCK_AUTH_TOKEN</code> в переменных окружения сервиса, нажмите
|
||||||
|
<strong>Authorize</strong> в Swagger, вставьте выданное вам значение в поле <code>X-Mock-Auth</code>
|
||||||
|
(Value) и подтвердите кнопкой <strong>Authorize</strong>. Токен выдаёт администратор; Swagger его не
|
||||||
|
показывает. Если защита не включена, авторизация не требуется.
|
||||||
|
</p>
|
||||||
|
</aside>
|
||||||
|
|
||||||
<div id="swagger-ui"></div>
|
<div id="swagger-ui"></div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
|
<script src="https://cdn.jsdelivr.net/npm/swagger-ui-dist@5/swagger-ui-bundle.js" crossorigin></script>
|
||||||
@@ -123,12 +142,12 @@
|
|||||||
if (link) {
|
if (link) {
|
||||||
link.innerHTML = '<img class="logo" src="{{ url_for("static", filename="logo.svg") }}" alt="Nubes">' +
|
link.innerHTML = '<img class="logo" src="{{ url_for("static", filename="logo.svg") }}" alt="Nubes">' +
|
||||||
'<span class="version-badge">polygon v{{ version }}</span>';
|
'<span class="version-badge">polygon v{{ version }}</span>';
|
||||||
link.href = "/";
|
link.href = "{{ home_url }}";
|
||||||
}
|
}
|
||||||
// Добавляем ссылку «← На главную»
|
// Добавляем ссылку «← На главную»
|
||||||
const navLink = document.createElement("a");
|
const navLink = document.createElement("a");
|
||||||
navLink.className = "nav-link";
|
navLink.className = "nav-link";
|
||||||
navLink.href = "/";
|
navLink.href = "{{ home_url }}";
|
||||||
navLink.textContent = "← На главную";
|
navLink.textContent = "← На главную";
|
||||||
topbar.appendChild(navLink);
|
topbar.appendChild(navLink);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ def test_swagger_does_not_embed_mock_auth_token():
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert b"test-token-123" not in response.data
|
assert b"test-token-123" not in response.data
|
||||||
assert b"auth_token" not in response.data
|
assert b"auth_token" not in response.data
|
||||||
|
assert b"MOCK_AUTH_TOKEN" in response.data
|
||||||
|
assert b"X-Mock-Auth" in response.data
|
||||||
|
|
||||||
|
|
||||||
def test_openapi_schema_and_auth_match_runtime_contract():
|
def test_openapi_schema_and_auth_match_runtime_contract():
|
||||||
@@ -49,6 +51,9 @@ def test_openapi_schema_and_auth_match_runtime_contract():
|
|||||||
if "/_mock/" in path:
|
if "/_mock/" in path:
|
||||||
operation = path_item.get("get") or path_item.get("post")
|
operation = path_item.get("get") or path_item.get("post")
|
||||||
assert operation["security"] == [{"mockAuth": []}, {}]
|
assert operation["security"] == [{"mockAuth": []}, {}]
|
||||||
|
set_param_responses = paths["/api/v1/svc/instanceOperationCfsParams"]["post"]["responses"]
|
||||||
|
assert set_param_responses["200"]["content"]["application/json"]["schema"]["type"] == "object"
|
||||||
|
assert set_param_responses["409"]["description"] == "Операция уже завершена"
|
||||||
|
|
||||||
|
|
||||||
def test_homepage_documents_real_operation_routes():
|
def test_homepage_documents_real_operation_routes():
|
||||||
@@ -59,6 +64,7 @@ def test_homepage_documents_real_operation_routes():
|
|||||||
assert b"/api/v1/svc/instanceOperationCfsParams" in response.data
|
assert b"/api/v1/svc/instanceOperationCfsParams" in response.data
|
||||||
assert b"/api/v1/svc/openapi.json" in response.data
|
assert b"/api/v1/svc/openapi.json" in response.data
|
||||||
assert b"/api/v1/svc/instanceOperations/<uid>/status" not in response.data
|
assert b"/api/v1/svc/instanceOperations/<uid>/status" not in response.data
|
||||||
|
assert "вставьте токен".encode() in response.data
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("seconds", ["nan", "inf", "-inf"])
|
@pytest.mark.parametrize("seconds", ["nan", "inf", "-inf"])
|
||||||
@@ -88,6 +94,16 @@ def test_operation_routes_reject_non_object_json():
|
|||||||
assert param_response.status_code == 400
|
assert param_response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("body", [[], "", 0, False, None])
|
||||||
|
def test_create_instance_rejects_non_object_json(body):
|
||||||
|
response = polygon_app.app.test_client().post(
|
||||||
|
"/api/v1/svc/instances", json=body
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert response.get_json()["error"] == "request body must be a JSON object"
|
||||||
|
|
||||||
|
|
||||||
def test_create_instance_rejects_non_string_display_name():
|
def test_create_instance_rejects_non_string_display_name():
|
||||||
service_id = next(iter(config.get_services(config.DEFAULT_STAND)))
|
service_id = next(iter(config.get_services(config.DEFAULT_STAND)))
|
||||||
|
|
||||||
@@ -128,6 +144,43 @@ def test_create_operation_rejects_foreign_service_operation():
|
|||||||
assert response.status_code == 404
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_operation_rejects_mismatched_operation_name():
|
||||||
|
stand_id = config.DEFAULT_STAND
|
||||||
|
services = config.get_services(stand_id)
|
||||||
|
service_id, service = next(
|
||||||
|
(service_id, definition)
|
||||||
|
for service_id, definition in services.items()
|
||||||
|
if len(definition.get("operations", [])) > 1
|
||||||
|
)
|
||||||
|
target_op, other_op = service["operations"][:2]
|
||||||
|
instance_uid = mock_state.get_state(stand_id).create_instance(
|
||||||
|
service_id, "route-test", service
|
||||||
|
)
|
||||||
|
|
||||||
|
response = polygon_app.app.test_client().post(
|
||||||
|
"/api/v1/svc/instanceOperations",
|
||||||
|
json={
|
||||||
|
"instanceUid": instance_uid,
|
||||||
|
"operation": other_op["operation"],
|
||||||
|
"svcOperationId": target_op["svcOperationId"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert response.status_code == 400
|
||||||
|
assert mock_state.get_state(stand_id).operations == {}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("stand_id", config.list_stands())
|
||||||
|
def test_homepage_swagger_link_keeps_current_stand(stand_id):
|
||||||
|
if len(config.list_stands()) <= 1:
|
||||||
|
pytest.skip("stand-specific Swagger link is needed only for multiple stands")
|
||||||
|
|
||||||
|
response = polygon_app.app.test_client().get(f"/{stand_id}/")
|
||||||
|
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert f'href="/{stand_id}/swagger"'.encode() in response.data
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("page_size", [0, -5])
|
@pytest.mark.parametrize("page_size", [0, -5])
|
||||||
def test_instances_page_size_is_clamped_to_one(page_size):
|
def test_instances_page_size_is_clamped_to_one(page_size):
|
||||||
response = polygon_app.app.test_client().get(
|
response = polygon_app.app.test_client().get(
|
||||||
|
|||||||
Reference in New Issue
Block a user