Skip to content

Commit

Permalink
Merge pull request #415 from WoH/deepobj
Browse files Browse the repository at this point in the history
ObjectTypeLiteral support
  • Loading branch information
dgreene1 authored Sep 1, 2019
2 parents ddbf2fd + d0fe540 commit 28e599d
Show file tree
Hide file tree
Showing 21 changed files with 1,310 additions and 14 deletions.
9 changes: 8 additions & 1 deletion src/metadataGeneration/tsoa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,12 +87,13 @@ export namespace Tsoa {
| 'any'
| 'refEnum'
| 'refObject'
| 'nestedObjectLiteral'
| 'union'
| 'intersection';

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

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

export interface Type {
dataType: TypeStringLiteral;
Expand All @@ -107,6 +108,12 @@ export namespace Tsoa {
enums: string[];
}

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

export interface ArrayType extends Type {
dataType: 'array';
elementType: Type;
Expand Down
45 changes: 40 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 @@ -89,8 +89,43 @@ export class TypeResolver {
return { dataType: 'any' } as Tsoa.Type;
}

if (this.typeNode.kind === ts.SyntaxKind.TypeLiteral) {
return { dataType: 'any' } as Tsoa.Type;
if (ts.isTypeLiteralNode(this.typeNode)) {
const properties = this.typeNode.members
.filter(member => ts.isPropertySignature(member))
.reduce((res, propertySignature: ts.PropertySignature) => {
const type = new TypeResolver(propertySignature.type as ts.TypeNode, this.current, propertySignature).resolve();
const property: Tsoa.Property = {
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) || {},
};

return [property, ...res];
}, []);

const indexMember = this.typeNode.members.find(member => ts.isIndexSignatureDeclaration(member));
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();
}

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

if (this.typeNode.kind === ts.SyntaxKind.ObjectKeyword) {
Expand Down Expand Up @@ -492,7 +527,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 @@ -694,7 +729,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 @@ -228,6 +228,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;
}
}
61 changes: 61 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,65 @@ export class ValidationService {
}
}

public validateNestedObjectLiteral(
name: string,
value: any,
fieldErrors: FieldErrors,
swaggerConfig: SwaggerConfigRelatedToRoutes,
nestedProperties: { [name: string]: TsoaRoute.PropertySchema } | undefined,
additionalProperties: TsoaRoute.PropertySchema | boolean | undefined,
parent: string,
) {
if (!(value instanceof Object)) {
fieldErrors[parent + name] = {
message: `invalid object`,
value,
};
return;
}

if (!nestedProperties) {
throw new Error(
'internal tsoa error: ' +
'the metadata that was generated should have had nested property schemas since it’s for a nested object,' +
'however it did not. ' +
'Please file an issue with tsoa at https://github.com/lukeautry/tsoa/issues',
);
}

const propHandling = this.resolveAdditionalPropSetting(swaggerConfig);
if (propHandling !== 'ignore') {
const excessProps = this.getExcessPropertiesFor({ properties: nestedProperties, additionalProperties }, Object.keys(value), swaggerConfig);
if (excessProps.length > 0) {
if (propHandling === 'silently-remove-extras') {
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.reduce((acc, propName) => ({ [propName]: value[propName], ...acc }), {}),
};
}
}
}

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

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

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
20 changes: 20 additions & 0 deletions src/swagger/specGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ 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 === 'nestedObjectLiteral') {
return this.getSwaggerTypeForObjectLiteral(type as Tsoa.ObjectLiteralType);
} else {
return assertNever(type.dataType);
}
Expand All @@ -87,6 +89,24 @@ export abstract class SpecGenerator {
protected abstract getSwaggerTypeForUnionType(type: Tsoa.UnionType);

protected abstract getSwaggerTypeForIntersectionType(type: Tsoa.IntersectionType);
public getSwaggerTypeForObjectLiteral(objectLiteral: Tsoa.ObjectLiteralType): Swagger.Schema {
const properties = objectLiteral.properties.reduce((acc, property: Tsoa.Property) => {
return {
[property.name]: this.getSwaggerType(property.type),
...acc,
};
}, {});

const required = objectLiteral.properties.filter(prop => prop.required).map(prop => prop.name);

// An empty list required: [] is not valid.
// If all properties are optional, do not specify the required keyword.
return {
properties,
...(required && required.length && { required }),
type: 'object',
};
}

protected getSwaggerTypeForReferenceType(referenceType: Tsoa.ReferenceType): Swagger.BaseSchema {
return {
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
3 changes: 3 additions & 0 deletions tests/fixtures/controllers/getController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ export class GetTestController extends Controller {
modelsArray: new Array<TestSubModel>(),
numberArray: [1, 2, 3],
numberValue: 1,
objLiteral: {
name: 'a string',
},
object: {
a: 'a',
},
Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/inversify/managedService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export class ManagedService {
modelsArray: new Array<TestSubModel>(),
numberArray: [1, 2, 3],
numberValue: 1,
objLiteral: {
name: 'hello',
},
object: {
a: 'a',
},
Expand Down
3 changes: 3 additions & 0 deletions tests/fixtures/services/modelService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@ export class ModelService {
modelsArray: new Array<TestSubModel>(),
numberArray: [1, 2, 3],
numberValue: 1,
objLiteral: {
name: 'hello',
},
object: {
a: 'a',
},
Expand Down
95 changes: 89 additions & 6 deletions tests/fixtures/testModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@ export interface TestModel extends Model {
genericNestedArrayKeyword2?: GenericRequest<Array<TypeAliasModel2>>;
genericNestedArrayCharacter2?: GenericRequest<TypeAliasModel2[]>;
mixedUnion?: string | TypeAliasModel1;

objLiteral: {
name: string;
nested?: {
bool: boolean;
optional?: number;
allNestedOptional: {
one?: string;
two?: string;
};
additionals?: {
[name: string]: TypeAliasModel1;
};
};
};
}

export interface TypeAliasModel1 {
Expand Down Expand Up @@ -268,6 +283,80 @@ export class ValidateModel {
public intersection?: TypeAliasModel1 & TypeAliasModel2;
public intersectionNoAdditional?: TypeAliasModel1 & TypeAliasModel2;
public mixedUnion?: string | TypeAliasModel1;

public nestedObject: {
/**
* @isFloat Invalid float error message.
*/
floatValue: number;
/**
* @isDouble Invalid double error message.
*/
doubleValue: number;
/**
* @isInt invalid integer number
*/
intValue: number;
/**
* @isLong Custom Required long number.
*/
longValue: number;
/**
* @isBoolean
*/
booleanValue: boolean;
/**
* @isArray
*/
arrayValue: number[];
/**
* @isDate invalid ISO 8601 date format, i.e. YYYY-MM-DD
*/
dateValue: Date;
/**
* @isDateTime
*/
datetimeValue: Date;

/**
* @maximum 10
*/
numberMax10: number;
/**
* @minimum 5
*/
numberMin5: number;
/**
* @maxLength 10
*/
stringMax10Lenght: string;
/**
* @minLength 5
*/
stringMin5Lenght: string;
/**
* @pattern ^[a-zA-Z]+$
*/
stringPatternAZaz: string;

/**
* @maxItems 5
*/
arrayMax5Item: number[];
/**
* @minItems 2
*/
arrayMin2Item: number[];
/**
* @uniqueItems
*/
arrayUniqueItem: number[];

model: TypeAliasModel1;
intersection?: TypeAliasModel1 & TypeAliasModel2;
intersectionNoAdditional?: TypeAliasModel1 & TypeAliasModel2;
mixedUnion?: string | TypeAliasModel1;
};
}

export interface ValidateMapStringToNumber {
Expand Down Expand Up @@ -333,12 +422,6 @@ export class TestClassModel extends TestClassBaseModel {
stringProperty: string;
protected protectedStringProperty: string;

public static typeLiterals = {
booleanTypeLiteral: { $type: Boolean },
numberTypeLiteral: { $type: Number },
stringTypeLiteral: { $type: String },
};

/**
* @param publicConstructorVar This is a description for publicConstructorVar
*/
Expand Down
Loading

0 comments on commit 28e599d

Please sign in to comment.