@workos/oagen-emitters 0.2.1 → 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.
Files changed (103) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +10 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +11893 -3226
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/go.md +338 -0
  10. package/docs/sdk-architecture/php.md +315 -0
  11. package/docs/sdk-architecture/python.md +511 -0
  12. package/oagen.config.ts +298 -2
  13. package/package.json +9 -5
  14. package/scripts/generate-php.js +13 -0
  15. package/scripts/git-push-with-published-oagen.sh +21 -0
  16. package/smoke/sdk-go.ts +116 -42
  17. package/smoke/sdk-php.ts +28 -26
  18. package/smoke/sdk-python.ts +5 -2
  19. package/src/go/client.ts +141 -0
  20. package/src/go/enums.ts +196 -0
  21. package/src/go/fixtures.ts +212 -0
  22. package/src/go/index.ts +81 -0
  23. package/src/go/manifest.ts +36 -0
  24. package/src/go/models.ts +254 -0
  25. package/src/go/naming.ts +191 -0
  26. package/src/go/resources.ts +827 -0
  27. package/src/go/tests.ts +751 -0
  28. package/src/go/type-map.ts +82 -0
  29. package/src/go/wrappers.ts +261 -0
  30. package/src/index.ts +3 -0
  31. package/src/node/client.ts +78 -115
  32. package/src/node/enums.ts +9 -0
  33. package/src/node/errors.ts +37 -232
  34. package/src/node/field-plan.ts +726 -0
  35. package/src/node/fixtures.ts +9 -1
  36. package/src/node/index.ts +2 -9
  37. package/src/node/models.ts +178 -21
  38. package/src/node/naming.ts +49 -111
  39. package/src/node/resources.ts +374 -364
  40. package/src/node/sdk-errors.ts +41 -0
  41. package/src/node/tests.ts +32 -12
  42. package/src/node/type-map.ts +4 -2
  43. package/src/node/utils.ts +13 -71
  44. package/src/node/wrappers.ts +151 -0
  45. package/src/php/client.ts +171 -0
  46. package/src/php/enums.ts +67 -0
  47. package/src/php/errors.ts +9 -0
  48. package/src/php/fixtures.ts +181 -0
  49. package/src/php/index.ts +96 -0
  50. package/src/php/manifest.ts +36 -0
  51. package/src/php/models.ts +310 -0
  52. package/src/php/naming.ts +298 -0
  53. package/src/php/resources.ts +561 -0
  54. package/src/php/tests.ts +533 -0
  55. package/src/php/type-map.ts +90 -0
  56. package/src/php/utils.ts +18 -0
  57. package/src/php/wrappers.ts +151 -0
  58. package/src/python/client.ts +337 -0
  59. package/src/python/enums.ts +313 -0
  60. package/src/python/fixtures.ts +196 -0
  61. package/src/python/index.ts +95 -0
  62. package/src/python/manifest.ts +38 -0
  63. package/src/python/models.ts +688 -0
  64. package/src/python/naming.ts +209 -0
  65. package/src/python/resources.ts +1322 -0
  66. package/src/python/tests.ts +1335 -0
  67. package/src/python/type-map.ts +93 -0
  68. package/src/python/wrappers.ts +191 -0
  69. package/src/shared/model-utils.ts +255 -0
  70. package/src/shared/naming-utils.ts +107 -0
  71. package/src/shared/non-spec-services.ts +54 -0
  72. package/src/shared/resolved-ops.ts +109 -0
  73. package/src/shared/wrapper-utils.ts +59 -0
  74. package/test/go/client.test.ts +92 -0
  75. package/test/go/enums.test.ts +132 -0
  76. package/test/go/errors.test.ts +9 -0
  77. package/test/go/models.test.ts +265 -0
  78. package/test/go/resources.test.ts +408 -0
  79. package/test/go/tests.test.ts +143 -0
  80. package/test/node/client.test.ts +18 -12
  81. package/test/node/enums.test.ts +2 -0
  82. package/test/node/errors.test.ts +2 -41
  83. package/test/node/models.test.ts +2 -0
  84. package/test/node/naming.test.ts +23 -0
  85. package/test/node/resources.test.ts +99 -69
  86. package/test/node/serializers.test.ts +3 -1
  87. package/test/node/type-map.test.ts +11 -0
  88. package/test/php/client.test.ts +94 -0
  89. package/test/php/enums.test.ts +173 -0
  90. package/test/php/errors.test.ts +9 -0
  91. package/test/php/models.test.ts +497 -0
  92. package/test/php/resources.test.ts +644 -0
  93. package/test/php/tests.test.ts +118 -0
  94. package/test/python/client.test.ts +200 -0
  95. package/test/python/enums.test.ts +228 -0
  96. package/test/python/errors.test.ts +16 -0
  97. package/test/python/manifest.test.ts +74 -0
  98. package/test/python/models.test.ts +716 -0
  99. package/test/python/resources.test.ts +617 -0
  100. package/test/python/tests.test.ts +202 -0
  101. package/src/node/common.ts +0 -273
  102. package/src/node/config.ts +0 -71
  103. package/src/node/serializers.ts +0 -746
