Skip to content

Commit 31ad450

Browse files
committed
feat(core): add tagsSplitDeduplication option for barrel + shared type extraction
Add a new `tagsSplitDeduplication` output option (default: false) that extracts shared infrastructure types (e.g. HTTPStatusCode*) emitted by client header builders into a `common-types.ts` file, then generates a barrel `index.ts` with named re-exports for public types. When enabled (with `indexFiles: true`): - Shared types are collected across all per-tag files and deduplicated - Extracted to `<commonTypesFileName>.ts` (default: 'common-types') - Per-tag files import shared types from the common file - Barrel uses named re-exports for exported types + `export *` for tags When disabled (default): behavior is identical to before — shared types are inlined per-tag, no barrel, no extraction. The header builder API is backward compatible: `ClientHeaderBuilder` return type widens from `string` to `string | HeaderResult`. Existing builders returning `string` continue to work unchanged. Currently only `generateFetchHeader` emits `sharedTypes` (HTTPStatusCode* family). The extraction mechanism is generic — other header builders can adopt it incrementally. Remove stale barrel snapshots from the previous naive implementation (gated on `indexFiles` alone). The barrel now requires both `indexFiles` and `tagsSplitDeduplication` to be `true`. Related design discussion: #3553 Closes #3553
1 parent e768015 commit 31ad450

50 files changed

Lines changed: 216 additions & 104 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

