From 56d551b43b0a4e48b1ef780fe9d1bb4fcc6824c4 Mon Sep 17 00:00:00 2001 From: "S.Gromov" Date: Tue, 5 May 2026 13:35:25 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20endpoint=20picture/srcset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - добавлен contract DTO для picture sources и fallback image - реализована выдача versioned Gateway URLs по static presets - обновлена документация business API и dev smoke flow --- README.md | 1 + .../src/assets/asset-picture-response.dto.ts | 55 ++++++ apps/backend/src/assets/assets.controller.ts | 25 +++ apps/backend/src/assets/assets.service.ts | 167 +++++++++++++++++- docs/backend-contract-draft.md | 32 +++- docs/development.md | 1 + 6 files changed, 279 insertions(+), 2 deletions(-) create mode 100644 apps/backend/src/assets/asset-picture-response.dto.ts diff --git a/README.md b/README.md index 7416c86..4d1bc26 100644 --- a/README.md +++ b/README.md @@ -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' \ diff --git a/apps/backend/src/assets/asset-picture-response.dto.ts b/apps/backend/src/assets/asset-picture-response.dto.ts new file mode 100644 index 0000000..084518c --- /dev/null +++ b/apps/backend/src/assets/asset-picture-response.dto.ts @@ -0,0 +1,55 @@ +import { ApiProperty } from "@nestjs/swagger" + +export class AssetPictureImageResponseDto { + @ApiProperty({ description: "Fallback image URL для ``.", 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 для ``.", 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 для ``.", example: "100vw" }) + sizes!: string + + @ApiProperty({ description: "Width descriptors, вошедшие в srcset.", example: [320, 640, 960] }) + widths!: number[] + + @ApiProperty({ description: "Fallback image для ``.", type: AssetPictureImageResponseDto }) + image!: AssetPictureImageResponseDto + + @ApiProperty({ description: "Sources для ``.", type: [AssetPictureSourceResponseDto] }) + sources!: AssetPictureSourceResponseDto[] +} diff --git a/apps/backend/src/assets/assets.controller.ts b/apps/backend/src/assets/assets.controller.ts index 7d900c8..6f159a6 100644 --- a/apps/backend/src/assets/assets.controller.ts +++ b/apps/backend/src/assets/assets.controller.ts @@ -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: + "Возвращает готовый контракт для `` и `` по 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 { + return this.assets.getAssetPicture(publicId, { preset, quality, sizes, version }) + } + @Get(":publicId/variants") @ApiOperation({ summary: "получить variants asset", diff --git a/apps/backend/src/assets/assets.service.ts b/apps/backend/src/assets/assets.service.ts index 05cd717..a755e60 100644 --- a/apps/backend/src/assets/assets.service.ts +++ b/apps/backend/src/assets/assets.service.ts @@ -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 { + 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 { 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>) { + 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}` +} diff --git a/docs/backend-contract-draft.md b/docs/backend-contract-draft.md index d30caac..a68768c 100644 --- a/docs/backend-contract-draft.md +++ b/docs/backend-contract-draft.md @@ -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 diff --git a/docs/development.md b/docs/development.md index da48676..7c2997d 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 ```