Skip to content

Commit 6458b2a

Browse files
committed
refactor allof+anyof
Signed-off-by: xil <fridalu66@gmail.com>
1 parent d273415 commit 6458b2a

2 files changed

Lines changed: 130 additions & 110 deletions

File tree

tools/proto-convert/src/SchemaModifier.ts

Lines changed: 58 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ export class SchemaModifier {
2323
this.deduplicateOneOfWithArrayType(schema)
2424
this.collapseSingleItemComposite(schema);
2525
this.normalizeMixedOneOf(schema)
26-
this.hoistAnyOfFromAllOf(schema);
26+
this.normalizeAnyOfInAllOf(schema);
2727
},
2828
onSchema: (schema, schemaName) => {
2929
if (!schema || isReferenceObject(schema)) return;
@@ -34,7 +34,7 @@ export class SchemaModifier {
3434
this.handleOneOfConst(schema, schemaName)
3535
this.deduplicateOneOfWithArrayType(schema)
3636
this.collapseSingleItemComposite(schema);
37-
this.hoistAnyOfFromAllOf(schema);
37+
this.normalizeAnyOfInAllOf(schema);
3838
this.collapseOneOfObjectPropContainsTitleSchema(schema)
3939
this.normalizeMixedOneOf(schema)
4040
this.convertOneOfToMinMaxProperties(schema)
@@ -149,62 +149,78 @@ export class SchemaModifier {
149149
}
150150

151151
/**
152-
* Hoists anyOf from within allOf to the top level and moves all base items into the anyOf.
152+
* When allOf contains an anyOf item whose variants carry `title` fields, replaces
153+
* that anyOf item with a single merged properties object so the generator can
154+
* flatten the entire allOf naturally.
153155
*
154-
* Transforms:
155-
* allOf: [base1, { anyOf: [{ $ref: 'Variant1' }, { $ref: 'Variant2' }] }]
156+
* Only fires when at least one anyOf variant has a title (typed-discriminator pattern).
157+
* Base $refs in allOf are left untouched — the generator flattens them automatically.
156158
*
157-
* Into:
158-
* anyOf: [base1, { $ref: 'Variant1' }, { $ref: 'Variant2' }]
159+
* Input:
160+
* allOf:
161+
* - $ref: '#/components/schemas/AggregateBase'
162+
* - anyOf:
163+
* - { title: lterms, $ref: '#/components/schemas/LongTermsAggregate' }
164+
* - { title: max, $ref: '#/components/schemas/MaxAggregate' }
159165
*
160-
* This prevents OpenAPI Generator from flattening named schema variants while preserving
161-
* inline property alternatives.
166+
* Output:
167+
* allOf:
168+
* - $ref: '#/components/schemas/AggregateBase' # untouched, generator flattens
169+
* - type: object
170+
* properties:
171+
* lterms: { $ref: '#/components/schemas/LongTermsAggregate' }
172+
* max: { $ref: '#/components/schemas/MaxAggregate' }
162173
*/
163-
hoistAnyOfFromAllOf(schema: OpenAPIV3.SchemaObject): void {
174+
normalizeAnyOfInAllOf(schema: OpenAPIV3.SchemaObject): void {
164175
if (!Array.isArray(schema.allOf) || schema.allOf.length === 0) {
165176
return;
166177
}
167178

168-
// Find if any allOf item contains anyOf
169-
let variantItem: OpenAPIV3.SchemaObject | null = null;
170-
const baseItems: Array<OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject> = [];
171-
172-
for (const item of schema.allOf) {
173-
if (!item || typeof item !== 'object') continue;
179+
// Find the anyOf item inside allOf
180+
const anyOfIndex = schema.allOf.findIndex(
181+
item => item && typeof item === 'object' && !('$ref' in item) && Array.isArray((item as OpenAPIV3.SchemaObject).anyOf)
182+
);
174183

175-
const schemaItem = item as OpenAPIV3.SchemaObject;
176-
if ('anyOf' in schemaItem && Array.isArray(schemaItem.anyOf)) {
177-
variantItem = schemaItem;
178-
} else {
179-
baseItems.push(item);
180-
}
184+
if (anyOfIndex === -1) {
185+
return;
181186
}
182187

183-
// If we found anyOf, hoist it and move all items into it
184-
if (variantItem) {
185-
const variants = variantItem.anyOf as Array<OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject>;
188+
const anyOfItem = schema.allOf[anyOfIndex] as OpenAPIV3.SchemaObject;
189+
const variants = anyOfItem.anyOf as Array<OpenAPIV3.ReferenceObject | OpenAPIV3.SchemaObject>;
186190

187-
// Check if at least one variant is a $ref
188-
const hasRefVariants = variants.some(variant => {
189-
if (!variant || typeof variant !== 'object') return false;
190-
return '$ref' in variant;
191-
});
191+
// Only apply when variants carry titles (typed-discriminator pattern)
192+
if (!variants.some(v => (v as any).title)) {
193+
return;
194+
}
192195

193-
// Only apply transformation if variants are $ref (not inline objects)
194-
if (!hasRefVariants) {
195-
logger.info(`Skipping anyOf hoist: variants are inline objects (preserving property alternatives)`);
196-
return;
196+
// Merge variants into a single properties object.
197+
// Use title as the property name if present; otherwise derive from the $ref type name in snake_case.
198+
const properties: Record<string, OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject> = {};
199+
for (const variant of variants) {
200+
const v = variant as any;
201+
let propName: string;
202+
if (v.title) {
203+
propName = v.title;
204+
} else if (v['$ref']) {
205+
const refName = (v['$ref'] as string).split('/').pop() ?? '';
206+
propName = toSnakeCase(refName);
207+
} else {
208+
continue; // no way to derive a name
197209
}
210+
const propSchema: Record<string, any> = {};
211+
for (const key of Object.keys(v)) {
212+
if (key !== 'title') propSchema[key] = v[key];
213+
}
214+
properties[propName] = propSchema;
215+
}
198216

199-
// Combine base items + variant items into a single anyOf array
200-
const newAnyOf = [...baseItems, ...variants];
201-
202-
// Replace the schema with the hoisted structure
203-
delete schema.allOf;
204-
schema.anyOf = newAnyOf;
217+
// Replace the anyOf item in-place with a flat properties object
218+
schema.allOf[anyOfIndex] = {
219+
type: 'object',
220+
properties,
221+
};
205222

206-
logger.info(`Hoisted anyOf from allOf (moved ${baseItems.length} base items + ${variants.length} variants into anyOf)`);
207-
}
223+
logger.info(`normalizeAnyOfInAllOf: replaced anyOf (${variants.length} variants) with merged properties object`);
208224
}
209225

210226
isArraySchemaObject(schema: any): schema is OpenAPIV3.ArraySchemaObject {

tools/proto-convert/test/SchemaModifier.test.ts

Lines changed: 72 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1696,108 +1696,112 @@ describe('SchemaModifier', () => {
16961696
});
16971697
});
16981698

1699-
describe('hoistAnyOfFromAllOf', () => {
1700-
it('should move $ref variants into anyOf array', () => {
1699+
describe('normalizeAnyOfInAllOf', () => {
1700+
it('should replace anyOf item in allOf with merged properties object', () => {
17011701
const doc = createDocument();
17021702
doc.components!.schemas = {
17031703
TestSchema: {
17041704
allOf: [
17051705
{ $ref: '#/components/schemas/BaseSchema' },
17061706
{
17071707
anyOf: [
1708-
{ title: 'variant1', $ref: '#/components/schemas/Variant1' },
1709-
{ title: 'variant2', $ref: '#/components/schemas/Variant2' }
1708+
{ title: 'lterms', $ref: '#/components/schemas/Variant1' },
1709+
{ title: 'max', $ref: '#/components/schemas/Variant2' }
17101710
]
17111711
}
17121712
]
1713-
},
1714-
BaseSchema: { type: 'object' },
1713+
} as any,
1714+
BaseSchema: { type: 'object', properties: { meta: { type: 'string' } } },
17151715
Variant1: { type: 'object' },
17161716
Variant2: { type: 'object' }
17171717
};
17181718

17191719
const modifier = new SchemaModifier(doc) as any;
1720-
const schema = doc.components!.schemas!.TestSchema as OpenAPIV3.SchemaObject;
1720+
const schema = doc.components!.schemas!.TestSchema as any;
1721+
modifier.normalizeAnyOfInAllOf(schema);
17211722

1722-
modifier.hoistAnyOfFromAllOf(schema);
1723+
// allOf is preserved with base ref untouched
1724+
expect(schema.allOf).toBeDefined();
1725+
expect(schema.allOf).toHaveLength(2);
1726+
expect(schema.allOf[0]).toEqual({ $ref: '#/components/schemas/BaseSchema' });
1727+
// anyOf item replaced with merged properties object
1728+
expect(schema.allOf[1].anyOf).toBeUndefined();
1729+
expect(schema.allOf[1].type).toBe('object');
1730+
expect(schema.allOf[1].properties.lterms).toEqual({ $ref: '#/components/schemas/Variant1' });
1731+
expect(schema.allOf[1].properties.max).toEqual({ $ref: '#/components/schemas/Variant2' });
1732+
});
17231733

1724-
expect(schema.allOf).toBeUndefined();
1725-
expect(schema.anyOf).toBeDefined();
1726-
expect(schema.anyOf).toHaveLength(3);
1727-
expect(schema.anyOf![0]).toEqual({ $ref: '#/components/schemas/BaseSchema' });
1728-
expect(schema.anyOf![1]).toEqual({ title: 'variant1', $ref: '#/components/schemas/Variant1' });
1729-
expect(schema.anyOf![2]).toEqual({ title: 'variant2', $ref: '#/components/schemas/Variant2' });
1734+
it('should use type name snake_case for untitled variants mixed with titled ones', () => {
1735+
const doc = createDocument();
1736+
doc.components!.schemas = {
1737+
TestSchema: {
1738+
allOf: [
1739+
{ $ref: '#/components/schemas/BaseSchema' },
1740+
{
1741+
anyOf: [
1742+
{ title: 'lterms', $ref: '#/components/schemas/LongTermsAggregate' },
1743+
{ $ref: '#/components/schemas/MaxAggregate' } // no title
1744+
]
1745+
}
1746+
]
1747+
} as any,
1748+
BaseSchema: { type: 'object' },
1749+
LongTermsAggregate: { type: 'object' },
1750+
MaxAggregate: { type: 'object' }
1751+
};
1752+
1753+
const modifier = new SchemaModifier(doc) as any;
1754+
const schema = doc.components!.schemas!.TestSchema as any;
1755+
modifier.normalizeAnyOfInAllOf(schema);
1756+
1757+
expect(schema.allOf[1].type).toBe('object');
1758+
// titled variant uses its title
1759+
expect(schema.allOf[1].properties.lterms).toEqual({ $ref: '#/components/schemas/LongTermsAggregate' });
1760+
// untitled variant falls back to snake_case of the type name
1761+
expect(schema.allOf[1].properties.max_aggregate).toEqual({ $ref: '#/components/schemas/MaxAggregate' });
17301762
});
17311763

1732-
it('should NOT hoist anyOf when variants are inline objects (like field/script alternatives)', () => {
1764+
it('should not modify when variants have no titles', () => {
17331765
const doc = createDocument();
1734-
doc.components!.schemas!.TermsAggregationFields = {
1766+
const original = {
17351767
allOf: [
1736-
{
1737-
type: 'object',
1738-
properties: {
1739-
collect_mode: { type: 'string' },
1740-
min_doc_count: { type: 'integer' }
1741-
}
1742-
},
1743-
{
1744-
anyOf: [
1745-
{
1746-
type: 'object',
1747-
properties: {
1748-
field: { type: 'string' }
1749-
}
1750-
},
1751-
{
1752-
type: 'object',
1753-
properties: {
1754-
script: { type: 'string' }
1755-
}
1756-
}
1757-
]
1758-
}
1768+
{ $ref: '#/components/schemas/Base' },
1769+
{ anyOf: [{ $ref: '#/components/schemas/V1' }, { $ref: '#/components/schemas/V2' }] }
17591770
]
17601771
};
1772+
doc.components!.schemas!.TestSchema = JSON.parse(JSON.stringify(original)) as any;
17611773

17621774
const modifier = new SchemaModifier(doc) as any;
1763-
const schema = doc.components!.schemas!.TermsAggregationFields as OpenAPIV3.SchemaObject;
1764-
const originalSchema = JSON.parse(JSON.stringify(schema));
1765-
1766-
modifier.hoistAnyOfFromAllOf(schema);
1775+
const schema = doc.components!.schemas!.TestSchema as any;
1776+
modifier.normalizeAnyOfInAllOf(schema);
17671777

1768-
// Should NOT modify schema when variants are inline objects
1769-
expect(schema).toEqual(originalSchema);
1770-
expect(schema.allOf).toBeDefined();
1771-
expect(schema.anyOf).toBeUndefined();
1778+
expect(schema).toEqual(original);
17721779
});
17731780

1774-
it('should hoist even when $ref variants have additional properties like title', () => {
1781+
it('should not modify when allOf has no anyOf item', () => {
17751782
const doc = createDocument();
1776-
doc.components!.schemas = {
1777-
Aggregate: {
1778-
allOf: [
1779-
{ $ref: '#/components/schemas/AggregateBase' },
1780-
{
1781-
anyOf: [
1782-
{ title: 'adjacency_matrix', $ref: '#/components/schemas/AdjacencyMatrixAggregate' },
1783-
{ title: 'avg', $ref: '#/components/schemas/AvgAggregate' }
1784-
]
1785-
}
1786-
]
1787-
},
1788-
AggregateBase: { type: 'object' },
1789-
AdjacencyMatrixAggregate: { type: 'object' },
1790-
AvgAggregate: { type: 'object' }
1791-
};
1783+
doc.components!.schemas!.TestSchema = {
1784+
allOf: [{ $ref: '#/components/schemas/Base' }, { $ref: '#/components/schemas/Base2' }]
1785+
} as any;
17921786

17931787
const modifier = new SchemaModifier(doc) as any;
1794-
const schema = doc.components!.schemas!.Aggregate as OpenAPIV3.SchemaObject;
1788+
const schema = doc.components!.schemas!.TestSchema as any;
1789+
const original = JSON.parse(JSON.stringify(schema));
1790+
modifier.normalizeAnyOfInAllOf(schema);
17951791

1796-
modifier.hoistAnyOfFromAllOf(schema);
1792+
expect(schema).toEqual(original);
1793+
});
1794+
1795+
it('should not modify schema without allOf', () => {
1796+
const doc = createDocument();
1797+
doc.components!.schemas!.TestSchema = { type: 'object', properties: { x: { type: 'string' } } };
1798+
1799+
const modifier = new SchemaModifier(doc) as any;
1800+
const schema = doc.components!.schemas!.TestSchema as any;
1801+
modifier.normalizeAnyOfInAllOf(schema);
17971802

17981803
expect(schema.allOf).toBeUndefined();
1799-
expect(schema.anyOf).toBeDefined();
1800-
expect(schema.anyOf).toHaveLength(3);
1804+
expect(schema.type).toBe('object');
18011805
});
18021806
});
18031807
});

0 commit comments

Comments
 (0)