packages/core/src/test-utils/context.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,8 @@ export function createTestContextSpec({
4747
optionsParamRequired: false,
4848
propertySortOrder: PropertySortOrder.SPECIFICATION,
4949
factoryMethods: undefined,
50+
tagsSplitDeduplication: false,
51+
commonTypesFileName: 'common-types',
5052
override: {
5153
title: undefined,
5254
transformer: undefined,

packages/core/src/test-utils/split-modes.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,8 @@ export const createSplitModeOutput = (
7575
unionAddMissingProperties: false,
7676
optionsParamRequired: false,
7777
propertySortOrder: 'Alphabetical',
78+
tagsSplitDeduplication: false,
79+
commonTypesFileName: 'common-types',
7880
override: {
7981
tags: {},
8082
operations: {},

packages/core/src/types.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,8 @@ export interface NormalizedOutputOptions {
6868
optionsParamRequired: boolean;
6969
propertySortOrder: PropertySortOrder;
7070
factoryMethods?: NormalizedFactoryMethodsOptions;
71+
tagsSplitDeduplication: boolean;
72+
commonTypesFileName: string;
7173
}
7274

7375
export interface NormalizedParamsSerializerOptions {
@@ -360,6 +362,8 @@ export interface OutputOptions {
360362
optionsParamRequired?: boolean;
361363
propertySortOrder?: PropertySortOrder;
362364
factoryMethods?: FactoryMethodsOptions;
365+
tagsSplitDeduplication?: boolean;
366+
commonTypesFileName?: string;
363367
}
364368

365369
export interface InputFiltersOptions {
@@ -1396,6 +1400,7 @@ export interface GeneratorTarget {
13961400
paramsSerializer?: GeneratorMutator[];
13971401
paramsFilter?: GeneratorMutator[];
13981402
fetchReviver?: GeneratorMutator[];
1403+
sharedTypes?: SharedTypeDeclaration[];
13991404
}
14001405

14011406
export interface GeneratorTargetFull {
@@ -1409,6 +1414,7 @@ export interface GeneratorTargetFull {
14091414
paramsSerializer?: GeneratorMutator[];
14101415
paramsFilter?: GeneratorMutator[];
14111416
fetchReviver?: GeneratorMutator[];
1417+
sharedTypes?: SharedTypeDeclaration[];
14121418
}
14131419

14141420
export interface GeneratorOperation {
@@ -1507,6 +1513,17 @@ export type ClientExtraFilesBuilder = (
15071513
context: ContextSpec,
15081514
) => Promise<ClientFileBuilder[]>;
15091515

1516+
export interface SharedTypeDeclaration {
1517+
name: string;
1518+
exported: boolean;
1519+
code: string;
1520+
}
1521+
1522+
export type HeaderResult = {
1523+
implementation: string;
1524+
sharedTypes?: SharedTypeDeclaration[];
1525+
};
1526+
15101527
export type ClientHeaderBuilder = (params: {
15111528
title: string;
15121529
isRequestOptions: boolean;
@@ -1520,7 +1537,7 @@ export type ClientHeaderBuilder = (params: {
15201537
tag?: string;
15211538
isDefaultTagBucket?: boolean;
15221539
clientImplementation: string;
1523-
}) => string;
1540+
}) => string | HeaderResult;
15241541

15251542
export type ClientFooterBuilder = (params: {
15261543
noFunction?: boolean | undefined;
@@ -1759,6 +1776,7 @@ export interface GeneratorApiOperations {
17591776
export interface GeneratorClientExtra {
17601777
implementation: string;
17611778
implementationMock: string;
1779+
sharedTypes?: SharedTypeDeclaration[];
17621780
}
17631781

17641782
export type GeneratorClientTitle = (data: {

packages/core/src/writers/split-tags-mode.test.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -320,13 +320,14 @@ describe('writeSplitTagsMode — barrel index.ts at target root (#3553)', () =>
320320
fs.removeSync(tmpDir);
321321
});
322322

323-
it('writes index.ts when indexFiles is true', async () => {
323+
it('writes index.ts when indexFiles and tagsSplitDeduplication are true', async () => {
324324
const target = path.join(tmpDir, 'petstore.ts');
325325
const props = {
326326
...createSplitModeProps(target),
327327
output: createSplitModeOutput(target, {
328328
mode: OutputMode.TAGS_SPLIT,
329329
indexFiles: true,
330+
tagsSplitDeduplication: true,
330331
}),
331332
};
332333

@@ -340,13 +341,32 @@ describe('writeSplitTagsMode — barrel index.ts at target root (#3553)', () =>
340341
expect(content).toContain("export * from './pets/pets'");
341342
});
342343

344+
it('does not write index.ts when tagsSplitDeduplication is false', async () => {
345+
const target = path.join(tmpDir, 'petstore.ts');
346+
const props = {
347+
...createSplitModeProps(target),
348+
output: createSplitModeOutput(target, {
349+
mode: OutputMode.TAGS_SPLIT,
350+
indexFiles: true,
351+
tagsSplitDeduplication: false,
352+
}),
353+
};
354+
355+
const paths = await writeSplitTagsMode({ ...props, needSchema: false });
356+
357+
const indexPath = path.join(tmpDir, 'index.ts');
358+
expect(paths).not.toContain(indexPath);
359+
expect(fs.existsSync(indexPath)).toBe(false);
360+
});
361+
343362
it('does not write index.ts when indexFiles is false', async () => {
344363
const target = path.join(tmpDir, 'petstore.ts');
345364
const props = {
346365
...createSplitModeProps(target),
347366
output: createSplitModeOutput(target, {
348367
mode: OutputMode.TAGS_SPLIT,
349368
indexFiles: false,
369+
tagsSplitDeduplication: true,
350370
}),
351371
};
352372

@@ -364,6 +384,7 @@ describe('writeSplitTagsMode — barrel index.ts at target root (#3553)', () =>
364384
output: createSplitModeOutput(target, {
365385
mode: OutputMode.TAGS_SPLIT,
366386
indexFiles: true,
387+
tagsSplitDeduplication: true,
367388
}),
368389
};
369390

@@ -380,6 +401,7 @@ describe('writeSplitTagsMode — barrel index.ts at target root (#3553)', () =>
380401
output: createSplitModeOutput(target, {
381402
mode: OutputMode.TAGS_SPLIT,
382403
indexFiles: true,
404+
tagsSplitDeduplication: true,
383405
workspace: path.join(tmpDir, 'workspace'),
384406
}),
385407
};
@@ -390,4 +412,22 @@ describe('writeSplitTagsMode — barrel index.ts at target root (#3553)', () =>
390412
expect(paths).not.toContain(indexPath);
391413
expect(fs.existsSync(indexPath)).toBe(false);
392414
});
415+
416+
it('does not write common-types.ts when no shared types are present', async () => {
417+
const target = path.join(tmpDir, 'petstore.ts');
418+
const props = {
419+
...createSplitModeProps(target),
420+
output: createSplitModeOutput(target, {
421+
mode: OutputMode.TAGS_SPLIT,
422+
indexFiles: true,
423+
tagsSplitDeduplication: true,
424+
}),
425+
};
426+
427+
const paths = await writeSplitTagsMode({ ...props, needSchema: false });
428+
429+
const commonTypesPath = path.join(tmpDir, 'common-types.ts');
430+
expect(paths).not.toContain(commonTypesPath);
431+
expect(fs.existsSync(commonTypesPath)).toBe(false);
432+
});
393433
});

packages/core/src/writers/split-tags-mode.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import path from 'node:path';
22

33
import { generateModelsInline, generateMutatorImports } from '../generators';
4-
import { OutputClient, OutputMockType, type WriteModeProps } from '../types';
4+
import {
5+
OutputClient,
6+
OutputMockType,
7+
type SharedTypeDeclaration,
8+
type WriteModeProps,
9+
} from '../types';
510
import {
611
conventionName,
712
getFileInfo,
@@ -72,6 +77,33 @@ export async function writeSplitTagsMode({
7277
a.localeCompare(b),
7378
);
7479

80+
const deduplicationEnabled =
81+
output.tagsSplitDeduplication && !output.workspace;
82+
83+
const collectedSharedTypes: SharedTypeDeclaration[] = [];
84+
const seenSharedTypeNames = new Set<string>();
85+
for (const [, target] of tagEntries) {
86+
if (!target.sharedTypes) continue;
87+
for (const t of target.sharedTypes) {
88+
if (!seenSharedTypeNames.has(t.name)) {
89+
seenSharedTypeNames.add(t.name);
90+
collectedSharedTypes.push(t);
91+
}
92+
}
93+
}
94+
95+
const commonTypesImportExtension = getImportExtension(
96+
extension,
97+
output.tsconfig,
98+
);
99+
const commonTypesBasename = output.commonTypesFileName;
100+
const commonTypesPath = path.join(dirname, commonTypesBasename + extension);
101+
const commonTypesRelativeImport =
102+
'..' +
103+
'/' +
104+
commonTypesBasename +
105+
(deduplicationEnabled ? commonTypesImportExtension : '');
106+
75107
const generatedFilePathsArray = await Promise.all(
76108
tagEntries.map(async ([tag, target]) => {
77109
try {
@@ -90,6 +122,15 @@ export async function writeSplitTagsMode({
90122

91123
let implementationData = header;
92124

125+
if (
126+
deduplicationEnabled &&
127+
target.sharedTypes &&
128+
target.sharedTypes.length > 0
129+
) {
130+
const typeNames = target.sharedTypes.map((t) => t.name).join(', ');
131+
implementationData += `import type { ${typeNames} } from '${commonTypesRelativeImport}';\n`;
132+
}
133+
93134
const importerPath = path.join(dirname, tag, tag + extension);
94135
const schemaCustomImportPath = getSchemasImportPath(output.schemas);
95136
const relativeSchemasPath = output.schemas
@@ -363,24 +404,46 @@ export async function writeSplitTagsMode({
363404
}
364405
}
365406

407+
let commonTypesFilePath: string | undefined;
408+
if (deduplicationEnabled && collectedSharedTypes.length > 0) {
409+
const commonTypesContent =
410+
collectedSharedTypes
411+
.map((t) => `${t.exported ? 'export ' : ''}${t.code}`)
412+
.join('\n') + '\n';
413+
commonTypesFilePath = commonTypesPath;
414+
await writeGeneratedFile(commonTypesPath, commonTypesContent);
415+
}
416+
366417
let indexFilePath: string | undefined;
367-
if (output.indexFiles && !output.workspace && tagEntries.length > 0) {
418+
if (output.indexFiles && deduplicationEnabled && tagEntries.length > 0) {
368419
const importExtension = getImportExtension(
369420
output.fileExtension,
370421
output.tsconfig,
371422
);
372423
const serviceSuffix =
373424
OutputClient.ANGULAR === output.client ? '.service' : '';
374-
const indexContent = tagEntries
425+
426+
const publicSharedTypeNames = collectedSharedTypes
427+
.filter((t) => t.exported)
428+
.map((t) => t.name);
429+
430+
const namedReExports =
431+
publicSharedTypeNames.length > 0
432+
? `export type { ${publicSharedTypeNames.join(', ')} } from './${commonTypesBasename}${importExtension}';\n`
433+
: '';
434+
435+
const tagReExports = tagEntries
375436
.map(([tag]) => {
376437
const tagFile = upath.joinSafe(
377438
'./',
378439
tag,
379440
tag + serviceSuffix + importExtension,
380441
);
381-
return `export * from '${tagFile}'\n`;
442+
return `export * from '${tagFile}';\n`;
382443
})
383444
.join('');
445+
446+
const indexContent = namedReExports + tagReExports;
384447
indexFilePath = path.join(dirname, `index${extension}`);
385448
await writeGeneratedFile(indexFilePath, indexContent);
386449
}
@@ -392,6 +455,7 @@ export async function writeSplitTagsMode({
392455
path.join(mockDir, `index.${ext}${extension}`),
393456
)
394457
: []),
458+
...(commonTypesFilePath ? [commonTypesFilePath] : []),
395459
...(indexFilePath ? [indexFilePath] : []),
396460
...generatedFilePathsArray.flat(),
397461
]),

packages/core/src/writers/target-tags.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,16 @@ export function generateTargetForTags(
251251
clientImplementation: target.implementation,
252252
});
253253

254+
const sharedTypes = header.sharedTypes;
255+
const deduplicationActive =
256+
options.tagsSplitDeduplication && !options.workspace;
257+
const inlinedSharedTypes =
258+
!deduplicationActive && sharedTypes && sharedTypes.length > 0
259+
? sharedTypes
260+
.map((t) => `${t.exported ? 'export ' : ''}${t.code}`)
261+
.join('\n') + '\n\n'
262+
: '';
263+
254264
// Apply the per-tag header/footer wrap to each mock output that has
255265
// accumulated handler entries. Mock outputs without a handler (faker
256266
// only) skip the wrap.
@@ -274,6 +284,7 @@ export function generateTargetForTags(
274284

275285
transformed[tag] = {
276286
implementation:
287+
inlinedSharedTypes +
277288
header.implementation +
278289
target.implementation +
279290
footer.implementation,
@@ -286,6 +297,7 @@ export function generateTargetForTags(
286297
paramsSerializer: target.paramsSerializer,
287298
paramsFilter: target.paramsFilter,
288299
fetchReviver: target.fetchReviver,
300+
sharedTypes: deduplicationActive ? sharedTypes : undefined,
289301
};
290302
}
291303
allTargetTags = transformed;

packages/core/src/writers/target.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ export function generateTarget(
5858
paramsSerializer: [],
5959
paramsFilter: [],
6060
fetchReviver: [],
61+
sharedTypes: [],
6162
};
6263
const operations = Object.values(builder.operations);
6364
for (const [index, operation] of operations.entries()) {
@@ -146,7 +147,15 @@ export function generateTarget(
146147
clientImplementation: target.implementation,
147148
});
148149

149-
target.implementation = header.implementation + target.implementation;
150+
const inlinedSharedTypes =
151+
header.sharedTypes && header.sharedTypes.length > 0
152+
? header.sharedTypes
153+
.map((t) => `${t.exported ? 'export ' : ''}${t.code}`)
154+
.join('\n') + '\n\n'
155+
: '';
156+
157+
target.implementation =
158+
inlinedSharedTypes + header.implementation + target.implementation;
150159

151160
const footer = builder.footer({
152161
outputClient: options.client,

0 commit comments

Comments
 (0)