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

fix: Validate intersection with multiple unions #1501

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/src/routeGeneration/routeGenerator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export abstract class AbstractRouteGenerator<Config extends ExtendedRoutesConfig
return convertBracesPathParams(path);
}

protected buildContext() {
protected buildContext(): any {
const authenticationModule = this.options.authenticationModule ? this.getRelativeImportPath(this.options.authenticationModule) : undefined;
const iocModule = this.options.iocModule ? this.getRelativeImportPath(this.options.iocModule) : undefined;

Expand Down
64 changes: 39 additions & 25 deletions packages/runtime/src/routeGeneration/templateHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -545,7 +545,7 @@ export class ValidationService {
return;
}

const schemas = this.selfIntersectionExcludingCombinations(subSchemas.map(subSchema => this.toModelLike(subSchema)));
const schemas = this.selfIntersectionCombinations(subSchemas.map(subSchema => this.toModelLike(subSchema)));

const getRequiredPropError = (schema: TsoaRoute.ModelSchema) => {
const requiredPropError = {};
Expand Down Expand Up @@ -607,7 +607,7 @@ export class ValidationService {
} else if (schema.subSchemas && schema.dataType === 'intersection') {
const modelss: TsoaRoute.RefObjectModelSchema[][] = schema.subSchemas.map(subSchema => this.toModelLike(subSchema));

return this.selfIntersectionExcludingCombinations(modelss);
return this.selfIntersectionCombinations(modelss);
} else if (schema.subSchemas && schema.dataType === 'union') {
const modelss: TsoaRoute.RefObjectModelSchema[][] = schema.subSchemas.map(subSchema => this.toModelLike(subSchema));
return modelss.reduce((acc, models) => [...acc, ...models], []);
Expand All @@ -618,41 +618,55 @@ export class ValidationService {
}

/**
* combine all schemas once without backwards combinations ie
* combine all schemas once, ignoring order ie
* input: [[value1], [value2]] should be [[value1, value2]]
* not [[value1, value2],[value2, value1]]
* and
* input: [[value1], [value2], [value3]] should be [
* [value1, value2, value3],
* [value1, value2],
* [value1, value3],
* [value2, value3]
* input: [[value1, value2], [value3, value4], [value5, value6]] should be [
* [value1, value3, value5],
* [value1, value3, value6],
* [value1, value4, value5],
* [value1, value4, value6],
* [value2, value3, value5],
* [value2, value3, value6],
* [value2, value4, value5],
* [value2, value4, value6],
* ]
* @param modelSchemass
*/
private selfIntersectionExcludingCombinations(modelSchemass: TsoaRoute.RefObjectModelSchema[][]): TsoaRoute.RefObjectModelSchema[] {
private selfIntersectionCombinations(modelSchemass: TsoaRoute.RefObjectModelSchema[][]): TsoaRoute.RefObjectModelSchema[] {
const res: TsoaRoute.RefObjectModelSchema[] = [];

for (let outerIndex = 0; outerIndex < modelSchemass.length; outerIndex++) {
let currentCollector = { ...modelSchemass[outerIndex][0] };
for (let innerIndex = outerIndex + 1; innerIndex < modelSchemass.length; innerIndex++) {
currentCollector = { ...this.intersectRefObjectModelSchemas([currentCollector], modelSchemass[innerIndex])[0] };
Comment on lines -637 to -639
Copy link
Contributor Author

Choose a reason for hiding this comment

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

The bug was caused on lines 637 and 639, using the first subschema from each schema of the intersection

if (innerIndex - outerIndex > 1) {
res.push(currentCollector);
}
const currentCombination = this.intersectRefObjectModelSchemas(modelSchemass[outerIndex], modelSchemass[innerIndex]);
res.push(...currentCombination);
// Picks one schema from each sub-array
const combinations = this.getAllCombinations(modelSchemass);

for (const combination of combinations) {
// Combine all schemas of this combination
let currentCollector = { ...combination[0] };
for (let subSchemaIdx = 1; subSchemaIdx < combination.length; subSchemaIdx++) {
currentCollector = { ...this.combineProperties(currentCollector, combination[subSchemaIdx]) };
}
res.push(currentCollector);
}

return res;
}

private intersectRefObjectModelSchemas(a: TsoaRoute.RefObjectModelSchema[], b: TsoaRoute.RefObjectModelSchema[]): TsoaRoute.RefObjectModelSchema[] {
return a.reduce<TsoaRoute.RefObjectModelSchema[]>(
(acc, aModel) => [...acc, ...b.reduce<TsoaRoute.RefObjectModelSchema[]>((acc, bModel) => [...acc, this.combineProperties(aModel, bModel)], [])],
[],
);
private getAllCombinations<T>(arrays: T[][]): T[][] {
function combine(current: T[], index: number) {
if (index === arrays.length) {
result.push(current.slice());
return;
}

for (let i = 0; i < arrays[index].length; i++) {
current.push(arrays[index][i]);
combine(current, index + 1);
current.pop();
}
}

const result: T[][] = [];
combine([], 0);
return result;
}

private combineProperties(a: TsoaRoute.RefObjectModelSchema, b: TsoaRoute.RefObjectModelSchema): TsoaRoute.RefObjectModelSchema {
Expand Down
264 changes: 171 additions & 93 deletions tests/unit/swagger/templateHelpers.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1003,99 +1003,7 @@ describe('ValidationService', () => {
expect(validatedData2).to.deep.equal(expectedValues2);
});

it('should handle cases with unions', () => {
const refName = 'ExampleModel';
const subSchemas = [{ ref: 'TypeAliasModel1' }, { ref: 'TypeAliasUnion' }];
const models: TsoaRoute.Models = {
[refName]: {
dataType: 'refObject',
properties: {
and: {
dataType: 'intersection',
subSchemas,
required: true,
},
},
},
TypeAliasModel1: {
dataType: 'refObject',
properties: {
value1: { dataType: 'string', required: true },
},
additionalProperties: false,
},
TypeAliasUnion: {
dataType: 'refAlias',
type: {
dataType: 'union',
subSchemas: [{ ref: 'UnionModel1' }, { ref: 'UnionModel2' }, { ref: 'UnionModel3' }],
},
},
UnionModel1: {
dataType: 'refObject',
properties: {
value2: { dataType: 'string', required: true },
},
additionalProperties: false,
},
UnionModel2: {
dataType: 'refObject',
properties: {
dateTimeValue: { dataType: 'datetime', required: true },
},
additionalProperties: false,
},
UnionModel3: {
dataType: 'refObject',
properties: {
dateValue: { dataType: 'date', required: true },
},
additionalProperties: false,
},
};
const v = new ValidationService(models);
const errorDictionary: FieldErrors = {};
const dataToValidate: TypeAliasModel1 & (TypeAliasModel2 | TypeAliasDateTime | TypeAliasDate) = {
value1: 'this is value 1',
dateValue: '2017-01-01' as unknown as Date,
};

// Act
const name = 'dataToValidate';
const validatedData = v.validateIntersection('and', dataToValidate, errorDictionary, minimalSwaggerConfig, subSchemas, name + '.');

// Assert
const expectedValues = { ...dataToValidate, dateValue: new Date('2017-01-01') };
expect(errorDictionary).to.deep.equal({});
expect(validatedData).to.deep.equal(expectedValues);

const errorDictionary2: FieldErrors = {};
const dataToValidate2: TypeAliasModel1 & (TypeAliasModel2 | TypeAliasDateTime | TypeAliasDate) = {
value1: 'this is value 1',
dateTimeValue: '2017-01-01T00:00:00' as unknown as Date,
};

// Act
const validatedData2 = v.validateIntersection('and', dataToValidate2, errorDictionary2, minimalSwaggerConfig, subSchemas, name + '.');

// Assert
const expectedValues2 = { ...dataToValidate2, dateTimeValue: new Date('2017-01-01T00:00:00') };
expect(errorDictionary2).to.deep.equal({});
expect(validatedData2).to.deep.equal(expectedValues2);

const errorDictionary3: FieldErrors = {};
const dataToValidate3: TypeAliasModel1 & (TypeAliasModel2 | TypeAliasDateTime | TypeAliasDate) = {
value1: 'this is value 1',
value2: 'this is value 2',
};

// Act
const validatedData3 = v.validateIntersection('and', dataToValidate3, errorDictionary3, minimalSwaggerConfig, subSchemas, name + '.');

// Assert
expect(errorDictionary3).to.deep.equal({});
expect(validatedData3).to.deep.equal(dataToValidate3);

it('should validate intersection of one union', () => {
const withUnionsName = 'withUnions';
const withUnionsSubSchemas = [{ ref: 'ServiceObject' }, { ref: 'BigUnion' }];
const WithUnionModels: TsoaRoute.Models = {
Expand Down Expand Up @@ -1205,6 +1113,176 @@ describe('ValidationService', () => {
expect(withUnionErrorDictionary3).to.deep.equal({});
expect(validatedResult3).to.deep.equal(withUnionDataToValidate3);
});

it('should validate intersection of 3+ unions', () => {
const refName = 'ExampleModel';
const subSchemas = [{ ref: 'TypeAliasUnion1' }, { ref: 'TypeAliasUnion2' }, { ref: 'TypeAliasUnion3' }];
const models: TsoaRoute.Models = {
[refName]: {
dataType: 'refObject',
properties: {
and: {
dataType: 'intersection',
subSchemas,
required: true,
},
},
},
TypeAliasUnion1: {
dataType: 'refAlias',
type: {
dataType: 'union',
subSchemas: [{ ref: 'UnionModel1a' }, { ref: 'UnionModel1b' }],
},
},
TypeAliasUnion2: {
dataType: 'refAlias',
type: {
dataType: 'union',
subSchemas: [{ ref: 'UnionModel2a' }, { ref: 'UnionModel2b' }],
},
},
TypeAliasUnion3: {
dataType: 'refAlias',
type: {
dataType: 'union',
subSchemas: [{ ref: 'UnionModel3a' }, { ref: 'UnionModel3b' }],
},
},
UnionModel1a: {
dataType: 'refObject',
properties: {
value1a: { dataType: 'string', required: true },
},
additionalProperties: false,
},
UnionModel1b: {
dataType: 'refObject',
properties: {
value1a: { dataType: 'boolean', required: true },
value1b: { dataType: 'string', required: true },
},
additionalProperties: false,
},
UnionModel2a: {
dataType: 'refObject',
properties: {
value2a: { dataType: 'string', required: true },
},
additionalProperties: false,
},
UnionModel2b: {
dataType: 'refObject',
properties: {
value2b: { dataType: 'string', required: true },
},
additionalProperties: false,
},
UnionModel3a: {
dataType: 'refObject',
properties: {
dateTimeValue: { dataType: 'datetime', required: true },
},
additionalProperties: false,
},
UnionModel3b: {
dataType: 'refObject',
properties: {
dateValue: { dataType: 'date', required: true },
},
additionalProperties: false,
},
};
const v = new ValidationService(models);

// Validate all schema combinations
const validInputs = [
{
input: { value1a: 'value 1a', value2a: 'value 2a', dateTimeValue: '2017-01-01T00:00:00' },
output: { value1a: 'value 1a', value2a: 'value 2a', dateTimeValue: new Date('2017-01-01T00:00:00') },
},
{
input: { value1a: 'value 1a', value2a: 'value 2a', dateValue: '2017-01-01' },
output: { value1a: 'value 1a', value2a: 'value 2a', dateValue: new Date('2017-01-01') },
},
{
input: { value1a: 'value 1a', value2b: 'value 2b', dateTimeValue: '2017-01-01T00:00:00' },
output: { value1a: 'value 1a', value2b: 'value 2b', dateTimeValue: new Date('2017-01-01T00:00:00') },
},
{
input: { value1a: 'value 1a', value2b: 'value 2b', dateValue: '2017-01-01' },
output: { value1a: 'value 1a', value2b: 'value 2b', dateValue: new Date('2017-01-01') },
},
{
input: { value1a: false, value1b: 'value 1b', value2a: 'value 2a', dateTimeValue: '2017-01-01T00:00:00' },
output: { value1a: false, value1b: 'value 1b', value2a: 'value 2a', dateTimeValue: new Date('2017-01-01T00:00:00') },
},
{
input: { value1a: false, value1b: 'value 1b', value2a: 'value 2a', dateValue: '2017-01-01' },
output: { value1a: false, value1b: 'value 1b', value2a: 'value 2a', dateValue: new Date('2017-01-01') },
},
{
input: { value1a: false, value1b: 'value 1b', value2b: 'value 2b', dateTimeValue: '2017-01-01T00:00:00' },
output: { value1a: false, value1b: 'value 1b', value2b: 'value 2b', dateTimeValue: new Date('2017-01-01T00:00:00') },
},
{
input: { value1a: false, value1b: 'value 1b', value2b: 'value 2b', dateValue: '2017-01-01' },
output: { value1a: false, value1b: 'value 1b', value2b: 'value 2b', dateValue: new Date('2017-01-01') },
},
];

for (let i = 0; i < validInputs.length; i++) {
const { input, output } = validInputs[i];

// Act
const errorDictionary: FieldErrors = {};
const validatedData = v.validateIntersection('and', input, errorDictionary, minimalSwaggerConfig, subSchemas, refName + '.');

// Assert
expect(errorDictionary, `validInputs[${i}] returned errors`).to.deep.equal({});
expect(validatedData, `validInputs[${i}] did not match output`).to.deep.equal(output);
}

// Invalid inputs
const invalidDataTypes: any[] = [];
const excessProperties: any[] = [];
const missingRequiredProperties: any[] = [];

for (const validInput of validInputs) {
// Invalid datatype per key
for (const key in validInput.input) {
invalidDataTypes.push({ ...validInput.input, [key]: 123 });
}

// Excess properties
excessProperties.push({ ...validInput.input, excessProperty: 'excess' });

// Missing required properties
for (const key in validInput.input) {
const invalidInput = { ...validInput.input };
delete invalidInput[key];
missingRequiredProperties.push(invalidInput);
}
}

function testInvalidInputs(name: string, inputs: any[]) {
for (let i = 0; i < inputs.length; i++) {
const invalidInput = inputs[i];

// Act
const errorDictionary: FieldErrors = {};
const validatedData = v.validateIntersection('and', invalidInput, errorDictionary, minimalSwaggerConfig, subSchemas, refName + '.');

// Assert
expect(errorDictionary, `${name}[${i}] did not return errors`).to.not.deep.equal({});
expect(validatedData, `${name}[${i}] returned data`).to.equal(undefined);
}
}

testInvalidInputs('invalidDataTypes', invalidDataTypes);
testInvalidInputs('excessProperties', excessProperties);
testInvalidInputs('missingRequiredProperties', missingRequiredProperties);
});
});
});

Expand Down