Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci-dependency-review.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ jobs:
steps:
- uses: actions/checkout@v5
- name: Dependency Review
uses: actions/dependency-review-action@v4.7.3
uses: actions/dependency-review-action@v4.8.0
with:
show-openssf-scorecard: false
2 changes: 1 addition & 1 deletion packages/apps/dashboard/client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"react-number-format": "^5.4.3",
"react-router-dom": "^6.23.1",
"recharts": "^2.13.0-alpha.4",
"simplebar-react": "^3.2.5",
"simplebar-react": "^3.3.2",
"styled-components": "^6.1.11",
"swiper": "^11.1.3",
"use-debounce": "^10.0.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export class IsValidRoleConstraint implements ValidatorConstraintInterface {
}

defaultMessage() {
return `Role must be one of the following values: ${Object.values(
return `role must be one of the following values: ${Object.values(
Role,
).join(', ')}`;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ export class BaseError extends Error {
}

export class ValidationError extends BaseError {
constructor(message: string, stack?: string) {
public errors?: string[];
constructor(message: string, stack?: string, errors?: string[]) {
super(message, stack);
this.errors = errors;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,15 @@ export class ExceptionFilter implements IExceptionFilter {

response.removeHeader('Cache-Control');

response.status(status).json({
const payload: any = {
status_code: status,
timestamp: new Date().toISOString(),
message: message,
path: request.url,
});
};
if (exception instanceof ValidationError && exception.errors?.length) {
payload.validation_errors = exception.errors;
}
response.status(status).json(payload);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,26 @@ import {
} from '@nestjs/common';
import { Observable } from 'rxjs';
import { map } from 'rxjs/operators';
import { CaseConverter } from '../utils/case-converter';
import {
transformKeysFromCamelToSnake,
transformKeysFromSnakeToCamel,
} from '../utils/case-converter';

@Injectable()
export class SnakeCaseInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();

if (request.body) {
request.body = CaseConverter.transformToCamelCase(request.body);
request.body = transformKeysFromSnakeToCamel(request.body);
}

if (request.query) {
request.query = CaseConverter.transformToCamelCase(request.query);
request.query = transformKeysFromSnakeToCamel(request.query);
}

return next
.handle()
.pipe(map((data) => CaseConverter.transformToSnakeCase(data)));
.pipe(map((data) => transformKeysFromCamelToSnake(data)));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,29 @@ import {
ValidationPipeOptions,
} from '@nestjs/common';
import { ValidationError } from '../errors';
import { camelToSnake } from '../utils/case-converter';

@Injectable()
export class HttpValidationPipe extends ValidationPipe {
constructor(options?: ValidationPipeOptions) {
super({
exceptionFactory: (errors: ValidError[]): ValidationError => {
const flattenErrors = this.flattenValidationErrors(errors);
throw new ValidationError(flattenErrors.join(', '));
const messages = this.formatErrorsSnakeCase(errors);
throw new ValidationError('Validation error', undefined, messages);
},
transform: true,
whitelist: true,
...options,
});
}

private formatErrorsSnakeCase(errors: ValidError[]): string[] {
return errors
.flatMap((error) => this.mapChildrenToValidationErrors(error))
.flatMap((error) =>
Object.values(error.constraints || {}).map((msg) =>
msg.replace(error.property, camelToSnake(error.property)),
),
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import { faker } from '@faker-js/faker';

import * as CaseConverter from './case-converter';

describe('Case converting utilities', () => {
describe('transformKeysFromSnakeToCamel', () => {
it.each([
'string',
42,
BigInt(0),
new Date(),
Symbol('test'),
true,
null,
undefined,
])('should not transform basic value [%#]', (value: unknown) => {
expect(CaseConverter.transformKeysFromSnakeToCamel(value)).toEqual(value);
});

it('should not transform simple array', () => {
const input = faker.helpers.multiple(() => faker.string.sample());

const output = CaseConverter.transformKeysFromSnakeToCamel(input);

expect(output).toEqual(input);
});

it('should transform array of objects', () => {
const input = faker.helpers.multiple(() => ({
test_case: faker.string.sample(),
}));
const expectedOutput = input.map((v) => ({
testCase: v.test_case,
}));

const output = CaseConverter.transformKeysFromSnakeToCamel(input);

expect(output).toEqual(expectedOutput);
});

it('should transform plain object to camelCase', () => {
const input = {
random_string: faker.string.sample(),
random_number: faker.number.float(),
random_boolean: faker.datatype.boolean(),
always_null: null,
};

const output = CaseConverter.transformKeysFromSnakeToCamel(input);

expect(output).toEqual({
randomString: input.random_string,
randomNumber: input.random_number,
randomBoolean: input.random_boolean,
alwaysNull: null,
});
});

it('should transform input with nested data', () => {
const randomString = faker.string.sample();

const input = {
nested_object: {
with_array: [
{
of_objects: {
with_random_string: randomString,
},
},
],
},
};

const output = CaseConverter.transformKeysFromSnakeToCamel(input);

expect(output).toEqual({
nestedObject: {
withArray: [
{
ofObjects: {
withRandomString: randomString,
},
},
],
},
});
});
});

describe('transformKeysFromCamelToSnake', () => {
it.each([
'string',
42,
BigInt(0),
new Date(),
Symbol('test'),
true,
null,
undefined,
])('should not transform primitive [%#]', (value: unknown) => {
expect(CaseConverter.transformKeysFromCamelToSnake(value)).toEqual(value);
});

it('should not transform simple array', () => {
const input = faker.helpers.multiple(() => faker.string.sample());

const output = CaseConverter.transformKeysFromCamelToSnake(input);

expect(output).toEqual(input);
});

it('should transform array of objects', () => {
const input = faker.helpers.multiple(() => ({
testCase: faker.string.sample(),
}));
const expectedOutput = input.map((v) => ({
test_case: v.testCase,
}));

const output = CaseConverter.transformKeysFromCamelToSnake(input);

expect(output).toEqual(expectedOutput);
});

it('should transform plain object to camelCase', () => {
const input = {
randomString: faker.string.sample(),
randomNumber: faker.number.float(),
randomBoolean: faker.datatype.boolean(),
alwaysNull: null,
};

const output = CaseConverter.transformKeysFromCamelToSnake(input);

expect(output).toEqual({
random_string: input.randomString,
random_number: input.randomNumber,
random_boolean: input.randomBoolean,
always_null: null,
});
});

it('should transform input with nested data', () => {
const randomString = faker.string.sample();

const input = {
nestedObject: {
withArray: [
{
ofObjects: {
withRandomString: randomString,
},
},
],
},
};

const output = CaseConverter.transformKeysFromCamelToSnake(input);

expect(output).toEqual({
nested_object: {
with_array: [
{
of_objects: {
with_random_string: randomString,
},
},
],
},
});
});
});
});
Original file line number Diff line number Diff line change
@@ -1,37 +1,48 @@
export class CaseConverter {
static transformToCamelCase(obj: any): any {
if (Array.isArray(obj)) {
return obj.map((item) => CaseConverter.transformToCamelCase(item));
} else if (typeof obj === 'object' && obj !== null) {
return Object.keys(obj).reduce(
(acc: Record<string, any>, key: string) => {
const camelCaseKey = key.replace(/_([a-z])/g, (g) =>
g[1].toUpperCase(),
);
acc[camelCaseKey] = CaseConverter.transformToCamelCase(obj[key]);
return acc;
},
{},
);
} else {
return obj;
}
type CaseTransformer = (input: string) => string;

/**
* TODO: check if replacing it with lodash.camelCase
* won't break anything
*/
export const snakeToCamel: CaseTransformer = (input) => {
return input.replace(/_([a-z])/g, (_match, letter) => letter.toUpperCase());
};

/**
* TODO: check if replacing it with lodash.snakeCase
* won't break anything
*/
export const camelToSnake: CaseTransformer = (input) => {
return input.replace(/([A-Z])/g, '_$1').toLowerCase();
};

function transformKeysCase(
input: unknown,
transformer: CaseTransformer,
): unknown {
/**
* Primitives and Date objects returned as is
* to keep their original value for later use
*/
if (input === null || typeof input !== 'object' || input instanceof Date) {
return input;
}

if (Array.isArray(input)) {
return input.map((value) => transformKeysCase(value, transformer));
}

static transformToSnakeCase(obj: any): any {
if (Array.isArray(obj)) {
return obj.map((item) => CaseConverter.transformToSnakeCase(item));
} else if (typeof obj === 'object' && obj !== null) {
return Object.keys(obj).reduce(
(acc: Record<string, any>, key: string) => {
const snakeCaseKey = key.replace(/([A-Z])/g, '_$1').toLowerCase();
acc[snakeCaseKey] = CaseConverter.transformToSnakeCase(obj[key]);
return acc;
},
{},
);
} else {
return obj;
}
const transformedObject: Record<string, unknown> = {};
for (const [key, value] of Object.entries(input)) {
transformedObject[transformer(key)] = transformKeysCase(value, transformer);
}
return transformedObject;
}

export function transformKeysFromSnakeToCamel(input: unknown): unknown {
return transformKeysCase(input, snakeToCamel);
}

export function transformKeysFromCamelToSnake(input: unknown): unknown {
return transformKeysCase(input, camelToSnake);
}
Loading
Loading