REST API gateway — справочник¶
Базовый URL: https://aerozveno.ff (или локальный gateway). TLS: curl --cacert ff-ca/ff-ca.crt.
Реализация контроллеров: src/backend/gateway/src/{ops,plans,scenarios,fleet,validate}/*.controller.ts.
Служебные эндпоинты (без префикса /api)¶
GET /healthz¶
- 200 —
{"status":"ok"}(src/backend/gateway/src/ops/ops.controller.ts:24-28).
curl --cacert ff-ca/ff-ca.crt https://aerozveno.ff/healthz
GET /readyz¶
- 200 —
{"status":"ok"}если planner, validator и БД доступны. - 503 —
errorCode: UPSTREAM_UNAVAILABLE(ops.controller.ts:30-41).
curl --cacert ff-ca/ff-ca.crt https://aerozveno.ff/readyz
Пример (2026-09-17): {"status":"ok"}.
GET /status¶
- 200 — сервис
gateway, версия, git commit, uptime (ops.controller.ts:43-52).
curl --cacert ff-ca/ff-ca.crt https://aerozveno.ff/status
Фрагмент ответа (2026-09-17):
{
"service": "gateway",
"version": "0.1.0",
"commit": "7173c9af",
"bootTime": "2026-09-17T20:03:19.074Z",
"uptimeSec": 3620,
"timestamp": "2026-09-17T21:03:39.141Z"
}
GET /metrics¶
- 200 — текст Prometheus,
Content-Type: text/plain; version=0.0.4(ops.controller.ts:54-76). - В тексте есть метрики с префиксом
geoscan_(проверка e2e:test_stand_acceptance.py:99-107).
curl --cacert ff-ca/ff-ca.crt https://aerozveno.ff/metrics | grep -m3 '^geoscan_'
Фрагмент (2026-09-17):
geoscan_http_request_duration_seconds_bucket{le="0.005",service="gateway",method="GET",route="/readyz",status="200"} 0
geoscan_http_request_duration_seconds_bucket{le="0.01",service="gateway",method="GET",route="/readyz",status="200"} 0
geoscan_http_request_duration_seconds_bucket{le="0.025",service="gateway",method="GET",route="/readyz",status="200"} 6960
GET /api/openapi.json¶
Спецификация OpenAPI 3, генерируется Nest Swagger (main.ts:84-92). Swagger UI в браузере: https://aerozveno.ff/api/docs (тот же TLS).
curl --cacert ff-ca/ff-ca.crt -o /tmp/openapi.json https://aerozveno.ff/api/openapi.json
python3 -c "import json; d=json.load(open('/tmp/openapi.json')); print(len(d['paths']), 'paths'); print(*sorted(d['paths']), sep='\n')"
Пути на живом стенде (2026-09-17, 10 путей):
/api/fleet,/api/payloads/api/scenarios,/api/scenarios/{id}/api/plans,/api/plans/{id},/api/plans/{id}/events,/api/plans/{id}/export/{fmt},/api/plans/{id}/replan/api/validate
Каталоги¶
GET /api/fleet¶
Каталог моделей БВС из docs/task5/10-hardware/fleet.yaml (fleet.controller.ts:24-56).
| Query | Описание |
|---|---|
include_reference=true или 1 |
Включить reference_only модели (SR-API-19) |
Заголовки ответа: ETag, Cache-Control. Поддержка 304 при совпадении If-None-Match.
Пример:
curl --cacert ff-ca/ff-ca.crt -D - https://aerozveno.ff/api/fleet -o /tmp/fleet.json
На стенде: 200, etag: "2c2ab4137f8935e9", в JSON ключ верхнего уровня uav (не models) — 3 типа БВС без reference_only:
{
"schema_version": 1,
"uav": {
"geoscan_201": { "name": "Геоскан 201", … },
"geoscan_801": { "name": "Геоскан 801", … },
"geoscan_gemini": { "name": "Геоскан Gemini", … }
}
}
Сборка ответа: src/backend/gateway/src/fleet/fleet.catalog.ts:42-57.
GET /api/payloads¶
Каталог payloads.yaml (fleet.controller.ts:59-89).
- 200 — JSON с
schema_version,payloads. - 404 —
PAYLOAD_CATALOG_NOT_FOUND(если каталог недоступен в образе).
curl --cacert ff-ca/ff-ca.crt https://aerozveno.ff/api/payloads | head -c 320
Фрагмент ответа (2026-09-17):
{"schema_version":1,"payloads":{"geoscan_pf1b":{"name":"Geoscan PF1B","survey_type":"rgb",…}}}
Сценарии¶
GET /api/scenarios¶
- 200 —
{ "items": [ { "scenario_id", "name", "created_at", "source" } ] }(scenarios.service.ts:24-37).
На стенде число записей не фиксировано: встроенные s01–s10 плюс пользовательские черновики (source: "user").
Прогон 2026-09-17 (вечер, UTC+3): 15 items; порядок в items[] не гарантирован (первым может быть черновик doc-api-…, не s01-simple). Конкретное число меняется при каждом успешном POST /api/scenarios.
curl --cacert ff-ca/ff-ca.crt https://aerozveno.ff/api/scenarios
Фрагмент (2026-09-17):
{
"items": [
{
"scenario_id": "s01-simple",
"name": "Один борт, прямоугольник 1×1 км",
"created_at": "2026-09-16T16:16:28.669Z",
"source": "builtin"
}
]
}
GET /api/scenarios/{id}¶
- 200 — полный документ сценария (snake_case,
scenario.schema.json). - 404 —
SCENARIO_NOT_FOUND(scenarios.service.ts:40-52).
curl --cacert ff-ca/ff-ca.crt https://aerozveno.ff/api/scenarios/s01-simple | head -c 400
На живом стенде (2026-09-17, вечер): GET /api/scenarios/s01-simple → fleet длина 1 (как в src/backend/scenarios/s01-simple.json). Раньше в БД встречался снимок с fleet: []; на текущем стенде это не воспроизводится.
POST /api/scenarios¶
- Тело: документ сценария целиком.
- Валидация: AJV по
scenario.schema.json(scenario-document.pipe.ts:11-23). - 201 —
{ "scenario_id": "…" }(scenarios.controller.ts:38-41,scenarios.service.ts:55-59).
Ошибка валидации — 422:
curl --cacert ff-ca/ff-ca.crt -X POST https://aerozveno.ff/api/scenarios \
-H 'Content-Type: application/json' -d '{"schema_version":1}'
Фрагмент ответа (живой стенд):
{
"errorCode": "SCHEMA_VALIDATION_FAILED",
"message": "Сценарий не соответствует схеме",
"errors": [
{"path": "/scenario_id", "rule": "required", "message": "must have required property 'scenario_id'"},
…
]
}
Успешное сохранение — 201: в теле только { "scenario_id": "…" }, не полный документ (scenarios.controller.ts:38-41, scenarios.service.ts:55-57).
Эталон тела — файл репозитория (из корня worktree):
cd /path/to/geoscan # корень репозитория
python3 -c "
import json, time
s = json.load(open('src/backend/scenarios/s01-simple.json'))
s['scenario_id'] = 'doc-api-' + str(int(time.time()))
s['name'] = 'Док: черновик для POST 201'
json.dump(s, open('/tmp/scenario-post.json', 'w'))
"
curl --cacert ff-ca/ff-ca.crt -w '\nHTTP %{http_code}\n' -X POST https://aerozveno.ff/api/scenarios \
-H 'Content-Type: application/json' -d @/tmp/scenario-post.json
Фрагмент ответа (живой стенд, 2026-09-17, вечер):
{"scenario_id":"doc-api-1789678833"}
HTTP 201.
Планы¶
Сквозной сценарий: POST → poll или SSE → export¶
planId берётся из тела 202 и заголовка Location того же ответа — не из зафиксированных UUID прошлых прогонов.
CA="--cacert ff-ca/ff-ca.crt"
BASE="https://aerozveno.ff"
# 1) Постановка (inline-сценарий из репозитория — гарантирован непустой fleet)
python3 -c "
import json
s=json.load(open('src/backend/scenarios/s01-simple.json'))
json.dump({'scenario':s,'objective':'makespan','seed':20250917}, open('/tmp/plan-post.json','w'))
"
curl -sS $CA -D /tmp/plan.hdr -o /tmp/plan.body -X POST "$BASE/api/plans" \
-H 'Content-Type: application/json' -d @/tmp/plan-post.json
PLAN_ID=$(python3 -c "import json; print(json.load(open('/tmp/plan.body'))['planId'])")
echo "planId=$PLAN_ID"
grep -i '^location:' /tmp/plan.hdr
# 409: export сразу после POST (до completed)
curl -sS $CA -w '\nHTTP %{http_code}\n' "$BASE/api/plans/$PLAN_ID/export/kml"
# 2a) Поллинг до completed (или failed)
until STATUS=$(curl -sS $CA "$BASE/api/plans/$PLAN_ID" | python3 -c "import json,sys; print(json.load(sys.stdin)['status'])") \
&& [ "$STATUS" = "completed" -o "$STATUS" = "failed" ]; do sleep 1; done
echo "status=$STATUS"
# 2b) Альтернатива: SSE на том же planId (обрыв после stage completed)
# curl -sS $CA -H 'Accept: text/event-stream' "$BASE/api/plans/$PLAN_ID/events"
# 3) Выгрузка (только при status=completed)
curl -sS $CA -o /tmp/out.kml "$BASE/api/plans/$PLAN_ID/export/kml"
wc -c /tmp/out.kml
Прогон 2026-09-17 (вечер): planId=2acdc05c-9e9b-430e-9993-81853bd31718, status=completed, KML 38 633 байт,
metrics.sorties=1. Начало KML:
<name>ПЗ s01-simple</name>
<description>makespan 1672 с, налёт 1672 с, галсов 15, снимков 384</description>
Вариант только scenario_id: s01-simple: тот же прогон (seed 42) → planId=5834044d-5f63-4f7f-a1d4-cf2eb97350e6, после completed KML 38 633 байт (как при inline-сценарии).
Сразу после POST, до completed, export даёт 409 PLAN_NOT_READY (блок воспроизводим в § export).
POST /api/plans¶
Постановка расчёта в очередь.
| Поле тела | Обязательность | Описание |
|---|---|---|
scenario |
один из scenario / scenario_id |
Встроенный или inline сценарий |
scenario_id |
альтернатива | Загрузка документа с сервера |
objective |
нет (default makespan) |
Критерий: makespan, total_flight_time, pareto |
seed |
нет | Целое, передаётся в планировщик |
time_limit_s / solver.time_limit_s |
нет | Лимит солвера, с |
Валидация JSON Schema применяется только если в теле есть вложенный scenario
(scenario-schema.pipe.ts:37-40). При одном scenario_id схема на gateway не гоняется.
Заголовки:
| Заголовок | Эффект |
|---|---|
Idempotency-Key (≥ 8 символов) |
Повтор с тем же телом → тот же planId, Idempotency-Replayed: true |
| Разный body + тот же ключ | 409 IDEMPOTENCY_KEY_CONFLICT |
Ответы:
| Код | Тело |
|---|---|
| 202 | { "planId", "status": "queued", "parent_plan_id"? } + заголовок Location: /api/plans/{uuid} |
| 400 | SCHEMA_VALIDATION_FAILED (невалидный inline scenario) |
| 429 | QUEUE_FULL + Retry-After: 30 (domain-exception.filter.ts:90-92) |
curl --cacert ff-ca/ff-ca.crt -D - -o /tmp/plan.body -X POST https://aerozveno.ff/api/plans \
-H 'Content-Type: application/json' \
-d '{"scenario_id":"s01-simple","objective":"makespan","seed":42}'
cat /tmp/plan.body
Фрагмент (2026-09-17): 202, {"planId":"e9f46f76-3c56-46a9-a96e-7681178267e3","status":"queued"} (только scenario_id: s01-simple).
GET /api/plans/{id}¶
| Статус job | Тело |
|---|---|
queued / running |
planId, status, objective, опционально progress, parent_plan_id |
completed |
+ metrics, plan (полный документ), опционально unassigned |
failed |
+ error (errorCode, message, details?) |
- 404 —
PLAN_NOT_FOUND
Пример 200 completed (после сквозного сценария выше, фрагмент 2026-09-17):
curl --cacert ff-ca/ff-ca.crt https://aerozveno.ff/api/plans/2acdc05c-9e9b-430e-9993-81853bd31718 \
| python3 -c "import json,sys; d=json.load(sys.stdin); print(d['status'], list(d['metrics'].keys())[:5])"
completed ['solver', 'sorties', 'uav_used', 'objective', 'makespan_s']
Полный JSON плана в теле большой; для валидатора достаточно вложенного plan.
Пример 404 (живой стенд):
curl --cacert ff-ca/ff-ca.crt \
https://aerozveno.ff/api/plans/00000000-0000-4000-8000-000000000099
{"errorCode":"PLAN_NOT_FOUND","message":"План с указанным идентификатором не найден"}
GET /api/plans/{id}/events¶
Server-Sent Events (text/event-stream).
- 200 — поток до
stage: completedилиfailed. - 404 — план не найден.
Каждое событие:
id: <monotonic int>
data: <JSON PlanSsePayload>
PlanSsePayload: planId, stage, progress (0…1), ts, опционально message, objective_value, elapsed_s
(plans.repository.ts:8-16).
# PLAN_ID — из тела ответа 202 того же POST /api/plans
PLAN_ID=e1a1518e-7660-40fb-81f9-ce42178bb213 # снимок: POST scenario_id=s02-nfz, 2026-09-17
curl --cacert ff-ca/ff-ca.crt -H 'Accept: text/event-stream' \
"https://aerozveno.ff/api/plans/$PLAN_ID/events" | head -12
Фрагмент потока (живой стенд):
id: 1
data: {"ts":"2026-09-17T20:39:39.570Z","stage":"queued","planId":"e1a1518e-7660-40fb-81f9-ce42178bb213","message":"задача в очереди","progress":0}
id: 4
data: {"planId":"e1a1518e-7660-40fb-81f9-ce42178bb213","stage":"completed","progress":1,"message":"расчёт завершён",…}
GET /api/plans/{id}/export/{fmt}¶
fmt |
Описание |
|---|---|
geojson |
GeoJSON FeatureCollection |
kml |
KML 2.2 |
plan |
QGC plan file |
waypoints |
Список точек |
Query uav — фильтр по uav_id (plans.controller.ts:187-204).
| Код | Условие |
|---|---|
| 200 | Content-Disposition: attachment, бинарное/текстовое тело |
| 400 | EXPORT_FORMAT_UNSUPPORTED |
| 404 | План не найден |
| 409 | PLAN_NOT_READY — расчёт не завершён |
Проверка 409 — воспроизводимый паттерн «сразу после POST /api/plans», без архивного UUID:
CA="--cacert ff-ca/ff-ca.crt"
BASE="https://aerozveno.ff"
curl -sS $CA -o /tmp/p409.body -X POST "$BASE/api/plans" \
-H 'Content-Type: application/json' \
-d '{"scenario_id":"s02-nfz","objective":"makespan","seed":1}'
PLAN_ID=$(python3 -c "import json; print(json.load(open('/tmp/p409.body'))['planId'])")
curl -sS $CA -w '\nHTTP %{http_code}\n' "$BASE/api/plans/$PLAN_ID/export/kml"
Фрагмент (живой стенд, 2026-09-17, planId=e9f46f76-3c56-46a9-a96e-7681178267e3 сразу после POST):
{"errorCode":"PLAN_NOT_READY","message":"План ещё не готов к выгрузке"}
HTTP 409. После status: completed тот же URL даёт 200 и KML.
Все форматы после completed (inline s01 из сквозного сценария, planId=2acdc05c-9e9b-430e-9993-81853bd31718, 2026-09-17):
PLAN_ID=2acdc05c-9e9b-430e-9993-81853bd31718
for fmt in kml geojson plan waypoints; do
curl --cacert ff-ca/ff-ca.crt -o "/tmp/p.$fmt" -w "$fmt HTTP %{http_code} size=%{size_download}\n" \
"https://aerozveno.ff/api/plans/$PLAN_ID/export/$fmt"
done
fmt |
HTTP | Размер (байт) | Начало тела |
|---|---|---|---|
kml |
200 | 38 633 | <?xml version="1.0"… |
geojson |
200 | 28 339 | {"type":"FeatureCollection",… (39 features) |
plan |
200 | 25 498 | {"fileType":"Plan",… |
waypoints |
200 | 3 485 | # lon lat alt_m phase seq uav_id |
POST /api/plans/{id}/replan¶
Пересчёт после исключения бортов из копии сценария (plans.service.ts:141-173).
Тело:
{
"exclude_uav_ids": ["gemini-01"],
"keep_completed": false
}
- 202 — новый
planId,Location, опциональноparent_plan_idв ответе. - 404 — родительский план не найден.
curl --cacert ff-ca/ff-ca.crt -D - -X POST \
https://aerozveno.ff/api/plans/2acdc05c-9e9b-430e-9993-81853bd31718/replan \
-H 'Content-Type: application/json' \
-d '{"exclude_uav_ids":["geoscan_201-01"],"keep_completed":false}'
Фрагмент (2026-09-17, вечер):
HTTP/2 202
location: /api/plans/dbf031cf-c03b-4e5a-8bad-774730dc412b
{
"planId": "dbf031cf-c03b-4e5a-8bad-774730dc412b",
"status": "queued",
"parent_plan_id": "2acdc05c-9e9b-430e-9993-81853bd31718"
}
POST /api/validate¶
Синхронная проверка пары «сценарий + план» через validator gRPC (validate.controller.ts:10-22).
Тело (snake_case внутри документов):
{
"scenario": { … },
"plan": { … }
}
- 200 — обёртка
{ "report": { … } }(см.validate.service.ts:17-46). - Ошибки gRPC мапятся в HTTP через
DomainExceptionFilter.
Живой вызов (план из inline s01, planId=2acdc05c-…, сценарий из src/backend/scenarios/s01-simple.json):
curl --cacert ff-ca/ff-ca.crt -sS \
"https://aerozveno.ff/api/plans/2acdc05c-9e9b-430e-9993-81853bd31718" -o /tmp/job.json
python3 -c "
import json
s=json.load(open('src/backend/scenarios/s01-simple.json'))
p=json.load(open('/tmp/job.json'))['plan']
json.dump({'scenario':s,'plan':p}, open('/tmp/validate-body.json','w'))
"
curl --cacert ff-ca/ff-ca.crt -X POST https://aerozveno.ff/api/validate \
-H 'Content-Type: application/json' -d @/tmp/validate-body.json \
| python3 -c "import json,sys; r=json.load(sys.stdin)['report']; print('admitted', r['admitted']); print([c['id'] for c in r['checks'] if c['status']=='fail'])"
Фрагмент (2026-09-17):
admitted True
fails []
warns G1-05 G2-04
Тот же план + документ s01 с GET /api/scenarios/s01-simple (на стенде fleet длина 1): admitted True, fails [].
Исторический fail G3-04 на s01 описан в docs/task5/98-fixes/energy-model.md и сводке рисков docs/task5/96-status/00-final-status.md:81-83; на стенде 2026-09-17 (вечер) для пары s01 + план выше не воспроизводится — см. contract-gaps.md.
admitted — см. validator/admission.py: любой check со статусом fail или ненулевые жёсткие нарушения
отменяют допуск.
Идемпотентность (пример)¶
KEY="doc-test-$(date +%s)"
curl --cacert ff-ca/ff-ca.crt -D /tmp/h1 -X POST https://aerozveno.ff/api/plans \
-H 'Content-Type: application/json' -H "Idempotency-Key: $KEY" \
-d '{"scenario_id":"s01-simple","objective":"makespan","seed":77}'
curl --cacert ff-ca/ff-ca.crt -D /tmp/h2 -X POST https://aerozveno.ff/api/plans \
-H 'Content-Type: application/json' -H "Idempotency-Key: $KEY" \
-d '{"scenario_id":"s01-simple","objective":"makespan","seed":77}'
Второй ответ: тот же JSON planId, заголовок idempotency-replayed: true.