Skip to content

Commit 84f8e77

Browse files
committed
Enhance validation error handling and improve trigger value transformation logic in lifecycle DTO
1 parent 0da5b0e commit 84f8e77

File tree

2 files changed

+198
-9
lines changed

2 files changed

+198
-9
lines changed

src/management/lifecycle/_dto/config.dto.ts

Lines changed: 131 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,127 @@
11
import { ApiProperty } from '@nestjs/swagger';
2-
import { Type } from 'class-transformer';
3-
import { IsArray, IsEnum, IsNegative, IsNotEmpty, IsNumber, IsObject, IsOptional, ValidateNested } from 'class-validator';
2+
import { Type, Transform } from 'class-transformer';
3+
import { IsArray, IsEnum, IsNegative, IsNotEmpty, IsNumber, IsObject, IsOptional, ValidateNested, registerDecorator, ValidationOptions, ValidationArguments, isString, isNumber } from 'class-validator';
44
import { IdentityLifecycle } from '~/management/identities/_enums/lifecycle.enum';
55

6+
/**
7+
* Transform trigger values to seconds.
8+
* - Numbers are interpreted as days and converted to seconds
9+
* - Strings with 'd' suffix are interpreted as days and converted to seconds
10+
* - Strings with 'm' suffix are interpreted as minutes and converted to seconds
11+
* - Strings with 's' suffix are already in seconds
12+
*
13+
* @param value The trigger value to transform
14+
* @returns The value converted to seconds
15+
*/
16+
function transformTriggerToSeconds(value: number | string): number | undefined {
17+
let isValid = false;
18+
19+
if (value === undefined || value === null) {
20+
return undefined;
21+
}
22+
23+
/**
24+
* Check if the value is a negative number.
25+
* If it's a number, we check if it's less than 0.
26+
* If it's a string, we check if it matches the regex for negative time strings.
27+
*/
28+
if (isNumber(value)) {
29+
isValid = value < 0;
30+
} else if (isString(value)) {
31+
const timeRegex = /^-?\d+[dms]$/;
32+
if (timeRegex.test(value)) {
33+
// Extract the number part and check if it's negative
34+
const numberPart = value.replace(/[dms]$/, '');
35+
const num = parseInt(numberPart, 10);
36+
isValid = num < 0;
37+
}
38+
}
39+
40+
if (!isValid) {
41+
throw new Error('Trigger must be a negative number (days) or a negative time string with units (e.g., "-90d", "-10m", "-45s")');
42+
}
43+
44+
/**
45+
* If the value is a number, we assume it's in days and convert it to seconds.
46+
* We multiply by 24 (hours) * 60 (minutes) * 60 (seconds) to get the total seconds.
47+
* This conversion preserves the sign of the number,
48+
* so if the input is negative, the output will also be negative.
49+
*/
50+
if (isNumber(value)) {
51+
return value * 24 * 60 * 60; // Convert days to seconds, preserving sign
52+
}
53+
54+
/**
55+
* If the value is a string, we check if it matches the regex for negative time strings.
56+
* If it does, we extract the number and unit, then convert it to seconds.
57+
* - 'd' is converted to seconds by multiplying by 24 * 60 * 60
58+
* - 'm' is converted to seconds by multiplying by 60
59+
* - 's' is already in seconds
60+
* This conversion preserves the sign of the number,
61+
* so if the input is negative, the output will also be negative.
62+
*/
63+
if (isString(value)) {
64+
const match = value.match(/^(-?\d+)([dms])$/);
65+
if (match) {
66+
const numValue = parseInt(match[1], 10);
67+
const unit = match[2];
68+
69+
switch (unit) {
70+
case 'd': // days
71+
return numValue * 24 * 60 * 60;
72+
73+
case 'm': // minutes
74+
return numValue * 60;
75+
76+
case 's': // seconds
77+
return numValue;
78+
79+
default:
80+
throw new Error(`Unsupported time unit: ${unit}`);
81+
}
82+
}
83+
}
84+
85+
// If we can't parse it, try to convert to number
86+
return Number(value) || undefined;
87+
}
88+
89+
/**
90+
* Custom decorator to validate that at least one of the properties 'rules' or 'trigger' is defined and not empty.
91+
* This decorator can be applied to a class to enforce this validation rule.
92+
*
93+
* @param validationOptions
94+
* @returns
95+
*/
96+
function ValidateRulesOrTrigger(validationOptions?: ValidationOptions) {
97+
return function (constructor: Function) {
98+
registerDecorator({
99+
name: 'validateRulesOrTrigger',
100+
target: constructor,
101+
propertyName: undefined,
102+
options: validationOptions,
103+
validator: {
104+
validate(_: any, args: ValidationArguments) {
105+
const obj = args.object as ConfigObjectIdentitiesDTO;
106+
107+
/**
108+
* Check if either 'rules' or 'trigger' is defined and not empty.
109+
* 'rules' should be an object with at least one key-value pair,
110+
* and 'trigger' should be a number that is not null.
111+
*/
112+
const hasRules = obj.rules !== undefined && obj.rules !== null && (typeof obj.rules === 'object' && Object.keys(obj.rules).length > 0);
113+
const hasTrigger = obj.trigger !== undefined && obj.trigger !== null;
114+
return hasRules || hasTrigger;
115+
},
116+
defaultMessage(_: ValidationArguments) {
117+
return 'Either rules or trigger must be provided';
118+
}
119+
}
120+
});
121+
};
122+
}
123+
124+
@ValidateRulesOrTrigger({ message: 'Either rules or trigger must be provided' })
6125
export class ConfigObjectIdentitiesDTO {
7126
@IsEnum(IdentityLifecycle, { each: true })
8127
@ApiProperty({
@@ -19,10 +138,17 @@ export class ConfigObjectIdentitiesDTO {
19138
public rules: object;
20139

21140
@IsOptional()
141+
@Transform(({ value }) => transformTriggerToSeconds(value))
22142
@IsNumber()
23-
@IsNegative()
24-
@Type(() => Number)
25-
@ApiProperty({ type: Number, required: false })
143+
@ApiProperty({
144+
oneOf: [
145+
{ type: 'number', description: 'Negative number representing days' },
146+
{ type: 'string', description: 'Negative time string with units (d=days, m=minutes, s=seconds)' }
147+
],
148+
required: false,
149+
description: 'Trigger time as negative number (days) or negative time string with units (converted to negative seconds internally)',
150+
examples: [-90, '-90d', '-10m', '-45s']
151+
})
26152
public trigger: number;
27153

28154
@IsNotEmpty()

src/management/lifecycle/lifecycle.service.ts

Lines changed: 67 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
1010
import { parse } from 'yaml';
1111
import { plainToInstance } from 'class-transformer';
1212
import { ConfigObjectIdentitiesDTO, ConfigObjectSchemaDTO } from './_dto/config.dto';
13-
import { validateOrReject } from 'class-validator';
13+
import { validateOrReject, ValidationError } from 'class-validator';
1414
import { omit } from 'radash';
1515
import { IdentitiesCrudService } from '../identities/identities-crud.service';
1616

@@ -144,9 +144,9 @@ export class LifecycleService extends AbstractServiceSchema implements OnApplica
144144
})
145145
this.logger.debug(`Validated schema for file: ${file}`);
146146
} catch (errors) {
147-
const err = new Error(`Validation failed for schema in file: ${file}`);
148-
err.message = errors.map((e) => e.toString()).join(', ') //TODO: improve error message
149-
throw err
147+
const formattedErrors = this.formatValidationErrors(errors, file);
148+
const err = new Error(`Validation errors in file '${file}':\n${formattedErrors}`);
149+
throw err;
150150
}
151151

152152
lifecycleRules.push(schema);
@@ -157,6 +157,69 @@ export class LifecycleService extends AbstractServiceSchema implements OnApplica
157157
return lifecycleRules;
158158
}
159159

