@workos/oagen-emitters 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.husky/pre-commit +1 -0
- package/.oxfmtrc.json +8 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +129 -0
- package/dist/index.d.mts +10 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +11943 -2728
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/go.md +338 -0
- package/docs/sdk-architecture/php.md +315 -0
- package/docs/sdk-architecture/python.md +511 -0
- package/oagen.config.ts +298 -2
- package/package.json +9 -5
- package/scripts/generate-php.js +13 -0
- package/scripts/git-push-with-published-oagen.sh +21 -0
- package/smoke/sdk-dotnet.ts +17 -3
- package/smoke/sdk-elixir.ts +17 -3
- package/smoke/sdk-go.ts +137 -46
- package/smoke/sdk-kotlin.ts +23 -4
- package/smoke/sdk-node.ts +15 -3
- package/smoke/sdk-php.ts +28 -26
- package/smoke/sdk-python.ts +5 -2
- package/smoke/sdk-ruby.ts +17 -3
- package/smoke/sdk-rust.ts +16 -3
- package/src/go/client.ts +141 -0
- package/src/go/enums.ts +196 -0
- package/src/go/fixtures.ts +212 -0
- package/src/go/index.ts +81 -0
- package/src/go/manifest.ts +36 -0
- package/src/go/models.ts +254 -0
- package/src/go/naming.ts +191 -0
- package/src/go/resources.ts +827 -0
- package/src/go/tests.ts +751 -0
- package/src/go/type-map.ts +82 -0
- package/src/go/wrappers.ts +261 -0
- package/src/index.ts +3 -0
- package/src/node/client.ts +167 -122
- package/src/node/enums.ts +13 -4
- package/src/node/errors.ts +42 -233
- package/src/node/field-plan.ts +726 -0
- package/src/node/fixtures.ts +15 -5
- package/src/node/index.ts +65 -16
- package/src/node/models.ts +264 -96
- package/src/node/naming.ts +52 -25
- package/src/node/resources.ts +621 -172
- package/src/node/sdk-errors.ts +41 -0
- package/src/node/tests.ts +71 -27
- package/src/node/type-map.ts +4 -2
- package/src/node/utils.ts +56 -64
- package/src/node/wrappers.ts +151 -0
- package/src/php/client.ts +171 -0
- package/src/php/enums.ts +67 -0
- package/src/php/errors.ts +9 -0
- package/src/php/fixtures.ts +181 -0
- package/src/php/index.ts +96 -0
- package/src/php/manifest.ts +36 -0
- package/src/php/models.ts +310 -0
- package/src/php/naming.ts +298 -0
- package/src/php/resources.ts +561 -0
- package/src/php/tests.ts +533 -0
- package/src/php/type-map.ts +90 -0
- package/src/php/utils.ts +18 -0
- package/src/php/wrappers.ts +151 -0
- package/src/python/client.ts +337 -0
- package/src/python/enums.ts +313 -0
- package/src/python/fixtures.ts +196 -0
- package/src/python/index.ts +95 -0
- package/src/python/manifest.ts +38 -0
- package/src/python/models.ts +688 -0
- package/src/python/naming.ts +209 -0
- package/src/python/resources.ts +1322 -0
- package/src/python/tests.ts +1335 -0
- package/src/python/type-map.ts +93 -0
- package/src/python/wrappers.ts +191 -0
- package/src/shared/model-utils.ts +255 -0
- package/src/shared/naming-utils.ts +107 -0
- package/src/shared/non-spec-services.ts +54 -0
- package/src/shared/resolved-ops.ts +109 -0
- package/src/shared/wrapper-utils.ts +59 -0
- package/test/go/client.test.ts +92 -0
- package/test/go/enums.test.ts +132 -0
- package/test/go/errors.test.ts +9 -0
- package/test/go/models.test.ts +265 -0
- package/test/go/resources.test.ts +408 -0
- package/test/go/tests.test.ts +143 -0
- package/test/node/client.test.ts +199 -94
- package/test/node/enums.test.ts +75 -3
- package/test/node/errors.test.ts +2 -41
- package/test/node/models.test.ts +109 -20
- package/test/node/naming.test.ts +37 -4
- package/test/node/resources.test.ts +662 -30
- package/test/node/serializers.test.ts +36 -7
- package/test/node/type-map.test.ts +11 -0
- package/test/php/client.test.ts +94 -0
- package/test/php/enums.test.ts +173 -0
- package/test/php/errors.test.ts +9 -0
- package/test/php/models.test.ts +497 -0
- package/test/php/resources.test.ts +644 -0
- package/test/php/tests.test.ts +118 -0
- package/test/python/client.test.ts +200 -0
- package/test/python/enums.test.ts +228 -0
- package/test/python/errors.test.ts +16 -0
- package/test/python/manifest.test.ts +74 -0
- package/test/python/models.test.ts +716 -0
- package/test/python/resources.test.ts +617 -0
- package/test/python/tests.test.ts +202 -0
- package/src/node/common.ts +0 -273
- package/src/node/config.ts +0 -71
- package/src/node/serializers.ts +0 -744
package/src/node/fixtures.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Model, TypeRef, Enum, EmitterContext } from '@workos/oagen';
|
|
2
|
-
import { wireFieldName, fileName,
|
|
2
|
+
import { wireFieldName, fileName, resolveServiceDir } from './naming.js';
|
|
3
3
|
import { resolveResourceClassName } from './resources.js';
|
|
4
4
|
import { createServiceDirResolver, assignModelsToServices, isListMetadataModel, isListWrapperModel } from './utils.js';
|
|
5
5
|
|
|
@@ -42,12 +42,13 @@ export function generateFixtures(
|
|
|
42
42
|
? createServiceDirResolver(spec.models, ctx.spec.services, ctx)
|
|
43
43
|
: {
|
|
44
44
|
modelToService: assignModelsToServices(spec.models, spec.services),
|
|
45
|
-
resolveDir: (irService: string | undefined) => (irService ?
|
|
45
|
+
resolveDir: (irService: string | undefined) => (irService ? resolveServiceDir(irService) : 'common'),
|
|
46
46
|
};
|
|
47
47
|
const modelMap = new Map(spec.models.map((m) => [m.name, m]));
|
|
48
48
|
const enumMap = new Map(spec.enums.map((e) => [e.name, e]));
|
|
49
49
|
const files: { path: string; content: string }[] = [];
|
|
50
50
|
|
|
51
|
+
const seenFixturePaths = new Set<string>();
|
|
51
52
|
for (const model of spec.models) {
|
|
52
53
|
// Skip redundant list-metadata and list-wrapper models (handled by shared types)
|
|
53
54
|
if (isListMetadataModel(model)) continue;
|
|
@@ -55,10 +56,17 @@ export function generateFixtures(
|
|
|
55
56
|
|
|
56
57
|
const service = modelToService.get(model.name);
|
|
57
58
|
const dirName = resolveDir(service);
|
|
59
|
+
const fixturePath = `src/${dirName}/fixtures/${fileName(model.name)}.fixture.json`;
|
|
60
|
+
|
|
61
|
+
// After noise suffix stripping, multiple models may resolve to the same
|
|
62
|
+
// fixture path (e.g., OrganizationDto and Organization). Skip duplicates.
|
|
63
|
+
if (seenFixturePaths.has(fixturePath)) continue;
|
|
64
|
+
seenFixturePaths.add(fixturePath);
|
|
65
|
+
|
|
58
66
|
const fixture = generateModelFixture(model, modelMap, enumMap);
|
|
59
67
|
|
|
60
68
|
files.push({
|
|
61
|
-
path:
|
|
69
|
+
path: fixturePath,
|
|
62
70
|
content: JSON.stringify(fixture, null, 2),
|
|
63
71
|
});
|
|
64
72
|
}
|
|
@@ -66,7 +74,7 @@ export function generateFixtures(
|
|
|
66
74
|
// Generate list fixtures for models that appear in paginated responses
|
|
67
75
|
for (const service of spec.services) {
|
|
68
76
|
const resolvedName = ctx ? resolveResourceClassName(service, ctx) : service.name;
|
|
69
|
-
const serviceDir =
|
|
77
|
+
const serviceDir = resolveServiceDir(resolvedName);
|
|
70
78
|
for (const op of service.operations) {
|
|
71
79
|
if (op.pagination) {
|
|
72
80
|
let itemModel = op.pagination.itemType.kind === 'model' ? modelMap.get(op.pagination.itemType.name) : null;
|
|
@@ -175,7 +183,9 @@ function generateFieldValue(
|
|
|
175
183
|
}
|
|
176
184
|
return null;
|
|
177
185
|
case 'map':
|
|
178
|
-
return {
|
|
186
|
+
return {
|
|
187
|
+
key: generateFieldValue(ref.valueType, 'value', modelName, modelMap, enumMap),
|
|
188
|
+
};
|
|
179
189
|
}
|
|
180
190
|
}
|
|
181
191
|
|
package/src/node/index.ts
CHANGED
|
@@ -1,41 +1,55 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Emitter,
|
|
3
|
+
EmitterContext,
|
|
4
|
+
FormatCommand,
|
|
5
|
+
GeneratedFile,
|
|
6
|
+
ApiSpec,
|
|
7
|
+
Model,
|
|
8
|
+
Enum,
|
|
9
|
+
Service,
|
|
10
|
+
} from '@workos/oagen';
|
|
11
|
+
import * as fs from 'node:fs';
|
|
12
|
+
import * as path from 'node:path';
|
|
2
13
|
|
|
3
|
-
import {
|
|
14
|
+
import { generateModelsAndSerializers } from './models.js';
|
|
4
15
|
import { generateEnums } from './enums.js';
|
|
5
|
-
import { generateSerializers } from './serializers.js';
|
|
6
16
|
import { generateResources } from './resources.js';
|
|
7
17
|
import { generateClient } from './client.js';
|
|
8
18
|
import { generateErrors } from './errors.js';
|
|
9
|
-
import { generateConfig } from './config.js';
|
|
10
|
-
import { generateCommon } from './common.js';
|
|
11
19
|
import { generateTests } from './tests.js';
|
|
12
20
|
import { generateManifest } from './manifest.js';
|
|
13
21
|
|
|
22
|
+
/** Ensure every generated file's content ends with a trailing newline. */
|
|
23
|
+
function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
|
|
24
|
+
for (const f of files) {
|
|
25
|
+
if (f.content && !f.content.endsWith('\n')) {
|
|
26
|
+
f.content += '\n';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return files;
|
|
30
|
+
}
|
|
31
|
+
|
|
14
32
|
export const nodeEmitter: Emitter = {
|
|
15
33
|
language: 'node',
|
|
16
34
|
|
|
17
35
|
generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
18
|
-
return
|
|
36
|
+
return ensureTrailingNewlines(generateModelsAndSerializers(models, ctx));
|
|
19
37
|
},
|
|
20
38
|
|
|
21
39
|
generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
|
|
22
|
-
return generateEnums(enums, ctx);
|
|
40
|
+
return ensureTrailingNewlines(generateEnums(enums, ctx));
|
|
23
41
|
},
|
|
24
42
|
|
|
25
43
|
generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
26
|
-
return generateResources(services, ctx);
|
|
44
|
+
return ensureTrailingNewlines(generateResources(services, ctx));
|
|
27
45
|
},
|
|
28
46
|
|
|
29
47
|
generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
30
|
-
return generateClient(spec, ctx);
|
|
48
|
+
return ensureTrailingNewlines(generateClient(spec, ctx));
|
|
31
49
|
},
|
|
32
50
|
|
|
33
51
|
generateErrors(ctx: EmitterContext): GeneratedFile[] {
|
|
34
|
-
return generateErrors(ctx);
|
|
35
|
-
},
|
|
36
|
-
|
|
37
|
-
generateConfig(_ctx: EmitterContext): GeneratedFile[] {
|
|
38
|
-
return [...generateConfig(), ...generateCommon()];
|
|
52
|
+
return ensureTrailingNewlines(generateErrors(ctx));
|
|
39
53
|
},
|
|
40
54
|
|
|
41
55
|
generateTypeSignatures(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile[] {
|
|
@@ -44,14 +58,49 @@ export const nodeEmitter: Emitter = {
|
|
|
44
58
|
},
|
|
45
59
|
|
|
46
60
|
generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
47
|
-
return generateTests(spec, ctx);
|
|
61
|
+
return ensureTrailingNewlines(generateTests(spec, ctx));
|
|
48
62
|
},
|
|
49
63
|
|
|
50
64
|
generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
|
|
51
|
-
return generateManifest(spec, ctx);
|
|
65
|
+
return ensureTrailingNewlines(generateManifest(spec, ctx));
|
|
52
66
|
},
|
|
53
67
|
|
|
54
68
|
fileHeader(): string {
|
|
55
69
|
return '// This file is auto-generated by oagen. Do not edit.';
|
|
56
70
|
},
|
|
71
|
+
|
|
72
|
+
formatCommand(targetDir: string): FormatCommand | null {
|
|
73
|
+
const hasPrettier = fs.existsSync(path.join(targetDir, '.prettierrc'));
|
|
74
|
+
const hasEslint =
|
|
75
|
+
fs.existsSync(path.join(targetDir, 'eslint.config.mjs')) ||
|
|
76
|
+
fs.existsSync(path.join(targetDir, 'eslint.config.js')) ||
|
|
77
|
+
fs.existsSync(path.join(targetDir, '.eslintrc.json')) ||
|
|
78
|
+
fs.existsSync(path.join(targetDir, '.eslintrc.js'));
|
|
79
|
+
|
|
80
|
+
if (hasPrettier && hasEslint) {
|
|
81
|
+
// Chain ESLint autofix (e.g. unused-import removal) then prettier.
|
|
82
|
+
// ESLint errors are suppressed so formatting still runs on lint failure.
|
|
83
|
+
return {
|
|
84
|
+
cmd: 'bash',
|
|
85
|
+
args: [
|
|
86
|
+
'-c',
|
|
87
|
+
'npx eslint --fix --no-error-on-unmatched-pattern "$@" 2>/dev/null; npx prettier --write --log-level silent "$@"',
|
|
88
|
+
'--',
|
|
89
|
+
],
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (hasPrettier) {
|
|
93
|
+
return {
|
|
94
|
+
cmd: 'npx',
|
|
95
|
+
args: ['prettier', '--write', '--log-level', 'silent'],
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
if (hasEslint) {
|
|
99
|
+
return {
|
|
100
|
+
cmd: 'npx',
|
|
101
|
+
args: ['eslint', '--fix', '--no-error-on-unmatched-pattern'],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
return null;
|
|
105
|
+
},
|
|
57
106
|
};
|
package/src/node/models.ts
CHANGED
|
@@ -14,8 +14,16 @@ import {
|
|
|
14
14
|
isListMetadataModel,
|
|
15
15
|
isListWrapperModel,
|
|
16
16
|
buildDeduplicationMap,
|
|
17
|
+
relativeImport,
|
|
17
18
|
} from './utils.js';
|
|
18
19
|
import { assignEnumsToServices } from './enums.js';
|
|
20
|
+
import {
|
|
21
|
+
renderSerializerTypeParams,
|
|
22
|
+
buildSerializerImports,
|
|
23
|
+
buildSkipFormatFields,
|
|
24
|
+
shouldSkipSerializeForModel,
|
|
25
|
+
emitSerializerBody,
|
|
26
|
+
} from './field-plan.js';
|
|
19
27
|
|
|
20
28
|
/**
|
|
21
29
|
* Detect baseline interfaces that are generic (have type parameters) even though
|
|
@@ -58,26 +66,21 @@ function enrichGenericDefaultsFromBaseline(
|
|
|
58
66
|
}
|
|
59
67
|
}
|
|
60
68
|
|
|
61
|
-
export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
69
|
+
export function generateModels(models: Model[], ctx: EmitterContext, shared?: SharedModelContext): GeneratedFile[] {
|
|
62
70
|
if (models.length === 0) return [];
|
|
63
71
|
|
|
64
|
-
const {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
// (e.g., Profile<CustomAttributesType>). Detect these by checking if any
|
|
73
|
-
// field type contains a PascalCase name that isn't a known model, enum, or builtin.
|
|
74
|
-
enrichGenericDefaultsFromBaseline(genericDefaults, models, ctx, resolveDir, modelToService);
|
|
72
|
+
const {
|
|
73
|
+
modelToService,
|
|
74
|
+
resolveDir,
|
|
75
|
+
useStringDates,
|
|
76
|
+
dedup: sharedDedup,
|
|
77
|
+
genericDefaults: sharedDefaults,
|
|
78
|
+
} = shared ?? buildSharedContext(models, ctx);
|
|
79
|
+
const genericDefaults = sharedDefaults;
|
|
75
80
|
const typeRefOpts = useStringDates ? { stringDates: true, genericDefaults } : { genericDefaults };
|
|
76
81
|
const wireTypeRefOpts = { genericDefaults };
|
|
77
82
|
const files: GeneratedFile[] = [];
|
|
78
|
-
|
|
79
|
-
// Detect structurally identical or same-name models — emit type aliases for duplicates
|
|
80
|
-
const dedup = buildDeduplicationMap(models, ctx);
|
|
83
|
+
const dedup = sharedDedup;
|
|
81
84
|
|
|
82
85
|
for (const model of models) {
|
|
83
86
|
// Fix #4: Skip per-domain ListMetadata interfaces — the shared ListMetadata type covers these
|
|
@@ -90,14 +93,28 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
90
93
|
// emit a type alias instead of a full interface.
|
|
91
94
|
const canonicalName = dedup.get(model.name);
|
|
92
95
|
if (canonicalName) {
|
|
93
|
-
const domainName = resolveInterfaceName(model.name, ctx);
|
|
94
|
-
const responseName = wireInterfaceName(domainName);
|
|
95
|
-
const canonDomainName = resolveInterfaceName(canonicalName, ctx);
|
|
96
|
-
const canonResponseName = wireInterfaceName(canonDomainName);
|
|
97
96
|
const service = modelToService.get(model.name);
|
|
98
97
|
const dirName = resolveDir(service);
|
|
98
|
+
|
|
99
|
+
// Skip typeAlias resolution for dedup models. The canonical file may
|
|
100
|
+
// be preserved (skipIfExists) and still export its raw name, so the
|
|
101
|
+
// import names must match the raw exports, not resolved aliases.
|
|
102
|
+
const skipTA = { skipTypeAlias: true };
|
|
103
|
+
const domainName = resolveInterfaceName(model.name, ctx, skipTA);
|
|
104
|
+
const responseName = wireInterfaceName(domainName);
|
|
105
|
+
const canonDomainName = resolveInterfaceName(canonicalName, ctx, skipTA);
|
|
106
|
+
const canonResponseName = wireInterfaceName(canonDomainName);
|
|
107
|
+
|
|
99
108
|
const canonService = modelToService.get(canonicalName);
|
|
100
109
|
const canonDir = resolveDir(canonService);
|
|
110
|
+
|
|
111
|
+
// After noise suffix stripping (e.g., "OrganizationDto" → "Organization"),
|
|
112
|
+
// the alias and canonical may resolve to the same file path or the same
|
|
113
|
+
// type names. Skip — the canonical file already provides the types.
|
|
114
|
+
const aliasPath = `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
115
|
+
const canonPath = `src/${canonDir}/interfaces/${fileName(canonicalName)}.interface.ts`;
|
|
116
|
+
if (aliasPath === canonPath) continue;
|
|
117
|
+
if (domainName === canonDomainName) continue;
|
|
101
118
|
const canonRelPath =
|
|
102
119
|
canonDir === dirName
|
|
103
120
|
? `./${fileName(canonicalName)}.interface`
|
|
@@ -109,7 +126,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
109
126
|
`export type ${responseName} = ${canonResponseName};`,
|
|
110
127
|
];
|
|
111
128
|
files.push({
|
|
112
|
-
path:
|
|
129
|
+
path: aliasPath,
|
|
113
130
|
content: aliasLines.join('\n'),
|
|
114
131
|
skipIfExists: true,
|
|
115
132
|
});
|
|
@@ -118,7 +135,11 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
118
135
|
|
|
119
136
|
const service = modelToService.get(model.name);
|
|
120
137
|
const dirName = resolveDir(service);
|
|
121
|
-
|
|
138
|
+
// If this model is a dedup canonical (other models alias to it), skip
|
|
139
|
+
// typeAlias resolution so the file exports the raw name. Dedup aliases
|
|
140
|
+
// import using the raw name to stay consistent with preserved files.
|
|
141
|
+
const isDedupCanonical = [...dedup.values()].includes(model.name);
|
|
142
|
+
const domainName = resolveInterfaceName(model.name, ctx, isDedupCanonical ? { skipTypeAlias: true } : undefined);
|
|
122
143
|
const responseName = wireInterfaceName(domainName);
|
|
123
144
|
const deps = collectFieldDependencies(model);
|
|
124
145
|
const lines: string[] = [];
|
|
@@ -252,87 +273,98 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
252
273
|
if (model.description) {
|
|
253
274
|
lines.push(...docComment(model.description));
|
|
254
275
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
if (field.readOnly
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
276
|
+
if (model.fields.length === 0) {
|
|
277
|
+
lines.push(`export type ${domainName}${typeParams} = object;`);
|
|
278
|
+
} else {
|
|
279
|
+
lines.push(`export interface ${domainName}${typeParams} {`);
|
|
280
|
+
for (const field of model.fields) {
|
|
281
|
+
const domainFieldName = fieldName(field.name);
|
|
282
|
+
if (seenDomainFields.has(domainFieldName)) continue;
|
|
283
|
+
seenDomainFields.add(domainFieldName);
|
|
284
|
+
if (field.description || field.deprecated || field.readOnly || field.writeOnly || field.default !== undefined) {
|
|
285
|
+
const parts: string[] = [];
|
|
286
|
+
if (field.description) parts.push(field.description);
|
|
287
|
+
if (field.readOnly) parts.push('@readonly');
|
|
288
|
+
if (field.writeOnly) parts.push('@writeonly');
|
|
289
|
+
if (field.default !== undefined) parts.push(`@default ${JSON.stringify(field.default)}`);
|
|
290
|
+
if (field.deprecated) parts.push('@deprecated');
|
|
291
|
+
lines.push(...docComment(parts.join('\n'), 2));
|
|
292
|
+
}
|
|
293
|
+
const baselineField = baselineDomain?.fields?.[domainFieldName];
|
|
294
|
+
// For the domain interface, also check that the response baseline's optionality
|
|
295
|
+
// is compatible — the serializer reads from the response type and assigns to the domain type.
|
|
296
|
+
// If the domain baseline says required but the response baseline says optional,
|
|
297
|
+
// the serializer would produce T | undefined for a field expecting T.
|
|
298
|
+
const domainWireField = wireFieldName(field.name);
|
|
299
|
+
const responseBaselineField = baselineResponse?.fields?.[domainWireField];
|
|
300
|
+
const domainResponseOptionalMismatch =
|
|
301
|
+
baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
|
|
302
|
+
const readonlyPrefix = field.readOnly ? 'readonly ' : '';
|
|
303
|
+
if (
|
|
304
|
+
baselineField &&
|
|
305
|
+
!domainResponseOptionalMismatch &&
|
|
306
|
+
baselineTypeResolvable(baselineField.type, importableNames) &&
|
|
307
|
+
baselineFieldCompatible(baselineField, field)
|
|
308
|
+
) {
|
|
309
|
+
const opt = baselineField.optional ? '?' : '';
|
|
310
|
+
lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${baselineField.type};`);
|
|
311
|
+
} else {
|
|
312
|
+
// When a baseline exists for this model, new fields (not present in the
|
|
313
|
+
// baseline) are generated as optional. The merger can deep-merge new
|
|
314
|
+
// fields into existing interfaces, but it cannot update existing
|
|
315
|
+
// deserializer function bodies. Making the field optional prevents a
|
|
316
|
+
// type error where the interface requires a field that the preserved
|
|
317
|
+
// deserializer never populates.
|
|
318
|
+
const isNewFieldOnExistingModel = baselineDomain && !baselineField;
|
|
319
|
+
// Also make the field optional when the response baseline has it as optional
|
|
320
|
+
// but the domain baseline has it as required — the deserializer reads from
|
|
321
|
+
// the response type, so if the response field is optional, the domain value
|
|
322
|
+
// may be undefined.
|
|
323
|
+
// Additionally, when a baseline exists for the RESPONSE interface but NOT the
|
|
324
|
+
// domain interface, fields that are new on the response baseline become optional
|
|
325
|
+
// in the wire type. The domain type must also be optional to match, otherwise
|
|
326
|
+
// the deserializer produces T | undefined for a field typed as T.
|
|
327
|
+
const isNewFieldOnExistingResponse = !baselineDomain && baselineResponse && !responseBaselineField;
|
|
328
|
+
const opt =
|
|
329
|
+
!field.required ||
|
|
330
|
+
isNewFieldOnExistingModel ||
|
|
331
|
+
domainResponseOptionalMismatch ||
|
|
332
|
+
isNewFieldOnExistingResponse
|
|
333
|
+
? '?'
|
|
334
|
+
: '';
|
|
335
|
+
lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${mapTypeRef(field.type, modelTypeRefOpts)};`);
|
|
336
|
+
}
|
|
309
337
|
}
|
|
310
|
-
|
|
311
|
-
|
|
338
|
+
lines.push('}');
|
|
339
|
+
} // close else for non-empty domain interface
|
|
312
340
|
lines.push('');
|
|
313
341
|
|
|
314
342
|
// Wire/response interface (snake_case fields) — deduplicate by snake_case name
|
|
315
343
|
const seenWireFields = new Set<string>();
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
344
|
+
if (model.fields.length === 0) {
|
|
345
|
+
lines.push(`export type ${responseName}${typeParams} = object;`);
|
|
346
|
+
} else {
|
|
347
|
+
lines.push(`export interface ${responseName}${typeParams} {`);
|
|
348
|
+
for (const field of model.fields) {
|
|
349
|
+
const wireField = wireFieldName(field.name);
|
|
350
|
+
if (seenWireFields.has(wireField)) continue;
|
|
351
|
+
seenWireFields.add(wireField);
|
|
352
|
+
const baselineField = baselineResponse?.fields?.[wireField];
|
|
353
|
+
if (
|
|
354
|
+
baselineField &&
|
|
355
|
+
baselineTypeResolvable(baselineField.type, importableNames) &&
|
|
356
|
+
baselineFieldCompatible(baselineField, field)
|
|
357
|
+
) {
|
|
358
|
+
const opt = baselineField.optional ? '?' : '';
|
|
359
|
+
lines.push(` ${wireField}${opt}: ${baselineField.type};`);
|
|
360
|
+
} else {
|
|
361
|
+
const isNewFieldOnExistingModel = baselineResponse && !baselineField;
|
|
362
|
+
const opt = !field.required || isNewFieldOnExistingModel ? '?' : '';
|
|
363
|
+
lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
|
|
364
|
+
}
|
|
333
365
|
}
|
|
334
|
-
|
|
335
|
-
|
|
366
|
+
lines.push('}');
|
|
367
|
+
} // close else for non-empty wire interface
|
|
336
368
|
|
|
337
369
|
files.push({
|
|
338
370
|
path: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
|
|
@@ -437,3 +469,139 @@ function renderTypeParams(model: Model, genericDefaults?: Map<string, string>):
|
|
|
437
469
|
});
|
|
438
470
|
return `<${params.join(', ')}>`;
|
|
439
471
|
}
|
|
472
|
+
|
|
473
|
+
// ---------------------------------------------------------------------------
|
|
474
|
+
// Shared context — computed once and reused by interface + serializer passes
|
|
475
|
+
// ---------------------------------------------------------------------------
|
|
476
|
+
|
|
477
|
+
interface SharedModelContext {
|
|
478
|
+
modelToService: Map<string, string>;
|
|
479
|
+
resolveDir: (irService: string | undefined) => string;
|
|
480
|
+
useStringDates: boolean;
|
|
481
|
+
dedup: Map<string, string>;
|
|
482
|
+
genericDefaults: Map<string, string>;
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function buildSharedContext(models: Model[], ctx: EmitterContext): SharedModelContext {
|
|
486
|
+
const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
|
|
487
|
+
const useStringDates = detectStringDateConvention(models, ctx);
|
|
488
|
+
const genericDefaults = buildGenericModelDefaults(ctx.spec.models);
|
|
489
|
+
enrichGenericDefaultsFromBaseline(genericDefaults, models, ctx, resolveDir, modelToService);
|
|
490
|
+
const dedup = buildDeduplicationMap(models, ctx);
|
|
491
|
+
return { modelToService, resolveDir, useStringDates, dedup, genericDefaults };
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---------------------------------------------------------------------------
|
|
495
|
+
// Serializer file generation (moved from serializers.ts)
|
|
496
|
+
// ---------------------------------------------------------------------------
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Generate serializer files for all models.
|
|
500
|
+
* Can accept pre-computed shared context to avoid duplicating work
|
|
501
|
+
* when called alongside generateModels.
|
|
502
|
+
*/
|
|
503
|
+
export function generateSerializers(
|
|
504
|
+
models: Model[],
|
|
505
|
+
ctx: EmitterContext,
|
|
506
|
+
shared?: SharedModelContext,
|
|
507
|
+
): GeneratedFile[] {
|
|
508
|
+
if (models.length === 0) return [];
|
|
509
|
+
|
|
510
|
+
const { modelToService, resolveDir, useStringDates, dedup } = shared ?? buildSharedContext(models, ctx);
|
|
511
|
+
const files: GeneratedFile[] = [];
|
|
512
|
+
const skippedSerializeModels = new Set<string>();
|
|
513
|
+
|
|
514
|
+
for (const model of models) {
|
|
515
|
+
if (isListMetadataModel(model)) continue;
|
|
516
|
+
if (isListWrapperModel(model)) continue;
|
|
517
|
+
|
|
518
|
+
// Deduplication: for structurally identical models, re-export the canonical serializer
|
|
519
|
+
const canonicalName = dedup.get(model.name);
|
|
520
|
+
if (canonicalName) {
|
|
521
|
+
const service = modelToService.get(model.name);
|
|
522
|
+
const dirName = resolveDir(service);
|
|
523
|
+
// Skip typeAlias resolution for dedup serializers (same reason as interfaces).
|
|
524
|
+
const skipTA = { skipTypeAlias: true };
|
|
525
|
+
const domainName = resolveInterfaceName(model.name, ctx, skipTA);
|
|
526
|
+
const canonDomainName = resolveInterfaceName(canonicalName, ctx, skipTA);
|
|
527
|
+
|
|
528
|
+
const canonService = modelToService.get(canonicalName);
|
|
529
|
+
const canonDir = resolveDir(canonService);
|
|
530
|
+
const serializerPath = `src/${dirName}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
531
|
+
const canonSerializerPath = `src/${canonDir}/serializers/${fileName(canonicalName)}.serializer.ts`;
|
|
532
|
+
|
|
533
|
+
// After noise suffix stripping, alias and canonical may resolve to the
|
|
534
|
+
// same serializer path or the same function names. Skip — the canonical
|
|
535
|
+
// serializer already provides the functions.
|
|
536
|
+
if (serializerPath === canonSerializerPath) continue;
|
|
537
|
+
if (domainName === canonDomainName) continue;
|
|
538
|
+
const rel = relativeImport(serializerPath, canonSerializerPath);
|
|
539
|
+
files.push({
|
|
540
|
+
path: serializerPath,
|
|
541
|
+
content: `export { deserialize${canonDomainName} as deserialize${domainName}, serialize${canonDomainName} as serialize${domainName} } from '${rel}';`,
|
|
542
|
+
overwriteExisting: true,
|
|
543
|
+
});
|
|
544
|
+
continue;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
const service = modelToService.get(model.name);
|
|
548
|
+
const dirName = resolveDir(service);
|
|
549
|
+
const isDedupCanonical = [...dedup.values()].includes(model.name);
|
|
550
|
+
const domainName = resolveInterfaceName(model.name, ctx, isDedupCanonical ? { skipTypeAlias: true } : undefined);
|
|
551
|
+
const responseName = wireInterfaceName(domainName);
|
|
552
|
+
const serializerPath = `src/${dirName}/serializers/${fileName(model.name)}.serializer.ts`;
|
|
553
|
+
const typeParams = renderSerializerTypeParams(model, ctx);
|
|
554
|
+
const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
|
|
555
|
+
const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
|
|
556
|
+
|
|
557
|
+
const skipFormatFields = buildSkipFormatFields(model, useStringDates, baselineDomain);
|
|
558
|
+
const shouldSkipSerialize = shouldSkipSerializeForModel(
|
|
559
|
+
model,
|
|
560
|
+
baselineResponse,
|
|
561
|
+
baselineDomain,
|
|
562
|
+
dedup,
|
|
563
|
+
skippedSerializeModels,
|
|
564
|
+
ctx,
|
|
565
|
+
);
|
|
566
|
+
if (shouldSkipSerialize) {
|
|
567
|
+
skippedSerializeModels.add(model.name);
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const sctx = { modelToService, resolveDir, useStringDates, dedup, skippedSerializeModels, ctx };
|
|
571
|
+
const lines = [
|
|
572
|
+
...buildSerializerImports(model, serializerPath, dirName, domainName, responseName, sctx),
|
|
573
|
+
...emitSerializerBody(
|
|
574
|
+
model,
|
|
575
|
+
domainName,
|
|
576
|
+
responseName,
|
|
577
|
+
typeParams,
|
|
578
|
+
baselineDomain,
|
|
579
|
+
baselineResponse,
|
|
580
|
+
skipFormatFields,
|
|
581
|
+
shouldSkipSerialize,
|
|
582
|
+
ctx,
|
|
583
|
+
),
|
|
584
|
+
];
|
|
585
|
+
|
|
586
|
+
files.push({
|
|
587
|
+
path: serializerPath,
|
|
588
|
+
content: pruneUnusedImports(lines).join('\n'),
|
|
589
|
+
});
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
return files;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// ---------------------------------------------------------------------------
|
|
596
|
+
// Combined generation — single shared context, two output streams
|
|
597
|
+
// ---------------------------------------------------------------------------
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Generate both interface files and serializer files in a single pass
|
|
601
|
+
* with shared context computation.
|
|
602
|
+
*/
|
|
603
|
+
export function generateModelsAndSerializers(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
604
|
+
if (models.length === 0) return [];
|
|
605
|
+
const shared = buildSharedContext(models, ctx);
|
|
606
|
+
return [...generateModels(models, ctx, shared), ...generateSerializers(models, ctx, shared)];
|
|
607
|
+
}
|