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:
@@ -73,6 +73,7 @@ Business API без админки:
|
|||||||
```bash
|
```bash
|
||||||
curl -sS http://localhost:3001/api/presets
|
curl -sS http://localhost:3001/api/presets
|
||||||
curl -sS http://localhost:3001/api/assets
|
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 http://localhost:3001/api/assets/asset_demo/variants
|
||||||
curl -sS -X POST http://localhost:3001/api/assets/asset_demo/versions \
|
curl -sS -X POST http://localhost:3001/api/assets/asset_demo/versions \
|
||||||
-H 'content-type: application/json' \
|
-H 'content-type: application/json' \
|
||||||
|
|||||||
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"
|
} from "@nestjs/swagger"
|
||||||
|
|
||||||
import { AssetsService } from "./assets.service"
|
import { AssetsService } from "./assets.service"
|
||||||
|
import { AssetPictureResponseDto } from "./asset-picture-response.dto"
|
||||||
import { AssetResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
|
import { AssetResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
|
||||||
import { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.dto"
|
import { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.dto"
|
||||||
import { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto"
|
import { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto"
|
||||||
@@ -77,6 +78,30 @@ export class AssetsController {
|
|||||||
return this.assets.createAssetVersion(publicId, request)
|
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")
|
@Get(":publicId/variants")
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
summary: "получить variants asset",
|
summary: "получить variants asset",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { createHash, randomUUID } from "node:crypto"
|
|||||||
|
|
||||||
import { DatabaseService } from "../infra/database.service"
|
import { DatabaseService } from "../infra/database.service"
|
||||||
import { QueueService } from "../infra/queue.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 { AssetResponseDto, AssetVariantResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto"
|
||||||
import type { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.dto"
|
import type { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.dto"
|
||||||
import type { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto"
|
import type { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto"
|
||||||
@@ -38,6 +39,16 @@ type VariantTransform = NormalizedImageTransform & {
|
|||||||
version: number
|
version: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PublicImageUrlInput = {
|
||||||
|
assetVersion: number
|
||||||
|
format: ActualImageFormat
|
||||||
|
height: number | null
|
||||||
|
preset: string
|
||||||
|
quality: number
|
||||||
|
resizeMode: "fill" | "fit"
|
||||||
|
width: number
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class AssetsService {
|
export class AssetsService {
|
||||||
private readonly allowedHosts = loadAllowedSourceHostsFromEnv()
|
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> {
|
async listAssetVariants(publicId: string, versionInput?: string): Promise<AssetVariantsResponseDto> {
|
||||||
const asset = await this.loadAssetVersion(publicId, parseOptionalVersion(versionInput))
|
const asset = await this.loadAssetVersion(publicId, parseOptionalVersion(versionInput))
|
||||||
const conditions = [eq(imageVariants.assetId, asset.assetId)]
|
const conditions = [eq(imageVariants.assetId, asset.assetId)]
|
||||||
@@ -642,6 +725,38 @@ function parseOptionalVersion(value: string | undefined) {
|
|||||||
return parsed
|
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 {
|
function selectDefaultFormat(preset: string, allowCustomTransforms: boolean): ActualImageFormat {
|
||||||
const config = getImagePreset(preset)
|
const config = getImagePreset(preset)
|
||||||
|
|
||||||
@@ -674,7 +789,49 @@ function createVariantHash(publicId: string, transform: VariantTransform) {
|
|||||||
.slice(0, 32)
|
.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 url = new URL(`/images/${publicId}/v${variant.assetVersion}/${variant.preset}`, baseUrl)
|
||||||
const isFixedPresetUrl = variant.preset !== CUSTOM_PRESET_NAME && variant.height && variant.height > 0
|
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()
|
return url.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function contentTypeForFormat(format: ActualImageFormat) {
|
||||||
|
if (format === "jpg") {
|
||||||
|
return "image/jpeg"
|
||||||
|
}
|
||||||
|
|
||||||
|
return `image/${format}`
|
||||||
|
}
|
||||||
|
|||||||
@@ -111,12 +111,13 @@ POST /assets
|
|||||||
GET /assets
|
GET /assets
|
||||||
GET /assets/:publicId
|
GET /assets/:publicId
|
||||||
POST /assets/:publicId/versions
|
POST /assets/:publicId/versions
|
||||||
|
GET /assets/:publicId/picture
|
||||||
GET /assets/:publicId/variants
|
GET /assets/:publicId/variants
|
||||||
POST /assets/:publicId/variants
|
POST /assets/:publicId/variants
|
||||||
DELETE /assets/:id
|
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:
|
`POST /assets` request:
|
||||||
|
|
||||||
@@ -194,6 +195,35 @@ Response:
|
|||||||
|
|
||||||
Новая версия становится `currentVersion` asset. Старые Gateway URLs с `/v1/` остаются immutable и не требуют purge.
|
Новая версия становится `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
|
## Variants
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
|||||||
@@ -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/presets
|
||||||
curl -sS http://localhost:3001/api/assets
|
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
|
||||||
|
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 http://localhost:3001/api/assets/asset_demo/variants
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user