Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rename field with wildcard support #1215

Open
wants to merge 4 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,10 @@ export type Meta = {
*/
export type CustomValidator = (input: any, meta: Meta) => any;
export type StandardValidator = (input: string, ...options: any[]) => boolean;
export type RenameEvaluator = (
input: any,
meta: Meta,
) => string | Promise<string> | null | undefined;

export type CustomSanitizer = (input: any, meta: Meta) => any;
export type StandardSanitizer = (input: string, ...options: any[]) => any;
Expand Down
9 changes: 7 additions & 2 deletions src/chain/context-handler-impl.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { ContextBuilder } from '../context-builder';
import { Optional } from '../context';
import { ChainCondition, CustomCondition } from '../context-items';
import { CustomValidator } from '../base';
import { ChainCondition, CustomCondition, RenameFieldContextItem } from '../context-items';
import { CustomValidator, RenameEvaluator } from '../base';
import { Bail } from '../context-items/bail';
import { ContextHandler } from './context-handler';
import { ValidationChain } from './validation-chain';
Expand Down Expand Up @@ -37,4 +37,9 @@ export class ContextHandlerImpl<Chain> implements ContextHandler<Chain> {

return this.chain;
}

rename(evaluator: RenameEvaluator) {
this.builder.addItem(new RenameFieldContextItem(evaluator));
return this.chain;
}
}
27 changes: 26 additions & 1 deletion src/chain/context-handler.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { CustomValidator } from '../base';
import { CustomValidator, RenameEvaluator } from '../base';
import { Optional } from '../context';
import { ValidationChain } from './validation-chain';

Expand Down Expand Up @@ -51,4 +51,29 @@ export interface ContextHandler<Chain> {
* @returns the current validation chain
*/
optional(options?: Partial<Optional> | true): Chain;

/**
* Adds a field rename functionality to the validation chain.
* Only renames the field, if return value is `string`
*
* @param evaluator the custom evaluator
*
* @example
* check('username')
* .isEmail()
* .rename("email")
* // If multiple conditions
* .rename(function(value) {
* if (isEmail(value)) {
* return "email";
* } else if (isPhone(value)) {
* return "phone";
* }
* // return `undefined` or `null`, if no key is intended to be renamed.
* return null;
* })
*
* @returns the current validation chain
*/
rename(evaluator: RenameEvaluator | string): Chain;
}
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';
119 changes: 119 additions & 0 deletions src/context-items/rename-field.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
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: 'bar.foo',
value: 'Hello World!',
originalValue: 123,
},
]);
} else {
expect(context.getData()).toStrictEqual([
{
location: 'body',
path: 'bar',
originalPath: 'bar',
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);
});
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: 'foo.*.bar.*.child.new_field',
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);
});
it('Renames the field foo to bar', createSyncTest({ returnValue: 'bar', isWildcard: false }));
});
23 changes: 23 additions & 0 deletions src/context-items/rename-field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { Meta, RenameEvaluator } from '../base';
import { Context } from '../context';
import { ContextItem } from './context-item';

export class RenameFieldContextItem implements ContextItem {
constructor(private readonly evaluator: RenameEvaluator | string) {}

async run(context: Context, value: any, meta: Meta) {
try {
// short circuit if the evaluator is string
if (typeof this.evaluator === 'string') {
return context.renameFieldInstance(this.evaluator, meta);
}
const result = this.evaluator(value, meta);
const actualResult = await result;

if (typeof actualResult !== 'string' || actualResult.length < 1) {
return;
}
context.renameFieldInstance(actualResult, meta);
} catch (err) {}
}
}
29 changes: 28 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 @@ -78,6 +79,10 @@ export class Context {
});
}

removeFieldInstance(instance: FieldInstance) {
this.dataMap.delete(getDataMapKey(instance.path, instance.location));
}

setData(path: string, value: any, location: Location) {
const instance = this.dataMap.get(getDataMapKey(path, location));
if (!instance) {
Expand Down Expand Up @@ -106,9 +111,31 @@ export class Context {
});
}
}
renameFieldInstance(newPath: string, meta: Meta) {
const { path, location } = meta;
const newOriginalPath = newPath;
const instance = this.dataMap.get(getDataMapKey(path, location));
if (!instance) {
throw new Error('Attempt to rename field that did not pre-exist in context');
}
if (this.fields.length !== 1) {
throw new Error('Attempt to rename multiple fields.');
}
if (/\.|\*/g.test(newPath)) {
newPath = fieldRenameUtility(newPath, instance);
}
this.removeFieldInstance(instance);
this.addFieldInstances([
{
...instance,
originalPath: newOriginalPath,
path: newPath,
},
]);
}
}

export type ReadonlyContext = Pick<
Context,
Exclude<keyof Context, 'setData' | 'addFieldInstances' | 'addError'>
Exclude<keyof Context, 'setData' | 'addFieldInstances' | 'removeFieldInstance' | 'addError'>
>;
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;
}