refactor!: migrate class-validator to zod (#26597)

This commit is contained in:
Timon
2026-04-14 23:39:03 +02:00
committed by GitHub
parent 3753b7a4d1
commit 7d8f843be6
318 changed files with 7830 additions and 8316 deletions
-32
View File
@@ -1,32 +0,0 @@
import { applyDecorators } from '@nestjs/common';
import { ApiPropertyOptions } from '@nestjs/swagger';
import { Transform, Type } from 'class-transformer';
import { IsNotEmpty, ValidateNested } from 'class-validator';
import { Property } from 'src/decorators';
import { BBoxDto } from 'src/dtos/bbox.dto';
import { Optional } from 'src/validation';
type BBoxOptions = { optional?: boolean };
export const ValidateBBox = (options: BBoxOptions & ApiPropertyOptions = {}) => {
const { optional, ...apiPropertyOptions } = options;
return applyDecorators(
Transform(({ value }) => {
if (typeof value !== 'string') {
return value;
}
const [west, south, east, north] = value.split(',', 4).map(Number);
return Object.assign(new BBoxDto(), { west, south, east, north });
}),
Type(() => BBoxDto),
ValidateNested(),
Property({
type: 'string',
description: 'Bounding box coordinates as west,south,east,north (WGS84)',
example: '11.075683,49.416711,11.117589,49.454875',
...apiPropertyOptions,
}),
optional ? Optional({}) : IsNotEmpty(),
);
};
+12 -11
View File
@@ -1,10 +1,8 @@
import AsyncLock from 'async-lock';
import { instanceToPlain, plainToInstance } from 'class-transformer';
import { validate } from 'class-validator';
import { load as loadYaml } from 'js-yaml';
import * as _ from 'lodash';
import { SystemConfig, defaults } from 'src/config';
import { SystemConfigDto } from 'src/dtos/system-config.dto';
import { SystemConfigSchema } from 'src/dtos/system-config.dto';
import { DatabaseLock, SystemMetadataKey } from 'src/enum';
import { ConfigRepository } from 'src/repositories/config.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
@@ -101,19 +99,22 @@ const buildConfig = async (repos: RepoDeps) => {
logger.warn(`Unknown keys found: ${JSON.stringify(unknownKeys, null, 2)}`);
}
// validate full config
const instance = plainToInstance(SystemConfigDto, rawConfig);
const errors = await validate(instance);
if (errors.length > 0) {
// validate with Zod schema
const result = SystemConfigSchema.safeParse(rawConfig);
if (!result.success) {
const messages = ['Invalid system config: '];
for (const issue of result.error.issues) {
const path = issue.path.join('.');
messages.push(` - [${path}] ${issue.message}`);
}
if (configFile) {
throw new Error(`Invalid value(s) in file: ${errors}`);
throw new Error(messages.join('\n'));
} else {
logger.error('Validation error', errors);
logger.error('Validation error', messages);
}
}
// return config with class-transform changes
const config = instanceToPlain(instance) as SystemConfig;
const config = (result.success ? result.data : rawConfig) as SystemConfig;
if (config.server.externalDomain.length > 0) {
const domain = new URL(config.server.externalDomain);
+12
View File
@@ -1,9 +1,21 @@
import { DateTime } from 'luxon';
/**
* Convert a date to a ISO 8601 datetime string.
* @param x - The date to convert.
* @returns The ISO 8601 datetime string.
* @deprecated Remove this and all references when using `ZodSerializerDto` on the controllers. Then the codec in `isoDatetimeToDate` in validation.ts will handle the conversion instead.
*/
export const asDateString = <T extends Date | string | undefined | null>(x: T) => {
return x instanceof Date ? x.toISOString() : (x as Exclude<T, Date>);
};
/**
* Convert a date to a date string.
* @param x - The date to convert.
* @returns The date string.
* @deprecated Remove this and all references when using `ZodSerializerDto` on the controllers. Then the codec in `isoDateToDate` in validation.ts will handle the conversion instead.
*/
export const asBirthDateString = (x: Date | string | null): string | null => {
return x instanceof Date ? x.toISOString().split('T')[0] : x;
};
+13 -7
View File
@@ -1,12 +1,16 @@
import { AssetResponseDto } from 'src/dtos/asset-response.dto';
import { ExifResponseSchema } from 'src/dtos/exif.dto';
import { AssetType, AssetVisibility } from 'src/enum';
import { getExifCount, suggestDuplicate, suggestDuplicateKeepAssetIds } from 'src/utils/duplicate';
import { describe, expect, it } from 'vitest';
import type { z } from 'zod';
type ExifInfoInput = Partial<z.infer<typeof ExifResponseSchema>>;
const createAsset = (
id: string,
fileSizeInByte: number | null = null,
exifFields: Record<string, unknown> = {},
exifFields: ExifInfoInput = {},
): AssetResponseDto => ({
id,
type: AssetType.Image,
@@ -33,7 +37,9 @@ const createAsset = (
visibility: AssetVisibility.Timeline,
checksum: 'checksum',
exifInfo:
fileSizeInByte !== null || Object.keys(exifFields).length > 0 ? { fileSizeInByte, ...exifFields } : undefined,
fileSizeInByte !== null || Object.keys(exifFields).length > 0
? ExifResponseSchema.parse({ fileSizeInByte, ...exifFields })
: undefined,
});
describe('duplicate utils', () => {
@@ -46,7 +52,7 @@ describe('duplicate utils', () => {
it('should return 0 for empty exifInfo', () => {
const asset = createAsset('asset-1');
asset.exifInfo = {};
asset.exifInfo = ExifResponseSchema.parse({});
expect(getExifCount(asset)).toBe(0);
});
@@ -54,7 +60,7 @@ describe('duplicate utils', () => {
const asset = createAsset('asset-1', 1000, {
make: 'Canon',
model: 'EOS 5D',
dateTimeOriginal: new Date(),
dateTimeOriginal: new Date().toISOString(),
timeZone: 'UTC',
latitude: 40.7128,
longitude: -74.006,
@@ -107,7 +113,7 @@ describe('duplicate utils', () => {
const moreExif = createAsset('more-exif', 1000, {
make: 'Canon',
model: 'EOS 5D',
dateTimeOriginal: new Date(),
dateTimeOriginal: new Date().toISOString(),
city: 'New York',
});
@@ -125,7 +131,7 @@ describe('duplicate utils', () => {
it('should handle assets with exifInfo but no fileSizeInByte', () => {
const noFileSize = createAsset('no-file-size');
noFileSize.exifInfo = { make: 'Canon', model: 'EOS 5D' };
noFileSize.exifInfo = ExifResponseSchema.parse({ make: 'Canon', model: 'EOS 5D' });
const withFileSize = createAsset('with-file-size', 1000);
expect(suggestDuplicate([noFileSize, withFileSize])?.id).toBe('with-file-size');
@@ -148,7 +154,7 @@ describe('duplicate utils', () => {
const smallWithMoreExif = createAsset('small-more-exif', 1000, {
make: 'Canon',
model: 'EOS 5D',
dateTimeOriginal: new Date(),
dateTimeOriginal: new Date().toISOString(),
city: 'New York',
state: 'NY',
country: 'USA',
+31 -2
View File
@@ -12,6 +12,7 @@ import {
SchemaObject,
} from '@nestjs/swagger/dist/interfaces/open-api-spec.interface';
import _ from 'lodash';
import { cleanupOpenApiDoc } from 'nestjs-zod';
import { writeFileSync } from 'node:fs';
import path from 'node:path';
import picomatch from 'picomatch';
@@ -158,11 +159,38 @@ const isSchema = (schema: string | ReferenceObject | SchemaObject): schema is Sc
};
const patchOpenAPI = (document: OpenAPIObject) => {
const removeOpenApi30IncompatibleKeys = (target: unknown) => {
if (!target || typeof target !== 'object') {
return;
}
if (Array.isArray(target)) {
for (const item of target) {
removeOpenApi30IncompatibleKeys(item);
}
return;
}
const object = target as Record<string, unknown>;
delete object.propertyNames;
delete object.contentEncoding;
for (const value of Object.values(object)) {
removeOpenApi30IncompatibleKeys(value);
}
};
document.paths = sortKeys(document.paths);
// Allowed in OpenAPI v3.1 (JSON Schema 2020-12), but not in OpenAPI v3.0 (current spec).
removeOpenApi30IncompatibleKeys(document);
if (document.components?.schemas) {
const schemas = document.components.schemas as Record<string, SchemaObject>;
for (const schema of Object.values(schemas)) {
delete (schema as Record<string, unknown>).id;
}
document.components.schemas = sortKeys(schemas);
for (const [schemaName, schema] of Object.entries(schemas)) {
@@ -265,6 +293,7 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean })
};
const specification = SwaggerModule.createDocument(app, config, options);
const openApiDoc = cleanupOpenApiDoc(specification);
const customOptions: SwaggerCustomOptions = {
swaggerOptions: {
@@ -275,12 +304,12 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean })
customSiteTitle: 'Immich API Documentation',
};
SwaggerModule.setup('doc', app, specification, customOptions);
SwaggerModule.setup('doc', app, openApiDoc, customOptions);
if (write) {
// Generate API Documentation only in development mode
const outputPath = path.resolve(process.cwd(), '../open-api/immich-openapi-specs.json');
writeFileSync(outputPath, JSON.stringify(patchOpenAPI(specification), null, 2), { encoding: 'utf8' });
writeFileSync(outputPath, JSON.stringify(patchOpenAPI(openApiDoc), null, 2), { encoding: 'utf8' });
}
};