@workos/oagen-emitters 0.0.1 → 0.2.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/.github/workflows/release-please.yml +9 -1
- package/.husky/commit-msg +0 -0
- package/.husky/pre-commit +1 -0
- package/.husky/pre-push +1 -0
- package/.prettierignore +1 -0
- package/.release-please-manifest.json +3 -0
- package/.vscode/settings.json +3 -0
- package/CHANGELOG.md +54 -0
- package/README.md +2 -2
- package/dist/index.d.mts +7 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +3522 -0
- package/dist/index.mjs.map +1 -0
- package/package.json +14 -18
- package/release-please-config.json +11 -0
- package/src/node/client.ts +437 -204
- package/src/node/common.ts +74 -4
- package/src/node/config.ts +1 -0
- package/src/node/enums.ts +50 -6
- package/src/node/errors.ts +78 -3
- package/src/node/fixtures.ts +84 -15
- package/src/node/index.ts +2 -2
- package/src/node/manifest.ts +4 -2
- package/src/node/models.ts +195 -79
- package/src/node/naming.ts +16 -1
- package/src/node/resources.ts +721 -106
- package/src/node/serializers.ts +510 -52
- package/src/node/tests.ts +621 -105
- package/src/node/type-map.ts +89 -11
- package/src/node/utils.ts +377 -114
- package/test/node/client.test.ts +979 -15
- package/test/node/enums.test.ts +0 -1
- package/test/node/errors.test.ts +4 -21
- package/test/node/models.test.ts +409 -2
- package/test/node/naming.test.ts +0 -3
- package/test/node/resources.test.ts +964 -7
- package/test/node/serializers.test.ts +212 -3
- package/tsconfig.json +2 -3
- package/{tsup.config.ts → tsdown.config.ts} +1 -1
- package/dist/index.d.ts +0 -5
- package/dist/index.js +0 -2158
package/src/node/models.ts
CHANGED
|
@@ -1,48 +1,121 @@
|
|
|
1
|
-
import type { Model, Field, EmitterContext, GeneratedFile
|
|
2
|
-
import { walkTypeRef } from '@workos/oagen';
|
|
1
|
+
import type { Model, Field, TypeRef, EmitterContext, GeneratedFile } from '@workos/oagen';
|
|
3
2
|
import { mapTypeRef, mapWireTypeRef } from './type-map.js';
|
|
3
|
+
import { fieldName, wireFieldName, fileName, resolveInterfaceName, wireInterfaceName } from './naming.js';
|
|
4
4
|
import {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
5
|
+
collectFieldDependencies,
|
|
6
|
+
docComment,
|
|
7
|
+
buildGenericModelDefaults,
|
|
8
|
+
pruneUnusedImports,
|
|
9
|
+
TS_BUILTINS,
|
|
10
|
+
detectStringDateConvention,
|
|
11
|
+
buildKnownTypeNames,
|
|
12
|
+
isBaselineGeneric,
|
|
13
|
+
createServiceDirResolver,
|
|
14
|
+
isListMetadataModel,
|
|
15
|
+
isListWrapperModel,
|
|
16
|
+
buildDeduplicationMap,
|
|
17
|
+
} from './utils.js';
|
|
18
|
+
import { assignEnumsToServices } from './enums.js';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Detect baseline interfaces that are generic (have type parameters) even though
|
|
22
|
+
* the IR model has no typeParams (OpenAPI doesn't support generics).
|
|
23
|
+
*
|
|
24
|
+
* Heuristic: if any field type in the baseline interface contains a PascalCase
|
|
25
|
+
* name that isn't a known model, enum, or builtin, it's likely a type parameter
|
|
26
|
+
* (e.g., `CustomAttributesType`), indicating the interface is generic.
|
|
27
|
+
*
|
|
28
|
+
* When detected, adds a default generic type arg so references like `Profile`
|
|
29
|
+
* become `Profile<Record<string, unknown>>`.
|
|
30
|
+
*/
|
|
31
|
+
function enrichGenericDefaultsFromBaseline(
|
|
32
|
+
genericDefaults: Map<string, string>,
|
|
33
|
+
models: Model[],
|
|
34
|
+
ctx: EmitterContext,
|
|
35
|
+
resolveDir: (irService: string | undefined) => string,
|
|
36
|
+
modelToService: Map<string, string>,
|
|
37
|
+
): void {
|
|
38
|
+
if (!ctx.apiSurface?.interfaces) return;
|
|
39
|
+
const knownNames = buildKnownTypeNames(models, ctx);
|
|
40
|
+
|
|
41
|
+
for (const model of models) {
|
|
42
|
+
if (genericDefaults.has(model.name)) continue; // IR already handles it
|
|
43
|
+
const domainName = resolveInterfaceName(model.name, ctx);
|
|
44
|
+
const baseline = ctx.apiSurface.interfaces[domainName];
|
|
45
|
+
if (!baseline?.fields) continue;
|
|
46
|
+
|
|
47
|
+
// Only enrich generic defaults for models whose baseline file will be
|
|
48
|
+
// preserved via skipIfExists (paths match). If the file is generated
|
|
49
|
+
// fresh in a new directory, it won't have generics, so references
|
|
50
|
+
// to it don't need type args.
|
|
51
|
+
const generatedPath = `src/${resolveDir(modelToService.get(model.name))}/interfaces/${fileName(model.name)}.interface.ts`;
|
|
52
|
+
const baselineSourceFile = (baseline as any).sourceFile as string | undefined;
|
|
53
|
+
if (baselineSourceFile && baselineSourceFile !== generatedPath) continue;
|
|
54
|
+
|
|
55
|
+
if (isBaselineGeneric(baseline.fields, knownNames)) {
|
|
56
|
+
genericDefaults.set(model.name, '<Record<string, unknown>>');
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
35
60
|
|
|
36
61
|
export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
|
|
37
62
|
if (models.length === 0) return [];
|
|
38
63
|
|
|
39
|
-
const modelToService =
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
64
|
+
const { modelToService, resolveDir } = createServiceDirResolver(models, ctx.spec.services, ctx);
|
|
65
|
+
// Detect whether the existing SDK uses string dates (ISO 8601) rather than Date objects.
|
|
66
|
+
// When detected, newly generated models also use string to maintain consistency.
|
|
67
|
+
const useStringDates = detectStringDateConvention(models, ctx);
|
|
68
|
+
const genericDefaults = buildGenericModelDefaults(ctx.spec.models);
|
|
69
|
+
// Enrich genericDefaults from baseline interfaces that appear to be generic.
|
|
70
|
+
// The IR doesn't carry typeParams for models parsed from OpenAPI (which has no
|
|
71
|
+
// generics), but the existing SDK may have hand-written generic interfaces
|
|
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);
|
|
75
|
+
const typeRefOpts = useStringDates ? { stringDates: true, genericDefaults } : { genericDefaults };
|
|
76
|
+
const wireTypeRefOpts = { genericDefaults };
|
|
43
77
|
const files: GeneratedFile[] = [];
|
|
44
78
|
|
|
79
|
+
// Detect structurally identical or same-name models — emit type aliases for duplicates
|
|
80
|
+
const dedup = buildDeduplicationMap(models, ctx);
|
|
81
|
+
|
|
45
82
|
for (const model of models) {
|
|
83
|
+
// Fix #4: Skip per-domain ListMetadata interfaces — the shared ListMetadata type covers these
|
|
84
|
+
if (isListMetadataModel(model)) continue;
|
|
85
|
+
|
|
86
|
+
// Fix #6: Skip per-domain list wrapper interfaces — the shared List<T>/ListResponse<T> covers these
|
|
87
|
+
if (isListWrapperModel(model)) continue;
|
|
88
|
+
|
|
89
|
+
// Deduplication: if this model is structurally identical to a canonical model,
|
|
90
|
+
// emit a type alias instead of a full interface.
|
|
91
|
+
const canonicalName = dedup.get(model.name);
|
|
92
|
+
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
|
+
const service = modelToService.get(model.name);
|
|
98
|
+
const dirName = resolveDir(service);
|
|
99
|
+
const canonService = modelToService.get(canonicalName);
|
|
100
|
+
const canonDir = resolveDir(canonService);
|
|
101
|
+
const canonRelPath =
|
|
102
|
+
canonDir === dirName
|
|
103
|
+
? `./${fileName(canonicalName)}.interface`
|
|
104
|
+
: `../../${canonDir}/interfaces/${fileName(canonicalName)}.interface`;
|
|
105
|
+
const aliasLines = [
|
|
106
|
+
`import type { ${canonDomainName}, ${canonResponseName} } from '${canonRelPath}';`,
|
|
107
|
+
'',
|
|
108
|
+
`export type ${domainName} = ${canonDomainName};`,
|
|
109
|
+
`export type ${responseName} = ${canonResponseName};`,
|
|
110
|
+
];
|
|
111
|
+
files.push({
|
|
112
|
+
path: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
|
|
113
|
+
content: aliasLines.join('\n'),
|
|
114
|
+
skipIfExists: true,
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
46
119
|
const service = modelToService.get(model.name);
|
|
47
120
|
const dirName = resolveDir(service);
|
|
48
121
|
const domainName = resolveInterfaceName(model.name, ctx);
|
|
@@ -50,6 +123,17 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
50
123
|
const deps = collectFieldDependencies(model);
|
|
51
124
|
const lines: string[] = [];
|
|
52
125
|
|
|
126
|
+
// Exclude the current model from generic defaults to avoid self-referencing
|
|
127
|
+
// (e.g., Profile's own fields should use TCustom, not Profile<Record<...>>)
|
|
128
|
+
let modelTypeRefOpts = typeRefOpts;
|
|
129
|
+
let modelWireTypeRefOpts = wireTypeRefOpts;
|
|
130
|
+
if (genericDefaults.has(model.name)) {
|
|
131
|
+
const filteredDefaults = new Map(genericDefaults);
|
|
132
|
+
filteredDefaults.delete(model.name);
|
|
133
|
+
modelTypeRefOpts = { ...typeRefOpts, genericDefaults: filteredDefaults };
|
|
134
|
+
modelWireTypeRefOpts = { genericDefaults: filteredDefaults };
|
|
135
|
+
}
|
|
136
|
+
|
|
53
137
|
// Baseline interface data (for compat field type matching)
|
|
54
138
|
const baselineDomain = ctx.apiSurface?.interfaces?.[domainName];
|
|
55
139
|
const baselineResponse = ctx.apiSurface?.interfaces?.[responseName];
|
|
@@ -71,9 +155,11 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
71
155
|
// Pre-pass: discover baseline type names that aren't directly importable.
|
|
72
156
|
// For each unresolvable name we either:
|
|
73
157
|
// 1. Import the real type from another service (if it exists as an enum/model there)
|
|
74
|
-
// 2. Create a local type
|
|
158
|
+
// 2. Create a local type alias from a suffix match
|
|
159
|
+
// 3. Mark as unresolvable — the field will fall back to the IR-generated type
|
|
75
160
|
const typeDecls = new Map<string, string>(); // aliasName → type expression
|
|
76
161
|
const crossServiceImports = new Map<string, { name: string; relPath: string }>(); // extra imports
|
|
162
|
+
const unresolvableNames = new Set<string>(); // names that can't be resolved — forces IR fallback
|
|
77
163
|
const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services);
|
|
78
164
|
// Build a lookup: resolved enum name → IR enum name
|
|
79
165
|
const resolvedEnumNames = new Map<string, string>();
|
|
@@ -92,10 +178,11 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
92
178
|
if (!names) continue;
|
|
93
179
|
|
|
94
180
|
for (const name of names) {
|
|
95
|
-
if (
|
|
181
|
+
if (TS_BUILTINS.has(name)) continue;
|
|
96
182
|
if (importableNames.has(name)) continue;
|
|
97
183
|
if (typeDecls.has(name)) continue;
|
|
98
184
|
if (crossServiceImports.has(name)) continue;
|
|
185
|
+
if (unresolvableNames.has(name)) continue;
|
|
99
186
|
|
|
100
187
|
// Check if this name exists as an enum in another service —
|
|
101
188
|
// import the actual type so the extractor sees the real name
|
|
@@ -119,11 +206,10 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
119
206
|
typeDecls.set(name, candidates[0]);
|
|
120
207
|
importableNames.add(name);
|
|
121
208
|
} else {
|
|
122
|
-
//
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
importableNames.add(name);
|
|
209
|
+
// Cannot resolve this baseline type name — mark it so the field
|
|
210
|
+
// falls back to the IR-generated type instead of the baseline.
|
|
211
|
+
// This avoids creating type aliases that reference undefined types.
|
|
212
|
+
unresolvableNames.add(name);
|
|
127
213
|
}
|
|
128
214
|
}
|
|
129
215
|
}
|
|
@@ -157,8 +243,9 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
157
243
|
}
|
|
158
244
|
if (typeDecls.size > 0) lines.push('');
|
|
159
245
|
|
|
160
|
-
// Type params (generics)
|
|
161
|
-
|
|
246
|
+
// Type params (generics) — pass genericDefaults so baseline-detected generics
|
|
247
|
+
// also get type parameter declarations on the interface itself.
|
|
248
|
+
const typeParams = renderTypeParams(model, genericDefaults);
|
|
162
249
|
|
|
163
250
|
// Domain interface (camelCase fields) — deduplicate by camelCase name
|
|
164
251
|
const seenDomainFields = new Set<string>();
|
|
@@ -170,9 +257,12 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
170
257
|
const domainFieldName = fieldName(field.name);
|
|
171
258
|
if (seenDomainFields.has(domainFieldName)) continue;
|
|
172
259
|
seenDomainFields.add(domainFieldName);
|
|
173
|
-
if (field.description || field.deprecated) {
|
|
260
|
+
if (field.description || field.deprecated || field.readOnly || field.writeOnly || field.default !== undefined) {
|
|
174
261
|
const parts: string[] = [];
|
|
175
262
|
if (field.description) parts.push(field.description);
|
|
263
|
+
if (field.readOnly) parts.push('@readonly');
|
|
264
|
+
if (field.writeOnly) parts.push('@writeonly');
|
|
265
|
+
if (field.default !== undefined) parts.push(`@default ${JSON.stringify(field.default)}`);
|
|
176
266
|
if (field.deprecated) parts.push('@deprecated');
|
|
177
267
|
lines.push(...docComment(parts.join('\n'), 2));
|
|
178
268
|
}
|
|
@@ -185,6 +275,7 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
185
275
|
const responseBaselineField = baselineResponse?.fields?.[domainWireField];
|
|
186
276
|
const domainResponseOptionalMismatch =
|
|
187
277
|
baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
|
|
278
|
+
const readonlyPrefix = field.readOnly ? 'readonly ' : '';
|
|
188
279
|
if (
|
|
189
280
|
baselineField &&
|
|
190
281
|
!domainResponseOptionalMismatch &&
|
|
@@ -192,10 +283,29 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
192
283
|
baselineFieldCompatible(baselineField, field)
|
|
193
284
|
) {
|
|
194
285
|
const opt = baselineField.optional ? '?' : '';
|
|
195
|
-
lines.push(` ${domainFieldName}${opt}: ${baselineField.type};`);
|
|
286
|
+
lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${baselineField.type};`);
|
|
196
287
|
} else {
|
|
197
|
-
|
|
198
|
-
|
|
288
|
+
// When a baseline exists for this model, new fields (not present in the
|
|
289
|
+
// baseline) are generated as optional. The merger can deep-merge new
|
|
290
|
+
// fields into existing interfaces, but it cannot update existing
|
|
291
|
+
// deserializer function bodies. Making the field optional prevents a
|
|
292
|
+
// type error where the interface requires a field that the preserved
|
|
293
|
+
// deserializer never populates.
|
|
294
|
+
const isNewFieldOnExistingModel = baselineDomain && !baselineField;
|
|
295
|
+
// Also make the field optional when the response baseline has it as optional
|
|
296
|
+
// but the domain baseline has it as required — the deserializer reads from
|
|
297
|
+
// the response type, so if the response field is optional, the domain value
|
|
298
|
+
// may be undefined.
|
|
299
|
+
// Additionally, when a baseline exists for the RESPONSE interface but NOT the
|
|
300
|
+
// domain interface, fields that are new on the response baseline become optional
|
|
301
|
+
// in the wire type. The domain type must also be optional to match, otherwise
|
|
302
|
+
// the deserializer produces T | undefined for a field typed as T.
|
|
303
|
+
const isNewFieldOnExistingResponse = !baselineDomain && baselineResponse && !responseBaselineField;
|
|
304
|
+
const opt =
|
|
305
|
+
!field.required || isNewFieldOnExistingModel || domainResponseOptionalMismatch || isNewFieldOnExistingResponse
|
|
306
|
+
? '?'
|
|
307
|
+
: '';
|
|
308
|
+
lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${mapTypeRef(field.type, modelTypeRefOpts)};`);
|
|
199
309
|
}
|
|
200
310
|
}
|
|
201
311
|
lines.push('}');
|
|
@@ -217,15 +327,16 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
|
|
|
217
327
|
const opt = baselineField.optional ? '?' : '';
|
|
218
328
|
lines.push(` ${wireField}${opt}: ${baselineField.type};`);
|
|
219
329
|
} else {
|
|
220
|
-
const
|
|
221
|
-
|
|
330
|
+
const isNewFieldOnExistingModel = baselineResponse && !baselineField;
|
|
331
|
+
const opt = !field.required || isNewFieldOnExistingModel ? '?' : '';
|
|
332
|
+
lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
|
|
222
333
|
}
|
|
223
334
|
}
|
|
224
335
|
lines.push('}');
|
|
225
336
|
|
|
226
337
|
files.push({
|
|
227
338
|
path: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
|
|
228
|
-
content: lines.join('\n'),
|
|
339
|
+
content: pruneUnusedImports(lines).join('\n'),
|
|
229
340
|
skipIfExists: true,
|
|
230
341
|
});
|
|
231
342
|
}
|
|
@@ -246,7 +357,7 @@ function baselineTypeResolvable(typeStr: string, importableNames: Set<string>):
|
|
|
246
357
|
if (!matches) return true;
|
|
247
358
|
|
|
248
359
|
for (const name of matches) {
|
|
249
|
-
if (
|
|
360
|
+
if (TS_BUILTINS.has(name)) continue;
|
|
250
361
|
if (importableNames.has(name)) continue;
|
|
251
362
|
return false;
|
|
252
363
|
}
|
|
@@ -286,38 +397,43 @@ function baselineFieldCompatible(baselineField: { type: string; optional: boolea
|
|
|
286
397
|
// the serializer produces a definite value but the interface is looser — that's OK
|
|
287
398
|
// (the domain type is wider than the serializer output)
|
|
288
399
|
|
|
400
|
+
// If the baseline type is Record<string, unknown> but the IR field has a more specific
|
|
401
|
+
// type (model, enum, or union with named variants), prefer the IR type for better type safety
|
|
402
|
+
if (baselineField.type === 'Record<string, unknown>' && hasSpecificIRType(irField.type)) {
|
|
403
|
+
return false;
|
|
404
|
+
}
|
|
405
|
+
|
|
289
406
|
return true;
|
|
290
407
|
}
|
|
291
408
|
|
|
292
|
-
|
|
293
|
-
|
|
409
|
+
/** Check if an IR type is more specific than Record<string, unknown>. */
|
|
410
|
+
function hasSpecificIRType(ref: TypeRef): boolean {
|
|
411
|
+
switch (ref.kind) {
|
|
412
|
+
case 'model':
|
|
413
|
+
case 'enum':
|
|
414
|
+
return true;
|
|
415
|
+
case 'union':
|
|
416
|
+
// A union with named model/enum variants is more specific
|
|
417
|
+
return ref.variants.some((v) => v.kind === 'model' || v.kind === 'enum');
|
|
418
|
+
case 'nullable':
|
|
419
|
+
return hasSpecificIRType(ref.inner);
|
|
420
|
+
default:
|
|
421
|
+
return false;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function renderTypeParams(model: Model, genericDefaults?: Map<string, string>): string {
|
|
426
|
+
if (!model.typeParams?.length) {
|
|
427
|
+
// Fallback: if genericDefaults indicates this model is generic (detected
|
|
428
|
+
// from the baseline), generate a default generic type parameter declaration.
|
|
429
|
+
if (genericDefaults?.has(model.name)) {
|
|
430
|
+
return '<GenericType extends Record<string, unknown> = Record<string, unknown>>';
|
|
431
|
+
}
|
|
432
|
+
return '';
|
|
433
|
+
}
|
|
294
434
|
const params = model.typeParams.map((tp) => {
|
|
295
435
|
const def = tp.default ? ` = ${mapTypeRef(tp.default)}` : '';
|
|
296
436
|
return `${tp.name}${def}`;
|
|
297
437
|
});
|
|
298
438
|
return `<${params.join(', ')}>`;
|
|
299
439
|
}
|
|
300
|
-
|
|
301
|
-
function assignEnumsToServices(enums: { name: string }[], services: Service[]): Map<string, string> {
|
|
302
|
-
const enumToService = new Map<string, string>();
|
|
303
|
-
const enumNames = new Set(enums.map((e) => e.name));
|
|
304
|
-
for (const service of services) {
|
|
305
|
-
for (const op of service.operations) {
|
|
306
|
-
const refs = new Set<string>();
|
|
307
|
-
const collect = (ref: any) => {
|
|
308
|
-
walkTypeRef(ref, { enum: (r: any) => refs.add(r.name) });
|
|
309
|
-
};
|
|
310
|
-
if (op.requestBody) collect(op.requestBody);
|
|
311
|
-
collect(op.response);
|
|
312
|
-
for (const p of [...op.pathParams, ...op.queryParams, ...op.headerParams]) {
|
|
313
|
-
collect(p.type);
|
|
314
|
-
}
|
|
315
|
-
for (const name of refs) {
|
|
316
|
-
if (enumNames.has(name) && !enumToService.has(name)) {
|
|
317
|
-
enumToService.set(name, service.name);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
return enumToService;
|
|
323
|
-
}
|
package/src/node/naming.ts
CHANGED
|
@@ -70,7 +70,22 @@ export function buildServiceNameMap(services: Service[], ctx: EmitterContext): M
|
|
|
70
70
|
export function resolveMethodName(op: Operation, _service: Service, ctx: EmitterContext): string {
|
|
71
71
|
const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
|
|
72
72
|
const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
|
|
73
|
-
if (existing)
|
|
73
|
+
if (existing) {
|
|
74
|
+
// Fix: when the path ends with a path parameter (single-resource operation)
|
|
75
|
+
// and the overlay method name is plural, prefer the singular form.
|
|
76
|
+
// E.g., getUsers → getUser when path is /user_management/users/{id}
|
|
77
|
+
const isSingleResource = /\/\{[^}]+\}$/.test(op.path);
|
|
78
|
+
if (isSingleResource && existing.methodName.endsWith('s') && !existing.methodName.endsWith('ss')) {
|
|
79
|
+
const singular = existing.methodName.slice(0, -1);
|
|
80
|
+
// Only singularize if it looks like a typical pluralization (ends in 's')
|
|
81
|
+
// and the spec-derived name agrees it should be singular
|
|
82
|
+
const specDerived = toCamelCase(op.name);
|
|
83
|
+
if (specDerived === singular || specDerived.endsWith(singular.slice(singular.length - 4))) {
|
|
84
|
+
return singular;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return existing.methodName;
|
|
88
|
+
}
|
|
74
89
|
return toCamelCase(op.name);
|
|
75
90
|
}
|
|
76
91
|
|