mirror of
https://github.com/immich-app/immich.git
synced 2026-05-27 09:22:34 -04:00
refactor!: migrate class-validator to zod (#26597)
This commit is contained in:
@@ -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
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user