feat: добавить endpoint picture/srcset

- добавлен contract DTO для picture sources и fallback image
- реализована выдача versioned Gateway URLs по static presets
- обновлена документация business API и dev smoke flow
This commit is contained in:
2026-05-05 13:35:25 +03:00
parent 3ec1e51bea
commit 56d551b43b
6 changed files with 279 additions and 2 deletions

View File

@@ -73,6 +73,7 @@ Business API без админки:
```bash
curl -sS http://localhost:3001/api/presets
curl -sS http://localhost:3001/api/assets
curl -sS 'http://localhost:3001/api/assets/asset_demo/picture?preset=card&sizes=100vw'
curl -sS http://localhost:3001/api/assets/asset_demo/variants
curl -sS -X POST http://localhost:3001/api/assets/asset_demo/versions \
-H 'content-type: application/json' \

View File

@@ -0,0 +1,55 @@
import { ApiProperty } from "@nestjs/swagger"
export class AssetPictureImageResponseDto {
@ApiProperty({ description: "Fallback image URL для `<img src>`.", example: "http://localhost:8888/images/asset_demo/v1/card?w=960&q=80&f=jpg" })
src!: string
@ApiProperty({ description: "Fallback image format.", enum: ["avif", "webp", "jpg", "png"], example: "jpg" })
format!: string
@ApiProperty({ description: "Fallback image Content-Type.", example: "image/jpeg" })
type!: string
@ApiProperty({ description: "Fallback image width.", example: 960 })
width!: number
@ApiProperty({ description: "Fallback image height. `0` означает auto height.", example: 0 })
height!: number
}
export class AssetPictureSourceResponseDto {
@ApiProperty({ description: "Source format.", enum: ["avif", "webp", "jpg", "png"], example: "webp" })
format!: string
@ApiProperty({ description: "Source MIME type для `<source type>`.", example: "image/webp" })
type!: string
@ApiProperty({ description: "Готовая строка srcset с width descriptors." })
srcSet!: string
}
export class AssetPictureResponseDto {
@ApiProperty({ description: "Публичный идентификатор asset.", example: "asset_demo" })
publicId!: string
@ApiProperty({ description: "Preset, для которого построен picture contract.", example: "card" })
preset!: string
@ApiProperty({ description: "Версия source image.", example: 1 })
version!: number
@ApiProperty({ description: "Quality для всех URL.", example: 80 })
quality!: number
@ApiProperty({ description: "Значение sizes для `<img sizes>`.", example: "100vw" })
sizes!: string
@ApiProperty({ description: "Width descriptors, вошедшие в srcset.", example: [320, 640, 960] })
widths!: number[]
@ApiProperty({ description: "Fallback image для `<img>`.", type: AssetPictureImageResponseDto })
image!: AssetPictureImageResponseDto
@ApiProperty({ description: "Sources для `<picture>`.", type: [AssetPictureSourceResponseDto] })
sources!: AssetPictureSourceResponseDto[]
}

View File

@@ -12,6 +12,7 @@ import {
} from "@nestjs/swagger"
import { AssetsService } from "./assets.service"
import { AssetPictureResponseDto } from "./asset-picture-response.dto"
import { AssetResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
import { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.dto"
import { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto"
@@ -77,6 +78,30 @@ export class AssetsController {
return this.assets.createAssetVersion(publicId, request)
}
@Get(":publicId/picture")
@ApiOperation({
summary: "получить picture/srcset URLs",
description:
"Возвращает готовый контракт для `<picture>` и `<img>` по static preset: sources, srcset, fallback src, sizes и versioned Gateway URLs. Endpoint не ставит generation jobs: Gateway сгенерирует bytes lazy или отдаст cache.",
})
@ApiParam({ description: "Публичный идентификатор asset.", example: "asset_demo", name: "publicId" })
@ApiQuery({ description: "Static preset для picture contract.", example: "card", name: "preset", required: true })
@ApiQuery({ description: "Версия source image. Если не передана, используется currentVersion asset.", example: 1, name: "version", required: false })
@ApiQuery({ description: "Quality. Если не передано, берётся default quality preset.", example: 80, name: "quality", required: false })
@ApiQuery({ description: "Значение для HTML `sizes`.", example: "(min-width: 768px) 50vw, 100vw", name: "sizes", required: false })
@ApiOkResponse({ description: "Picture/srcset contract возвращён.", type: AssetPictureResponseDto })
@ApiBadRequestResponse({ description: "Некорректный preset, version, quality или sizes." })
@ApiNotFoundResponse({ description: "Asset или version не найдены." })
getAssetPicture(
@Param("publicId") publicId: string,
@Query("preset") preset?: string,
@Query("version") version?: string,
@Query("quality") quality?: string,
@Query("sizes") sizes?: string,
): Promise<AssetPictureResponseDto> {
return this.assets.getAssetPicture(publicId, { preset, quality, sizes, version })
}
@Get(":publicId/variants")
@ApiOperation({
summary: "получить variants asset",

View File

@@ -17,6 +17,7 @@ import { createHash, randomUUID } from "node:crypto"
import { DatabaseService } from "../infra/database.service"
import { QueueService } from "../infra/queue.service"
import type { AssetPictureResponseDto } from "./asset-picture-response.dto"
import type { AssetResponseDto, AssetVariantResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
import type { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.dto"
import type { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto"
@@ -38,6 +39,16 @@ type VariantTransform = NormalizedImageTransform & {
version: number
}
type PublicImageUrlInput = {
assetVersion: number
format: ActualImageFormat
height: number | null
preset: string
quality: number
resizeMode: "fill" | "fit"
width: number
}
@Injectable()
export class AssetsService {
private readonly allowedHosts = loadAllowedSourceHostsFromEnv()
@@ -218,6 +229,78 @@ export class AssetsService {
}
}
async getAssetPicture(
publicId: string,
input: { preset?: string; quality?: string; sizes?: string; version?: string },
): Promise<AssetPictureResponseDto> {
if (!input.preset) {
throw new BadRequestException("preset is required")
}
const preset = getImagePreset(input.preset)
if (!preset || input.preset === CUSTOM_PRESET_NAME) {
throw new BadRequestException("picture endpoint supports only static presets")
}
const asset = await this.loadAssetVersion(publicId, parseOptionalVersion(input.version))
const quality = parseOptionalQuality(input.quality)
const sizes = normalizeSizes(input.sizes)
const widths = getPresetWidths(input.preset, preset)
const sources = preset.formats.map((format) => {
const srcSet = widths
.map((width) => {
const transform = this.normalizeTransform({
format,
preset: input.preset!,
quality,
version: asset.version,
width: preset.mode === "fixed" ? undefined : width,
})
return `${buildPublicImageUrl(this.publicImageBaseUrl, asset.publicId, transformToPublicUrlInput(transform))} ${transform.width}w`
})
.join(", ")
return {
format,
srcSet,
type: contentTypeForFormat(format),
}
})
const fallbackFormat = selectFallbackFormat(preset.formats)
const fallbackWidth = widths[widths.length - 1]
if (fallbackWidth === undefined) {
throw new BadRequestException(`preset ${input.preset} has no widths configured`)
}
const fallbackTransform = this.normalizeTransform({
format: fallbackFormat,
preset: input.preset,
quality,
version: asset.version,
width: preset.mode === "fixed" ? undefined : fallbackWidth,
})
return {
image: {
format: fallbackTransform.format,
height: fallbackTransform.height,
src: buildPublicImageUrl(this.publicImageBaseUrl, asset.publicId, transformToPublicUrlInput(fallbackTransform)),
type: contentTypeForFormat(fallbackTransform.format),
width: fallbackTransform.width,
},
preset: input.preset,
publicId: asset.publicId,
quality: fallbackTransform.quality,
sizes,
sources,
version: asset.version,
widths,
}
}
async listAssetVariants(publicId: string, versionInput?: string): Promise<AssetVariantsResponseDto> {
const asset = await this.loadAssetVersion(publicId, parseOptionalVersion(versionInput))
const conditions = [eq(imageVariants.assetId, asset.assetId)]
@@ -642,6 +725,38 @@ function parseOptionalVersion(value: string | undefined) {
return parsed
}
function parseOptionalQuality(value: string | undefined) {
if (value === undefined) {
return undefined
}
if (!/^\d+$/.test(value)) {
throw new BadRequestException("quality must be a positive integer")
}
const parsed = Number.parseInt(value, 10)
if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 100) {
throw new BadRequestException("quality must be between 1 and 100")
}
return parsed
}
function normalizeSizes(value: string | undefined) {
if (value === undefined) {
return "100vw"
}
const normalized = value.trim()
if (!normalized || normalized.length > 256 || /[<>]/.test(normalized)) {
throw new BadRequestException("sizes is invalid")
}
return normalized
}
function selectDefaultFormat(preset: string, allowCustomTransforms: boolean): ActualImageFormat {
const config = getImagePreset(preset)
@@ -674,7 +789,49 @@ function createVariantHash(publicId: string, transform: VariantTransform) {
.slice(0, 32)
}
function buildPublicImageUrl(baseUrl: string, publicId: string, variant: VariantRow) {
function getPresetWidths(presetName: string, preset: NonNullable<ReturnType<typeof getImagePreset>>) {
if (preset.mode === "fixed") {
if (!preset.width) {
throw new BadRequestException(`preset ${presetName} has no width configured`)
}
return [preset.width]
}
if (!preset.widths?.length) {
throw new BadRequestException(`preset ${presetName} has no widths configured`)
}
return [...preset.widths]
}
function selectFallbackFormat(formats: readonly ActualImageFormat[]) {
if (formats.includes("jpg")) {
return "jpg"
}
const format = formats[formats.length - 1]
if (!format) {
throw new BadRequestException("preset has no formats configured")
}
return format
}
function transformToPublicUrlInput(transform: VariantTransform): PublicImageUrlInput {
return {
assetVersion: transform.version,
format: transform.format,
height: transform.height,
preset: transform.preset,
quality: transform.quality,
resizeMode: transform.resize,
width: transform.width,
}
}
function buildPublicImageUrl(baseUrl: string, publicId: string, variant: PublicImageUrlInput) {
const url = new URL(`/images/${publicId}/v${variant.assetVersion}/${variant.preset}`, baseUrl)
const isFixedPresetUrl = variant.preset !== CUSTOM_PRESET_NAME && variant.height && variant.height > 0
@@ -695,3 +852,11 @@ function buildPublicImageUrl(baseUrl: string, publicId: string, variant: Variant
return url.toString()
}
function contentTypeForFormat(format: ActualImageFormat) {
if (format === "jpg") {
return "image/jpeg"
}
return `image/${format}`
}

View File

@@ -111,12 +111,13 @@ 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/variants`, `POST /assets/:publicId/variants`.
Сейчас реализованы `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:
@@ -194,6 +195,35 @@ Response:
Новая версия становится `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

View File

@@ -90,6 +90,7 @@ curl -sS -X POST http://localhost:3001/api/assets \
curl -sS http://localhost:3001/api/presets
curl -sS http://localhost:3001/api/assets
curl -sS http://localhost:3001/api/assets/asset_demo
curl -sS 'http://localhost:3001/api/assets/asset_demo/picture?preset=card&sizes=100vw'
curl -sS http://localhost:3001/api/assets/asset_demo/variants
```