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 3 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
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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, this is better!
A few comments here though:

  1. This shouldn't stay in the Validators interface. ContextHandler sounds more like it.
  2. Let's add a type specific for the rename callback, so that CustomValidator can evolve independently from the rename functionality.
  3. WDYT of accepting a string value too, which works as a shortcut? For example, rename('banana') automatically renames that field to banana.


/**
* 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
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;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When you move rename out of Validators, I think that this won't be necessary anymore


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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ditto for negated


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) {}
}
}
17 changes: 17 additions & 0 deletions 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 @@ -106,6 +107,22 @@ export class Context {
});
}
}
renameFieldInstance(newPath: string, meta: Meta) {
const { path, location } = meta;
const instance = this.dataMap.get(getDataMapKey(path, location));
if (!instance) {
throw new Error('Attempt to rename field that did not pre-exist in context');
}
const { originalPath } = instance;
if (this.fields.length !== 1) {
throw new Error('Attempt to rename multiple fields.');
}
if (/\.|\*/g.test(originalPath)) {
instance.path = fieldRenameUtility(newPath, instance);
return;
}
instance.path = newPath;
}
}

export type ReadonlyContext = Pick<
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;
}