Перейти к содержанию

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 и БД доступны.
  • 503errorCode: 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.
  • 404PAYLOAD_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).
  • 404SCENARIO_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-simplefleet длина 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?)
  • 404PLAN_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.