Skip to content

Commit

Permalink
- Rename functionality separated from CustomValidation
Browse files Browse the repository at this point in the history
- Wildcard support
- Test cases added for wildcard and non-wildcard field rename
  • Loading branch information
anasshakil committed Feb 27, 2023
1 parent c2a7195 commit 5d4dafc
Show file tree
Hide file tree
Showing 8 changed files with 194 additions and 8 deletions.
10 changes: 7 additions & 3 deletions src/chain/validators-impl.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
import * as validator from 'validator';
import { CustomValidator, StandardValidator } from '../base';
import { CustomValidation, StandardValidation } from '../context-items';
import { CustomValidation, RenameFieldContextItem, StandardValidation } from '../context-items';
import { ContextBuilder } from '../context-builder';
import * as Options from '../options';
import { Validators } from './validators';

export class ValidatorsImpl<Chain> implements Validators<Chain> {
private lastValidator: CustomValidation | StandardValidation;
private lastValidator: CustomValidation | RenameFieldContextItem | StandardValidation;
private negateNext = false;

constructor(private readonly builder: ContextBuilder, private readonly chain: Chain) {}

private addItem(item: CustomValidation | StandardValidation) {
private addItem(item: CustomValidation | RenameFieldContextItem | StandardValidation) {
this.builder.addItem(item);
this.lastValidator = item;
// Reset this.negateNext so that next validation isn't negated too
Expand All @@ -36,6 +36,10 @@ export class ValidatorsImpl<Chain> implements Validators<Chain> {
return this.addItem(new CustomValidation(validator, this.negateNext));
}

rename(evaluator: CustomValidator) {
return this.addItem(new RenameFieldContextItem(evaluator, this.negateNext));
}

exists(options: { checkFalsy?: boolean; checkNull?: boolean } = {}) {
let validator: CustomValidator;
if (options.checkFalsy) {
Expand Down
8 changes: 8 additions & 0 deletions src/chain/validators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,14 @@ export interface Validators<Return> {
*/
custom(validator: CustomValidator): Return;

/**
* Adds a field rename functionality to the validation chain.
*
* @param evaluator the custom evaluator based on the `CustomValidator`
* @returns the current validation chain
*/
rename(evaluator: CustomValidator): Return;

/**
* Adds a validator to check that the fields exist in the request.
* By default, this means that the value of the fields may not be `undefined`;
Expand Down
4 changes: 0 additions & 4 deletions src/context-items/custom-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,6 @@ export class CustomValidation implements ContextItem {
if ((!isPromise && failed) || (isPromise && this.negated)) {
context.addError(this.message, value, meta);
}
// rename field if return type is string
if (typeof actualResult === 'string') {
context.renameFieldInstance(actualResult, meta);
}
} catch (err) {
if (this.negated) {
return;
Expand Down
1 change: 1 addition & 0 deletions src/context-items/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './context-item';
export * from './custom-condition';
export * from './custom-validation';
export * from './standard-validation';
export * from './rename-field';
121 changes: 121 additions & 0 deletions src/context-items/rename-field.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { Context } from '../context';
import { ContextBuilder } from '../context-builder';
import { Meta } from '../base';
import { RenameFieldContextItem } from './rename-field';

let context: Context;
let validator: jest.Mock;
let validation: RenameFieldContextItem;
let meta: Meta;

beforeEach(() => {
jest.spyOn(context, 'addError');
validator = jest.fn();
});

const createSyncTest = (options: { returnValue: any; isWildcard: boolean }) => async () => {
validator.mockReturnValue(options.returnValue);
await validation.run(context, options.returnValue, meta);
if (options.isWildcard) {
expect(context.getData()).toStrictEqual([
{
location: 'body',
path: 'bar.foo',
originalPath: 'foo.bar',
value: 'Hello World!',
originalValue: 123,
},
]);
} else {
expect(context.getData()).toStrictEqual([
{
location: 'body',
path: 'bar',
originalPath: 'foo',
value: 'Hello World!',
originalValue: 123,
},
]);
}
};

describe('Rename wildcard paths', () => {
beforeAll(() => {
meta = {
req: { body: { foo: { bar: 'foobar' } } },
location: 'body',
path: 'foo.bar',
};
context = new ContextBuilder().setFields(['foo.bar']).setLocations(['body']).build();
});
beforeEach(() => {
context.addFieldInstances([
{
location: 'body',
path: 'foo.bar',
originalPath: 'foo.bar',
value: 'Hello World!',
originalValue: 123,
},
]);
validation = new RenameFieldContextItem(validator, false);
validation.message = 'nope';
});
it(
'Renames the field foo.bar to bar.foo',
createSyncTest({ returnValue: 'bar.foo', isWildcard: true }),
);
it('Renames the wildcard field with nested objects and arrays', async () => {
meta = {
req: { body: { bar: [{ foo: { end: 'Hello World!' } }] } },
location: 'body',
path: 'bar.*.foo.end',
};
context = new ContextBuilder().setFields(['bar.*.foo.end']).setLocations(['body']).build();
context.addFieldInstances([
{
location: 'body',
path: 'bar.*.foo.end',
originalPath: 'bar.*.foo.end',
value: 'Hello World!',
originalValue: 123,
},
]);
validator.mockReturnValue('foo.*.bar.*.child.new_field');
await validation.run(context, 'foo.*.bar.*.child.new_field', meta);
expect(context.getData()).toStrictEqual([
{
location: 'body',
path: 'foo[0].bar[0].child.new_field',
originalPath: 'bar.*.foo.end',
value: 'Hello World!',
originalValue: 123,
},
]);
});
});

describe('Rename non-wildcard fields', () => {
beforeAll(() => {
meta = {
req: { body: { foo: 'Hello World!' } },
location: 'body',
path: 'foo',
};
context = new ContextBuilder().setFields(['foo']).setLocations(['body']).build();
});
beforeEach(() => {
context.addFieldInstances([
{
location: 'body',
path: 'foo',
originalPath: 'foo',
value: 'Hello World!',
originalValue: 123,
},
]);
validation = new RenameFieldContextItem(validator, false);
validation.message = 'nope';
});
it('Renames the field foo to bar', createSyncTest({ returnValue: 'bar', isWildcard: false }));
});
28 changes: 28 additions & 0 deletions src/context-items/rename-field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { CustomValidator, Meta } from '../base';
import { Context } from '../context';
import { ContextItem } from './context-item';

export class RenameFieldContextItem implements ContextItem {
message: any;

constructor(private readonly evaluator: CustomValidator, private readonly negated: boolean) {}

async run(context: Context, value: any, meta: Meta) {
try {
const result = this.evaluator(value, meta);
const actualResult = await result;
const isPromise = result && result.then;
const failed = this.negated ? actualResult : !actualResult;

// A promise that was resolved only adds an error if negated.
// Otherwise it always suceeds
if ((!isPromise && failed) || (isPromise && this.negated)) {
return;
}
// rename field if return type is string
if (typeof actualResult === 'string') {
context.renameFieldInstance(actualResult, meta);
}
} catch (err) {}
}
}
4 changes: 3 additions & 1 deletion src/context.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as _ from 'lodash';
import { FieldInstance, Location, Meta, ValidationError } from './base';
import { ContextItem } from './context-items';
import { fieldRenameUtility } from './utils';

function getDataMapKey(path: string, location: Location) {
return `${location}:${path}`;
Expand Down Expand Up @@ -117,7 +118,8 @@ export class Context {
throw new Error('Attempt to rename multiple fields.');
}
if (/\.|\*/g.test(originalPath)) {
throw new Error('Attempt to rename a wildcard field');
instance.path = fieldRenameUtility(newPath, instance);
return;
}
instance.path = newPath;
}
Expand Down
26 changes: 26 additions & 0 deletions src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { FieldInstance } from './base';

export const bindAll = <T>(object: T): { [K in keyof T]: T[K] } => {
const protoKeys = Object.getOwnPropertyNames(Object.getPrototypeOf(object)) as (keyof T)[];
protoKeys.forEach(key => {
Expand Down Expand Up @@ -26,3 +28,27 @@ export function toString(value: any, deep = true): string {

return String(value);
}

export function fieldRenameUtility(path: string, field: FieldInstance) {
if (path.includes('.*')) {
return _renameFieldWithAsterisk(path, field);
}
// Normal dot notation wildcard path
return path;
}

function _renameFieldWithAsterisk(path: string, field: FieldInstance) {
const { path: original } = field;
// Extract the indices from the input string
const regExp = /\[(\d+)\]/g;
const matches = [...original.matchAll(regExp)];
const indices = matches.map(([, index]) => index);

// Replace the placeholders in the format with the corresponding indices
let result = path;
result = result.replace(/\.\*/g, () => {
const _index = Number(indices.shift());
return !isNaN(_index) ? `[${_index}]` : '[0]';
});
return result;
}

0 comments on commit 5d4dafc

Please sign in to comment.