docs: Diplodoc setup + полное руководство интеграции по API
Новая документация-as-code на платформе Diplodoc (Yandex open-source) с YFM (Yandex Flavored Markdown). Build → static HTML, deployable куда угодно. Что добавлено: - package.json + .yfm + toc.yaml — Diplodoc setup, навигация для sidebar - index.md — главная страница с tabs для разных audiences - README.md — инструкция для contributors Раздел integration/api/ — 11 страниц про интеграцию consumers: - index — обзор + глоссарий - auth — Keycloak service accounts, scope mapping - read-endpoints — GET /dictionaries, list/by-key, spatial filters - events — Kafka outbox топики, partition keys, idempotency, DLQ - webhooks — HMAC, SSRF guard, retry policy, test ping endpoint - bitemporal — validFrom/validTo + history, future-dated entries, bulk close/export - x-references — FK syntax, validation errors, scope hiding, orphan scanner, v2 cascade preview - caching — TTL / push / snapshot strategies, hybrid pattern - errors — full error code reference (validation/auth/conflict/server), retry strategy - best-practices — 33 numbered rules для consumers - bundle-cuod — все 40 справочников с FK графом и сценариями Обновлены: - ops/README.md — link на новую integration/api/, убран inline <br> - ops/slo.md — broken cross-repo links заменены на text references Build: cd docs && pnpm install && pnpm build → ./_dist (20 HTML files, 7.1M) pnpm serve # → http://localhost:8088 CI deploy job для GitLab Pages — в roadmap (см. docs/README.md). Существующий tech/api-integration.md оставлен в toc как legacy (содержимое мигрировано + расширено в integration/api/).
This commit is contained in:
@@ -0,0 +1,112 @@
|
||||
# Авторизация
|
||||
|
||||
Ordinis принимает JWT от Keycloak realm `nstart`:
|
||||
|
||||
- **Issuer:** `https://auth.nstart.space/realms/nstart`
|
||||
- **Алгоритм:** RS256
|
||||
- **JWK Set discoverится** через `/.well-known/openid-configuration`
|
||||
|
||||
## Service account (m2m)
|
||||
|
||||
Для backend-to-backend интеграции (типичный сценарий):
|
||||
|
||||
1. Запросить у Ordinis team создание клиента в realm `nstart`.
|
||||
2. Назначить role: `ordinis-public` / `ordinis-internal` / `ordinis-restricted`
|
||||
(по нужному scope, см. ниже).
|
||||
3. Получить `client_id` + `client_secret`. Хранить в Vault либо secret manager,
|
||||
**никогда** в коде.
|
||||
|
||||
## Получение access token
|
||||
|
||||
```bash
|
||||
curl -X POST https://auth.nstart.space/realms/nstart/protocol/openid-connect/token \
|
||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
||||
-d "grant_type=client_credentials" \
|
||||
-d "client_id=altum-backend" \
|
||||
-d "client_secret=<SECRET>"
|
||||
```
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOi...",
|
||||
"expires_in": 300,
|
||||
"token_type": "Bearer"
|
||||
}
|
||||
```
|
||||
|
||||
{% note warning %}
|
||||
|
||||
Кэшируй токен с TTL = `expires_in - 30` (буфер на retries) в backend
|
||||
memory. Не делай новый token request на каждый API call — Keycloak rate
|
||||
limits и латентность.
|
||||
|
||||
{% endnote %}
|
||||
|
||||
## Использование
|
||||
|
||||
Каждый запрос требует header:
|
||||
|
||||
```
|
||||
Authorization: Bearer eyJhbGciOi...
|
||||
```
|
||||
|
||||
Без header → `401 Unauthorized`.
|
||||
С невалидным/expired токеном → `401`.
|
||||
Token валиден но scope не позволяет доступ к словарю → `403 Forbidden`.
|
||||
|
||||
## Маппинг ролей в DataScope
|
||||
|
||||
Ordinis использует трёхуровневый доступ к словарям через `scope` field в
|
||||
`DictionaryDefinition`:
|
||||
|
||||
| Keycloak роль | Доступные scopes |
|
||||
|---|---|
|
||||
| `ordinis-public` | `PUBLIC` |
|
||||
| `ordinis-internal` | `PUBLIC` + `INTERNAL` |
|
||||
| `ordinis-restricted` | `PUBLIC` + `INTERNAL` + `RESTRICTED` |
|
||||
|
||||
Альтернативный синтаксис ролей (для compat с другими сервисами в realm):
|
||||
суффикс `-PUBLIC|INTERNAL|RESTRICTED` (например `altum-PUBLIC`,
|
||||
`geoportal-INTERNAL`). Маппинг определяется в `ordinis-auth/.../ScopeContext.java`.
|
||||
|
||||
{% note info %}
|
||||
|
||||
Альтум consumer на проде использует role `ordinis-internal` — доступ к
|
||||
PUBLIC + INTERNAL справочникам. RESTRICTED закрытый только для специально
|
||||
authorized clients.
|
||||
|
||||
{% endnote %}
|
||||
|
||||
## Refresh tokens
|
||||
|
||||
`client_credentials` flow **не поддерживает refresh tokens** — это
|
||||
m2m flow без user session. Получай новый access token при истечении.
|
||||
|
||||
Если используешь user-on-behalf flow (rare для backend) — refresh token
|
||||
работает, см. Keycloak документацию.
|
||||
|
||||
## Multiple environments
|
||||
|
||||
| Среда | Issuer | Realm |
|
||||
|---|---|---|
|
||||
| prod | `https://auth.nstart.space/realms/nstart` | `nstart` |
|
||||
| staging | same | same |
|
||||
| local | same либо local Keycloak (опц) | same |
|
||||
|
||||
Один Keycloak realm для всех сред. Service accounts shared (но `client_id`
|
||||
обычно различается per env: `altum-backend-prod`, `altum-backend-staging`).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Симптом | Причина | Fix |
|
||||
|---|---|---|
|
||||
| 401 на каждый запрос | Token expired | Refresh с TTL buffer |
|
||||
| 401 на новый token | Invalid client_secret | Re-fetch у Ordinis team |
|
||||
| 403 на конкретные dictionaries | Scope mismatch | Запросить upgrade role у Ordinis team |
|
||||
| 500 от Ordinis | Issuer unreachable / JWK fetch fail | Check `auth.nstart.space` health |
|
||||
|
||||
## Дальше
|
||||
|
||||
→ [Read-side endpoints](read-endpoints.md) — что можно читать с этим токеном.
|
||||
@@ -0,0 +1,156 @@
|
||||
# Best practices
|
||||
|
||||
Чек-лист для consumer'ов Ordinis API.
|
||||
|
||||
## Идентификация записей
|
||||
|
||||
1. **Используй `businessKey`, не UUID.** UUID — внутренний идентификатор
|
||||
записи и **меняется** при создании новой версии (через update).
|
||||
`businessKey` стабилен через всю жизнь записи.
|
||||
|
||||
2. **Не делай FK на копию справочника.** Если копируешь данные к себе для
|
||||
snapshot — храни `business_key VARCHAR` как key, не `id UUID` либо
|
||||
`internal_id`. Это устойчиво к updates.
|
||||
|
||||
## Авторизация
|
||||
|
||||
3. **Кэшируй access token с TTL = `expires_in - 30s`.** Не делай новый
|
||||
token request на каждый API call — Keycloak rate limits + латентность.
|
||||
|
||||
4. **Refresh JWT перед TTL.** Не дожидайся 401 — большинство Keycloak SDK
|
||||
рефрешат автоматически.
|
||||
|
||||
5. **Не храни `client_secret` в коде / git.** Vault либо secret manager.
|
||||
|
||||
## Чтение
|
||||
|
||||
6. **Кэшируй per `(dictionary, lang)`.** Inline в коде — `staleTime` /
|
||||
TTL. См. [Caching](caching.md).
|
||||
|
||||
7. **Используй `?lang=` параметр** для flatten i18n полей если UI на одном
|
||||
языке. Без `lang` ответ возвращает `{ru-RU: "...", en-US: "..."}`
|
||||
объект — лишний размер payload + проще баг.
|
||||
|
||||
8. **Не запрашивай history endpoint** на hot path. История нужна только
|
||||
admin UI / audit. Список + by-key endpoints достаточны для типичного
|
||||
consumer.
|
||||
|
||||
## Запись (admin only)
|
||||
|
||||
9. **Не пиши массовых update'ов** через одиночные API calls. Ordinis
|
||||
оптимизирован под редкие записи и частые чтения. Для bulk import
|
||||
используй `POST /admin/bundle/import` (через bundle Maven module).
|
||||
|
||||
10. **Bulk close через `_bulk-close`** endpoint, не loop одиночных POST.
|
||||
Per-record transactions, classification (skipped vs error vs closed),
|
||||
меньше latency.
|
||||
|
||||
11. **Bulk export CSV** через `_export?format=csv` для extracting filtered
|
||||
данные в legacy системы.
|
||||
|
||||
## Обработка событий
|
||||
|
||||
12. **Idempotency** — события at-least-once, могут прийти дважды. Используй
|
||||
`(dictionary, businessKey, txTime, version)` либо `deliveryId` (для webhooks)
|
||||
как dedup key.
|
||||
|
||||
13. **Async обработка** — отвечай webhook'у `202 Accepted` сразу, обрабатывай
|
||||
в background. Жёсткий timeout 10s.
|
||||
|
||||
14. **HMAC verify первым** — до парсинга JSON. Защита от spoofed events.
|
||||
|
||||
15. **Stale cache на 5xx / timeout** — отдавай старые данные с warn'ом, не
|
||||
падай каскадно.
|
||||
|
||||
## Производительность
|
||||
|
||||
16. **Parallel-fetch related dictionaries** при cascading select:
|
||||
|
||||
```ts
|
||||
const [spacecrafts, satTypes, instruments] = await Promise.all([
|
||||
fetch('/api/v1/spacecraft/records?lang=ru'),
|
||||
fetch('/api/v1/satellite_type/records?lang=ru'),
|
||||
fetch('/api/v1/instrument/records?lang=ru'),
|
||||
])
|
||||
```
|
||||
|
||||
Не последовательные `await`, не N+1 loops.
|
||||
|
||||
17. **Cascading filters в memory** — после loading dictionaries фильтруй
|
||||
клиент-side вместо новых API calls:
|
||||
|
||||
```ts
|
||||
const allowedFormats = formats.filter(f =>
|
||||
instrument.data.supportedFormatCodes.includes(f.businessKey))
|
||||
```
|
||||
|
||||
18. **Не делай polling в tight loop** — используй webhooks либо Kafka events
|
||||
для realtime invalidation. Polling каждые 5 мин — OK, каждые 10 сек — нет.
|
||||
|
||||
## Error handling
|
||||
|
||||
19. **Don't retry 4xx (кроме 401 и 429).** Permanent errors требуют code
|
||||
fix, не retry.
|
||||
|
||||
20. **Exponential backoff на 5xx / timeout.** Base 0.5s, multiplier 2,
|
||||
jitter 0-0.5s, max 5 attempts. См. [Errors](errors.md).
|
||||
|
||||
21. **Сохраняй `traceId`** при эскалации в Ordinis team. Без traceId
|
||||
investigation в Tempo занимает в 10x дольше.
|
||||
|
||||
## Bundle versioning
|
||||
|
||||
22. **Forward compatibility** — допускай новые поля в API responses которые
|
||||
ты не знаешь. Не fail on unknown fields. Schema validation у consumer'а
|
||||
должна быть `additionalProperties: true` (либо аналог).
|
||||
|
||||
23. **Подписывайся на `nsi.dictionary.changed` events** для новых
|
||||
справочников — они появляются с минор-bump bundle.
|
||||
|
||||
24. **Monitor schema versions** в `GET /dictionaries` response — если
|
||||
`schemaVersion` поменялся, проверь твой mapping не сломался.
|
||||
|
||||
## Безопасность
|
||||
|
||||
25. **HTTPS обязателен** — webhook URLs тоже только https. SSRF guard
|
||||
блокирует http:// и private CIDR.
|
||||
|
||||
26. **Не log access tokens** в plaintext. Большинство logging frameworks
|
||||
имеют redaction patterns для `Authorization` header.
|
||||
|
||||
27. **Rotate `client_secret`** регулярно. Раз в квартал — minimum, раз
|
||||
в месяц — recommended для production. Coordinate с Ordinis team
|
||||
при rotation.
|
||||
|
||||
## Коммуникация с командой
|
||||
|
||||
28. **Прежде чем bulk import** > 1000 records — talk to Ordinis team. У
|
||||
нас есть staging environment + bulk-import endpoint без rate limits.
|
||||
|
||||
29. **Запрашивай новый scope / role** через `zimin.an@nstart.space`
|
||||
либо issue с label `keycloak-role-request`.
|
||||
|
||||
30. **При проблеме**: воспроизведи на staging, скопируй traceId, открой
|
||||
issue. Скриншоты UI ошибок помогают.
|
||||
|
||||
## Тестирование
|
||||
|
||||
31. **Mock Ordinis в unit tests** — не завись от availability staging
|
||||
cluster. Нашa OpenAPI spec accurate, генерируй mock client.
|
||||
|
||||
32. **Integration tests против staging** — не prod. Staging данные — тестовые,
|
||||
можно create/close без последствий.
|
||||
|
||||
33. **Load tests на staging** в low-traffic окно. Координируй с Ordinis
|
||||
team — мы можем включить дополнительные replicas для вашего теста.
|
||||
|
||||
## Reference: bundle cuod
|
||||
|
||||
См. [Bundle cuod](bundle-cuod.md) — список 40 справочников, их связи,
|
||||
типичные operations.
|
||||
|
||||
## Дальше
|
||||
|
||||
- [События](events.md) — push-обновления
|
||||
- [Caching](caching.md) — стратегии кэша
|
||||
- [Errors](errors.md) — обработка ошибок
|
||||
@@ -0,0 +1,185 @@
|
||||
# Bitemporal модель
|
||||
|
||||
Каждая запись Ordinis имеет двухмерное время:
|
||||
|
||||
- **Бизнес-время** (`validFrom` / `validTo`) — когда запись действует в реальности.
|
||||
- **Транзакционное время** (Hibernate Envers `revinfo.timestamp`) — когда
|
||||
изменение зафиксировано в БД.
|
||||
|
||||
Это две независимые оси: можно ввести запись задним числом
|
||||
(business → past, txTime → now), либо запланировать на будущее
|
||||
(business → future, txTime → now), либо retroactively изменить
|
||||
прошлое (исправить ошибку — txTime → now, но business range уже past).
|
||||
|
||||
## Поля
|
||||
|
||||
```json
|
||||
{
|
||||
"businessKey": "RESURS-P-3",
|
||||
"validFrom": "2025-01-01T00:00:00+03:00",
|
||||
"validTo": "9999-12-31T23:59:59+03:00",
|
||||
"version": 2,
|
||||
"data": { ... }
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `validFrom` | timestamp tz | Когда версия начинает действовать. |
|
||||
| `validTo` | timestamp tz | До какого момента (или `9999-12-31` если бессрочно). |
|
||||
| `version` | int | Optimistic lock + порядковый номер версии. Monotonic per businessKey. |
|
||||
|
||||
## Update создаёт **новую версию**
|
||||
|
||||
Update — это не overwrite. Это:
|
||||
|
||||
1. Старая запись: `validTo = now()` (закрыта).
|
||||
2. Новая запись: `validFrom = now()`, `validTo = 9999...`, `version + 1`.
|
||||
|
||||
Обе версии остаются в БД. История доступна через
|
||||
`GET /records/{key}/history` (см. [Read endpoints](read-endpoints.md)).
|
||||
|
||||
```mermaid
|
||||
gantt
|
||||
dateFormat YYYY-MM-DD
|
||||
title Версии записи RESURS-P-3
|
||||
section v1
|
||||
v1 (status=in-orbit) :2024-06-01, 2024-12-30
|
||||
section v2
|
||||
v2 (status=active) :2024-12-30, 2026-05-07
|
||||
section v3
|
||||
v3 (name updated) :2026-05-07, 9999-12-31
|
||||
```
|
||||
|
||||
## Запросы на момент
|
||||
|
||||
`?at=2025-06-15` возвращает версию, которая была активна 15 июня 2025:
|
||||
|
||||
```bash
|
||||
curl "https://ordinis.k8s.264.nstart.cloud/api/v1/spacecraft/records/RESURS-P-3?at=2025-06-15T00:00:00Z"
|
||||
```
|
||||
|
||||
→ v2 (status=active), потому что `2024-12-30 ≤ 2025-06-15 < 2026-05-07`.
|
||||
|
||||
Без `?at` — default = `now()`.
|
||||
|
||||
## Future-dated entries
|
||||
|
||||
Можно ввести запись в будущее:
|
||||
|
||||
```json
|
||||
PUT /api/v1/dictionaries/spacecraft/records/MISSION-2027
|
||||
{
|
||||
"validFrom": "2027-01-01T00:00:00+03:00",
|
||||
"data": { "name": "Запланированный КА", "status": "planned" }
|
||||
}
|
||||
```
|
||||
|
||||
Запись существует в БД, но `GET ?at=now` её не вернёт (потому что
|
||||
`now < validFrom`). Появится автоматически 1 января 2027.
|
||||
|
||||
## Закрытие записи (close)
|
||||
|
||||
```bash
|
||||
POST /api/v1/dictionaries/spacecraft/records/RESURS-P-3/close
|
||||
{
|
||||
"at": "2026-05-07T12:00:00+03:00",
|
||||
"reason": "Списан с орбиты"
|
||||
}
|
||||
```
|
||||
|
||||
Устанавливает `validTo = at`. После этого:
|
||||
- `GET ?at=2026-05-07T11:00:00Z` → возвращает запись (ещё активна).
|
||||
- `GET ?at=2026-05-07T13:00:00Z` → 404 (уже закрыта).
|
||||
- `GET ?at=2026-05-07T11:30:00Z&include_closed=true` → для UI history.
|
||||
|
||||
Outbox emit'ит event `action: closed`.
|
||||
|
||||
## Bulk close
|
||||
|
||||
Закрытие нескольких записей одной транзакцией:
|
||||
|
||||
```bash
|
||||
POST /api/v1/dictionaries/spacecraft/records/_bulk-close
|
||||
{
|
||||
"businessKeys": ["RESURS-P-1", "RESURS-P-2", "RESURS-P-3"],
|
||||
"at": "2026-05-07T00:00:00+03:00",
|
||||
"reason": "Программа Ресурс-П завершена"
|
||||
}
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"closed": 3,
|
||||
"skipped": [],
|
||||
"errors": []
|
||||
}
|
||||
```
|
||||
|
||||
Каждая запись в собственной sub-transaction (Spring AOP per-record).
|
||||
Если одна fail'нула с invalid_key — она в `errors`, остальные продолжают.
|
||||
|
||||
## Bulk export CSV
|
||||
|
||||
```bash
|
||||
GET /api/v1/dictionaries/spacecraft/records/_export?format=csv&lang=ru-RU
|
||||
```
|
||||
|
||||
Stream CSV (RFC 4180) с активными на now записями. Используется UI bulk
|
||||
operations.
|
||||
|
||||
Параметры — те же что list endpoint (`lang`, `at`, фильтры).
|
||||
|
||||
## Использование для consumer'ов
|
||||
|
||||
### Snapshot at order time
|
||||
|
||||
Когда нужен «как было на момент заказа» — два варианта:
|
||||
|
||||
**Вариант A: query с `?at=`**
|
||||
|
||||
```bash
|
||||
GET /api/v1/spacecraft/records/RESURS-P-3?at=2025-06-15T10:00:00Z
|
||||
```
|
||||
|
||||
Pro: всегда свежие данные если возможна retroactive correction.
|
||||
Con: depends on Ordinis availability + retention.
|
||||
|
||||
**Вариант B: snapshot в свою БД**
|
||||
|
||||
```sql
|
||||
CREATE TABLE imaging_order (
|
||||
id UUID PRIMARY KEY,
|
||||
spacecraft_business_key VARCHAR(256),
|
||||
reference_snapshot JSONB, -- копии записей, не FK
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
Pro: read-only к Ordinis, не зависит от availability/retention.
|
||||
Con: stale если Ordinis retroactively исправил данные.
|
||||
|
||||
Альтум использует **Вариант B** (snapshot) для legal compliance — заказы
|
||||
attestation требуют не depending on external service.
|
||||
|
||||
## Update с конкретным validFrom
|
||||
|
||||
Не всегда нужно `validFrom = now()`. Можно указать:
|
||||
|
||||
```bash
|
||||
PUT /api/v1/dictionaries/spacecraft/records/RESURS-P-3
|
||||
{
|
||||
"validFrom": "2026-05-15T00:00:00+03:00",
|
||||
"data": { "name": "Ресурс-П № 3 (новое имя с 15 мая)" }
|
||||
}
|
||||
```
|
||||
|
||||
Старая версия закрывается с `validTo = 2026-05-15`. Новая `validFrom =
|
||||
2026-05-15`. До 15 мая `?at=now` возвращает старую, после — новую.
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Cross-references (FK)](x-references.md) — связи между записями
|
||||
- [Caching](caching.md) — учитываем bitemporal в инвалидации
|
||||
@@ -0,0 +1,138 @@
|
||||
# Bundle справочников cuod (anchor v1)
|
||||
|
||||
ЦУОД (Центр обработки данных дистанционного зондирования Земли) — anchor
|
||||
customer Ordinis v1. Bundle `cuod-bundle:1.2.1` содержит 40 справочников.
|
||||
|
||||
## Полный список
|
||||
|
||||
| Имя | Scope | Записей (typical) | Назначение |
|
||||
|---|---|---|---|
|
||||
| `spacecraft` | PUBLIC | 4-30 | Каталог КА (космические аппараты) |
|
||||
| `satellite_type` | PUBLIC | 5-15 | Классификация типов КА |
|
||||
| `spacecraft_status` | PUBLIC | 5-10 | Статусы КА (planned, in-orbit, active, decommissioned) |
|
||||
| `mission` | PUBLIC | 9-20 | Программы (Ресурс-П, Канопус, Sentinel...) |
|
||||
| `ground_station` | PUBLIC | 3-10 | Наземные пункты приёма |
|
||||
| `antenna` | PUBLIC | 5-30 | Антенны на ground stations |
|
||||
| `instrument` | PUBLIC | 10-50 | Бортовые сенсоры (Геотон, MODIS, C-SAR...) |
|
||||
| `sensor_type` | PUBLIC | 5-15 | Типы сенсоров (optical, SAR, LIDAR, hyperspectral) |
|
||||
| `data_format` | PUBLIC | 8-15 | GeoTIFF, NITF, SAFE, COG, JPEG2000, HDF5 |
|
||||
| `processing_level` | PUBLIC | 8 | L0-L4 уровни обработки |
|
||||
| `orbit_type` | PUBLIC | 5-10 | LEO, MEO, GEO, SSO, polar |
|
||||
| `coverage_pattern` | PUBLIC | 5-15 | global, regional, targeted, swath |
|
||||
| `revisit_period` | PUBLIC | 10-20 | Категории revisit time |
|
||||
| `band_definition` | PUBLIC | 50-200 | Спектральные band definitions |
|
||||
| `radiometric_resolution` | PUBLIC | 5-15 | bit depth (8, 12, 16-bit) |
|
||||
| `swath_width` | PUBLIC | 10-20 | Категории ширины полосы |
|
||||
| `temporal_resolution` | PUBLIC | 10-20 | Категории временного разрешения |
|
||||
| `spatial_resolution` | PUBLIC | 15-30 | Категории пространственного разрешения |
|
||||
| `aoi_type` | PUBLIC | 5-10 | Типы AOI (regions, footprint, custom polygon) |
|
||||
| `acquisition_mode` | PUBLIC | 5-15 | Modes (push-broom, whisk-broom, frame, SAR strip) |
|
||||
| `pointing_capability` | PUBLIC | 5-10 | Off-nadir capabilities (nadir-only, ±15°, ±45°) |
|
||||
| `polarisation_mode` | PUBLIC | 5-10 | SAR polarisation (HH, HV, VH, VV, dual) |
|
||||
| `antenna_type` | PUBLIC | 10-20 | Антенны: parabolic, phased-array, helical |
|
||||
| `tracking_mode` | PUBLIC | 5-10 | Tracking algorithms |
|
||||
| `data_product_type` | PUBLIC | 15-30 | Конечные продукты (orthorectified, mosaic, classified) |
|
||||
| `coordinate_system` | PUBLIC | 20-50 | Проекции (WGS84, GK-Pulkovo, UTM-zones, MGRS) |
|
||||
| `agency` | PUBLIC | 10-20 | Космические агентства (Roscosmos, NASA, ESA, JAXA) |
|
||||
| `orbit_inclination` | PUBLIC | 10 | Категории наклонения |
|
||||
| `quality_indicator` | INTERNAL | 10-20 | Индикаторы качества (cloud cover, snow cover, off-nadir) |
|
||||
| `tasking_priority` | INTERNAL | 5-10 | Приоритеты заказов |
|
||||
| `pricing_tier` | INTERNAL | 5-15 | Тарифы (по resolution / coverage) |
|
||||
| `customer_segment` | INTERNAL | 10-20 | Сегменты заказчиков |
|
||||
| `delivery_format` | INTERNAL | 10-20 | Формы поставки (FTP, S3, USB, physical media) |
|
||||
| `processing_chain_type` | INTERNAL | 10-20 | Цепочки обработки |
|
||||
| `archive_tier` | INTERNAL | 5 | Tiers хранения (hot/warm/cold) |
|
||||
| `retention_policy` | RESTRICTED | 5-10 | Сроки хранения по типам данных |
|
||||
| `access_classification` | RESTRICTED | 5-10 | Уровни секретности |
|
||||
| `export_control` | RESTRICTED | 5-15 | Экспортные ограничения |
|
||||
| `licensing_terms` | RESTRICTED | 10-20 | Лицензионные условия |
|
||||
| `data_sharing_agreement` | RESTRICTED | 5-10 | Соглашения об обмене |
|
||||
|
||||
(Точное число записей варьируется — bundle ships seed data, операторы
|
||||
добавляют свои.)
|
||||
|
||||
## Cross-references (FK графа)
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
SC[spacecraft]
|
||||
ST[satellite_type]
|
||||
SS[spacecraft_status]
|
||||
M[mission]
|
||||
I[instrument]
|
||||
DF[data_format]
|
||||
PL[processing_level]
|
||||
SE[sensor_type]
|
||||
OT[orbit_type]
|
||||
AG[agency]
|
||||
|
||||
SC --satelliteTypeCode--> ST
|
||||
SC --statusCode--> SS
|
||||
SC --orbitTypeCode--> OT
|
||||
SC --agencyCode--> AG
|
||||
M --spacecraftCodes--> SC
|
||||
I --spacecraftCodes--> SC
|
||||
I --sensorTypeCode--> SE
|
||||
I --supportedFormatCodes--> DF
|
||||
I --supportedLevelCodes--> PL
|
||||
```
|
||||
|
||||
(Это subset, для full графа см. отдельные schemas в коде:
|
||||
`ordinis-cuod-bundle/src/main/resources/cuod/schemas/`.)
|
||||
|
||||
## Типичные сценарии
|
||||
|
||||
### Сценарий: список всех КА с их типами
|
||||
|
||||
1. `GET /api/v1/spacecraft/records?lang=ru` — все КА
|
||||
2. `GET /api/v1/satellite_type/records?lang=ru` — все типы (parallel)
|
||||
3. Memory join: `spacecraft.data.satelliteTypeCode === satellite_type.businessKey`
|
||||
|
||||
### Сценарий: cascading select для UI
|
||||
|
||||
```ts
|
||||
// User выбрал "instrument: MSI-Sentinel-2"
|
||||
const instrument = await get('/api/v1/instrument/records/MSI-S2?lang=ru')
|
||||
|
||||
// Показать только supported formats
|
||||
const allFormats = await get('/api/v1/data_format/records?lang=ru')
|
||||
const allowed = allFormats.filter(f =>
|
||||
instrument.data.supportedFormatCodes.includes(f.businessKey))
|
||||
|
||||
// allowed: SAFE, GEOTIFF, COG, JPEG2000
|
||||
```
|
||||
|
||||
### Сценарий: snapshot для legal compliance
|
||||
|
||||
При создании imaging order — snapshot всех использованных справочников
|
||||
в свою БД. См. [Caching → Snapshot at order time](caching.md#snapshot-at-order-time).
|
||||
|
||||
## Performance considerations
|
||||
|
||||
- Полный bundle (все 40 справочников × средние 15 записей × ~1KB JSON =
|
||||
~600KB total). Cold load на startup — допустимо.
|
||||
- После cold load — кэш на 5 мин TTL, рефреш по событиям через webhook /
|
||||
Kafka.
|
||||
- Spatial фильтры (`?bbox=`) — только на словари с geometry полями
|
||||
(`spacecraft.aoi`, `mission.coverage_area`). Использует PostGIS GiST
|
||||
index.
|
||||
|
||||
## Bundle versioning policy
|
||||
|
||||
| Изменение | Bump |
|
||||
|---|---|
|
||||
| Add field в schema (optional) | Patch (1.2.1 → 1.2.2) |
|
||||
| Add справочник | Minor (1.2.x → 1.3.0) |
|
||||
| Make required field из optional | Minor (consumers compatible если default sane) |
|
||||
| Remove field / справочник | Major (1.x.x → 2.0.0) |
|
||||
| Change field type | Major |
|
||||
| Add x-references | Minor (но requires re-validation existing data) |
|
||||
|
||||
Major changes coordinated с consumers через `nsi.dictionary.changed` events
|
||||
+ admin announcement.
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Read endpoints](read-endpoints.md) — как читать
|
||||
- [Cross-references](x-references.md) — детали FK
|
||||
- [Caching](caching.md) — стратегии кэша для bundle scale
|
||||
@@ -0,0 +1,203 @@
|
||||
# Стратегии кэширования
|
||||
|
||||
Ordinis оптимизирован под редкие записи и частые чтения. Кэшируй у себя,
|
||||
не делай round-trip на каждый GET.
|
||||
|
||||
Три рекомендуемых стратегии:
|
||||
|
||||
## A. Простой TTL (рекомендуется для v1)
|
||||
|
||||
Самый простой подход — TTL 5 мин на per-`(dictionary, lang)` ключ.
|
||||
|
||||
### TanStack Query (React)
|
||||
|
||||
```ts
|
||||
useQuery({
|
||||
queryKey: ['ordinis', 'spacecraft', lang],
|
||||
queryFn: () =>
|
||||
fetch(`/api/reference/spacecraft?lang=${lang}`, {
|
||||
headers: { Authorization: `Bearer ${token}` }
|
||||
}).then(r => r.json()),
|
||||
staleTime: 5 * 60_000,
|
||||
gcTime: 30 * 60_000,
|
||||
retry: 3,
|
||||
})
|
||||
```
|
||||
|
||||
### Server-side кэш (Caffeine + Spring)
|
||||
|
||||
```java
|
||||
@Cacheable(value = "ordinis-records", key = "#dictionary + ':' + #lang")
|
||||
public List<Record> fetchRecords(String dictionary, String lang) {
|
||||
return ordinisClient.list(dictionary, lang);
|
||||
}
|
||||
|
||||
// application.yml
|
||||
spring:
|
||||
cache:
|
||||
caffeine:
|
||||
spec: maximumSize=10000,expireAfterWrite=5m
|
||||
```
|
||||
|
||||
**Pro:** просто, работает out of the box.
|
||||
**Con:** до 5 мин stale данных. При закрытии записи consumer покажет
|
||||
её ещё 5 мин.
|
||||
|
||||
## B. Webhook / Kafka push (рекомендуется для prod)
|
||||
|
||||
Realtime invalidation через push-уведомления.
|
||||
|
||||
### Kafka
|
||||
|
||||
Подпишись на `ordinis.cuod.events.public` (см. [События](events.md)),
|
||||
для каждого пришедшего event — invalidate соответствующий cache key:
|
||||
|
||||
```python
|
||||
def on_event(event):
|
||||
dict_name = event['dictionary']
|
||||
business_key = event['businessKey']
|
||||
redis.delete(f"ordinis:{dict_name}:list") # invalidate list
|
||||
redis.delete(f"ordinis:{dict_name}:byKey:{business_key}")
|
||||
```
|
||||
|
||||
### Webhook
|
||||
|
||||
Тот же pattern но на HTTP push (см. [Webhooks](webhooks.md)):
|
||||
|
||||
```javascript
|
||||
app.post('/webhooks/ordinis', (req, res) => {
|
||||
if (!verifyHmac(req)) return res.status(403).send()
|
||||
|
||||
for (const event of req.body.events) {
|
||||
cache.delete(`ordinis:${event.dictionary}:list`)
|
||||
cache.delete(`ordinis:${event.dictionary}:byKey:${event.businessKey}`)
|
||||
}
|
||||
res.status(202).send()
|
||||
})
|
||||
```
|
||||
|
||||
**Pro:** < 1s invalidation latency.
|
||||
**Con:** требует Kafka/HTTPS infrastructure + handle duplicates (at-least-once).
|
||||
|
||||
## C. Snapshot at order time
|
||||
|
||||
Для аудит-кейсов когда нужен «как было на момент заказа» (legal compliance).
|
||||
|
||||
### Pattern
|
||||
|
||||
```sql
|
||||
CREATE TABLE imaging_order (
|
||||
id UUID PRIMARY KEY,
|
||||
spacecraft_business_key VARCHAR(256),
|
||||
instrument_business_key VARCHAR(256),
|
||||
reference_snapshot JSONB, -- копии записей, не FK
|
||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
||||
);
|
||||
```
|
||||
|
||||
При создании заказа:
|
||||
|
||||
```python
|
||||
spacecraft_data = ordinis.get(f"/spacecraft/records/{key}")
|
||||
instrument_data = ordinis.get(f"/instrument/records/{key}")
|
||||
|
||||
db.execute(
|
||||
"INSERT INTO imaging_order(id, spacecraft_business_key, instrument_business_key, reference_snapshot) "
|
||||
"VALUES (%s, %s, %s, %s)",
|
||||
(order_id, spacecraft_key, instrument_key, json.dumps({
|
||||
'spacecraft': spacecraft_data,
|
||||
'instrument': instrument_data,
|
||||
}))
|
||||
)
|
||||
```
|
||||
|
||||
**Pro:** гарантированная история, не зависит от Ordinis availability /
|
||||
retention. Если Ordinis retroactively изменит запись — твоя копия не
|
||||
сдвинется.
|
||||
|
||||
**Con:** дубликат данных, увеличение твоей БД. Для Альтум typical scale
|
||||
(~100k orders/year × ~1KB snapshot = 100MB/year) — не проблема.
|
||||
|
||||
### Альтернатива: bitemporal query
|
||||
|
||||
Можно не делать копию, а позже запрашивать `?at=<order.created_at>`:
|
||||
|
||||
```bash
|
||||
GET /api/v1/spacecraft/records/RESURS-P-3?at=2025-06-15T10:00:00Z
|
||||
```
|
||||
|
||||
См. [Bitemporal](bitemporal.md). Pro: меньше storage. Con: depends on
|
||||
Ordinis availability + retention. Альтум выбрала **snapshot** для legal
|
||||
compliance.
|
||||
|
||||
## Hybrid: TTL + push invalidation
|
||||
|
||||
Лучший из двух — TTL 1 час (long enough чтобы сократить request rate) +
|
||||
event-driven invalidation (для realtime updates):
|
||||
|
||||
```python
|
||||
# Получаем event → invalidate
|
||||
def on_event(event):
|
||||
cache.delete(f"ordinis:{event['dictionary']}:list")
|
||||
|
||||
# При cache miss — refetch с TTL 1h
|
||||
def get_records(dictionary, lang):
|
||||
cached = cache.get(f"ordinis:{dictionary}:list:{lang}")
|
||||
if cached:
|
||||
return cached
|
||||
fresh = ordinis_client.list(dictionary, lang)
|
||||
cache.setex(f"ordinis:{dictionary}:list:{lang}", 3600, fresh)
|
||||
return fresh
|
||||
```
|
||||
|
||||
## Cache key patterns
|
||||
|
||||
| Что кэшируем | Ключ |
|
||||
|---|---|
|
||||
| List of dictionaries | `ordinis:dictionaries:{scope}` |
|
||||
| List of records per dict | `ordinis:{dict}:list:{lang}` |
|
||||
| Single record | `ordinis:{dict}:byKey:{businessKey}:{lang}` |
|
||||
| History of record | `ordinis:{dict}:history:{businessKey}` (rare, TTL=24h) |
|
||||
|
||||
Включай `lang` в key — иначе разные locales перетирают друг друга.
|
||||
|
||||
## Invalidation на cascade close (v2 roadmap)
|
||||
|
||||
В будущем при cascade close источника emit'ятся events для всех
|
||||
referencing records. Consumer должен слушать events и invalidate их тоже:
|
||||
|
||||
```python
|
||||
def on_event(event):
|
||||
cache.delete(f"ordinis:{event['dictionary']}:byKey:{event['businessKey']}")
|
||||
|
||||
# NEW v2: cascade source — заодно invalidate dependents
|
||||
if event.get('cascadeSource'):
|
||||
# Источник тоже нужно invalidate (мы видим только закрытие dependent)
|
||||
src = event['cascadeSource']
|
||||
cache.delete(f"ordinis:{src['dictionary']}:byKey:{src['businessKey']}")
|
||||
```
|
||||
|
||||
См. design doc `dict-relationships-v2` (внутренний artifact).
|
||||
|
||||
## Stale cache на 5xx / timeout
|
||||
|
||||
Если Ordinis недоступен — отдавай старые данные с warn'ом, не падай каскадно:
|
||||
|
||||
```python
|
||||
def get_records_safe(dictionary, lang):
|
||||
try:
|
||||
return get_records(dictionary, lang)
|
||||
except (TimeoutError, HTTPError) as e:
|
||||
log.warning(f"Ordinis unavailable, using stale cache: {e}")
|
||||
stale = cache.get(f"ordinis:{dictionary}:list:{lang}", ignore_ttl=True)
|
||||
if stale:
|
||||
return stale
|
||||
raise
|
||||
```
|
||||
|
||||
Метрика `<consumer>_ordinis_stale_serves_total` для мониторинга.
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Best practices](best-practices.md) — общие рекомендации
|
||||
- [Bundle cuod](bundle-cuod.md) — что именно нужно кэшировать
|
||||
@@ -0,0 +1,169 @@
|
||||
# Ошибки и rate limits
|
||||
|
||||
## HTTP status coды
|
||||
|
||||
Ordinis следует стандартам REST с расширениями:
|
||||
|
||||
| Status | Используется для | Можно retry? |
|
||||
|---|---|---|
|
||||
| `200 OK` | GET / PUT успешен | — |
|
||||
| `201 Created` | POST создан новый ресурс | — |
|
||||
| `202 Accepted` | Async операция принята | — |
|
||||
| `204 No Content` | DELETE / close успешен | — |
|
||||
| `400 Bad Request` | Malformed JSON / missing required fields | Нет — fix payload |
|
||||
| `401 Unauthorized` | Token expired / invalid / missing | Refresh token и retry |
|
||||
| `403 Forbidden` | Token валиден, но scope не позволяет | Нет — request higher role |
|
||||
| `404 Not Found` | Resource не существует / закрыт / out of scope | Зависит от context |
|
||||
| `409 Conflict` | Optimistic lock fail / cascade block | Refetch + retry |
|
||||
| `422 Unprocessable Entity` | Validation errors (schema / x-references) | Нет — fix data |
|
||||
| `429 Too Many Requests` | Rate limit hit | Wait + retry с backoff |
|
||||
| `500 Internal Server Error` | Server bug | Retry с exponential backoff |
|
||||
| `502 Bad Gateway` | Upstream (config-server, Vault) недоступен | Retry |
|
||||
| `503 Service Unavailable` | Postgres недоступен / restart | Retry с long backoff |
|
||||
| `504 Gateway Timeout` | Запрос > timeout (typically 30s) | Retry |
|
||||
|
||||
## Error response format
|
||||
|
||||
Все 4xx/5xx с body имеют единый shape:
|
||||
|
||||
```json
|
||||
{
|
||||
"timestamp": "2026-05-07T15:23:45.123+03:00",
|
||||
"status": 422,
|
||||
"error": "Validation Error",
|
||||
"errors": [
|
||||
{
|
||||
"path": "/data/satelliteTypeCode",
|
||||
"code": "x_references_target_not_found",
|
||||
"message": "Referenced record not found in 'satellite_type' for code='UNKNOWN'"
|
||||
},
|
||||
{
|
||||
"path": "/data/launchDate",
|
||||
"code": "schema_format_invalid",
|
||||
"message": "Expected ISO 8601 date, got 'tomorrow'"
|
||||
}
|
||||
],
|
||||
"traceId": "abc123-def456-789"
|
||||
}
|
||||
```
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
| `timestamp` | Server time когда ошибка возникла. |
|
||||
| `status` | HTTP status code. |
|
||||
| `error` | Категория (`Validation Error`, `Authorization Error` и т.д.). |
|
||||
| `errors` | Array of detail errors. Может быть один или несколько. |
|
||||
| `errors[].path` | JSON Pointer к полю с ошибкой (например `/data/launchDate`). |
|
||||
| `errors[].code` | Machine-readable код для programmatic handling. |
|
||||
| `errors[].message` | Human-readable описание (русский для admin UI, английский для consumers). |
|
||||
| `traceId` | Tempo trace ID — приложи при эскалации в Ordinis team. |
|
||||
|
||||
## Известные error codes
|
||||
|
||||
### Validation (422)
|
||||
|
||||
| Code | Описание | Fix |
|
||||
|---|---|---|
|
||||
| `schema_required_missing` | Required field пропущен | Добавить поле |
|
||||
| `schema_format_invalid` | Wrong format (date / email / etc) | Fix format |
|
||||
| `schema_type_mismatch` | Wrong JSON type (string vs number) | Fix type |
|
||||
| `x_references_dict_not_found` | Referenced dictionary не существует | Schema bug — escalate |
|
||||
| `x_references_target_not_found` | Referenced record не активен / out of scope | Use existing key либо upgrade scope |
|
||||
| `x_references_non_string` | v1 поддерживает только string refs | Fix schema |
|
||||
| `x_references_malformed` | Bad x-references syntax | Fix schema |
|
||||
| `x_localized_default_missing` | i18n field без `default_locale` | Add default locale |
|
||||
| `geometry_invalid_wkt` | Невалидный WKT для geometry | Fix WKT |
|
||||
| `geometry_invalid_lat_lon` | lat/lon вне диапазона | Use valid coords |
|
||||
|
||||
### Authorization (401/403)
|
||||
|
||||
| Code | Описание |
|
||||
|---|---|
|
||||
| `auth_token_missing` | No `Authorization` header |
|
||||
| `auth_token_invalid` | Token не verified (signature, issuer) |
|
||||
| `auth_token_expired` | Token expired |
|
||||
| `auth_scope_insufficient` | Scope не покрывает запрашиваемый словарь |
|
||||
|
||||
### Conflict (409)
|
||||
|
||||
| Code | Описание | Fix |
|
||||
|---|---|---|
|
||||
| `optimistic_lock_failed` | Запись изменилась между read и write | Refetch + retry |
|
||||
| `record_already_closed` | close() на уже закрытой записи | No-op либо ignore |
|
||||
| `bulk_partial_failure` | Bulk операция: часть успешна, часть нет | Check `errors` array в response |
|
||||
|
||||
### Server (5xx)
|
||||
|
||||
| Code | Описание |
|
||||
|---|---|
|
||||
| `internal_unexpected` | Server bug — escalate с traceId |
|
||||
| `db_unavailable` | Postgres недоступен |
|
||||
| `vault_unavailable` | Vault sealed либо unreachable |
|
||||
| `kafka_publish_failed` | Outbox не смог publish (но запись в БД OK — eventually consistent) |
|
||||
|
||||
## Rate limits
|
||||
|
||||
Сегодня (v1.2.1) Ordinis **не имеет explicit rate limits**. Но есть soft
|
||||
limits через resource constraints:
|
||||
|
||||
| Resource | Limit |
|
||||
|---|---|
|
||||
| Postgres connections | 200 (shared между read-api + writer + projection-writer) |
|
||||
| Read-api app pods | 2× в проде, 1× CPU + 512MB memory |
|
||||
| Writer pods | 2× в проде |
|
||||
| Concurrent JWT verifications | bounded heap (~5k/sec практический максимум) |
|
||||
|
||||
При превышении (типично DDoS-like patterns):
|
||||
- HTTP `429 Too Many Requests` после ingress rate limit (ingress nginx default).
|
||||
- HTTP `503 Service Unavailable` при exhausting connection pool.
|
||||
- `5xx error rate spike` → alert `OrdinisReadApiErrorBudgetBurnFast`.
|
||||
|
||||
### Recommended client-side limits
|
||||
|
||||
Чтобы не triggers backend limits:
|
||||
|
||||
| Сценарий | Recommended rate |
|
||||
|---|---|
|
||||
| Cold load на startup | `≤ 100 req/sec` parallel, then cache |
|
||||
| Steady state read | `≤ 50 req/sec` per consumer |
|
||||
| Bulk import / migration | Talk to Ordinis team первым |
|
||||
|
||||
Внутренний `RateLimiter` (Caffeine `Bucket4j` — TBD) — в roadmap для v2 если
|
||||
наблюдаются abuse patterns.
|
||||
|
||||
## Retry strategy
|
||||
|
||||
```python
|
||||
import time, random
|
||||
from typing import Callable
|
||||
|
||||
def retry_with_backoff(
|
||||
fn: Callable, max_attempts: int = 5, base: float = 0.5
|
||||
) -> any:
|
||||
for attempt in range(max_attempts):
|
||||
try:
|
||||
return fn()
|
||||
except (HTTPError, TimeoutError) as e:
|
||||
if attempt == max_attempts - 1:
|
||||
raise
|
||||
if e.status_code in (400, 401, 403, 404, 422):
|
||||
raise # don't retry permanent errors
|
||||
sleep = base * (2 ** attempt) + random.uniform(0, 0.5)
|
||||
time.sleep(sleep)
|
||||
```
|
||||
|
||||
## Tracing
|
||||
|
||||
Каждый response имеет `X-Trace-Id` header и `traceId` field в error body.
|
||||
Ordinis эмитит OpenTelemetry traces в Tempo (`tempo.monitoring:4317`).
|
||||
|
||||
При эскалации Ordinis team:
|
||||
1. Скопируй `traceId` из response либо логов.
|
||||
2. Создай issue с воспроизведением.
|
||||
3. Ordinis team query'ит Tempo: `traceId="abc123-..."`.
|
||||
4. Trace покажет full path: ingress → read-api → postgres → response.
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Best practices](best-practices.md) — как писать robust integration code
|
||||
- [Авторизация](auth.md) — как избежать 401/403
|
||||
@@ -0,0 +1,189 @@
|
||||
# События (Kafka outbox)
|
||||
|
||||
Ordinis публикует все изменения справочников в Kafka через
|
||||
**outbox pattern** (at-least-once гарантия). Consumers подписываются на
|
||||
топики и инвалидируют свой кэш / обновляют projection.
|
||||
|
||||
## Топики
|
||||
|
||||
| Топик | Scope | Назначение |
|
||||
|---|---|---|
|
||||
| `ordinis.cuod.events.public` | PUBLIC | Изменения публичных справочников. |
|
||||
| `ordinis.cuod.events.internal` | INTERNAL | INTERNAL словари — нужна role `ordinis-internal`+. |
|
||||
| `ordinis.cuod.events.restricted` | RESTRICTED | RESTRICTED — role `ordinis-restricted`. |
|
||||
|
||||
Topology: 1 broker (single-node на cluster.142), 3+ partitions per topic
|
||||
(historical setup имел 12 partitions, при cluster recreate 2026-05-07
|
||||
spec задал 3; existing topics annotated `strimzi.io/managed=false`,
|
||||
broker partitions сохранены), `cleanup.policy: delete`,
|
||||
`retention.ms: 604800000` (7 дней).
|
||||
|
||||
## Подключение
|
||||
|
||||
### Адреса bootstrap
|
||||
|
||||
| Среда | Bootstrap servers | Auth |
|
||||
|---|---|---|
|
||||
| prod | `ordinis-kafka-kafka-bootstrap.ordinis-prod:9092` (TLS+SASL: `:9093`) | SCRAM-SHA-512 |
|
||||
| staging | `ordinis-kafka-kafka-bootstrap.ordinis-staging:9092` | SCRAM-SHA-512 |
|
||||
|
||||
### Получение SASL креденшелов
|
||||
|
||||
Запросить у Ordinis team создание Kafka user (Strimzi `KafkaUser` CR):
|
||||
|
||||
```yaml
|
||||
apiVersion: kafka.strimzi.io/v1beta2
|
||||
kind: KafkaUser
|
||||
metadata:
|
||||
name: altum-consumer
|
||||
namespace: ordinis-prod
|
||||
labels:
|
||||
strimzi.io/cluster: ordinis-kafka
|
||||
spec:
|
||||
authentication:
|
||||
type: scram-sha-512
|
||||
authorization:
|
||||
type: simple
|
||||
acls:
|
||||
- resource:
|
||||
type: topic
|
||||
name: ordinis.cuod.events.public
|
||||
patternType: literal
|
||||
operations: [Read, Describe]
|
||||
- resource:
|
||||
type: group
|
||||
name: altum-consumer-
|
||||
patternType: prefix
|
||||
operations: [Read]
|
||||
```
|
||||
|
||||
Strimzi user-operator создаст Secret `altum-consumer` с полями `username`,
|
||||
`password`. Используй их в Kafka client config.
|
||||
|
||||
## Schema события
|
||||
|
||||
### `dictionary.changed`
|
||||
|
||||
Все события имеют общий envelope:
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"eventType": "dictionary.changed",
|
||||
"occurredAt": "2026-05-07T15:23:45.123+03:00",
|
||||
"txTime": "2026-05-07T15:23:45.100+03:00",
|
||||
"changedBy": "admin@nstart.space",
|
||||
"changeReason": "Обновление after launch",
|
||||
"scope": "PUBLIC",
|
||||
"dictionary": "spacecraft",
|
||||
"businessKey": "RESURS-P-3",
|
||||
"action": "updated",
|
||||
"version": 3,
|
||||
"validFrom": "2026-05-07T00:00:00+03:00",
|
||||
"validTo": "9999-12-31T23:59:59+03:00",
|
||||
"data": {
|
||||
"name": { "ru-RU": "Ресурс-П № 3 (обновлён)" },
|
||||
"status": "active",
|
||||
"satelliteTypeCode": "EARTH-OBSERVATION-OPTICAL"
|
||||
},
|
||||
"previousData": {
|
||||
"name": { "ru-RU": "Ресурс-П № 3" },
|
||||
"status": "in-orbit"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Поля envelope
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
| `schemaVersion` | Semver схемы события. Bump major = breaking change. |
|
||||
| `eventType` | `dictionary.changed` для CRUD, `dictionary.bulk_imported` для bundle imports. |
|
||||
| `occurredAt` | Wall-clock когда событие создано. |
|
||||
| `txTime` | Transaction time изменения в БД (для ordering). |
|
||||
| `action` | `created` / `updated` / `closed`. |
|
||||
| `version` | Optimistic lock version. Monotonic per businessKey. |
|
||||
| `data` | Payload новой версии. |
|
||||
| `previousData` | Diff-like — поля, которые поменялись (только для `updated`). Полезен для consumer'ов которые хотят минимальный set re-fetches. |
|
||||
| `validFrom` / `validTo` | Bitemporal границы. |
|
||||
|
||||
### `action` semantics
|
||||
|
||||
- **created** — новая запись с уникальным `businessKey`. `previousData = null`.
|
||||
- **updated** — существующий businessKey, новая версия. `previousData` =
|
||||
изменённые поля старой версии.
|
||||
- **closed** — `validTo` обновлён в `now()` (раньше `9999-...`). Запись больше
|
||||
не возвращается на `?at=now`. `previousData` = вся старая версия.
|
||||
|
||||
### Cascade events (v2 roadmap)
|
||||
|
||||
При cascade close (см. design doc `dict-relationships-v2` — внутренний
|
||||
artifact в `~/.gstack/projects/claude/`) на каждую закрытую запись
|
||||
отдельное событие emit'ится в outbox с дополнительным полем
|
||||
`cascadeSource` = `{dictionary, businessKey}` источника. Consumer может
|
||||
trace cascade chain.
|
||||
|
||||
## Partition key
|
||||
|
||||
Сообщения партиционируются по `dictionary + businessKey` (одна запись —
|
||||
одна partition). Это даёт consumer'ам ordering guarantee per record.
|
||||
|
||||
```
|
||||
key = "spacecraft:RESURS-P-3"
|
||||
```
|
||||
|
||||
{% note info %}
|
||||
|
||||
Если consumer обрабатывает события параллельно (multiple consumer threads /
|
||||
pods), Kafka гарантирует только partition-level ordering. Ordering между
|
||||
records в разных partitions не определён. Для consumer'ов это норма —
|
||||
each businessKey изолирован.
|
||||
|
||||
{% endnote %}
|
||||
|
||||
## At-least-once семантика
|
||||
|
||||
Outbox pattern гарантирует **at-least-once** delivery:
|
||||
|
||||
1. Writer пишет запись в БД + outbox table в одной транзакции.
|
||||
2. OutboxPoller (scheduled @1s) читает unpublished rows, отправляет в Kafka.
|
||||
3. После Kafka ACK — отмечает row как published (`published_at` set).
|
||||
|
||||
Что это значит для consumer'а:
|
||||
|
||||
- Можешь получить **дубликат** события если poller crashed после ACK но до
|
||||
`published_at` write. Idempotency у consumer'а — must.
|
||||
- При network partition между Kafka и poller — сообщения накапливаются в
|
||||
outbox (alert `OrdinisOutboxLagHigh` срабатывает > 100 events).
|
||||
|
||||
## Idempotency
|
||||
|
||||
Используй `(dictionary, businessKey, txTime, version)` как dedup key:
|
||||
|
||||
```python
|
||||
seen = redis.get(f"ordinis:event:{dict}:{key}:{tx_time}:{ver}")
|
||||
if not seen:
|
||||
process_event(event)
|
||||
redis.setex(f"ordinis:event:...", 86400, "1")
|
||||
```
|
||||
|
||||
## DLQ (Dead-letter queue)
|
||||
|
||||
Если событие не публикуется > 1000 retry попыток (network issues,
|
||||
Kafka unavailable) — переходит в DLQ table в Ordinis. Не теряется, но
|
||||
требует ручного разбора.
|
||||
|
||||
Симптом: alert `OrdinisOutboxDLQNonEmpty` → check `/api/v1/admin/audit?action=outbox.dlq`.
|
||||
|
||||
## Топик для discovery
|
||||
|
||||
В roadmap планируется `nsi.dictionary.changed` aggregator topic — single
|
||||
firehose для consumers которые хотят видеть все scope'ы в одном потоке
|
||||
(c фильтрацией consumer-side). Сейчас **нет**, подписывайся на 3 scope
|
||||
топика отдельно.
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Webhooks](webhooks.md) — alternative для consumer'ов без Kafka access
|
||||
- [Bitemporal](bitemporal.md) — как интерпретировать `validFrom`/`validTo`
|
||||
- [Caching](caching.md) — invalidation pattern с Kafka events
|
||||
@@ -0,0 +1,54 @@
|
||||
# Интеграция по API — обзор
|
||||
|
||||
Этот раздел описывает как backend-сервисы (Альтум, геопортал, BI и т.д.)
|
||||
читают и пишут справочники Ordinis НСИ ЦУОД.
|
||||
|
||||
**Owner:** Ordinis team — `zimin.an@nstart.space`.
|
||||
|
||||
## С чего начать
|
||||
|
||||
1. [Получи Keycloak service account](auth.md) — нужен для всех API вызовов.
|
||||
2. [Прочитай read-side endpoints](read-endpoints.md) — как получить данные.
|
||||
3. [Выбери стратегию обновления](caching.md) — TTL, webhooks, либо Kafka events.
|
||||
|
||||
## Что Ordinis даёт
|
||||
|
||||
- **Bitemporal CRUD** для справочников — `validFrom` / `validTo` + revision
|
||||
history через Hibernate Envers.
|
||||
- **Multi-locale** — каждое поле может иметь `{ru-RU, en-US, ...}` варианты.
|
||||
Flatten через `?lang=` параметр.
|
||||
- **Cross-dictionary FK** через `x-references` schema extension. Server-side
|
||||
валидация на CRUD + orphan scanner periodic.
|
||||
- **Geometry support** — PostGIS для bbox/polygon фильтров (`spacecraft.aoi`,
|
||||
`mission.coverage_area`).
|
||||
- **Audit log** — все изменения через `/api/v1/admin/audit` (UI: `/audit`).
|
||||
- **Outbox + Kafka** — at-least-once delivery всех изменений в 3 топика.
|
||||
- **Webhooks** — push-уведомления HTTP к зарегистрированным URLs (для
|
||||
consumers без Kafka access).
|
||||
- **Bulk operations** — multi-select close + CSV export.
|
||||
|
||||
## Что Ordinis НЕ делает
|
||||
|
||||
- Не хранит "снимок на момент заказа" — для этого consumer должен сделать
|
||||
snapshot rough copy в свою БД (см. [Caching → Snapshot strategy](caching.md#snapshot-at-order-time)).
|
||||
- Не enforce'ит FK на уровне DB — это NoSQL-style references поверх JSONB.
|
||||
Cascade rules в roadmap (см. design `dict-relationships-v2`).
|
||||
- Не обеспечивает realtime sync — TTL = 5 мин минимум либо event-driven invalidation.
|
||||
|
||||
## Версионирование
|
||||
|
||||
- **API** — `/api/v1/...` стабилен. Breaking changes только через major (`v2`).
|
||||
- **Bundle** (cuod-bundle) — semver. Minor bump при schema изменении, major
|
||||
при удалении справочника. См. [Bundle cuod](bundle-cuod.md).
|
||||
|
||||
## Глоссарий
|
||||
|
||||
| Термин | Значение |
|
||||
|---|---|
|
||||
| Dictionary | Справочник (например `spacecraft`, `satellite_type`). |
|
||||
| Record | Запись в справочнике с уникальным `businessKey`. |
|
||||
| businessKey | Стабильный идентификатор (string). Не меняется при update. |
|
||||
| Bundle | Maven module с schemas + seed data. ЦУОД bundle = 40 справочников. |
|
||||
| Scope | Уровень доступа: `PUBLIC` / `INTERNAL` / `RESTRICTED`. |
|
||||
| Bitemporal | Запись имеет `validFrom`/`validTo` + revision history (transaction time). |
|
||||
| x-references | JSON Schema extension для cross-dictionary FK. |
|
||||
@@ -0,0 +1,191 @@
|
||||
# Read-side endpoints
|
||||
|
||||
Read-API в Ordinis — отдельный deployable (`ordinis-read-api`, scaled 2× в проде)
|
||||
работающий на Postgres replica. Запросы из read-api **не блокируют** writer'а.
|
||||
|
||||
Base URL:
|
||||
|
||||
| Среда | URL |
|
||||
|---|---|
|
||||
| prod | `https://ordinis.k8s.264.nstart.cloud/api/v1` |
|
||||
| staging | `https://ordinis.k8s.265.nstart.cloud/api/v1` |
|
||||
|
||||
Все endpoints требуют JWT (см. [Авторизация](auth.md)).
|
||||
|
||||
## OpenAPI / Swagger UI
|
||||
|
||||
- **Swagger UI:** `https://ordinis.k8s.264.nstart.cloud/swagger-ui.html`
|
||||
- **Spec JSON:** `https://ordinis.k8s.264.nstart.cloud/v3/api-docs`
|
||||
- **Spec YAML:** `https://ordinis.k8s.264.nstart.cloud/v3/api-docs.yaml`
|
||||
|
||||
Из spec'а можно generate'ить клиент:
|
||||
|
||||
```bash
|
||||
openapi-generator-cli generate \
|
||||
-i https://ordinis.k8s.264.nstart.cloud/v3/api-docs \
|
||||
-g python \
|
||||
-o ./altum-backend/clients/ordinis
|
||||
```
|
||||
|
||||
Поддерживаются: Python, TypeScript, Go, Java, Kotlin, Ruby и др.
|
||||
|
||||
## `GET /api/v1/dictionaries`
|
||||
|
||||
Список доступных справочников (фильтрован по scope JWT'а).
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
https://ordinis.k8s.264.nstart.cloud/api/v1/dictionaries
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "spacecraft",
|
||||
"displayName": "Космические аппараты",
|
||||
"scope": "PUBLIC",
|
||||
"schemaVersion": "1.0.0",
|
||||
"recordsCount": 4
|
||||
},
|
||||
{
|
||||
"name": "mission",
|
||||
"displayName": "Миссии / программы",
|
||||
"scope": "PUBLIC",
|
||||
"schemaVersion": "1.0.0",
|
||||
"recordsCount": 9
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
## `GET /api/v1/{name}/records`
|
||||
|
||||
{% note warning %}
|
||||
|
||||
**БЕЗ префикса `/dictionaries/`!** Read-api сидит на `/api/v1/{name}/records`.
|
||||
Префикс `/api/v1/dictionaries/...` — это admin write controller (CRUD), у него
|
||||
нет bulk-list эндпоинта.
|
||||
|
||||
{% endnote %}
|
||||
|
||||
Активные записи на момент `at` (default: сейчас).
|
||||
|
||||
### Параметры
|
||||
|
||||
| Параметр | Тип | Пример | Назначение |
|
||||
|---|---|---|---|
|
||||
| `lang` | string | `ru-RU`, `en-US` | Flatten i18n полей в строку. Короткие `ru`/`en` тоже. |
|
||||
| `at` | ISO 8601 | `2026-05-04T12:00:00+03:00` | Bitemporal query — активные на момент. |
|
||||
| `as_scope` | string | `PUBLIC,INTERNAL` | Legacy для anonymous + `allowQueryScope=true`. |
|
||||
|
||||
### Пример
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"https://ordinis.k8s.264.nstart.cloud/api/v1/spacecraft/records?lang=ru-RU"
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"businessKey": "RESURS-P-3",
|
||||
"displayKey": "Ресурс-П № 3",
|
||||
"scope": "PUBLIC",
|
||||
"validFrom": "2025-01-01T00:00:00+03:00",
|
||||
"validTo": "9999-12-31T23:59:59+03:00",
|
||||
"data": {
|
||||
"name": "Ресурс-П № 3",
|
||||
"satelliteTypeCode": "EARTH-OBSERVATION-OPTICAL",
|
||||
"launchDate": "2024-12-25",
|
||||
"status": "active"
|
||||
},
|
||||
"version": 2
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### Без `?lang` (raw i18n)
|
||||
|
||||
```json
|
||||
"name": { "ru-RU": "Ресурс-П № 3", "en-US": "Resurs-P No.3" }
|
||||
```
|
||||
|
||||
С `?lang=ru-RU`:
|
||||
|
||||
```json
|
||||
"name": "Ресурс-П № 3"
|
||||
```
|
||||
|
||||
Если запрошенного языка нет — fallback к `default_locale` (обычно `ru-RU`).
|
||||
|
||||
## `GET /api/v1/{name}/records/{businessKey}`
|
||||
|
||||
Одна запись. Параметры те же что у list.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer $TOKEN" \
|
||||
"https://ordinis.k8s.264.nstart.cloud/api/v1/spacecraft/records/RESURS-P-3?lang=ru"
|
||||
```
|
||||
|
||||
`404 Not Found` — запись либо не существует, либо закрыта на момент `at`,
|
||||
либо вне scope JWT'а (Ordinis скрывает существование).
|
||||
|
||||
## `GET /api/v1/{name}/records/{businessKey}/history`
|
||||
|
||||
Все версии записи (включая закрытые) для построения истории изменений в UI.
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"version": 1,
|
||||
"validFrom": "2024-06-01T00:00:00+03:00",
|
||||
"validTo": "2025-01-01T00:00:00+03:00",
|
||||
"data": {...},
|
||||
"txTime": "2024-06-01T10:23:45+03:00",
|
||||
"changedBy": "admin@nstart.space",
|
||||
"changeReason": "initial"
|
||||
},
|
||||
{
|
||||
"version": 2,
|
||||
"validFrom": "2025-01-01T00:00:00+03:00",
|
||||
"validTo": "9999-12-31T23:59:59+03:00",
|
||||
"data": {...},
|
||||
"txTime": "2024-12-30T14:11:22+03:00",
|
||||
"changedBy": "admin@nstart.space",
|
||||
"changeReason": "Обновление статуса после успешного запуска"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Для consumers history endpoint обычно не нужен — это UI-feature.
|
||||
|
||||
## Spatial filters (geometry)
|
||||
|
||||
Если справочник имеет geometry поля (`spacecraft.aoi`, `mission.coverage_area`),
|
||||
поддерживаются bbox + polygon фильтры:
|
||||
|
||||
### `?bbox=` (bounding box)
|
||||
|
||||
```
|
||||
GET /api/v1/spacecraft/records?bbox=37.0,55.0,38.0,56.0
|
||||
```
|
||||
|
||||
Format: `min_lon,min_lat,max_lon,max_lat` (WGS84). Возвращает записи где
|
||||
`geometry` пересекает bbox через PostGIS `ST_Intersects` + GiST index.
|
||||
|
||||
### `?polygon=` (GeoJSON)
|
||||
|
||||
```
|
||||
GET /api/v1/spacecraft/records?polygon={"type":"Polygon","coordinates":[[[37,55],[38,55],[38,56],[37,56],[37,55]]]}
|
||||
```
|
||||
|
||||
Polygon — GeoJSON object (URL-encoded). Сложнее bbox, точнее.
|
||||
|
||||
## Дальше
|
||||
|
||||
- [События (Kafka outbox)](events.md) — push-обновления вместо polling
|
||||
- [Bitemporal](bitemporal.md) — детали `validFrom`/`validTo` модели
|
||||
- [Caching](caching.md) — стратегии актуализации
|
||||
@@ -0,0 +1,216 @@
|
||||
# Webhooks
|
||||
|
||||
Альтернатива Kafka events для consumer'ов которые не хотят / не могут
|
||||
поддерживать Kafka client. Ordinis отправляет HTTP POST на зарегистрированный
|
||||
URL при изменениях справочников.
|
||||
|
||||
{% note info %}
|
||||
|
||||
Если у тебя есть Kafka access — используй [события](events.md). Webhooks
|
||||
проще в развёртывании, но менее надёжны (HTTP retry vs Kafka durable log)
|
||||
и медленнее (HTTP overhead vs Kafka batch).
|
||||
|
||||
{% endnote %}
|
||||
|
||||
## Регистрация подписки
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://ordinis.k8s.264.nstart.cloud/api/v1/admin/webhooks/subscriptions \
|
||||
-d '{
|
||||
"name": "altum-cache-invalidation",
|
||||
"url": "https://altum-backend.altum.local/webhooks/ordinis",
|
||||
"secret": "<HMAC_SECRET_GENERATED_BY_YOU>",
|
||||
"scopes": ["PUBLIC", "INTERNAL"],
|
||||
"dictionaries": ["spacecraft", "satellite_type", "instrument"],
|
||||
"actions": ["created", "updated", "closed"],
|
||||
"active": true
|
||||
}'
|
||||
```
|
||||
|
||||
### Поля
|
||||
|
||||
| Поле | Тип | Назначение |
|
||||
|---|---|---|
|
||||
| `name` | string | Человекочитаемое имя для admin UI. |
|
||||
| `url` | string | HTTPS endpoint. HTTP блокируется SSRF guard'ом. |
|
||||
| `secret` | string | HMAC-SHA256 секрет для подписи payload (см. ниже). |
|
||||
| `scopes` | array | Фильтр по scope (subset из доступных consumer'у). |
|
||||
| `dictionaries` | array | Фильтр по dictionary names. `null`/`[]` = все доступные. |
|
||||
| `actions` | array | `created` / `updated` / `closed`. `null` = все. |
|
||||
| `active` | bool | Можно временно отключить без удаления. |
|
||||
|
||||
## SSRF guard
|
||||
|
||||
Webhook URLs валидируются перед отправкой:
|
||||
|
||||
- **Запрещено:** private CIDR (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16),
|
||||
localhost, link-local (169.254/16), metadata IPs.
|
||||
- **Запрещено:** non-HTTPS scheme. Только `https://`.
|
||||
- **Запрещено:** URL > 2048 chars.
|
||||
|
||||
Метрика `nsi_webhook_ssrf_rejected_total` increments при попытках. См.
|
||||
prometheus rule `OrdinisWebhookSsrfRejected`.
|
||||
|
||||
## Payload
|
||||
|
||||
POST на зарегистрированный URL с body:
|
||||
|
||||
```json
|
||||
{
|
||||
"deliveryId": "wd-abc123-xyz789",
|
||||
"subscriptionName": "altum-cache-invalidation",
|
||||
"occurredAt": "2026-05-07T15:23:45.123+03:00",
|
||||
"events": [
|
||||
{
|
||||
"schemaVersion": "1.0.0",
|
||||
"eventType": "dictionary.changed",
|
||||
"dictionary": "spacecraft",
|
||||
"businessKey": "RESURS-P-3",
|
||||
"action": "updated",
|
||||
"version": 3,
|
||||
"validFrom": "2026-05-07T00:00:00+03:00",
|
||||
"data": { ... }
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
{% note tip %}
|
||||
|
||||
Payload может содержать **несколько events** (`events` array) если они
|
||||
произошли в одной transaction (bulk close, bundle import). Не предполагай
|
||||
single-event payload.
|
||||
|
||||
{% endnote %}
|
||||
|
||||
## HMAC подпись
|
||||
|
||||
Каждый POST имеет header:
|
||||
|
||||
```
|
||||
X-Ordinis-Signature: sha256=<HEX>
|
||||
X-Ordinis-Delivery: wd-abc123-xyz789
|
||||
X-Ordinis-Subscription: altum-cache-invalidation
|
||||
```
|
||||
|
||||
Где `<HEX>` = HMAC-SHA256 от raw body bytes с `secret` который ты задал
|
||||
при регистрации.
|
||||
|
||||
### Verify (Python)
|
||||
|
||||
```python
|
||||
import hmac, hashlib
|
||||
|
||||
def verify(body: bytes, signature_header: str, secret: str) -> bool:
|
||||
expected = "sha256=" + hmac.new(
|
||||
secret.encode(), body, hashlib.sha256
|
||||
).hexdigest()
|
||||
return hmac.compare_digest(expected, signature_header)
|
||||
```
|
||||
|
||||
### Verify (Node.js)
|
||||
|
||||
```javascript
|
||||
import crypto from 'crypto'
|
||||
|
||||
function verify(body, signatureHeader, secret) {
|
||||
const expected = 'sha256=' + crypto
|
||||
.createHmac('sha256', secret)
|
||||
.update(body)
|
||||
.digest('hex')
|
||||
return crypto.timingSafeEqual(
|
||||
Buffer.from(expected),
|
||||
Buffer.from(signatureHeader)
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
{% note warning %}
|
||||
|
||||
Verify подпись **до** парсинга JSON. Если invalid — `403 Forbidden`,
|
||||
не трогай payload. Без verify ты уязвим к подделке events от любого, кто
|
||||
знает твой URL.
|
||||
|
||||
{% endnote %}
|
||||
|
||||
## Response contract
|
||||
|
||||
Consumer должен ответить `2xx` в течение 10 секунд:
|
||||
|
||||
| Status | Семантика |
|
||||
|---|---|
|
||||
| `200` / `201` / `202` / `204` | Принято. Ordinis помечает delivered. |
|
||||
| `4xx` | Permanent failure (твой bug). Ordinis НЕ retry. |
|
||||
| `5xx` / timeout | Transient. Ordinis retry с exponential backoff. |
|
||||
|
||||
## Retry policy
|
||||
|
||||
| Попытка | Интервал |
|
||||
|---|---|
|
||||
| 1 | сразу |
|
||||
| 2 | через 30s |
|
||||
| 3 | через 2 мин |
|
||||
| 4 | через 10 мин |
|
||||
| 5 | через 1ч |
|
||||
| 6-10 | каждые 6ч |
|
||||
| 1000+ | DLQ → ручной разбор |
|
||||
|
||||
После 1000 неудач delivery попадает в DLQ (`/api/v1/admin/webhooks/deliveries/dlq`).
|
||||
Alert: `OrdinisWebhookDLQNonEmpty`.
|
||||
|
||||
## Test ping
|
||||
|
||||
Endpoint для проверки доступности URL **до** регистрации:
|
||||
|
||||
```bash
|
||||
curl -X POST -H "Authorization: Bearer $ADMIN_TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
https://ordinis.k8s.264.nstart.cloud/api/v1/admin/webhooks/test-ping \
|
||||
-d '{
|
||||
"url": "https://altum-backend.altum.local/webhooks/ordinis",
|
||||
"secret": "<TEST_SECRET>"
|
||||
}'
|
||||
```
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"statusCode": 200,
|
||||
"responseBody": "OK",
|
||||
"elapsedMs": 142
|
||||
}
|
||||
```
|
||||
|
||||
Либо details про failure (DNS issue, timeout, SSRF reject и т.д.).
|
||||
|
||||
## Best practices
|
||||
|
||||
1. **Idempotency** — webhook может прийти **дважды** (network retry, partial
|
||||
failure). Используй `deliveryId` как dedup key.
|
||||
2. **Async обработка** — отвечай `202 Accepted` сразу, обрабатывай в
|
||||
background. 10s timeout жёсткий — если БД медленная, лучше принять и
|
||||
сложить в очередь.
|
||||
3. **HMAC verify первым** — до парсинга JSON.
|
||||
4. **Metrics** — track received / processed / errors для своего side.
|
||||
Сравнивай с Ordinis metrics при расследовании.
|
||||
5. **Whitelist Ordinis IP** на firewall если строгие правила. IP cluster.142
|
||||
= `192.168.100.142`, но в production будет ingress IP. Спросить у Ordinis
|
||||
team при registration.
|
||||
|
||||
## Alerting (Ordinis side)
|
||||
|
||||
| Alert | Trigger |
|
||||
|---|---|
|
||||
| `OrdinisWebhookHighFailureRate` | failure rate > 50% за 5 мин |
|
||||
| `OrdinisWebhookDLQNonEmpty` | events в DLQ > 0 |
|
||||
| `OrdinisWebhookDispatcherIdle` | 0 deliveries при > 0 active subscriptions |
|
||||
| `OrdinisWebhookSsrfRejected` | SSRF guard сработал > 0 раз за 15 мин |
|
||||
|
||||
## Дальше
|
||||
|
||||
- [События (Kafka)](events.md) — primary delivery channel
|
||||
- [Caching](caching.md) — webhook invalidation pattern
|
||||
@@ -0,0 +1,142 @@
|
||||
# Cross-references (FK между справочниками)
|
||||
|
||||
JSON Schema extension `x-references` объявляет, что значение поля должно
|
||||
ссылаться на active record в указанном словаре.
|
||||
|
||||
## Синтаксис
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"satelliteTypeCode": {
|
||||
"type": "string",
|
||||
"x-references": "satellite_type.code"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Format: `"<dictionary_name>.<field_name>"`. Когда POST/PUT приходит, Ordinis
|
||||
делает lookup в `satellite_type` где `data->>code == satelliteTypeCode`.
|
||||
Если не найден / не активен / вне scope — `422 Validation Error`.
|
||||
|
||||
## Текущие FK в bundle cuod
|
||||
|
||||
| Source | Target | Поле |
|
||||
|---|---|---|
|
||||
| `spacecraft.satelliteTypeCode` | `satellite_type` | `code` |
|
||||
| `spacecraft.statusCode` | `spacecraft_status` | `code` |
|
||||
| `mission.spacecraftCodes` (array) | `spacecraft` | `businessKey` |
|
||||
| `instrument.spacecraftCodes` (array) | `spacecraft` | `businessKey` |
|
||||
| `instrument.supportedFormatCodes` (array) | `data_format` | `businessKey` |
|
||||
| `instrument.supportedLevelCodes` (array) | `processing_level` | `businessKey` |
|
||||
|
||||
(Точное число и список могут меняться с bundle versions — см.
|
||||
[Bundle cuod](bundle-cuod.md).)
|
||||
|
||||
## Server-side validation
|
||||
|
||||
При POST/PUT ReferenceValidator проверяет каждое x-references поле:
|
||||
|
||||
| Симптом | Error code | Описание |
|
||||
|---|---|---|
|
||||
| Referenced dictionary не существует | `x_references_dict_not_found` | Опечатка в schema. |
|
||||
| Referenced record не найден / inactive | `x_references_target_not_found` | Bad value либо record закрыт. |
|
||||
| Value не string (для v1) | `x_references_non_string` | v1 поддерживает только string refs. |
|
||||
| Schema spec malformed | `x_references_malformed` | Bad schema. |
|
||||
|
||||
Response:
|
||||
|
||||
```json
|
||||
{
|
||||
"errors": [
|
||||
{
|
||||
"path": "/satelliteTypeCode",
|
||||
"code": "x_references_target_not_found",
|
||||
"message": "Referenced record not found in 'satellite_type' for code='UNKNOWN-TYPE'"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
## Scope hiding
|
||||
|
||||
Если referenced dictionary — INTERNAL/RESTRICTED, а consumer имеет только
|
||||
PUBLIC scope, ReferenceValidator возвращает тот же error
|
||||
(`x_references_target_not_found`) что и на real not-found. Ordinis **не leak'ает**
|
||||
existence of out-of-scope dictionary через differential errors.
|
||||
|
||||
## Orphan reference scanner
|
||||
|
||||
Возможна ситуация когда referenced record **закрывается**, а referencing
|
||||
record остаётся:
|
||||
|
||||
```
|
||||
1. spacecraft.RESURS-P-3 references satellite_type.EARTH-OPTICAL
|
||||
2. Admin closes satellite_type.EARTH-OPTICAL (record.closed event emitted)
|
||||
3. spacecraft.RESURS-P-3 теперь имеет dangling FK
|
||||
```
|
||||
|
||||
Periodic scanner (`OrphanReferenceScanner`, runs каждые 5 мин) находит
|
||||
такие orphan'ы и emit'ит metric:
|
||||
|
||||
```
|
||||
nsi_orphan_references_total{source_dict="spacecraft",target_dict="satellite_type"} 1
|
||||
```
|
||||
|
||||
Alert `OrdinisOrphanReferences` срабатывает при > 0 за 10 мин.
|
||||
|
||||
## Cascade rules — roadmap (v2)
|
||||
|
||||
Сегодня закрытие источника НЕ closes referencing automatically. Это
|
||||
дolжно явно делаться через bulk close либо individual updates.
|
||||
|
||||
В v2 планируется schema extension `x-references-on-close`:
|
||||
|
||||
```json
|
||||
{
|
||||
"satelliteTypeCode": {
|
||||
"type": "string",
|
||||
"x-references": "satellite_type.code",
|
||||
"x-references-on-close": "block"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Опции:
|
||||
- **block** — закрытие источника невозможно пока есть active referencing (default).
|
||||
- **warn** — закрытие проходит, в response warning + metric `nsi_cascade_warnings_total`.
|
||||
- **cascade** — все referencing закрываются в той же транзакции.
|
||||
|
||||
См. design doc `dict-relationships-v2` (внутренний artifact в
|
||||
`~/.gstack/projects/claude/`) для полного scope.
|
||||
|
||||
## Lineage discovery — roadmap (v2)
|
||||
|
||||
Сегодня нет endpoint для "что depends on X". Admin должен вручную проходить
|
||||
schemas чтобы найти ссылки. В v2 планируется:
|
||||
|
||||
- `GET /api/v1/dictionaries/{name}/dependents` — словари которые ссылаются.
|
||||
- `GET /api/v1/records/{dict}/{key}/dependents` — конкретные записи.
|
||||
|
||||
См. design выше для подробностей.
|
||||
|
||||
## Best practices
|
||||
|
||||
1. **Используй businessKey как target field** для большинства FK. Это
|
||||
стабильный идентификатор. Альтернатива (`code`, `name`) работает но
|
||||
требует поддержки `x-unique` в target (иначе ambiguous matches).
|
||||
2. **Не делай deeply nested refs** — v1 поддерживает только top-level
|
||||
fields. Nested objects с x-references — пропустят validation.
|
||||
3. **Array refs работают** — если поле `type: array, items: {type: string,
|
||||
x-references: ...}`, каждый элемент валидируется. (Но в v1.2.1 это
|
||||
делается на каждый элемент отдельно — for large arrays медленно. Будет
|
||||
batch'нуто в v2.)
|
||||
4. **Document FK в displayName** — `"description": "FK на satellite_type
|
||||
через code"` помогает при ревью schemas.
|
||||
|
||||
## Дальше
|
||||
|
||||
- [Bundle cuod](bundle-cuod.md) — список текущих FK в проде
|
||||
- [События](events.md) — учитывай cascade closure при инвалидации
|
||||
Reference in New Issue
Block a user