@workos/oagen-emitters 0.0.1 → 0.2.1

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.
Files changed (49) hide show
  1. package/.github/workflows/release-please.yml +9 -1
  2. package/.husky/commit-msg +0 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +1 -0
  5. package/.oxfmtrc.json +8 -1
  6. package/.prettierignore +1 -0
  7. package/.release-please-manifest.json +3 -0
  8. package/.vscode/settings.json +3 -0
  9. package/CHANGELOG.md +61 -0
  10. package/README.md +2 -2
  11. package/dist/index.d.mts +7 -0
  12. package/dist/index.d.mts.map +1 -0
  13. package/dist/index.mjs +4070 -0
  14. package/dist/index.mjs.map +1 -0
  15. package/package.json +14 -18
  16. package/release-please-config.json +11 -0
  17. package/smoke/sdk-dotnet.ts +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +21 -4
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-ruby.ts +17 -3
  23. package/smoke/sdk-rust.ts +16 -3
  24. package/src/node/client.ts +521 -206
  25. package/src/node/common.ts +74 -4
  26. package/src/node/config.ts +1 -0
  27. package/src/node/enums.ts +53 -9
  28. package/src/node/errors.ts +82 -3
  29. package/src/node/fixtures.ts +87 -16
  30. package/src/node/index.ts +66 -10
  31. package/src/node/manifest.ts +4 -2
  32. package/src/node/models.ts +251 -124
  33. package/src/node/naming.ts +107 -3
  34. package/src/node/resources.ts +1162 -108
  35. package/src/node/serializers.ts +512 -52
  36. package/src/node/tests.ts +650 -110
  37. package/src/node/type-map.ts +89 -11
  38. package/src/node/utils.ts +426 -113
  39. package/test/node/client.test.ts +1083 -20
  40. package/test/node/enums.test.ts +73 -4
  41. package/test/node/errors.test.ts +4 -21
  42. package/test/node/models.test.ts +499 -5
  43. package/test/node/naming.test.ts +14 -7
  44. package/test/node/resources.test.ts +1568 -9
  45. package/test/node/serializers.test.ts +241 -5
  46. package/tsconfig.json +2 -3
  47. package/{tsup.config.ts → tsdown.config.ts} +1 -1
  48. package/dist/index.d.ts +0 -5
  49. package/dist/index.js +0 -2158
