Skip to content
This repository has been archived by the owner on Aug 16, 2024. It is now read-only.

Commit

Permalink
feat(server): updated param validation - introduce custom param valid…
Browse files Browse the repository at this point in the history
…ators (#185)
  • Loading branch information
serhiisol authored Aug 9, 2023
1 parent 252a39d commit 886eee3
Show file tree
Hide file tree
Showing 12 changed files with 106 additions and 75 deletions.
10 changes: 5 additions & 5 deletions server/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -141,11 +141,11 @@ module.set('views', join(__dirname, '/views'));
```

### Parameter
* `@Body(paramName?: string)` - Request body object or single body param
* `@Cookies(paramName?: string)` - Request cookies or single cookies param
* `@Headers(paramName?: string)` - Request headers object or single headers param
* `@Params(paramName?: string)` - Request params object or single param
* `@Query(paramName?: string)` - Request query object or single query param
* `@Body(paramName?: string, paramValidator?: Validator)` - Request body object or single body param
* `@Cookies(paramName?: string, paramValidator?: Validator)` - Request cookies or single cookies param
* `@Headers(paramName?: string, paramValidator?: Validator)` - Request headers object or single headers param
* `@Params(paramName?: string, paramValidator?: Validator)` - Request params object or single param
* `@Query(paramName?: string, paramValidator?: Validator)` - Request query object or single query param
* `@Request(paramName?: string)` - Returns request object or any other object available in req object itself
* `@Response(paramName?: string)` - Returns response object or any other object available in response object itself

Expand Down
4 changes: 1 addition & 3 deletions server/example/modules/posts/decorators/access-param.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ export function AccessParam() {
return createParamDecorator((context: HttpContext) => {
const req = context.getRequest<Request>();

req['user'] = { id: 1, name: 'John Doe' };

return req['user'];
return req.query.access;
});
}
7 changes: 1 addition & 6 deletions server/example/modules/posts/posts.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,6 @@ class PostType {
title: string;
}

class User {
id: number;
name: string;
}

@Controller()
export class PostsController {
constructor(private postsService: PostsService) { }
Expand All @@ -31,7 +26,7 @@ export class PostsController {
@Pipe(AccessPipe)
@Get(':id', 200)
@Render('post')
post(@Params('id') id: string, @AccessParam() access: User) {
post(@Params('id', Number) id: number, @AccessParam() access: string) {
return { access, id };
}
}
6 changes: 3 additions & 3 deletions server/integration/http/params/src/app.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,9 +66,9 @@ export class AppController {
return body;
}

@Post('with-simple-validator/:example')
withSimpleValidator(
@Params('example') param: string,
@Post('with-custom-validator')
withCustomValidator(
@Body('example', (arg: unknown) => typeof arg === 'string') param: string,
) {
return param;
}
Expand Down
14 changes: 9 additions & 5 deletions server/integration/http/params/test/express.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,17 +95,21 @@ describe('Express Params', () => {
});
});

describe('with simple validator', () => {
describe('with custom validator', () => {
it('passes validation', () => {
return request(module.getHttpServer())
.post('/with-simple-validator/example')
.expect('example');
.post('/with-custom-validator')
.send({ example: 'param' })
.expect('param');
});

it('fails validation', () => {
return request(module.getHttpServer())
.post('/with-simple-validator/123')
.expect(({ body }) => expect(body.message).toBeDefined());
.post('/with-custom-validator')
.send({ example: 100 })
.expect(({ body }) => {
expect(body.message).toBeDefined();
});
});
});
});
4 changes: 2 additions & 2 deletions server/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -69,5 +69,5 @@
]
}
},
"version": "1.0.0-beta.7"
"version": "1.0.0-beta.8"
}
6 changes: 3 additions & 3 deletions server/src/core/helpers/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import { Context } from './context';
export function paramDecoratorFactory(metadata: object) {
return function (target: InstanceType<any>, methodName: string, index: number) {
const params = Reflect.getMetadata(PARAMS_METADATA, target.constructor) ?? [];
const validator = Reflect.getMetadata(PARAM_TYPE_METADATA, target, methodName)[index];
const argType = Reflect.getMetadata(PARAM_TYPE_METADATA, target, methodName)[index];
const argName = extractParamNames(target[methodName])[index];

params.push({ argName, index, methodName, validator, ...metadata });
params.push({ argName, argType, index, methodName, ...metadata });

Reflect.defineMetadata(PARAMS_METADATA, params, target.constructor);
};
Expand Down Expand Up @@ -45,7 +45,7 @@ export function methodDecoratorFactory(metadata: object) {
* authorize(@AccessParam() access: string)
* ...
*/
export function createParamDecorator(factory: (context: Context) => unknown) {
export function createParamDecorator(factory: (context: Context) => Promise<any> | any) {
return paramDecoratorFactory({ factory });
}

Expand Down
54 changes: 25 additions & 29 deletions server/src/core/helpers/param-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,23 @@ import { plainToInstance } from 'class-transformer';
import { getMetadataStorage, validate } from 'class-validator';

import { ClassConstructor, Handler, ParamMetadata } from '../types';
import { isClass, isFunction } from '../utils';
import { BadRequestError } from './errors';

@Injectable()
export class ParamValidator {
async validate(params: ParamMetadata[], args: any[]) {
for (const [i, arg] of args.entries()) {
const type = params[i].validator;
const validator = params[i].paramValidator;
const type = isClass(validator) ? validator : params[i].argType;

if (!type) {
continue;
if (isFunction(validator)) {
await this.useFunctionValidator(params[i], arg);
}

if (this.validateSimple(arg, type as Handler)) {
continue;
}

const instance = plainToInstance(type as ClassConstructor, arg);

if (this.hasDecorators(type)) {
const errors = await validate(instance, { validationError: { target: false } });

if (errors.length) {
throw new BadRequestError(`Invalid param “${params[i].argName}”`, errors);
}

continue;
}

if (instance instanceof type) {
continue;
await this.useMetadataValidator(type as ClassConstructor, params[i], arg);
}

throw new BadRequestError(
`Invalid param “${params[i].argName}”. “${params[i].validator.name}” expected, “${typeof arg}” received`,
);
}
}

Expand All @@ -48,11 +30,25 @@ export class ParamValidator {
return metadatas.length > 0;
}

private validateSimple(arg: unknown, Type: Handler) {
try {
return typeof arg === typeof Type();
} catch {
return false;
private async useFunctionValidator(meta: ParamMetadata, arg: any) {
if (await (meta.paramValidator as Handler)(arg)) {
return;
}

throw new BadRequestError(
`Invalid param "${meta.argName}". "${typeof arg}" received`,
);
}

private async useMetadataValidator(type: ClassConstructor, meta: ParamMetadata, arg: any) {
const instance = plainToInstance(type, arg);

if (this.hasDecorators(type)) {
const errors = await validate(instance, { validationError: { target: false } });

if (errors.length) {
throw new BadRequestError(`Invalid param "${meta.argName}".`, errors);
}
}
}
}
8 changes: 6 additions & 2 deletions server/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,13 +26,17 @@ export interface MethodMetadata {
url: string;
}

export type ValidatorFn = (arg: any) => Promise<boolean> | boolean;
export type Validator = Handler | ClassConstructor | ValidatorFn;

export interface ParamMetadata {
// argument name defined in the function
argName?: string;
factory?: (context: any) => Promise<unknown> | unknown;
argType?: Handler | ClassConstructor;
factory?: (context: any) => Promise<any> | any;
index: number;
methodName: string;
paramName: string;
paramType: string;
validator?: Handler | ClassConstructor;
paramValidator?: Validator;
}
14 changes: 11 additions & 3 deletions server/src/core/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Handler } from './types';
import { ClassConstructor, Handler } from './types';

export function addLeadingSlash(url: string): string {
if (url.startsWith('/')) {
Expand All @@ -24,8 +24,8 @@ export function toStandardType(param: unknown) {
return param === 'true';
}

if (!isNaN(Number(param))) {
return Number(param);
if (!isNaN(Number(param)) && !isNaN(parseFloat(param as string))) {
return parseFloat(param as string);
}

return param;
Expand All @@ -37,3 +37,11 @@ export function extractParamNames(handler: Handler) {
.split(',')
.map(key => key.trim());
}

export function isClass(type: Handler | ClassConstructor) {
return typeof type === 'function' && type.toString().startsWith('class');
}

export function isFunction(type: Handler | ClassConstructor) {
return typeof type === 'function' && !isClass(type);
}
52 changes: 39 additions & 13 deletions server/src/platforms/http/decorators/params.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,56 @@
import { paramDecoratorFactory } from '../../../core';
import { paramDecoratorFactory, Validator } from '../../../core';
import { ParameterType } from '../helpers';

export function Body(paramName?: string) {
return paramDecoratorFactory({ paramName, paramType: ParameterType.BODY });
export function Body(paramName?: string, paramValidator?: Validator) {
return paramDecoratorFactory({
paramName,
paramType: ParameterType.BODY,
paramValidator,
});
}

export function Cookies(paramName?: string) {
return paramDecoratorFactory({ paramName, paramType: ParameterType.COOKIE });
export function Cookies(paramName?: string, paramValidator?: Validator) {
return paramDecoratorFactory({
paramName,
paramType: ParameterType.COOKIE,
paramValidator,
});
}

export function Headers(paramName?: string) {
return paramDecoratorFactory({ paramName, paramType: ParameterType.HEADER });
export function Headers(paramName?: string, paramValidator?: Validator) {
return paramDecoratorFactory({
paramName,
paramType: ParameterType.HEADER,
paramValidator,
});
}

export function Params(paramName?: string) {
return paramDecoratorFactory({ paramName, paramType: ParameterType.PARAM });
export function Params(paramName?: string, paramValidator?: Validator) {
return paramDecoratorFactory({
paramName,
paramType: ParameterType.PARAM,
paramValidator,
});
}

export function Query(paramName?: string) {
return paramDecoratorFactory({ paramName, paramType: ParameterType.QUERY });
export function Query(paramName?: string, paramValidator?: Validator) {
return paramDecoratorFactory({
paramName,
paramType: ParameterType.QUERY,
paramValidator,
});
}

export function Request(paramName?: string) {
return paramDecoratorFactory({ paramName, paramType: ParameterType.REQUEST });
return paramDecoratorFactory({
paramName,
paramType: ParameterType.REQUEST,
});
}

export function Response(paramName?: string) {
return paramDecoratorFactory({ paramName, paramType: ParameterType.RESPONSE });
return paramDecoratorFactory({
paramName,
paramType: ParameterType.RESPONSE,
});
}

0 comments on commit 886eee3

Please sign in to comment.