Skip to content

Commit a8006fc

Browse files
committed
fix(validation): allow circular references
If your request body type has circular references, this causes a maximum call stack exceeded error. This change will fix that. closes #1802
1 parent 9c6e2b2 commit a8006fc

File tree

2 files changed

+289
-1
lines changed

2 files changed

+289
-1
lines changed

packages/runtime/src/routeGeneration/templateHelpers.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ export function ValidateParam(
2020
}
2121

2222
export class ValidationService {
23+
private validationStack: Set<string> = new Set();
24+
2325
constructor(
2426
private readonly models: TsoaRoute.Models,
2527
private readonly config: AdditionalProps,
@@ -87,7 +89,18 @@ export class ValidationService {
8789
return this.validateNestedObjectLiteral(name, value, fieldErrors, isBodyParam, property.nestedProperties, property.additionalProperties, parent);
8890
default:
8991
if (property.ref) {
90-
return this.validateModel({ name, value, modelDefinition: this.models[property.ref], fieldErrors, isBodyParam, parent });
92+
// Detect circular references to prevent stack overflow
93+
const refPath = `${parent}${name}:${property.ref}`;
94+
if (this.validationStack.has(refPath)) {
95+
return value;
96+
}
97+
98+
this.validationStack.add(refPath);
99+
try {
100+
return this.validateModel({ name, value, modelDefinition: this.models[property.ref], fieldErrors, isBodyParam, parent });
101+
} finally {
102+
this.validationStack.delete(refPath);
103+
}
91104
}
92105
return value;
93106
}

tests/unit/swagger/templateHelpers.spec.ts

Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1838,4 +1838,279 @@ describe('ValidationService', () => {
18381838
expect(result).to.be.undefined;
18391839
});
18401840
});
1841+
1842+
describe('Circular reference handling', () => {
1843+
it('should handle self-referencing refAlias without stack overflow', () => {
1844+
const models: TsoaRoute.Models = {
1845+
RecursiveType: {
1846+
dataType: 'refAlias',
1847+
type: { ref: 'RecursiveType' },
1848+
},
1849+
};
1850+
1851+
const v = new ValidationService(models, {
1852+
noImplicitAdditionalProperties: 'ignore',
1853+
bodyCoercion: true,
1854+
});
1855+
1856+
const fieldErrors: FieldErrors = {};
1857+
const value = { some: 'data' };
1858+
1859+
const result = v.validateModel({
1860+
name: '',
1861+
value,
1862+
modelDefinition: models.RecursiveType,
1863+
fieldErrors,
1864+
isBodyParam: true,
1865+
});
1866+
1867+
expect(result).to.deep.equal(value);
1868+
});
1869+
1870+
it('should handle deeply nested circular references', () => {
1871+
const models: TsoaRoute.Models = {
1872+
Widget: {
1873+
dataType: 'refAlias',
1874+
type: {
1875+
dataType: 'union',
1876+
subSchemas: [{ ref: 'Container' }, { ref: 'Wrapper' }],
1877+
},
1878+
},
1879+
Container: {
1880+
dataType: 'refObject',
1881+
properties: {
1882+
type: { dataType: 'string', required: true },
1883+
items: {
1884+
dataType: 'array',
1885+
array: { ref: 'Widget' },
1886+
required: true,
1887+
},
1888+
},
1889+
},
1890+
Wrapper: {
1891+
dataType: 'refObject',
1892+
properties: {
1893+
type: { dataType: 'string', required: true },
1894+
content: {
1895+
ref: 'Widget',
1896+
required: true,
1897+
},
1898+
},
1899+
},
1900+
};
1901+
1902+
const v = new ValidationService(models, {
1903+
noImplicitAdditionalProperties: 'ignore',
1904+
bodyCoercion: true,
1905+
});
1906+
1907+
const fieldErrors: FieldErrors = {};
1908+
1909+
const value = {
1910+
type: 'container',
1911+
items: [
1912+
{
1913+
type: 'wrapper',
1914+
content: {
1915+
type: 'nested-container',
1916+
items: [
1917+
{
1918+
type: 'deeply-nested-wrapper',
1919+
content: {
1920+
type: 'deeply-nested-container',
1921+
items: [],
1922+
},
1923+
},
1924+
],
1925+
},
1926+
},
1927+
],
1928+
};
1929+
1930+
const result = v.validateModel({
1931+
name: '',
1932+
value,
1933+
modelDefinition: models.Container,
1934+
fieldErrors,
1935+
isBodyParam: true,
1936+
});
1937+
1938+
expect(result).to.exist;
1939+
expect(result.type).to.equal('container');
1940+
});
1941+
1942+
it('should handle circular references with arrays', () => {
1943+
const models: TsoaRoute.Models = {
1944+
Node: {
1945+
dataType: 'refObject',
1946+
properties: {
1947+
id: { dataType: 'string', required: true },
1948+
children: {
1949+
dataType: 'array',
1950+
array: { ref: 'Node' },
1951+
required: false,
1952+
},
1953+
},
1954+
},
1955+
};
1956+
1957+
const v = new ValidationService(models, {
1958+
noImplicitAdditionalProperties: 'ignore',
1959+
bodyCoercion: true,
1960+
});
1961+
1962+
const fieldErrors: FieldErrors = {};
1963+
1964+
const value = {
1965+
id: 'root',
1966+
children: [
1967+
{
1968+
id: 'child1',
1969+
children: [
1970+
{
1971+
id: 'grandchild1',
1972+
},
1973+
],
1974+
},
1975+
{
1976+
id: 'child2',
1977+
},
1978+
],
1979+
};
1980+
1981+
const result = v.validateModel({
1982+
name: '',
1983+
value,
1984+
modelDefinition: models.Node,
1985+
fieldErrors,
1986+
isBodyParam: true,
1987+
});
1988+
1989+
expect(result).to.exist;
1990+
expect(result.id).to.equal('root');
1991+
expect(result.children).to.have.lengthOf(2);
1992+
});
1993+
1994+
it('should properly fail validation for invalid data in circular types', () => {
1995+
const models: TsoaRoute.Models = {
1996+
Node: {
1997+
dataType: 'refObject',
1998+
properties: {
1999+
id: { dataType: 'string', required: true },
2000+
value: { dataType: 'integer', required: true },
2001+
children: {
2002+
dataType: 'array',
2003+
array: { ref: 'Node' },
2004+
required: false,
2005+
},
2006+
},
2007+
},
2008+
};
2009+
2010+
const v = new ValidationService(models, {
2011+
noImplicitAdditionalProperties: 'throw-on-extras',
2012+
bodyCoercion: true,
2013+
});
2014+
2015+
const fieldErrors: FieldErrors = {};
2016+
2017+
const value = {
2018+
id: 'root',
2019+
value: 1,
2020+
children: [
2021+
{
2022+
value: 2,
2023+
children: [
2024+
{
2025+
id: 'grandchild1',
2026+
value: 3,
2027+
},
2028+
],
2029+
},
2030+
{
2031+
id: 'child2',
2032+
value: 'not-a-number',
2033+
},
2034+
],
2035+
};
2036+
2037+
const result = v.validateModel({
2038+
name: '',
2039+
value,
2040+
modelDefinition: models.Node,
2041+
fieldErrors,
2042+
isBodyParam: true,
2043+
});
2044+
2045+
expect(Object.keys(fieldErrors)).to.not.be.empty;
2046+
expect(fieldErrors).to.have.property('children.$0.id');
2047+
expect(fieldErrors['children.$0.id'].message).to.include('required');
2048+
expect(fieldErrors).to.have.property('children.$1.value');
2049+
expect(fieldErrors['children.$1.value'].message).to.include('integer');
2050+
expect(result).to.be.undefined;
2051+
});
2052+
2053+
it('should detect validation errors in deeply nested circular structures', () => {
2054+
const models: TsoaRoute.Models = {
2055+
Widget: {
2056+
dataType: 'refAlias',
2057+
type: {
2058+
dataType: 'union',
2059+
subSchemas: [{ ref: 'Container' }, { ref: 'Wrapper' }],
2060+
},
2061+
},
2062+
Container: {
2063+
dataType: 'refObject',
2064+
properties: {
2065+
type: { dataType: 'string', required: true },
2066+
items: {
2067+
dataType: 'array',
2068+
array: { ref: 'Widget' },
2069+
required: true,
2070+
},
2071+
},
2072+
},
2073+
Wrapper: {
2074+
dataType: 'refObject',
2075+
properties: {
2076+
type: { dataType: 'string', required: true },
2077+
content: {
2078+
ref: 'Widget',
2079+
required: true,
2080+
},
2081+
},
2082+
},
2083+
};
2084+
2085+
const v = new ValidationService(models, {
2086+
noImplicitAdditionalProperties: 'throw-on-extras',
2087+
bodyCoercion: true,
2088+
});
2089+
2090+
const fieldErrors: FieldErrors = {};
2091+
2092+
const value = {
2093+
type: 'container',
2094+
items: [
2095+
{
2096+
content: {
2097+
type: 'nested-container',
2098+
items: [],
2099+
},
2100+
},
2101+
],
2102+
};
2103+
2104+
const result = v.validateModel({
2105+
name: '',
2106+
value,
2107+
modelDefinition: models.Container,
2108+
fieldErrors,
2109+
isBodyParam: true,
2110+
});
2111+
2112+
expect(Object.keys(fieldErrors)).to.not.be.empty;
2113+
expect(result).to.be.undefined;
2114+
});
2115+
});
18412116
});

0 commit comments

Comments
 (0)