@@ -1,48 +1,121 @@
1
- import type { Model, Field, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
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
- fieldName,
6
- wireFieldName,
7
- fileName,
8
- serviceDirName,
9
- resolveInterfaceName,
10
- buildServiceNameMap,
11
- wireInterfaceName,
12
- } from './naming.js';
13
- import { assignModelsToServices, collectFieldDependencies, docComment } from './utils.js';
14
-
15
- /** Built-in TypeScript types that are always available (no import needed). */
16
- const BUILTINS = new Set([
17
- 'Record',
18
- 'Promise',
19
- 'Array',
20
- 'Map',
21
- 'Set',
22
- 'Date',
23
- 'string',
24
- 'number',
25
- 'boolean',
26
- 'void',
27
- 'null',
28
- 'undefined',
29
- 'any',
30
- 'never',
31
- 'unknown',
32
- 'true',
33
- 'false',
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 = assignModelsToServices(models, ctx.spec.services);
40
- const serviceNameMap = buildServiceNameMap(ctx.spec.services, ctx);
41
- const resolveDir = (irService: string | undefined) =>
42
- irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
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 declaration as a fallback
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 (BUILTINS.has(name)) continue;
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
- // No suffix match create a type alias using the IR-generated type
123
- const innerType = field.type.kind === 'nullable' ? field.type.inner : field.type;
124
- const typeExpr = mapTypeRef(innerType);
125
- typeDecls.set(name, typeExpr);
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,75 +243,111 @@ 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
- const typeParams = renderTypeParams(model);
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>();
165
252
  if (model.description) {
166
253
  lines.push(...docComment(model.description));
167
254
  }
168
- lines.push(`export interface ${domainName}${typeParams} {`);
169
- for (const field of model.fields) {
170
- const domainFieldName = fieldName(field.name);
171
- if (seenDomainFields.has(domainFieldName)) continue;
172
- seenDomainFields.add(domainFieldName);
173
- if (field.description || field.deprecated) {
174
- const parts: string[] = [];
175
- if (field.description) parts.push(field.description);
176
- if (field.deprecated) parts.push('@deprecated');
177
- lines.push(...docComment(parts.join('\n'), 2));
178
- }
179
- const baselineField = baselineDomain?.fields?.[domainFieldName];
180
- // For the domain interface, also check that the response baseline's optionality
181
- // is compatible the serializer reads from the response type and assigns to the domain type.
182
- // If the domain baseline says required but the response baseline says optional,
183
- // the serializer would produce T | undefined for a field expecting T.
184
- const domainWireField = wireFieldName(field.name);
185
- const responseBaselineField = baselineResponse?.fields?.[domainWireField];
186
- const domainResponseOptionalMismatch =
187
- baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
188
- if (
189
- baselineField &&
190
- !domainResponseOptionalMismatch &&
191
- baselineTypeResolvable(baselineField.type, importableNames) &&
192
- baselineFieldCompatible(baselineField, field)
193
- ) {
194
- const opt = baselineField.optional ? '?' : '';
195
- lines.push(` ${domainFieldName}${opt}: ${baselineField.type};`);
196
- } else {
197
- const opt = !field.required ? '?' : '';
198
- lines.push(` ${domainFieldName}${opt}: ${mapTypeRef(field.type)};`);
255
+ if (model.fields.length === 0) {
256
+ lines.push(`export type ${domainName}${typeParams} = object;`);
257
+ } else {
258
+ lines.push(`export interface ${domainName}${typeParams} {`);
259
+ for (const field of model.fields) {
260
+ const domainFieldName = fieldName(field.name);
261
+ if (seenDomainFields.has(domainFieldName)) continue;
262
+ seenDomainFields.add(domainFieldName);
263
+ if (field.description || field.deprecated || field.readOnly || field.writeOnly || field.default !== undefined) {
264
+ const parts: string[] = [];
265
+ if (field.description) parts.push(field.description);
266
+ if (field.readOnly) parts.push('@readonly');
267
+ if (field.writeOnly) parts.push('@writeonly');
268
+ if (field.default !== undefined) parts.push(`@default ${JSON.stringify(field.default)}`);
269
+ if (field.deprecated) parts.push('@deprecated');
270
+ lines.push(...docComment(parts.join('\n'), 2));
271
+ }
272
+ const baselineField = baselineDomain?.fields?.[domainFieldName];
273
+ // For the domain interface, also check that the response baseline's optionality
274
+ // is compatible the serializer reads from the response type and assigns to the domain type.
275
+ // If the domain baseline says required but the response baseline says optional,
276
+ // the serializer would produce T | undefined for a field expecting T.
277
+ const domainWireField = wireFieldName(field.name);
278
+ const responseBaselineField = baselineResponse?.fields?.[domainWireField];
279
+ const domainResponseOptionalMismatch =
280
+ baselineField && !baselineField.optional && responseBaselineField && responseBaselineField.optional;
281
+ const readonlyPrefix = field.readOnly ? 'readonly ' : '';
282
+ if (
283
+ baselineField &&
284
+ !domainResponseOptionalMismatch &&
285
+ baselineTypeResolvable(baselineField.type, importableNames) &&
286
+ baselineFieldCompatible(baselineField, field)
287
+ ) {
288
+ const opt = baselineField.optional ? '?' : '';
289
+ lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${baselineField.type};`);
290
+ } else {
291
+ // When a baseline exists for this model, new fields (not present in the
292
+ // baseline) are generated as optional. The merger can deep-merge new
293
+ // fields into existing interfaces, but it cannot update existing
294
+ // deserializer function bodies. Making the field optional prevents a
295
+ // type error where the interface requires a field that the preserved
296
+ // deserializer never populates.
297
+ const isNewFieldOnExistingModel = baselineDomain && !baselineField;
298
+ // Also make the field optional when the response baseline has it as optional
299
+ // but the domain baseline has it as required — the deserializer reads from
300
+ // the response type, so if the response field is optional, the domain value
301
+ // may be undefined.
302
+ // Additionally, when a baseline exists for the RESPONSE interface but NOT the
303
+ // domain interface, fields that are new on the response baseline become optional
304
+ // in the wire type. The domain type must also be optional to match, otherwise
305
+ // the deserializer produces T | undefined for a field typed as T.
306
+ const isNewFieldOnExistingResponse = !baselineDomain && baselineResponse && !responseBaselineField;
307
+ const opt =
308
+ !field.required ||
309
+ isNewFieldOnExistingModel ||
310
+ domainResponseOptionalMismatch ||
311
+ isNewFieldOnExistingResponse
312
+ ? '?'
313
+ : '';
314
+ lines.push(` ${readonlyPrefix}${domainFieldName}${opt}: ${mapTypeRef(field.type, modelTypeRefOpts)};`);
315
+ }
199
316
  }
200
- }
201
- lines.push('}');
317
+ lines.push('}');
318
+ } // close else for non-empty domain interface
202
319
  lines.push('');
203
320
 
204
321
  // Wire/response interface (snake_case fields) — deduplicate by snake_case name
205
322
  const seenWireFields = new Set<string>();
206
- lines.push(`export interface ${responseName}${typeParams} {`);
207
- for (const field of model.fields) {
208
- const wireField = wireFieldName(field.name);
209
- if (seenWireFields.has(wireField)) continue;
210
- seenWireFields.add(wireField);
211
- const baselineField = baselineResponse?.fields?.[wireField];
212
- if (
213
- baselineField &&
214
- baselineTypeResolvable(baselineField.type, importableNames) &&
215
- baselineFieldCompatible(baselineField, field)
216
- ) {
217
- const opt = baselineField.optional ? '?' : '';
218
- lines.push(` ${wireField}${opt}: ${baselineField.type};`);
219
- } else {
220
- const opt = !field.required ? '?' : '';
221
- lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type)};`);
323
+ if (model.fields.length === 0) {
324
+ lines.push(`export type ${responseName}${typeParams} = object;`);
325
+ } else {
326
+ lines.push(`export interface ${responseName}${typeParams} {`);
327
+ for (const field of model.fields) {
328
+ const wireField = wireFieldName(field.name);
329
+ if (seenWireFields.has(wireField)) continue;
330
+ seenWireFields.add(wireField);
331
+ const baselineField = baselineResponse?.fields?.[wireField];
332
+ if (
333
+ baselineField &&
334
+ baselineTypeResolvable(baselineField.type, importableNames) &&
335
+ baselineFieldCompatible(baselineField, field)
336
+ ) {
337
+ const opt = baselineField.optional ? '?' : '';
338
+ lines.push(` ${wireField}${opt}: ${baselineField.type};`);
339
+ } else {
340
+ const isNewFieldOnExistingModel = baselineResponse && !baselineField;
341
+ const opt = !field.required || isNewFieldOnExistingModel ? '?' : '';
342
+ lines.push(` ${wireField}${opt}: ${mapWireTypeRef(field.type, modelWireTypeRefOpts)};`);
343
+ }
222
344
  }
223
- }
224
- lines.push('}');
345
+ lines.push('}');
346
+ } // close else for non-empty wire interface
225
347
 
226
348
  files.push({
227
349
  path: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
228
- content: lines.join('\n'),
350
+ content: pruneUnusedImports(lines).join('\n'),
229
351
  skipIfExists: true,
230
352
  });
231
353
  }
@@ -246,7 +368,7 @@ function baselineTypeResolvable(typeStr: string, importableNames: Set<string>):
246
368
  if (!matches) return true;
247
369
 
248
370
  for (const name of matches) {
249
- if (BUILTINS.has(name)) continue;
371
+ if (TS_BUILTINS.has(name)) continue;
250
372
  if (importableNames.has(name)) continue;
251
373
  return false;
252
374
  }
@@ -286,38 +408,43 @@ function baselineFieldCompatible(baselineField: { type: string; optional: boolea
286
408
  // the serializer produces a definite value but the interface is looser — that's OK
287
409
  // (the domain type is wider than the serializer output)
288
410
 
411
+ // If the baseline type is Record<string, unknown> but the IR field has a more specific
412
+ // type (model, enum, or union with named variants), prefer the IR type for better type safety
413
+ if (baselineField.type === 'Record<string, unknown>' && hasSpecificIRType(irField.type)) {
414
+ return false;
415
+ }
416
+
289
417
  return true;
290
418
  }
291
419
 
292
- function renderTypeParams(model: Model): string {
293
- if (!model.typeParams?.length) return '';
420
+ /** Check if an IR type is more specific than Record<string, unknown>. */
421
+ function hasSpecificIRType(ref: TypeRef): boolean {
422
+ switch (ref.kind) {
423
+ case 'model':
424
+ case 'enum':
425
+ return true;
426
+ case 'union':
427
+ // A union with named model/enum variants is more specific
428
+ return ref.variants.some((v) => v.kind === 'model' || v.kind === 'enum');
429
+ case 'nullable':
430
+ return hasSpecificIRType(ref.inner);
431
+ default:
432
+ return false;
433
+ }
434
+ }
435
+
436
+ function renderTypeParams(model: Model, genericDefaults?: Map<string, string>): string {
437
+ if (!model.typeParams?.length) {
438
+ // Fallback: if genericDefaults indicates this model is generic (detected
439
+ // from the baseline), generate a default generic type parameter declaration.
440
+ if (genericDefaults?.has(model.name)) {
441
+ return '<GenericType extends Record<string, unknown> = Record<string, unknown>>';
442
+ }
443
+ return '';
444
+ }
294
445
  const params = model.typeParams.map((tp) => {
295
446
  const def = tp.default ? ` = ${mapTypeRef(tp.default)}` : '';
296
447
  return `${tp.name}${def}`;
297
448
  });
298
449
  return `<${params.join(', ')}>`;
299
450
  }
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
- }
@@ -66,26 +66,130 @@ export function buildServiceNameMap(services: Service[], ctx: EmitterContext): M
66
66
  return map;
67
67
  }
68
68
 
69
+ /**
70
+ * Explicit method name overrides for operations where the spec's operationId
71
+ * does not match the desired SDK method name and the spec cannot be changed.
72
+ * Key: "HTTP_METHOD /path", Value: camelCase method name.
73
+ */
74
+ const METHOD_NAME_OVERRIDES: Record<string, string> = {
75
+ 'POST /portal/generate_link': 'generatePortalLink',
76
+ };
77
+
78
+ /**
79
+ * Explicit service directory overrides. Maps a resolved PascalCase service name
80
+ * to a target directory (kebab-case). Use this when the spec's tag grouping
81
+ * does not match the desired SDK directory layout and the spec cannot be changed.
82
+ */
83
+ const SERVICE_DIR_OVERRIDES: Record<string, string> = {
84
+ ApplicationClientSecrets: 'workos-connect',
85
+ Applications: 'workos-connect',
86
+ Connections: 'sso',
87
+ Directories: 'directory-sync',
88
+ DirectoryGroups: 'directory-sync',
89
+ DirectoryUsers: 'directory-sync',
90
+ FeatureFlagsTargets: 'feature-flags',
91
+ MultiFactorAuth: 'mfa',
92
+ MultiFactorAuthChallenges: 'mfa',
93
+ OrganizationsApiKeys: 'organizations',
94
+ WebhooksEndpoints: 'webhooks',
95
+ UserManagementAuthentication: 'user-management',
96
+ UserManagementCorsOrigins: 'user-management',
97
+ UserManagementDataProviders: 'user-management',
98
+ UserManagementInvitations: 'user-management',
99
+ UserManagementJWTTemplate: 'user-management',
100
+ UserManagementMagicAuth: 'user-management',
101
+ UserManagementMultiFactorAuthentication: 'user-management',
102
+ UserManagementOrganizationMembership: 'user-management',
103
+ UserManagementRedirectUris: 'user-management',
104
+ UserManagementSessionTokens: 'user-management',
105
+ UserManagementUsers: 'user-management',
106
+ UserManagementUsersAuthorizedApplications: 'user-management',
107
+ WorkOSConnect: 'workos-connect',
108
+ };
109
+
110
+ /**
111
+ * Maps a service (by PascalCase name) to the existing hand-written class that
112
+ * already covers its endpoints. When a service appears here:
113
+ * - `resolveClassName` returns the target class (so generated code merges in)
114
+ * - `isServiceCoveredByExisting` returns true
115
+ * - `hasMethodsAbsentFromBaseline` checks the target class for missing methods,
116
+ * so new endpoints are added to the existing class rather than silently dropped
117
+ */
118
+ export const SERVICE_COVERED_BY: Record<string, string> = {
119
+ Connections: 'SSO',
120
+ Directories: 'DirectorySync',
121
+ DirectoryGroups: 'DirectorySync',
122
+ DirectoryUsers: 'DirectorySync',
123
+ FeatureFlagsTargets: 'FeatureFlags',
124
+ MultiFactorAuth: 'Mfa',
125
+ MultiFactorAuthChallenges: 'Mfa',
126
+ OrganizationsApiKeys: 'Organizations',
127
+ UserManagementAuthentication: 'UserManagement',
128
+ UserManagementInvitations: 'UserManagement',
129
+ UserManagementMagicAuth: 'UserManagement',
130
+ UserManagementMultiFactorAuthentication: 'UserManagement',
131
+ UserManagementOrganizationMembership: 'UserManagement',
132
+ UserManagementUsers: 'UserManagement',
133
+ };
134
+
135
+ /**
136
+ * Explicit class name overrides. Maps the default PascalCase service name
137
+ * to the desired SDK class name when toPascalCase produces the wrong casing.
138
+ */
139
+ const CLASS_NAME_OVERRIDES: Record<string, string> = {
140
+ WorkosConnect: 'WorkOSConnect',
141
+ };
142
+
143
+ /**
144
+ * Resolve the output directory for a service, checking overrides first.
145
+ * Falls back to the standard kebab-case conversion.
146
+ */
147
+ export function resolveServiceDir(resolvedServiceName: string): string {
148
+ return SERVICE_DIR_OVERRIDES[resolvedServiceName] ?? serviceDirName(resolvedServiceName);
149
+ }
150
+
69
151
  /** Resolve the SDK method name for an operation, checking overlay first. */
70
152
  export function resolveMethodName(op: Operation, _service: Service, ctx: EmitterContext): string {
71
153
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
154
+ const override = METHOD_NAME_OVERRIDES[httpKey];
155
+ if (override) return override;
72
156
  const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
73
- if (existing) return existing.methodName;
157
+ if (existing) {
158
+ // Fix: when the path ends with a path parameter (single-resource operation)
159
+ // and the overlay method name is plural, prefer the singular form.
160
+ // E.g., getUsers → getUser when path is /user_management/users/{id}
161
+ const isSingleResource = /\/\{[^}]+\}$/.test(op.path);
162
+ if (isSingleResource && existing.methodName.endsWith('s') && !existing.methodName.endsWith('ss')) {
163
+ const singular = existing.methodName.slice(0, -1);
164
+ // Only singularize if it looks like a typical pluralization (ends in 's')
165
+ // and the spec-derived name agrees it should be singular
166
+ const specDerived = toCamelCase(op.name);
167
+ if (specDerived === singular || specDerived.endsWith(singular.slice(singular.length - 4))) {
168
+ return singular;
169
+ }
170
+ }
171
+ return existing.methodName;
172
+ }
74
173
  return toCamelCase(op.name);
75
174
  }
76
175
 
77
176
  /** Resolve the SDK class name for a service, checking overlay for existing names. */
78
177
  export function resolveClassName(service: Service, ctx: EmitterContext): string {
178
+ // Explicit coverage: this service's endpoints belong to an existing class
179
+ const coveredBy = SERVICE_COVERED_BY[toPascalCase(service.name)];
180
+ if (coveredBy) return coveredBy;
181
+
79
182
  // Check overlay's methodByOperation for any operation in this service
80
183
  // to find the existing class name
81
184
  if (ctx.overlayLookup?.methodByOperation) {
82
185
  for (const op of service.operations) {
83
186
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
84
187
  const existing = ctx.overlayLookup.methodByOperation.get(httpKey);
85
- if (existing) return existing.className;
188
+ if (existing) return CLASS_NAME_OVERRIDES[existing.className] ?? existing.className;
86
189
  }
87
190
  }
88
- return toPascalCase(service.name);
191
+ const defaultName = toPascalCase(service.name);
192
+ return CLASS_NAME_OVERRIDES[defaultName] ?? defaultName;
89
193
  }
90
194
 
91
195
  /** Resolve the interface name for a model, checking overlay first. */