@@ -48,6 +48,7 @@ export function generateFixtures(
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: `src/${dirName}/fixtures/${fileName(model.name)}.fixture.json`,
69
+ path: fixturePath,
62
70
  content: JSON.stringify(fixture, null, 2),
63
71
  });
64
72
  }
package/src/node/index.ts CHANGED
@@ -11,14 +11,11 @@ import type {
11
11
  import * as fs from 'node:fs';
12
12
  import * as path from 'node:path';
13
13
 
14
- import { generateModels } from './models.js';
14
+ import { generateModelsAndSerializers } from './models.js';
15
15
  import { generateEnums } from './enums.js';
16
- import { generateSerializers } from './serializers.js';
17
16
  import { generateResources } from './resources.js';
18
17
  import { generateClient } from './client.js';
19
18
  import { generateErrors } from './errors.js';
20
- import { generateConfig } from './config.js';
21
- import { generateCommon } from './common.js';
22
19
  import { generateTests } from './tests.js';
23
20
  import { generateManifest } from './manifest.js';
24
21
 
@@ -36,7 +33,7 @@ export const nodeEmitter: Emitter = {
36
33
  language: 'node',
37
34
 
38
35
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
39
- return ensureTrailingNewlines([...generateModels(models, ctx), ...generateSerializers(models, ctx)]);
36
+ return ensureTrailingNewlines(generateModelsAndSerializers(models, ctx));
40
37
  },
41
38
 
42
39
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
@@ -55,10 +52,6 @@ export const nodeEmitter: Emitter = {
55
52
  return ensureTrailingNewlines(generateErrors(ctx));
56
53
  },
57
54
 
58
- generateConfig(_ctx: EmitterContext): GeneratedFile[] {
59
- return ensureTrailingNewlines([...generateConfig(), ...generateCommon()]);
60
- },
61
-
62
55
  generateTypeSignatures(_spec: ApiSpec, _ctx: EmitterContext): GeneratedFile[] {
63
56
  // TypeScript uses inline types — no separate type signature files needed
64
57
  return [];
@@ -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 { 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);
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: `src/${dirName}/interfaces/${fileName(model.name)}.interface.ts`,
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
- const domainName = resolveInterfaceName(model.name, ctx);
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[] = [];
@@ -448,3 +469,139 @@ function renderTypeParams(model: Model, genericDefaults?: Map<string, string>):
448
469
  });
449
470
  return `<${params.join(', ')}>`;
450
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
+ }
@@ -1,14 +1,21 @@
1
1
  import type { Operation, Service, EmitterContext } from '@workos/oagen';
2
2
  import { toPascalCase, toCamelCase, toKebabCase, toSnakeCase } from '@workos/oagen';
3
+ import { buildResolvedLookup, lookupMethodName } from '../shared/resolved-ops.js';
4
+ import { stripUrnPrefix } from '../shared/naming-utils.js';
5
+
6
+ /** Strip spec-noise suffixes (e.g., "Dto") from an IR name. */
7
+ export function stripNoiseSuffixes(name: string): string {
8
+ return name.replace(/Dto$/i, '');
9
+ }
3
10
 
4
11
  /** PascalCase class/interface name. */
5
12
  export function className(name: string): string {
6
- return toPascalCase(name);
13
+ return toPascalCase(stripUrnPrefix(name));
7
14
  }
8
15
 
9
16
  /** kebab-case file name (without extension). */
10
17
  export function fileName(name: string): string {
11
- return toKebabCase(name);
18
+ return toKebabCase(stripUrnPrefix(name));
12
19
  }
13
20
 
14
21
  /** camelCase method name. */