160+
/**
161+
* Format validation errors for better readability
162+
*
163+
* @param errors - Array of ValidationError objects from class-validator
164+
* @param file - The file name where the validation failed
165+
* @returns A formatted error message string
166+
*/
167+
private formatValidationErrors(errors: ValidationError[], file: string, basePath: string = '', isInArrayContext: boolean = false): string {
168+
const formatError = (error: ValidationError, currentPath: string, inArrayContext: boolean): string[] => {
169+
let propertyPath = currentPath;
170+
171+
/**
172+
* Check if error.property is defined, not null, not empty, and not the string 'undefined'.
173+
* If it is, we construct the property path based on whether we are in an array context or not.
174+
* If it is an array context, we use the index notation; otherwise, we use dot notation.
175+
*/
176+
if (error.property !== undefined &&
177+
error.property !== null &&
178+
error.property !== '' &&
179+
error.property !== 'undefined') {
180+
if (inArrayContext && !isNaN(Number(error.property))) {
181+
// C'est un index d'array
182+
propertyPath = currentPath ? `${currentPath}[${error.property}]` : `[${error.property}]`;
183+
} else {
184+
// C'est une propriété normale
185+
propertyPath = currentPath ? `${currentPath}.${error.property}` : error.property;
186+
}
187+
}
188+
189+
const errorMessages: string[] = [];
190+
191+
/**
192+
* Check if error.constraints is defined and not empty.
193+
* If it is, we iterate over each constraint and format the error message.
194+
*/
195+
if (error.constraints) {
196+
Object.entries(error.constraints).forEach(([constraintKey, message]) => {
197+
errorMessages.push(`Property '${propertyPath}': ${message} (constraint: ${constraintKey})`);
198+
});
199+
}
200+
201+
/**
202+
* If the error has children, we recursively format each child error.
203+
* We check if the error has children and if they are defined.
204+
*/
205+
if (error.children && error.children.length > 0) {
206+
const isNextLevelArray = Array.isArray(error.value);
207+
error.children.forEach(childError => {
208+
errorMessages.push(...formatError(childError, propertyPath, isNextLevelArray));
209+
});
210+
}
211+
212+
return errorMessages;
213+
};
214+
215+
const allErrorMessages: string[] = [];
216+
errors.forEach(error => {
217+
allErrorMessages.push(...formatError(error, basePath, isInArrayContext));
218+
});
219+
220+
return allErrorMessages.map(msg => `• ${msg}`).join('\n');
221+
}
222+
160223
/**
161224
* Handle identity update events
162225
* This method listens for events emitted after an identity is updated.

0 commit comments

Comments
 (0)