diff --git a/packages/runtime/src/routeGeneration/additionalProps.ts b/packages/runtime/src/routeGeneration/additionalProps.ts index c19667b02..a2a4eab13 100644 --- a/packages/runtime/src/routeGeneration/additionalProps.ts +++ b/packages/runtime/src/routeGeneration/additionalProps.ts @@ -3,4 +3,5 @@ import { Config, RoutesConfig } from '../config'; export interface AdditionalProps { noImplicitAdditionalProperties: Exclude; bodyCoercion: Exclude; + maxValidationErrorSize?: number; } diff --git a/packages/runtime/src/routeGeneration/templateHelpers.ts b/packages/runtime/src/routeGeneration/templateHelpers.ts index b2b6b7863..f4c4fa84e 100644 --- a/packages/runtime/src/routeGeneration/templateHelpers.ts +++ b/packages/runtime/src/routeGeneration/templateHelpers.ts @@ -516,7 +516,7 @@ export class ValidationService { // Clean value if it's not undefined or use undefined directly if it's undefined. // Value can be undefined if undefined is allowed datatype of the union - const validateableValue = value ? JSON.parse(JSON.stringify(value)) : value; + const validateableValue = value !== undefined ? this.deepClone(value) : value; const cleanValue = this.ValidateParam({ ...subSchema, validators: { ...property.validators, ...subSchema.validators } }, validateableValue, name, subFieldError, isBodyParam, parent); subFieldErrors.push(subFieldError); @@ -525,10 +525,7 @@ export class ValidationService { } } - fieldErrors[parent + name] = { - message: `Could not match the union against any of the items. Issues: ${JSON.stringify(subFieldErrors)}`, - value, - }; + this.addSummarizedError(fieldErrors, parent + name, 'Could not match the union against any of the items. Issues: ', subFieldErrors, value); return; } @@ -546,10 +543,9 @@ export class ValidationService { subSchemas.forEach(subSchema => { const subFieldError: FieldErrors = {}; - const cleanValue = new ValidationService(this.models, { + const cleanValue = this.createChildValidationService({ noImplicitAdditionalProperties: 'silently-remove-extras', - bodyCoercion: this.config.bodyCoercion, - }).ValidateParam(subSchema, JSON.parse(JSON.stringify(value)), name, subFieldError, isBodyParam, parent); + }).ValidateParam(subSchema, this.deepClone(value), name, subFieldError, isBodyParam, parent); cleanValues = { ...cleanValues, ...cleanValue, @@ -560,10 +556,7 @@ export class ValidationService { const filtered = subFieldErrors.filter(subFieldError => Object.keys(subFieldError).length !== 0); if (filtered.length > 0) { - fieldErrors[parent + name] = { - message: `Could not match the intersection against every type. Issues: ${JSON.stringify(filtered)}`, - value, - }; + this.addSummarizedError(fieldErrors, parent + name, 'Could not match the intersection against every type. Issues: ', filtered, value); return; } @@ -571,12 +564,11 @@ export class ValidationService { const getRequiredPropError = (schema: TsoaRoute.ModelSchema) => { const requiredPropError = {}; - new ValidationService(this.models, { + this.createChildValidationService({ noImplicitAdditionalProperties: 'ignore', - bodyCoercion: this.config.bodyCoercion, }).validateModel({ name, - value: JSON.parse(JSON.stringify(value)), + value: this.deepClone(value), modelDefinition: schema, fieldErrors: requiredPropError, isBodyParam, @@ -793,6 +785,141 @@ export class ValidationService { return value; } + + /** + * Creates a new ValidationService instance with specific configuration + * @param overrides Configuration overrides + * @returns New ValidationService instance + */ + private createChildValidationService(overrides: Partial = {}): ValidationService { + return new ValidationService(this.models, { + ...this.config, + ...overrides, + }); + } + + /** + * Deep clones an object without using JSON.stringify/parse to avoid: + * 1. Loss of undefined values + * 2. Loss of functions + * 3. Conversion of dates to strings + * 4. Exponential escaping issues with nested objects + */ + private deepClone(obj: T): T { + // Fast path for primitives + if (obj === null || obj === undefined) { + return obj; + } + + const type = typeof obj; + if (type !== 'object') { + return obj; + } + + // Handle built-in object types + if (obj instanceof Date) { + return new Date(obj.getTime()) as any; + } + + if (obj instanceof RegExp) { + return new RegExp(obj.source, obj.flags) as any; + } + + if (obj instanceof Array) { + const cloneArr: any[] = new Array(obj.length); + for (let i = 0; i < obj.length; i++) { + cloneArr[i] = this.deepClone(obj[i]); + } + return cloneArr as any; + } + + if (Buffer && obj instanceof Buffer) { + return Buffer.from(obj) as any; + } + + // Handle plain objects + const cloneObj: any = {}; + for (const key in obj) { + if (Object.prototype.hasOwnProperty.call(obj, key)) { + cloneObj[key] = this.deepClone(obj[key]); + } + } + return cloneObj; + } + + /** + * Adds a summarized error to the fieldErrors object + * @param fieldErrors The errors object to add to + * @param errorKey The key for the error + * @param prefix The error message prefix + * @param subErrors Array of sub-errors to summarize + * @param value The value that failed validation + */ + private addSummarizedError(fieldErrors: FieldErrors, errorKey: string, prefix: string, subErrors: FieldErrors[], value: any): void { + const maxErrorLength = this.config.maxValidationErrorSize ? this.config.maxValidationErrorSize - prefix.length : undefined; + + fieldErrors[errorKey] = { + message: `${prefix}${this.summarizeValidationErrors(subErrors, maxErrorLength)}`, + value, + }; + } + + /** + * Summarizes validation errors to prevent extremely large error messages + * @param errors Array of field errors from union/intersection validation + * @param maxLength Maximum length of the summarized message + * @returns Summarized error message + */ + private summarizeValidationErrors(errors: FieldErrors[], maxLength?: number): string { + const effectiveMaxLength = maxLength || this.config.maxValidationErrorSize || 1000; + + // If there are no errors, return empty + if (errors.length === 0) { + return '[]'; + } + + // Start with a count of total errors + const errorCount = errors.length; + const summary: string[] = []; + + // Try to include first few errors + let currentLength = 0; + let includedErrors = 0; + + // Calculate the size of the suffix if we need to truncate + const truncatedSuffix = `,...and ${errorCount} more errors]`; + const reservedSpace = truncatedSuffix.length + 10; // +10 for safety margin + + for (const error of errors) { + const errorStr = JSON.stringify(error); + const projectedLength = currentLength + errorStr.length + (summary.length > 0 ? 1 : 0) + 2; // +1 for comma if not first, +2 for brackets + + if (projectedLength + reservedSpace < effectiveMaxLength && includedErrors < 3) { + summary.push(errorStr); + currentLength = projectedLength; + includedErrors++; + } else { + break; + } + } + + // Build final message + if (includedErrors < errorCount) { + const result = `[${summary.join(',')},...and ${errorCount - includedErrors} more errors]`; + // Make sure we don't exceed the limit + if (result.length > effectiveMaxLength) { + // If still too long, remove the last error and try again + if (summary.length > 0) { + summary.pop(); + includedErrors--; + return `[${summary.join(',')},...and ${errorCount - includedErrors} more errors]`; + } + } + return result; + } else { + return `[${summary.join(',')}]`; + } + } } export interface IntegerValidator { diff --git a/tests/integration/validation-errors-express.spec.ts b/tests/integration/validation-errors-express.spec.ts new file mode 100644 index 000000000..2584970d0 --- /dev/null +++ b/tests/integration/validation-errors-express.spec.ts @@ -0,0 +1,138 @@ +import { expect } from 'chai'; +import 'mocha'; +import * as request from 'supertest'; +import { app } from '../fixtures/express/server'; + +const basePath = '/v1'; + +describe('Validation Error Size - Express Server', () => { + describe('Large Union Validation Errors', () => { + it('should return reasonably sized error response for union validation failures', async () => { + // Create a request that will fail validation against a union type + const invalidUnionData = { + unionProperty: { + type: 'invalid', + unknownProp: 'this should not be here', + anotherUnknownProp: 123, + }, + }; + + const response = await request(app) + .post(basePath + '/ValidationTest/UnionType') + .send(invalidUnionData) + .expect(400); + + // Check that the response size is reasonable + const responseSize = JSON.stringify(response.body).length; + expect(responseSize).to.be.lessThan(2000, 'Union validation error response should be under 2KB'); + + // Should still contain useful error information + expect(response.body).to.have.property('fields'); + expect(response.body.message).to.exist; + }); + }); + + describe('Deep Model Validation Errors', () => { + it('should not have excessive escaping in deep model errors', async () => { + // Create deeply nested invalid data + const deepInvalidData = { + level1: { + level2: { + level3: { + level4: { + level5: { + shouldBeString: 123, + extraProp: 'not allowed', + }, + }, + }, + }, + }, + }; + + const response = await request(app) + .post(basePath + '/ValidationTest/DeepModel') + .send(deepInvalidData) + .expect(400); + + // Check for excessive escaping + const responseText = JSON.stringify(response.body); + const backslashCount = (responseText.match(/\\\\/g) || []).length; + + expect(backslashCount).to.be.lessThan(20, 'Should not have excessive backslash escaping'); + }); + }); + + describe('Complex Deep Union Errors', () => { + it('should handle complex validation scenarios without huge responses', async () => { + // Complex nested structure with unions + const complexInvalidData = { + type: 'complex', + nested: { + unionField: { + type: 'typeA', + nested: { + deepUnion: { + type: 'subType1', + value: { + level1: { + level2: { + shouldBeNumber: 'not a number', + extraField: 'not allowed', + }, + unexpectedField: true, + }, + }, + }, + anotherExtraField: 123, + }, + }, + yetAnotherExtra: 'field', + }, + }; + + const response = await request(app) + .post(basePath + '/ValidationTest/ComplexUnionModel') + .send(complexInvalidData) + .expect(400); + + // Total response should be reasonably sized + const responseSize = JSON.stringify(response.body).length; + expect(responseSize).to.be.lessThan(10000, 'Complex validation error response should be under 10KB'); + + // Should not have repeated nested error structures + const responseText = JSON.stringify(response.body); + const issuesCount = (responseText.match(/Issues:/g) || []).length; + expect(issuesCount).to.be.lessThan(5, 'Should not have excessive nested Issues in error message'); + }); + }); + + describe('Performance of Large Union Validation', () => { + it('should validate large unions quickly without memory issues', async () => { + // Create data that doesn't match any of many union options + const largeUnionInvalidData = { + largeUnion: { + type: 'unknownType', + value: 'does not match any schema', + }, + }; + + const startTime = Date.now(); + + const response = await request(app) + .post(basePath + '/ValidationTest/LargeUnion') + .send(largeUnionInvalidData) + .expect(400); + + const endTime = Date.now(); + const duration = endTime - startTime; + + // Should complete quickly + expect(duration).to.be.lessThan(1000, 'Validation should complete in under 1 second'); + + // Response should be bounded + const responseSize = JSON.stringify(response.body).length; + expect(responseSize).to.be.lessThan(5000, 'Large union validation error should be under 5KB'); + }); + }); +}); diff --git a/tests/unit/swagger/validationErrors.spec.ts b/tests/unit/swagger/validationErrors.spec.ts new file mode 100644 index 000000000..f32d84566 --- /dev/null +++ b/tests/unit/swagger/validationErrors.spec.ts @@ -0,0 +1,236 @@ +import { expect } from 'chai'; +import 'mocha'; +import { ValidationService, FieldErrors } from '../../../packages/runtime/src/routeGeneration/templateHelpers'; +import { TsoaRoute } from '../../../packages/runtime/src/routeGeneration/tsoa-route'; + +describe('Validation Errors', () => { + describe('Large Union Types', () => { + it('should produce reasonably sized error messages for union validation failures', () => { + // Create a union type with many options + const unionSchema: TsoaRoute.PropertySchema = { + dataType: 'union', + subSchemas: [ + { dataType: 'nestedObjectLiteral', nestedProperties: { type: { dataType: 'string', required: true }, value1: { dataType: 'string', required: true } } }, + { dataType: 'nestedObjectLiteral', nestedProperties: { type: { dataType: 'string', required: true }, value2: { dataType: 'double', required: true } } }, + { dataType: 'nestedObjectLiteral', nestedProperties: { type: { dataType: 'string', required: true }, value3: { dataType: 'boolean', required: true } } }, + { dataType: 'nestedObjectLiteral', nestedProperties: { type: { dataType: 'string', required: true }, value4: { dataType: 'array', array: { dataType: 'string' }, required: true } } }, + { dataType: 'nestedObjectLiteral', nestedProperties: { type: { dataType: 'string', required: true }, value5: { dataType: 'datetime', required: true } } }, + { dataType: 'nestedObjectLiteral', nestedProperties: { type: { dataType: 'string', required: true }, value6: { dataType: 'double', required: true } } }, + { dataType: 'nestedObjectLiteral', nestedProperties: { type: { dataType: 'string', required: true }, value7: { dataType: 'float', required: true } } }, + { dataType: 'nestedObjectLiteral', nestedProperties: { type: { dataType: 'string', required: true }, value8: { dataType: 'integer', required: true } } }, + { dataType: 'nestedObjectLiteral', nestedProperties: { type: { dataType: 'string', required: true }, value9: { dataType: 'long', required: true } } }, + { dataType: 'nestedObjectLiteral', nestedProperties: { type: { dataType: 'string', required: true }, value10: { dataType: 'any', required: true } } }, + ], + required: true, + }; + + const models: TsoaRoute.Models = {}; + const validationService = new ValidationService(models, { noImplicitAdditionalProperties: 'throw-on-extras', bodyCoercion: true }); + const fieldErrors: FieldErrors = {}; + + // Try to validate data that doesn't match any union option + const invalidData = { type: 'unknown', invalidProperty: 'test' }; + validationService.ValidateParam(unionSchema, invalidData, 'testParam', fieldErrors, true, ''); + + // Check the error message + expect(fieldErrors.testParam).to.exist; + const errorMessage = fieldErrors.testParam.message; + + // The error message should be reasonably sized (under 1KB) + expect(errorMessage.length).to.be.lessThan(1000, 'Union validation error message should be under 1KB'); + + // It should contain useful information but not be overly verbose + expect(errorMessage).to.include('Could not match the union'); + }); + }); + + describe('Deep Model Validation', () => { + it('should not have excessive escaping in deep model validation errors', () => { + // Create a deeply nested structure + const deepSchema: TsoaRoute.PropertySchema = { + dataType: 'nestedObjectLiteral', + nestedProperties: { + level1: { + dataType: 'nestedObjectLiteral', + nestedProperties: { + level2: { + dataType: 'nestedObjectLiteral', + nestedProperties: { + level3: { + dataType: 'nestedObjectLiteral', + nestedProperties: { + level4: { + dataType: 'nestedObjectLiteral', + nestedProperties: { + level5: { + dataType: 'string', + required: true, + }, + }, + required: true, + }, + }, + required: true, + }, + }, + required: true, + }, + }, + required: true, + }, + }, + required: true, + }; + + const models: TsoaRoute.Models = {}; + const validationService = new ValidationService(models, { noImplicitAdditionalProperties: 'throw-on-extras', bodyCoercion: true }); + const fieldErrors: FieldErrors = {}; + + // Try to validate data with invalid deep property + const invalidData = { + level1: { + level2: { + level3: { + level4: { + level5: 123, // Should be string + }, + }, + }, + }, + }; + + validationService.ValidateParam(deepSchema, invalidData, 'deepParam', fieldErrors, true, ''); + + // Check that there's no excessive escaping + const errorKey = Object.keys(fieldErrors)[0]; + const errorMessage = JSON.stringify(fieldErrors[errorKey]); + + // Count backslashes - there shouldn't be excessive escaping + const backslashCount = (errorMessage.match(/\\/g) || []).length; + expect(backslashCount).to.be.lessThan(10, 'Should not have excessive backslash escaping'); + }); + }); + + describe('Combined Deep Union Validation', () => { + it('should handle deep models with unions without creating massive error messages', () => { + // Create a complex schema similar to the issue example + const complexSchema: TsoaRoute.PropertySchema = { + dataType: 'union', + subSchemas: [ + { + dataType: 'nestedObjectLiteral', + nestedProperties: { + type: { dataType: 'string', required: true }, + nested: { + dataType: 'union', + subSchemas: [ + { + dataType: 'nestedObjectLiteral', + nestedProperties: { + subType: { dataType: 'string', required: true }, + deepValue: { + dataType: 'nestedObjectLiteral', + nestedProperties: { + level1: { + dataType: 'nestedObjectLiteral', + nestedProperties: { + level2: { dataType: 'string', required: true }, + }, + required: true, + }, + }, + required: true, + }, + }, + }, + { + dataType: 'nestedObjectLiteral', + nestedProperties: { + subType: { dataType: 'string', required: true }, + simpleValue: { dataType: 'double', required: true }, + }, + }, + ], + required: true, + }, + }, + }, + { + dataType: 'nestedObjectLiteral', + nestedProperties: { + type: { dataType: 'string', required: true }, + value: { dataType: 'string', required: true }, + }, + }, + ], + required: true, + }; + + const models: TsoaRoute.Models = {}; + const validationService = new ValidationService(models, { noImplicitAdditionalProperties: 'throw-on-extras', bodyCoercion: true }); + const fieldErrors: FieldErrors = {}; + + // Try to validate data that doesn't match + const invalidData = { + type: 'complex', + nested: { + subType: 'deep', + deepValue: { + level1: { + level2: 123, // Should be string + extraProp: 'should not be here', + }, + }, + anotherExtraProp: 'also should not be here', + }, + }; + + validationService.ValidateParam(complexSchema, invalidData, 'complexParam', fieldErrors, true, ''); + + // Serialize the entire error object + const serializedErrors = JSON.stringify(fieldErrors); + + // The total serialized error should be reasonably sized + expect(serializedErrors.length).to.be.lessThan(5000, 'Total validation error size should be under 5KB'); + + // Should not contain excessive repetition + const issuesMatch = serializedErrors.match(/Issues:/g); + if (issuesMatch) { + expect(issuesMatch.length).to.be.lessThan(3, 'Should not have excessive nested Issues: repetition'); + } + }); + }); + + describe('Error Message Configuration', () => { + it('should respect maximum error size configuration when provided', () => { + // This test will fail initially as the feature doesn't exist yet + const models: TsoaRoute.Models = {}; + const config = { + noImplicitAdditionalProperties: 'throw-on-extras' as const, + bodyCoercion: true, + maxValidationErrorSize: 500, // Limit to 500 characters + }; + + const validationService = new ValidationService(models, config); + + // Create a large union that would normally produce a huge error + const largeUnion: TsoaRoute.PropertySchema = { + dataType: 'union', + subSchemas: Array.from({ length: 50 }, (_, i) => ({ + dataType: 'nestedObjectLiteral', + nestedProperties: { + type: { dataType: 'string', required: true }, + [`value${i}`]: { dataType: 'string', required: true }, + }, + })), + required: true, + }; + + const fieldErrors: FieldErrors = {}; + validationService.ValidateParam(largeUnion, { invalid: 'data' }, 'param', fieldErrors, true, ''); + + const errorMessage = fieldErrors.param?.message || ''; + expect(errorMessage.length).to.be.lessThanOrEqual(500, 'Error message should be truncated to configured max size'); + }); + }); +});