@@ -67,134 +74,65 @@ export function buildServiceNameMap(services: Service[], ctx: EmitterContext): M
67
74
  }
68
75
 
69
76
  /**
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.
77
+ * Resolve the output directory for a service.
78
+ * Mount rules already handle directory placement, so this is a simple kebab-case conversion.
146
79
  */
147
80
  export function resolveServiceDir(resolvedServiceName: string): string {
148
- return SERVICE_DIR_OVERRIDES[resolvedServiceName] ?? serviceDirName(resolvedServiceName);
81
+ return serviceDirName(resolvedServiceName);
149
82
  }
150
83
 
151
- /** Resolve the SDK method name for an operation, checking overlay first. */
84
+ /** Resolve the SDK method name for an operation, using resolved operations first. */
152
85
  export function resolveMethodName(op: Operation, _service: Service, ctx: EmitterContext): string {
86
+ const lookup = buildResolvedLookup(ctx);
87
+ const resolved = lookupMethodName(op, lookup);
88
+ if (resolved) return toCamelCase(resolved);
89
+ // Fallback to overlay, then spec-derived
153
90
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
154
- const override = METHOD_NAME_OVERRIDES[httpKey];
155
- if (override) return override;
156
91
  const existing = ctx.overlayLookup?.methodByOperation?.get(httpKey);
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
- }
92
+ if (existing) return existing.methodName;
173
93
  return toCamelCase(op.name);
174
94
  }
175
95
 
176
- /** Resolve the SDK class name for a service, checking overlay for existing names. */
96
+ /** Resolve the SDK class name for a service, using resolved ops mountOn as canonical. */
177
97
  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
-
182
- // Check overlay's methodByOperation for any operation in this service
183
- // to find the existing class name
98
+ // Use resolved ops mountOn as canonical class name
99
+ for (const r of ctx.resolvedOperations ?? []) {
100
+ if (r.service.name === service.name) return r.mountOn;
101
+ }
102
+ // Fallback to overlay
184
103
  if (ctx.overlayLookup?.methodByOperation) {
185
104
  for (const op of service.operations) {
186
105
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
187
106
  const existing = ctx.overlayLookup.methodByOperation.get(httpKey);
188
- if (existing) return CLASS_NAME_OVERRIDES[existing.className] ?? existing.className;
107
+ if (existing) return existing.className;
189
108
  }
190
109
  }
191
- const defaultName = toPascalCase(service.name);
192
- return CLASS_NAME_OVERRIDES[defaultName] ?? defaultName;
110
+ return toPascalCase(service.name);
193
111
  }
194
112
 
195
- /** Resolve the interface name for a model, checking overlay first. */
196
- export function resolveInterfaceName(name: string, ctx: EmitterContext): string {
113
+ /** Resolve the interface name for a model, checking overlay first.
114
+ *
115
+ * @param opts.skipTypeAlias - When true, skip apiSurface typeAlias resolution.
116
+ * Use this for dedup models to ensure the file exports match the import
117
+ * names (preserved files export the raw name, not the resolved alias).
118
+ */
119
+ export function resolveInterfaceName(name: string, ctx: EmitterContext, opts?: { skipTypeAlias?: boolean }): string {
197
120
  const existing = ctx.overlayLookup?.interfaceByName?.get(name);
198
121
  if (existing) return existing;
199
- return toPascalCase(name);
122
+
123
+ // If the model name is a type alias that points to a canonical interface,
124
+ // use the canonical name. This prevents the merger from generating unused
125
+ // backward-compat aliases (e.g., `type FlagOwner = FeatureFlagOwner`).
126
+ if (!opts?.skipTypeAlias && ctx.apiSurface?.typeAliases) {
127
+ const alias = ctx.apiSurface.typeAliases[name] as { value?: string } | undefined;
128
+ if (alias?.value && ctx.apiSurface.interfaces?.[alias.value]) {
129
+ return alias.value;
130
+ }
131
+ }
132
+
133
+ // Strip spec-noise suffixes (e.g., "Dto") only for models without a
134
+ // baseline. When an overlay exists (Scenario A), the overlay check above
135
+ // handles existing models. New models (no overlay entry) get clean names.
136
+ const cleaned = ctx.apiSurface ? name : stripNoiseSuffixes(name);
137
+ return toPascalCase(stripUrnPrefix(cleaned));
200
138
  }