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:
55
apps/backend/src/assets/asset-picture-response.dto.ts
Normal file
55
apps/backend/src/assets/asset-picture-response.dto.ts
Normal 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[]
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user