Skip to content

Commit

Permalink
Validation
Browse files Browse the repository at this point in the history
  • Loading branch information
WoH committed Aug 23, 2019
1 parent 6d21daf commit d1b29d1
Show file tree
Hide file tree
Showing 12 changed files with 177 additions and 28 deletions.
7 changes: 4 additions & 3 deletions src/metadataGeneration/tsoa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,13 +87,13 @@ export namespace Tsoa {
| 'any'
| 'refEnum'
| 'refObject'
| 'objectLiteral'
| 'nestedObjectLiteral'
| 'union'
| 'intersection';

export type RefTypeLiteral = 'refObject' | 'refEnum';

export type PrimitiveTypeLiteral = Exclude<TypeStringLiteral, RefTypeLiteral | 'enum' | 'array' | 'void' | 'objectLiteral' | 'union' | 'intersection'>;
export type PrimitiveTypeLiteral = Exclude<TypeStringLiteral, RefTypeLiteral | 'enum' | 'array' | 'void' | 'nestedObjectLiteral' | 'union' | 'intersection'>;

export interface Type {
dataType: TypeStringLiteral;
Expand All @@ -109,8 +109,9 @@ export namespace Tsoa {
}

export interface ObjectLiteralType extends Type {
dataType: 'objectLiteral';
dataType: 'nestedObjectLiteral';
properties: Property[];
additionalProperties?: Type;
}

export interface ArrayType extends Type {
Expand Down
32 changes: 27 additions & 5 deletions src/metadataGeneration/typeResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ syntaxKindMap[ts.SyntaxKind.VoidKeyword] = 'void';
const localReferenceTypeCache: { [typeName: string]: Tsoa.ReferenceType } = {};
const inProgressTypes: { [typeName: string]: boolean } = {};

type UsableDeclaration = ts.InterfaceDeclaration | ts.ClassDeclaration | ts.TypeAliasDeclaration;
type UsableDeclaration = ts.InterfaceDeclaration | ts.ClassDeclaration | ts.TypeAliasDeclaration | ts.PropertySignature;

export class TypeResolver {
constructor(private readonly typeNode: ts.TypeNode, private readonly current: MetadataGenerator, private readonly parentNode?: ts.Node, private readonly extractEnum = true) {}
Expand Down Expand Up @@ -90,22 +90,44 @@ export class TypeResolver {
}

if (this.typeNode.kind === ts.SyntaxKind.TypeLiteral) {
const properties = (this.typeNode as ts.TypeLiteralNode).members
const typeLiteralNode = this.typeNode as ts.TypeLiteralNode;
const properties = typeLiteralNode.members
.filter(member => member.kind === ts.SyntaxKind.PropertySignature)
.reduce((res, propertySignature: ts.PropertySignature) => {
const type = new TypeResolver(propertySignature.type as ts.TypeNode, this.current, this.typeNode).resolve();

return [
{
default: getJSDocComment(propertySignature, 'default'),
description: this.getNodeDescription(propertySignature),
format: this.getNodeFormat(propertySignature),
name: (propertySignature.name as ts.Identifier).text,
required: !propertySignature.questionToken,
type,
validators: getPropertyValidators(propertySignature),
} as Tsoa.Property,
...res,
];
}, []);

return { dataType: 'objectLiteral', properties } as Tsoa.ObjectLiteralType;
const indexMember = typeLiteralNode.members.find(member => member.kind === ts.SyntaxKind.IndexSignature);
let additionalType: Tsoa.Type | undefined;

if (indexMember) {
const indexSignatureDeclaration = indexMember as ts.IndexSignatureDeclaration;
const indexType = new TypeResolver(indexSignatureDeclaration.parameters[0].type as ts.TypeNode, this.current).resolve();
if (indexType.dataType !== 'string') {
throw new GenerateMetadataError(`Only string indexers are supported.`);
}

additionalType = new TypeResolver(indexSignatureDeclaration.type as ts.TypeNode, this.current).resolve();
}

return {
additionalProperties: indexMember && additionalType,
dataType: 'nestedObjectLiteral',
properties,
} as Tsoa.ObjectLiteralType;
}

if (this.typeNode.kind === ts.SyntaxKind.ObjectKeyword) {
Expand Down Expand Up @@ -507,7 +529,7 @@ export class TypeResolver {

const modelTypeDeclaration = node as UsableDeclaration;
return (modelTypeDeclaration.name as ts.Identifier).text === typeName;
}) as UsableDeclaration[];
}) as Array<Exclude<UsableDeclaration, ts.PropertySignature>>;

if (!modelTypes.length) {
throw new GenerateMetadataError(
Expand Down Expand Up @@ -709,7 +731,7 @@ export class TypeResolver {
return undefined;
}

private getModelInheritedProperties(modelTypeDeclaration: UsableDeclaration): Tsoa.Property[] {
private getModelInheritedProperties(modelTypeDeclaration: Exclude<UsableDeclaration, ts.PropertySignature>): Tsoa.Property[] {
const properties = [] as Tsoa.Property[];
if (modelTypeDeclaration.kind === ts.SyntaxKind.TypeAliasDeclaration) {
return [];
Expand Down
10 changes: 10 additions & 0 deletions src/routeGeneration/routeGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,6 +221,16 @@ export class RouteGenerator {
schema.subSchemas = (type as Tsoa.IntersectionType | Tsoa.UnionType).types.map(type => this.buildProperty(type));
}

if (type.dataType === 'nestedObjectLiteral') {
const objLiteral = type as Tsoa.ObjectLiteralType;

schema.nestedProperties = objLiteral.properties.reduce((acc, prop) => {
return { ...acc, [prop.name]: this.buildPropertySchema(prop) };
}, {});

schema.additionalProperties = objLiteral.additionalProperties && this.buildProperty(objLiteral.additionalProperties);
}

return schema;
}
}
48 changes: 48 additions & 0 deletions src/routeGeneration/templateHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,8 @@ export class ValidationService {
return this.validateIntersection(name, value, fieldErrors, minimalSwaggerConfig, property.subSchemas, parent);
case 'any':
return value;
case 'nestedObjectLiteral':
return this.validateNestedObjectLiteral(name, value, fieldErrors, minimalSwaggerConfig, property.nestedProperties, property.additionalProperties, parent);
default:
if (property.ref) {
return this.validateModel({ name, value, refName: property.ref, fieldErrors, parent, minimalSwaggerConfig });
Expand All @@ -79,6 +81,52 @@ export class ValidationService {
}
}

public validateNestedObjectLiteral(
name: string,
value: any,
fieldErrors: FieldErrors,
minimalSwaggerConfig: SwaggerConfigRelatedToRoutes,
nestedProperties: { [name: string]: TsoaRoute.PropertySchema } | undefined,
additionalProperties: TsoaRoute.PropertySchema | boolean | undefined,
parent: string,
) {
if (!nestedProperties) {
return;
}

const propHandling = minimalSwaggerConfig.noImplicitAdditionalProperties;
if (propHandling) {
const excessProps = this.getExcessPropertiesFor({ properties: nestedProperties, additionalProperties }, Object.keys(value), minimalSwaggerConfig);
if (excessProps.length > 0) {
if (propHandling) {
excessProps.forEach(excessProp => {
delete value[excessProp];
});
if (propHandling === 'throw-on-extras') {
fieldErrors[parent + name] = {
message: `"${excessProps}" is an excess property and therefore is not allowed`,
value: excessProps,
};
}
}
}
}

Object.keys(value).forEach(key => {
if (!nestedProperties[key]) {
if (additionalProperties && additionalProperties !== true) {
return this.ValidateParam(additionalProperties, value[key], key, fieldErrors, parent + name + '.', minimalSwaggerConfig);
} else {
return key;
}
}

return this.ValidateParam(nestedProperties[key], value[key], key, fieldErrors, parent + name + '.', minimalSwaggerConfig);
});

return value;
}

public validateInt(name: string, value: any, fieldErrors: FieldErrors, validators?: IntegerValidator, parent = '') {
if (!validator.isInt(String(value))) {
let message = `invalid integer number`;
Expand Down
2 changes: 2 additions & 0 deletions src/routeGeneration/tsoa-route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ export namespace TsoaRoute {
subSchemas?: PropertySchema[];
validators?: ValidatorSchema;
default?: any;
additionalProperties?: boolean | PropertySchema;
nestedProperties?: { [name: string]: PropertySchema };
}

export interface ParameterSchema extends PropertySchema {
Expand Down
2 changes: 1 addition & 1 deletion src/swagger/specGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ export abstract class SpecGenerator {
return this.getSwaggerTypeForUnionType(type as Tsoa.UnionType);
} else if (type.dataType === 'intersection') {
return this.getSwaggerTypeForIntersectionType(type as Tsoa.IntersectionType);
} else if (type.dataType === 'objectLiteral') {
} else if (type.dataType === 'nestedObjectLiteral') {
return this.getSwaggerTypeForObjectLiteral(type as Tsoa.ObjectLiteralType);
} else {
return assertNever(type.dataType);
Expand Down
2 changes: 1 addition & 1 deletion src/utils/validatorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ export function getParameterValidators(parameter: ts.ParameterDeclaration, param
);
}

export function getPropertyValidators(property: ts.PropertyDeclaration): Tsoa.Validators | undefined {
export function getPropertyValidators(property: ts.PropertyDeclaration | ts.PropertySignature): Tsoa.Validators | undefined {
const tags = getJSDocTags(property, tag => {
return getParameterTagSupport().some(value => value === tag.tagName.text);
});
Expand Down
5 changes: 4 additions & 1 deletion tests/fixtures/testModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,13 @@ export interface TestModel extends Model {
nested?: {
bool: boolean;
optional?: number;
allOptional: {
allNestedOptional: {
one?: string;
two?: string;
};
additionals?: {
[name: string]: TypeAliasModel1;
};
};
};
}
Expand Down
20 changes: 20 additions & 0 deletions tests/integration/express-server.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,26 @@ describe('Express Server', () => {
});
});

it('removes additional properties', () => {
const model = getFakeModel();
const data = {
...model,
objLiteral: {
...model.objLiteral,
extra: 123,
nested: {
anotherExtra: 123,
},
},
};

return verifyPostRequest(basePath + '/PostTest', data, (err: any, res: any) => {
const resModel = res.body as TestModel;
expect(resModel).to.deep.equal({ ...model, objLiteral: { ...model.objLiteral, nested: {} } });
expect(res.status).to.eq(200);
});
});

it('correctly returns status code', () => {
const data = getFakeModel();
const path = basePath + '/PostTest/WithDifferentReturnCode';
Expand Down
45 changes: 45 additions & 0 deletions tests/integration/koa-server-no-additional-allowed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,51 @@ describe('Koa Server (with noImplicitAdditionalProperties turned on)', () => {
);
});

it('should call out any additionalProperties', () => {
const data = Object.assign({}, getFakeModel(), {
objLiteral: {
extra: 123,
nested: {
anotherExtra: 123,
},
},
});

return verifyPostRequest(
basePath + '/PostTest',
data,
(err: any, res: any) => {
const body = JSON.parse(err.text);
expect(body.fields['model.objLiteral'].message).to.eql('"extra" is an excess property and therefore is not allowed');
expect(body.fields['model.objLiteral.nested'].message).to.eql('"anotherExtra" is an excess property and therefore is not allowed');
},
400,
);
});

it('should respect additional props', () => {
const fakeModel = getFakeModel();
const data = {
...fakeModel,
objLiteral: {
name: 'hello',
nested: {
additionals: {
one: { value1: '' },
},
allNestedOptional: {},
bool: true,
},
},
} as TestModel;

return verifyPostRequest(basePath + '/PostTest', data, (err: any, res: any) => {
expect(err).to.equal(false);
const model = res.body as TestModel;
expect(model).to.deep.equal(data);
});
});

it('should be okay if there are no additionalProperties', () => {
const data = getFakeModel();

Expand Down
17 changes: 9 additions & 8 deletions tests/unit/swagger/definitionsGeneration/definitions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,6 @@ describe('Definition generation', () => {
expect(definition.additionalProperties).to.eq(true, forSpec(currentSpec));
}
});

/**
* By creating a record of "keyof T" we ensure that contributors will need add a test for any new property that is added to the model
*/
Expand Down Expand Up @@ -423,21 +422,23 @@ describe('Definition generation', () => {
expect(propertySchema).to.not.haveOwnProperty('additionalProperties', `for property ${propertyName}`);
},
objLiteral: (propertyName, propertySchema) => {
expect(propertySchema).to.deep.include({
expect(propertySchema).to.deep.equal({
default: undefined,
description: undefined,
format: undefined,
properties: {
name: {
type: 'string',
},
name: { type: 'string' },
nested: {
properties: {
allOptional: {
additionals: { properties: {}, type: 'object' },
allNestedOptional: {
properties: { one: { type: 'string' }, two: { type: 'string' } },
type: 'object',
},
bool: { type: 'boolean' },
optional: { format: 'double', type: 'number' },
optional: { type: 'number', format: 'double' },
},
required: ['allOptional', 'bool'],
required: ['allNestedOptional', 'bool'],
type: 'object',
},
},
Expand Down
15 changes: 6 additions & 9 deletions tests/unit/swagger/schemaDetails3.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -222,14 +222,18 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
},
nested: {
properties: {
allOptional: {
additionals: {
properties: {},
type: 'object',
},
allNestedOptional: {
properties: { one: { type: 'string' }, two: { type: 'string' } },
type: 'object',
},
bool: { type: 'boolean' },
optional: { format: 'double', type: 'number' },
},
required: ['allOptional', 'bool'],
required: ['allNestedOptional', 'bool'],
type: 'object',
},
},
Expand Down Expand Up @@ -504,13 +508,6 @@ describe('Definition generation for OpenAPI 3.0.0', () => {
`because the swagger spec (${currentSpec.specName}) should only produce property schemas for properties that live on the TypeScript interface.`,
);
});

it('should have only created schemas for properties on the TypeScript interface', () => {
expect(Object.keys(assertionsPerProperty)).to.length(
Object.keys(testModel.properties!).length,
`because the swagger spec (${currentSpec.specName}) should only produce property schemas for properties that live on the TypeScript interface.`,
);
});
});
});
});
Expand Down

0 comments on commit d1b29d1

Please sign in to comment.