Files
image-platform/docs/backend-contract-draft.md
S.Gromov 56d551b43b feat: добавить endpoint picture/srcset
- добавлен contract DTO для picture sources и fallback image
- реализована выдача versioned Gateway URLs по static presets
- обновлена документация business API и dev smoke flow
2026-05-05 13:35:25 +03:00

384 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Черновик Backend Контракта
Это черновик бизнес-контракта для NestJS backend. Сейчас реализованы system endpoints, Swagger, `POST /api/assets` и internal image ensure MVP.
## System
```text
GET /api/health
GET /docs
GET /docs-json
```
NestJS backend отдаёт JSON, metadata, statuses и URLs. Public image origin находится в Fastify Gateway. Backend владеет PostgreSQL, S3 orchestration и RabbitMQ jobs.
## Internal Image Ensure
Этот internal endpoint вызывается Gateway на L1 miss. Gateway не ходит в DB/S3 напрямую.
```text
POST /api/internal/images/ensure
```
Текущий статус реализации - MVP реализован: Backend создаёт/переиспользует variant row, публикует RabbitMQ job, ждёт `ready` до timeout и возвращает image bytes из S3.
Request body:
```json
{
"assetId": "asset_123",
"version": 4,
"preset": "card",
"width": 640,
"height": 0,
"quality": 80,
"requestedFormat": "auto",
"resize": "fit",
"format": "webp"
}
```
Query params:
| Param | Описание |
|---|---|
| `w` | целевая ширина; обязательна для responsive presets и custom, запрещена/фиксирована для fixed presets |
| `h` | целевая высота; используется для custom, для fixed preset берётся из config |
| `q` | качество; если не передано, берётся из preset, иначе должно входить в allowlist preset |
| `f` | `auto`, `avif`, `webp`, `jpg`; для Next/image по умолчанию `auto` |
| `fit` | `fit` или `fill`; используется только для custom transforms |
Responsibilities:
- проверить `assetId` и статический preset/custom transform config;
- нормализовать width, height, resize, quality и format;
- вычислить deterministic `variantHash`;
- проверить PostgreSQL и S3;
- если variant готов в S3, вернуть bytes или stream metadata для Gateway;
- если variant отсутствует, создать/переиспользовать variant row;
- поставить `generate-variant` job в RabbitMQ;
- дождаться `ready` до timeout, чтобы первый `next/image` request мог получить картинку;
- вернуть image response или metadata для Gateway.
Response headers:
```http
Content-Type: image/avif | image/webp | image/jpeg
Cache-Control: public, max-age=31536000, immutable
Vary: Accept
ETag: "..."
```
Ошибки:
| Status | Когда |
|---|---|
| `400` | некорректные query params |
| `404` | asset или preset не найден |
| `502` | external imgproxy недоступен или S3 object отсутствует после `ready` |
| `504` | generation не завершилась до `IMAGE_ENSURE_WAIT_MS` |
Public endpoint реализуется в Fastify Gateway, internal ensure endpoint - в Backend:
```text
client -> CDN -> Gateway /images/* -> Backend ensure -> RabbitMQ -> Worker -> imgproxy -> S3
```
Важно: `/images/*` не должен жить в NestJS `/api`. Gateway остаётся public image origin, Backend остаётся управлением процессов.
Public image URL versioned: `/images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto`.
Примеры:
```text
/images/asset_demo/v1/card?w=640&q=80&f=auto
/images/asset_demo/v1/avatar?f=auto
/images/asset_demo/v1/custom?w=777&h=333&q=72&fit=fill&f=webp
```
## Allowed Hosts
```text
GET /allowed-hosts
POST /allowed-hosts
PATCH /allowed-hosts/:id
DELETE /allowed-hosts/:id
```
## Assets
```text
POST /assets
GET /assets
GET /assets/:publicId
POST /assets/:publicId/versions
GET /assets/:publicId/picture
GET /assets/:publicId/variants
POST /assets/:publicId/variants
DELETE /assets/:id
```
Сейчас реализованы `POST /assets`, `GET /assets`, `GET /assets/:publicId`, `POST /assets/:publicId/versions`, `GET /assets/:publicId/picture`, `GET /assets/:publicId/variants`, `POST /assets/:publicId/variants`.
`POST /assets` request:
```json
{
"sourceUrl": "https://storage.yandexcloud.net/shared1318/img/1.jpg",
"publicId": "asset_demo"
}
```
Response:
```json
{
"id": "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66",
"publicId": "asset_demo",
"version": 1,
"sourceHost": "storage.yandexcloud.net",
"imageBasePath": "/images/asset_demo/v1/card"
}
```
Responsibilities:
- validate source URL;
- check mock allowlist `SOURCE_ALLOWED_HOSTS`, если `SOURCE_HOST_ALLOW_ALL=false`;
- создать `image_assets` row;
- создать `image_asset_versions` row версии `1`;
- optionally save original to S3 later.
`GET /assets` response:
```json
{
"assets": [
{
"id": "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66",
"publicId": "asset_demo",
"currentVersion": 1,
"status": "active",
"sourceUrl": "https://storage.yandexcloud.net/shared1318/img/1.jpg",
"sourceHost": "storage.yandexcloud.net",
"createdAt": "2026-05-05T12:00:00.000Z",
"updatedAt": "2026-05-05T12:00:00.000Z"
}
]
}
```
`GET /assets/:publicId/variants` возвращает rows из `image_variants` с public Gateway URL, S3 key и status.
`POST /assets/:publicId/versions` request:
```json
{
"sourceUrl": "https://storage.yandexcloud.net/shared1318/img/1.jpg"
}
```
Response:
```json
{
"assetId": "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66",
"versionId": "3b5da974-bb7f-4d73-b172-d6ad9c244528",
"publicId": "asset_demo",
"previousVersion": 1,
"version": 2,
"sourceUrl": "https://storage.yandexcloud.net/shared1318/img/1.jpg",
"sourceHost": "storage.yandexcloud.net",
"imageBasePath": "/images/asset_demo/v2/card",
"createdAt": "2026-05-05T12:00:00.000Z"
}
```
Новая версия становится `currentVersion` asset. Старые Gateway URLs с `/v1/` остаются immutable и не требуют purge.
`GET /assets/:publicId/picture?preset=card&sizes=100vw` response:
```json
{
"publicId": "asset_demo",
"preset": "card",
"version": 2,
"quality": 80,
"sizes": "100vw",
"widths": [320, 640, 960],
"image": {
"src": "http://localhost:8888/images/asset_demo/v2/card?w=960&q=80&f=jpg",
"format": "jpg",
"type": "image/jpeg",
"width": 960,
"height": 0
},
"sources": [
{
"format": "avif",
"type": "image/avif",
"srcSet": "http://localhost:8888/images/asset_demo/v2/card?w=320&q=80&f=avif 320w, http://localhost:8888/images/asset_demo/v2/card?w=640&q=80&f=avif 640w, http://localhost:8888/images/asset_demo/v2/card?w=960&q=80&f=avif 960w"
}
]
}
```
Endpoint не ставит generation jobs: Gateway lazy path сгенерирует bytes на первом запросе или отдаст cache/S3.
## Variants
```text
GET /assets/:publicId/variants
POST /assets/:publicId/variants
POST /variants/:id/regenerate
DELETE /variants/:id
```
`POST /assets/:publicId/variants` request:
```json
{
"preset": "card",
"mode": "single",
"format": "webp",
"width": 640,
"quality": 80
}
```
Family generation:
```json
{
"preset": "card",
"mode": "family"
}
```
Для `family` Backend создаёт набор variants по разрешённым widths/formats static preset и ставит RabbitMQ jobs. Endpoint не ждёт image bytes, в отличие от internal ensure.
Response:
```json
{
"publicId": "asset_demo",
"version": 1,
"variants": [
{
"id": "variant_123",
"preset": "card",
"version": 1,
"width": 640,
"height": 0,
"resize": "fit",
"quality": 80,
"requestedFormat": "webp",
"format": "webp",
"status": "pending",
"url": "http://localhost:8888/images/asset_demo/v1/card?w=640&q=80&f=webp",
"s3Key": "variants/asset_demo/v1/abc.webp"
}
]
}
```
## Presets
```text
GET /presets
```
Возвращает static presets, custom transform limits и mock allowlist source hosts.
```json
{
"presets": [
{
"name": "card",
"mode": "responsive",
"formats": ["avif", "webp", "jpg"],
"qualities": [75, 80],
"quality": 80,
"resize": "fit",
"widths": [320, 640, 960]
}
],
"custom": {
"enabled": true,
"formats": ["avif", "webp", "jpg", "png"],
"maxWidth": 4096,
"maxHeight": 4096,
"quality": 80
},
"allowedSourceHosts": ["storage.yandexcloud.net"]
}
```
## Image URLs For UI
Для ручного UI можно добавить endpoint, который возвращает готовый набор URLs для `<picture>`/`srcset`. Для `next/image` основным контрактом остаётся custom loader из `docs/next-image-provider.md`.
```text
GET /assets/:id/picture?preset=card
```
Example response:
```json
{
"assetId": "asset_123",
"preset": "card",
"sources": [
{
"type": "image/avif",
"srcset": "http://localhost:8888/images/asset_123/v4/card?w=320&q=80&f=avif 320w, http://localhost:8888/images/asset_123/v4/card?w=640&q=80&f=avif 640w"
},
{
"type": "image/webp",
"srcset": "http://localhost:8888/images/asset_123/v4/card?w=320&q=80&f=webp 320w, http://localhost:8888/images/asset_123/v4/card?w=640&q=80&f=webp 640w"
}
],
"fallback": {
"src": "http://localhost:8888/images/asset_123/v4/card?w=640&q=80&f=jpg",
"width": 640,
"height": null
}
}
```
## Worker Lifecycle
Worker выполняет задачи из RabbitMQ. Задачи создаёт Backend.
RabbitMQ topology:
```text
exchange: image-platform.jobs
queue: image.generate-variant
dead-letter exchange: image-platform.jobs.dlx
dead-letter queue: image.generate-variant.dlq
```
Job payload должен быть минимальным:
```json
{
"jobId": "job_123",
"variantId": "variant_123"
}
```
Worker читает детали variant из PostgreSQL, вызывает imgproxy, пишет результат в S3 и обновляет status в PostgreSQL.
Если генерация тяжёлая или не успела завершиться до timeout, Backend может вернуть Gateway `504`, а job продолжит выполняться/retry по очереди.
PostgreSQL может выступить первой очередью:
```text
SELECT * FROM image_variants
WHERE status = 'pending'
FOR UPDATE SKIP LOCKED
LIMIT 1
```
PostgreSQL-backed очередь не используется как основной механизм: для jobs выбран RabbitMQ.