@workos/oagen-emitters 0.12.4 → 0.13.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/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-nmiHN7Ko.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-B9F2jmwy.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.12.4",
3
+ "version": "0.13.0",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -40,20 +40,20 @@
40
40
  "devDependencies": {
41
41
  "@commitlint/cli": "^21.0.1",
42
42
  "@commitlint/config-conventional": "^21.0.1",
43
- "@types/node": "^25.9.0",
43
+ "@types/node": "^25.9.1",
44
44
  "husky": "^9.1.7",
45
- "oxfmt": "^0.50.0",
46
- "oxlint": "^1.65.0",
45
+ "oxfmt": "^0.51.0",
46
+ "oxlint": "^1.66.0",
47
47
  "prettier": "^3.8.3",
48
48
  "tsdown": "^0.22.0",
49
- "tsx": "^4.22.2",
49
+ "tsx": "^4.22.3",
50
50
  "typescript": "^6.0.3",
51
- "vitest": "^4.1.6"
51
+ "vitest": "^4.1.7"
52
52
  },
53
53
  "engines": {
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.19.1"
57
+ "@workos/oagen": "^0.19.5"
58
58
  }
59
59
  }
@@ -1,5 +1,5 @@
1
1
  import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
- import { apiClassName, packageSegment, servicePropertyName } from './naming.js';
2
+ import { resolveApiClassName, packageSegment, servicePropertyName, buildExportedClassNameSet } from './naming.js';
3
3
  import { getMountTarget } from '../shared/resolved-ops.js';
4
4
 
5
5
  const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
@@ -23,8 +23,9 @@ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFil
23
23
 
24
24
  const imports = new Set<string>();
25
25
  const accessorLines: string[] = [];
26
+ const exportedClasses = buildExportedClassNameSet(ctx);
26
27
  for (const mount of targets) {
27
- const apiCls = apiClassName(mount);
28
+ const apiCls = resolveApiClassName(mount, exportedClasses);
28
29
  const fqn = `com.workos.${packageSegment(mount)}.${apiCls}`;
29
30
  imports.add(fqn);
30
31
  const prop = servicePropertyName(mount);
@@ -1,4 +1,8 @@
1
1
  import type { Operation, Service, EmitterContext, TypeRef } from '@workos/oagen';
2
+ import {
3
+ buildExportedClassNameSet as buildExportedClassNameSetShared,
4
+ resolveServiceTarget as resolveServiceTargetShared,
5
+ } from '../shared/service-name-collision.js';
2
6
  import { toPascalCase, toCamelCase, toSnakeCase } from '@workos/oagen';
3
7
  import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
4
8
  import { stripUrnPrefix } from '../shared/naming-utils.js';
@@ -73,6 +77,36 @@ export function apiClassName(name: string): string {
73
77
  return className(name);
74
78
  }
75
79
 
80
+ /**
81
+ * Resolve the Kotlin service class name with the collision suffix applied
82
+ * when needed. Wraps `apiClassName` so callers don't need to thread the
83
+ * exported-classes set through unrelated emission logic.
84
+ */
85
+ export function resolveApiClassName(name: string, exportedClasses: Set<string>): string {
86
+ return apiClassName(resolveServiceTarget(name, exportedClasses));
87
+ }
88
+
89
+ /**
90
+ * Build the set of model + enum class names exported by the SDK. Used to
91
+ * detect collisions with operation-client class names — a colliding service
92
+ * gets a `Service` suffix appended.
93
+ */
94
+ export function buildExportedClassNameSet(ctx: EmitterContext): Set<string> {
95
+ return buildExportedClassNameSetShared(ctx, className);
96
+ }
97
+
98
+ /**
99
+ * Resolve a service's mount-target identifier, appending `Service` on
100
+ * collision with an exported model/enum class. Kotlin sees the collision
101
+ * when a service class shares a simple name with an imported model class
102
+ * (e.g. `com.workos.models.OrganizationMembership` vs
103
+ * `com.workos.organizationmembership.OrganizationMembership`) — the file's
104
+ * local declaration shadows the import for unqualified references.
105
+ */
106
+ export function resolveServiceTarget(target: string, exportedClasses: Set<string>): string {
107
+ return resolveServiceTargetShared(target, exportedClasses, className);
108
+ }
109
+
76
110
  /** Accessor property exposed on the WorkOS client (camelCase). */
77
111
  export function servicePropertyName(name: string): string {
78
112
  return toCamelCase(name);
@@ -16,7 +16,7 @@ import { enumCanonicalMap } from './enums.js';
16
16
  import {
17
17
  className,
18
18
  propertyName,
19
- apiClassName,
19
+ resolveApiClassName,
20
20
  packageSegment,
21
21
  resolveMethodName,
22
22
  ktLiteral,
@@ -24,6 +24,7 @@ import {
24
24
  escapeReserved,
25
25
  humanize,
26
26
  maybeShortenEnumParamDescription,
27
+ buildExportedClassNameSet,
27
28
  } from './naming.js';
28
29
  import {
29
30
  buildResolvedLookup,
@@ -96,13 +97,14 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
96
97
 
97
98
  const files: GeneratedFile[] = [];
98
99
  const resolvedLookup = buildResolvedLookup(ctx);
100
+ const exportedClasses = buildExportedClassNameSet(ctx);
99
101
 
100
102
  for (const [mountName, group] of mountGroups) {
101
103
  const classCode = generateApiClass(mountName, group.operations, ctx, resolvedLookup);
102
104
  if (!classCode) continue;
103
105
  const pkg = packageSegment(mountName);
104
106
  files.push({
105
- path: `${KOTLIN_SRC_PREFIX}com/workos/${pkg}/${apiClassName(mountName)}.kt`,
107
+ path: `${KOTLIN_SRC_PREFIX}com/workos/${pkg}/${resolveApiClassName(mountName, exportedClasses)}.kt`,
106
108
  content: classCode,
107
109
  overwriteExisting: true,
108
110
  });
@@ -118,7 +120,7 @@ function generateApiClass(
118
120
  resolvedLookup: Map<string, ResolvedOperation>,
119
121
  ): string | null {
120
122
  if (operations.length === 0) return null;
121
- const apiClass = apiClassName(mountName);
123
+ const apiClass = resolveApiClassName(mountName, buildExportedClassNameSet(ctx));
122
124
  const pkg = `com.workos.${packageSegment(mountName)}`;
123
125
 
124
126
  const imports = new Set<string>();
@@ -10,7 +10,15 @@ import type {
10
10
  ResolvedWrapper,
11
11
  } from '@workos/oagen';
12
12
  import { planOperation } from '@workos/oagen';
13
- import { apiClassName, packageSegment, resolveMethodName, ktStringLiteral, className, propertyName } from './naming.js';
13
+ import {
14
+ resolveApiClassName,
15
+ packageSegment,
16
+ resolveMethodName,
17
+ ktStringLiteral,
18
+ className,
19
+ propertyName,
20
+ buildExportedClassNameSet,
21
+ } from './naming.js';
14
22
  import { mapTypeRef } from './type-map.js';
15
23
  import {
16
24
  groupByMount,
@@ -70,12 +78,13 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
70
78
  const mountGroups = groupByMount(ctx);
71
79
  const resolvedLookup = buildResolvedLookup(ctx);
72
80
 
81
+ const exportedClasses = buildExportedClassNameSet(ctx);
73
82
  for (const [mountName, group] of mountGroups) {
74
83
  const content = generateServiceTestClass(mountName, group.operations, ctx, resolvedLookup);
75
84
  if (!content) continue;
76
85
  const pkg = packageSegment(mountName);
77
86
  files.push({
78
- path: `${TEST_PREFIX}com/workos/${pkg}/${apiClassName(mountName)}Test.kt`,
87
+ path: `${TEST_PREFIX}com/workos/${pkg}/${resolveApiClassName(mountName, exportedClasses)}Test.kt`,
79
88
  content,
80
89
  overwriteExisting: true,
81
90
  });
@@ -203,7 +212,7 @@ function generateServiceTestClass(
203
212
  }
204
213
 
205
214
  const pkg = packageSegment(mountName);
206
- const apiCls = apiClassName(mountName);
215
+ const apiCls = resolveApiClassName(mountName, buildExportedClassNameSet(ctx));
207
216
 
208
217
  // If any operation would emit a disabled placeholder test, preregister
209
218
  // the `Disabled` import before we serialize the header.
@@ -2,7 +2,13 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import type { ApiSpec, AuthScheme, EmitterContext, GeneratedFile } from '@workos/oagen';
4
4
 
5
- import { fileName, servicePropertyName, resolveInterfaceName, wireInterfaceName } from './naming.js';
5
+ import {
6
+ fileName,
7
+ servicePropertyName,
8
+ resolveInterfaceName,
9
+ wireInterfaceName,
10
+ resolveServiceName,
11
+ } from './naming.js';
6
12
  import { isInlineEnum } from './type-map.js';
7
13
  import {
8
14
  docComment,
@@ -89,7 +95,10 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
89
95
  for (const service of spec.services) {
90
96
  if (coveredServices.has(service.name)) continue;
91
97
  const resolvedName = resolveResourceClassName(service, ctx);
92
- const propName = servicePropertyName(resolvedName);
98
+ // Accessor name uses the un-suffixed service name so that the public API
99
+ // stays `client.organizationMembership` even when the class itself was
100
+ // renamed to `OrganizationMembershipService` to avoid a model collision.
101
+ const propName = servicePropertyName(resolveServiceName(service, ctx));
93
102
  if (existingProps.has(propName)) continue;
94
103
  // Propagate `@deprecated` from the service class to the property so
95
104
  // IDEs surface the strikethrough at `workos.xyz` access sites, not
@@ -666,6 +666,10 @@ interface SerializerContext {
666
666
  resolveDir: (irService: string | undefined) => string;
667
667
  dedup: Map<string, string>;
668
668
  skippedSerializeModels: Set<string>;
669
+ /** Models reachable from any response — anything outside this set is
670
+ * request-only and won't have a `deserialize<X>` emitted. `undefined`
671
+ * means "no usage info available, assume deserialize exists". */
672
+ responseReachableModels: Set<string> | undefined;
669
673
  ctx: EmitterContext;
670
674
  }
671
675
 
@@ -723,6 +727,10 @@ export function buildSerializerImports(
723
727
  const canon = sctx.dedup.get(dep);
724
728
  const depSkipSerialize =
725
729
  sctx.skippedSerializeModels.has(dep) || (canon != null && sctx.skippedSerializeModels.has(canon));
730
+ const depSkipDeserialize =
731
+ sctx.responseReachableModels !== undefined &&
732
+ !sctx.responseReachableModels.has(dep) &&
733
+ (canon == null || !sctx.responseReachableModels.has(canon));
726
734
 
727
735
  // Decide whether this serializer is reachable at runtime:
728
736
  // - file on disk → honor what it exports (hasDeser/hasSer)
@@ -759,8 +767,11 @@ export function buildSerializerImports(
759
767
  continue;
760
768
  }
761
769
 
770
+ if (depSkipSerialize && depSkipDeserialize) continue;
762
771
  if (depSkipSerialize) {
763
772
  lines.push(`import { deserialize${depName} } from '${rel}';`);
773
+ } else if (depSkipDeserialize) {
774
+ lines.push(`import { serialize${depName} } from '${rel}';`);
764
775
  } else {
765
776
  lines.push(`import { deserialize${depName}, serialize${depName} } from '${rel}';`);
766
777
  }
@@ -821,27 +832,30 @@ export function emitSerializerBody(
821
832
  baselineResponse: BaselineInterface | undefined,
822
833
  skipFormatFields: Set<string>,
823
834
  shouldSkipSerialize: boolean,
835
+ shouldSkipDeserialize: boolean,
824
836
  ctx: EmitterContext,
825
837
  ): string[] {
826
838
  const lines: string[] = [];
827
839
 
828
- const seenDeserFields = new Set<string>();
829
- const deserParamPrefix = model.fields.length === 0 ? '_' : '';
830
- lines.push(`export const deserialize${domainName} = ${typeParams.decl}(`);
831
- lines.push(` ${deserParamPrefix}response: ${responseName}${typeParams.usage},`);
832
- lines.push(`): ${domainName}${typeParams.usage} => ({`);
833
- for (const field of model.fields) {
834
- const domain = fieldName(field.name);
835
- if (seenDeserFields.has(domain)) continue;
836
- seenDeserFields.add(domain);
837
- const plan = planDeserializeField(field, baselineDomain, baselineResponse, skipFormatFields, ctx);
838
- if (!plan.skip) lines.push(plan.line);
840
+ if (!shouldSkipDeserialize) {
841
+ const seenDeserFields = new Set<string>();
842
+ const deserParamPrefix = model.fields.length === 0 ? '_' : '';
843
+ lines.push(`export const deserialize${domainName} = ${typeParams.decl}(`);
844
+ lines.push(` ${deserParamPrefix}response: ${responseName}${typeParams.usage},`);
845
+ lines.push(`): ${domainName}${typeParams.usage} => ({`);
846
+ for (const field of model.fields) {
847
+ const domain = fieldName(field.name);
848
+ if (seenDeserFields.has(domain)) continue;
849
+ seenDeserFields.add(domain);
850
+ const plan = planDeserializeField(field, baselineDomain, baselineResponse, skipFormatFields, ctx);
851
+ if (!plan.skip) lines.push(plan.line);
852
+ }
853
+ lines.push('});');
839
854
  }
840
- lines.push('});');
841
855
 
842
856
  if (!shouldSkipSerialize) {
857
+ if (!shouldSkipDeserialize) lines.push('');
843
858
  const serParamPrefix = model.fields.length === 0 ? '_' : '';
844
- lines.push('');
845
859
  lines.push(`export const serialize${domainName} = ${typeParams.decl}(`);
846
860
  lines.push(` ${serParamPrefix}model: ${domainName}${typeParams.usage},`);
847
861
  lines.push(`): ${responseName}${typeParams.usage} => ({`);
@@ -1,6 +1,5 @@
1
1
  import type { ApiSpec, EmitterContext, OperationsMap } from '@workos/oagen';
2
- import { resolveMethodName, servicePropertyName } from './naming.js';
3
- import { resolveResourceClassName } from './resources.js';
2
+ import { resolveMethodName, servicePropertyName, resolveServiceName } from './naming.js';
4
3
  import { buildResolvedLookup, lookupResolved } from '../shared/resolved-ops.js';
5
4
 
6
5
  export function buildOperationsMap(spec: ApiSpec, ctx: EmitterContext): OperationsMap {
@@ -8,7 +7,10 @@ export function buildOperationsMap(spec: ApiSpec, ctx: EmitterContext): Operatio
8
7
  const resolvedLookup = buildResolvedLookup(ctx);
9
8
 
10
9
  for (const service of spec.services) {
11
- const serviceProp = servicePropertyName(resolveResourceClassName(service, ctx));
10
+ // Accessor name reflects the un-suffixed service mount target so the
11
+ // manifest matches `client.organizationMembership` (not the suffixed
12
+ // class name used to dodge model collisions).
13
+ const serviceProp = servicePropertyName(resolveServiceName(service, ctx));
12
14
  for (const op of service.operations) {
13
15
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
14
16
  const method = resolveMethodName(op, service, ctx);
@@ -61,6 +61,10 @@ interface SharedModelContext {
61
61
  interface GeneratedResourceModelUsage {
62
62
  interfaceRoots: Set<string>;
63
63
  serializerRoots: Set<string>;
64
+ /** Models that are directly used as a request body. Drive `serialize<X>`. */
65
+ requestRoots: Set<string>;
66
+ /** Models that are directly used as a response body. Drive `deserialize<X>`. */
67
+ responseRoots: Set<string>;
64
68
  }
65
69
 
66
70
  function buildSharedContext(models: Model[], ctx: EmitterContext): SharedModelContext {
@@ -655,6 +659,14 @@ export function generateSerializers(
655
659
  const serializerEligibleModels = resourceUsage
656
660
  ? expandModelRoots(resourceUsage.serializerRoots, projectedByName)
657
661
  : undefined;
662
+ // Models reachable from any response — only these need a `deserialize<X>`.
663
+ // A model used solely as a request body (e.g. `CreateWebhookEndpoint`)
664
+ // would otherwise emit a deserializer with a partial response shape that
665
+ // silently misbehaves if called. Undefined means "no resource usage info,
666
+ // emit both halves" (standalone generation, smoke tests).
667
+ const responseReachableModels = resourceUsage
668
+ ? expandModelRoots(resourceUsage.responseRoots, projectedByName)
669
+ : undefined;
658
670
 
659
671
  const serializerReachable = computeNonEventReachable(ctx.spec.services, models);
660
672
 
@@ -765,9 +777,19 @@ export function generateSerializers(
765
777
  if (domainName === canonDomainName) continue;
766
778
  const rel = relativeImport(serializerPath, canonSerializerPath);
767
779
  const canonSkipSerialize = skippedSerializeModels.has(canonicalName) || skippedSerializeModels.has(model.name);
768
- const reexportContent = canonSkipSerialize
769
- ? `export { deserialize${canonDomainName} as deserialize${domainName} } from '${rel}';`
770
- : `export { deserialize${canonDomainName} as deserialize${domainName}, serialize${canonDomainName} as serialize${domainName} } from '${rel}';`;
780
+ const canonSkipDeserialize =
781
+ responseReachableModels !== undefined &&
782
+ !responseReachableModels.has(canonicalName) &&
783
+ !responseReachableModels.has(model.name);
784
+ if (canonSkipSerialize && canonSkipDeserialize) continue;
785
+ const parts: string[] = [];
786
+ if (!canonSkipDeserialize) {
787
+ parts.push(`deserialize${canonDomainName} as deserialize${domainName}`);
788
+ }
789
+ if (!canonSkipSerialize) {
790
+ parts.push(`serialize${canonDomainName} as serialize${domainName}`);
791
+ }
792
+ const reexportContent = `export { ${parts.join(', ')} } from '${rel}';`;
771
793
  files.push({
772
794
  path: serializerPath,
773
795
  content: reexportContent,
@@ -787,8 +809,21 @@ export function generateSerializers(
787
809
 
788
810
  const skipFormatFields = buildSkipFormatFields(model, baselineDomain);
789
811
  const shouldSkipSerialize = skippedSerializeModels.has(model.name);
790
-
791
- const sctx = { modelToService, resolveDir, dedup, skippedSerializeModels, ctx };
812
+ // Skip `deserialize<X>` when the model never appears as a response (and
813
+ // we have usage info to verify that `undefined` means "emit both halves
814
+ // conservatively"). Cuts unused, partially-typed deserializers like
815
+ // `deserializeCreateWebhookEndpoint` from request-body-only models.
816
+ const shouldSkipDeserialize = responseReachableModels !== undefined && !responseReachableModels.has(model.name);
817
+ if (shouldSkipSerialize && shouldSkipDeserialize) continue;
818
+
819
+ const sctx = {
820
+ modelToService,
821
+ resolveDir,
822
+ dedup,
823
+ skippedSerializeModels,
824
+ responseReachableModels,
825
+ ctx,
826
+ };
792
827
  const lines = [
793
828
  ...buildSerializerImports(model, serializerPath, dirName, domainName, responseName, sctx),
794
829
  ...emitSerializerBody(
@@ -800,6 +835,7 @@ export function generateSerializers(
800
835
  baselineResponse,
801
836
  skipFormatFields,
802
837
  shouldSkipSerialize,
838
+ shouldSkipDeserialize,
803
839
  ctx,
804
840
  ),
805
841
  ];
@@ -812,6 +848,10 @@ export function generateSerializers(
812
848
  }
813
849
 
814
850
  (ctx as any)._skippedSerializeModels = skippedSerializeModels;
851
+ // Surface the response-reachable set so the serializer-roundtrip test
852
+ // generator can fall back to a deserialize-skipped path for request-only
853
+ // models (where `deserialize<X>` was deliberately not emitted).
854
+ (ctx as any)._responseReachableModels = responseReachableModels;
815
855
 
816
856
  // Emit a `serializers/index.ts` barrel per directory that received serializer
817
857
  // files in this pass. Mirrors the per-service `interfaces/index.ts` barrel so
@@ -882,6 +922,8 @@ function buildGeneratedResourceModelUsage(
882
922
  const modelMap = new Map(models.map((model) => [model.name, model]));
883
923
  const interfaceRoots = new Set<string>();
884
924
  const serializerRoots = new Set<string>();
925
+ const requestRoots = new Set<string>();
926
+ const responseRoots = new Set<string>();
885
927
  const resolvedLookup = buildResolvedLookup(ctx);
886
928
  const mountGroups = groupByMount(ctx);
887
929
  const services: Service[] =
@@ -924,16 +966,19 @@ function buildGeneratedResourceModelUsage(
924
966
  if (unwrapped) itemName = unwrapped.name;
925
967
  interfaceRoots.add(itemName);
926
968
  serializerRoots.add(itemName);
969
+ responseRoots.add(itemName);
927
970
  }
928
971
  } else if (plan.responseModelName) {
929
972
  interfaceRoots.add(plan.responseModelName);
930
973
  serializerRoots.add(plan.responseModelName);
974
+ responseRoots.add(plan.responseModelName);
931
975
  }
932
976
 
933
977
  const bodyInfo = extractRequestBodyModels(op, ctx);
934
978
  for (const name of bodyInfo) {
935
979
  interfaceRoots.add(name);
936
980
  serializerRoots.add(name);
981
+ requestRoots.add(name);
937
982
  }
938
983
 
939
984
  for (const param of [...op.pathParams, ...op.queryParams, ...op.headerParams]) {
@@ -945,6 +990,7 @@ function buildGeneratedResourceModelUsage(
945
990
  for (const name of collectWrapperResponseModels(resolved)) {
946
991
  interfaceRoots.add(name);
947
992
  serializerRoots.add(name);
993
+ responseRoots.add(name);
948
994
  }
949
995
  for (const wrapper of resolved.wrappers ?? []) {
950
996
  for (const { field } of resolveWrapperParams(wrapper, ctx)) {
@@ -955,7 +1001,7 @@ function buildGeneratedResourceModelUsage(
955
1001
  }
956
1002
  }
957
1003
 
958
- return { interfaceRoots, serializerRoots };
1004
+ return { interfaceRoots, serializerRoots, requestRoots, responseRoots };
959
1005
  }
960
1006
 
961
1007
  function extractRequestBodyModels(op: Operation, ctx: EmitterContext): string[] {
@@ -2,6 +2,10 @@ import type { Operation, Service, EmitterContext } from '@workos/oagen';
2
2
  import { toPascalCase, toCamelCase, toKebabCase, toSnakeCase } from '@workos/oagen';
3
3
  import { buildResolvedLookup, lookupMethodName } from '../shared/resolved-ops.js';
4
4
  import { stripUrnPrefix } from '../shared/naming-utils.js';
5
+ import {
6
+ buildExportedClassNameSet as buildExportedClassNameSetShared,
7
+ resolveServiceTarget as resolveServiceTargetShared,
8
+ } from '../shared/service-name-collision.js';
5
9
 
6
10
  /** Strip spec-noise suffixes (e.g., "Dto") from an IR name. */
7
11
  export function stripNoiseSuffixes(name: string): string {
@@ -117,6 +121,26 @@ export function resolveServiceName(service: Service, ctx: EmitterContext): strin
117
121
  return resolveClassName(service, ctx);
118
122
  }
119
123
 
124
+ /**
125
+ * Build the set of model + enum class names exported by the SDK. Used to
126
+ * detect collisions with operation-client class names — a colliding service
127
+ * gets a `Service` suffix appended.
128
+ */
129
+ export function buildExportedClassNameSet(ctx: EmitterContext): Set<string> {
130
+ return buildExportedClassNameSetShared(ctx, className);
131
+ }
132
+
133
+ /**
134
+ * Resolve a service's mount-target identifier, appending `Service` on
135
+ * collision with an exported model/enum class. The result feeds `className`
136
+ * and `fileName` so both the `export class` declaration and its file name
137
+ * stay aligned (e.g. `OrganizationMembershipService` /
138
+ * `organization-membership-service.ts`).
139
+ */
140
+ export function resolveServiceTarget(target: string, exportedClasses: Set<string>): string {
141
+ return resolveServiceTargetShared(target, exportedClasses, className);
142
+ }
143
+
120
144
  /**
121
145
  * Build a map from IR service name -> resolved service name.
122
146
  */
@@ -53,6 +53,8 @@ import {
53
53
  resolveInterfaceName,
54
54
  resolveServiceName,
55
55
  wireInterfaceName,
56
+ buildExportedClassNameSet,
57
+ resolveServiceTarget,
56
58
  } from './naming.js';
57
59
  import {
58
60
  docComment,
@@ -102,21 +104,31 @@ export function hasCompatibleConstructor(className: string, ctx: EmitterContext)
102
104
  */
103
105
  export function resolveResourceClassName(service: Service, ctx: EmitterContext): string {
104
106
  const overlayName = resolveServiceName(service, ctx);
107
+ let base: string;
105
108
  if (hasCompatibleConstructor(overlayName, ctx)) {
106
- return overlayName;
107
- }
108
- // Incompatible constructor — fall back to IR name
109
- const irName = toPascalCase(service.name);
110
- if (irName === overlayName) {
111
- return irName + 'Endpoints';
112
- }
113
- return irName;
109
+ base = overlayName;
110
+ } else {
111
+ // Incompatible constructor — fall back to IR name, with `Endpoints` suffix
112
+ // if it would collide with the overlay name.
113
+ const irName = toPascalCase(service.name);
114
+ base = irName === overlayName ? `${irName}Endpoints` : irName;
115
+ }
116
+ // Cross-language `Service` suffix when the chosen class name would shadow
117
+ // an exported model/enum (e.g. tag `OrganizationMembership` + schema
118
+ // `OrganizationMembership`).
119
+ return resolveServiceTarget(base, buildExportedClassNameSet(ctx));
114
120
  }
115
121
 
116
122
  export function resolveResourceDir(service: Service, ctx: EmitterContext): string {
117
123
  const resolvedName = resolveResourceClassName(service, ctx);
118
124
  if (resolvedName === 'WebhooksEndpoints') return 'webhooks';
119
- return resolveServiceDir(resolvedName);
125
+ // Drop the collision-`Service` suffix when picking the directory so the
126
+ // resource and its model-interfaces share a folder (e.g.
127
+ // `organization-membership/` houses both `OrganizationMembershipService`
128
+ // and `OrganizationMembership`'s interface files).
129
+ const overlayName = resolveServiceName(service, ctx);
130
+ const dirBase = resolvedName === `${overlayName}Service` ? overlayName : resolvedName;
131
+ return resolveServiceDir(dirBase);
120
132
  }
121
133
 
122
134
  /** Standard pagination query params handled by PaginationOptions — not imported individually. */
package/src/node/tests.ts CHANGED
@@ -11,6 +11,7 @@ import {
11
11
  servicePropertyName,
12
12
  resolveMethodName,
13
13
  resolveInterfaceName,
14
+ resolveServiceName,
14
15
  wireInterfaceName,
15
16
  } from './naming.js';
16
17
  import { generateFixtures } from './fixtures.js';
@@ -142,7 +143,9 @@ function generateServiceTest(
142
143
  const resolvedName = resolveResourceClassName(service, ctx);
143
144
  const serviceDir = resolveResourceDir(service, ctx);
144
145
  const serviceClass = resolvedName;
145
- const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolvedName);
146
+ // Accessor stays un-suffixed so `client.organizationMembership` resolves even
147
+ // when the class was renamed to dodge a model-name collision.
148
+ const serviceProp = mountAccessors?.get(service.name) ?? servicePropertyName(resolveServiceName(service, ctx));
146
149
  const testPath = `src/${serviceDir}/${fileName(resolvedName)}.spec.ts`;
147
150
 
148
151
  const plans = service.operations.map((op) => ({
@@ -838,18 +841,33 @@ function buildTestPayload(
838
841
  }
839
842
  if (!model) return null;
840
843
 
841
- const fields = model.fields.filter((f) => f.required);
844
+ const requiredFields = model.fields.filter((f) => f.required);
842
845
  // Only use fields that we can generate deterministic values for (primitives, enums, and nested models)
843
- const usableFields = fields.filter((f) => fixtureValueForType(f.type, f.name, model.name, modelMap) !== null);
844
-
845
- // Only generate a typed payload when ALL required fields have fixture values.
846
- // A partial payload missing required fields would fail TypeScript type checking.
847
- if (usableFields.length === 0 || usableFields.length < fields.length) return null;
846
+ const usableRequired = requiredFields.filter(
847
+ (f) => fixtureValueForType(f.type, f.name, model.name, modelMap) !== null,
848
+ );
849
+
850
+ let chosenFields: typeof model.fields;
851
+ if (requiredFields.length > 0) {
852
+ // Only generate a typed payload when ALL required fields have fixture values.
853
+ // A partial payload missing required fields would fail TypeScript type checking.
854
+ if (usableRequired.length < requiredFields.length) return null;
855
+ chosenFields = usableRequired;
856
+ } else {
857
+ // All-optional model (e.g. PATCH `Update<X>` bodies). Pick the first few
858
+ // optional fields with available fixture values so the test asserts the
859
+ // wire format instead of falling back to `expect(fetchBody()).toBeDefined()`.
860
+ const usableOptional = model.fields.filter(
861
+ (f) => !f.required && fixtureValueForType(f.type, f.name, model.name, modelMap) !== null,
862
+ );
863
+ if (usableOptional.length === 0) return null;
864
+ chosenFields = usableOptional.slice(0, 2);
865
+ }
848
866
 
849
867
  const camelEntries: string[] = [];
850
868
  const snakeEntries: string[] = [];
851
869
 
852
- for (const field of usableFields) {
870
+ for (const field of chosenFields) {
853
871
  const camelValue = fixtureValueForType(field.type, field.name, model.name, modelMap)!;
854
872
  const wireValue = fixtureValueForType(field.type, field.name, model.name, modelMap, true)!;
855
873
  const camelKey = fieldName(field.name);
@@ -935,6 +953,10 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
935
953
  // Use the skipped-serialize set computed by the serializer generator.
936
954
  // It's stashed on the context during generateSerializers.
937
955
  const serializeSkipped: Set<string> = (ctx as any)._skippedSerializeModels ?? new Set<string>();
956
+ // Response-reachable models — anything outside this set is request-only
957
+ // and has no `deserialize<X>` to test. `undefined` means "no usage info,
958
+ // assume deserialize exists" (standalone generation, smoke tests).
959
+ const responseReachableModels: Set<string> | undefined = (ctx as any)._responseReachableModels;
938
960
 
939
961
  // Group eligible models by service directory for one test file per service
940
962
  const modelsByDir = new Map<string, Model[]>();
@@ -958,6 +980,7 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
958
980
  const interfaceImports: string[] = [];
959
981
  const fixtureImports: string[] = [];
960
982
  const deserializeOnlyModels = new Set<string>();
983
+ const serializeOnlyModels = new Set<string>();
961
984
 
962
985
  for (const model of models) {
963
986
  const domainName = resolveInterfaceName(model.name, ctx);
@@ -966,13 +989,24 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
966
989
  const serializerPath = `src/${modelDir}/serializers/${fileName(model.name)}.serializer.ts`;
967
990
  const interfacePath = `src/${modelDir}/interfaces/${fileName(model.name)}.interface.ts`;
968
991
  const fixturePath = `src/${modelDir}/fixtures/${fileName(model.name)}.json`;
969
- const deserializeOnly = serializeSkipped.has(model.name) || fixtureIsHandOwned(fixturePath, ctx);
992
+ // Request-only models (e.g. `CreateWebhookEndpoint`) have no
993
+ // `deserialize<X>` emitted, so they can only be tested via serialize.
994
+ // This check has to come first: a hand-owned fixture would otherwise
995
+ // route through the deserialize-only branch, which then imports a
996
+ // function that doesn't exist.
997
+ const isRequestOnly = responseReachableModels !== undefined && !responseReachableModels.has(model.name);
998
+ const deserializeOnly =
999
+ !isRequestOnly && (serializeSkipped.has(model.name) || fixtureIsHandOwned(fixturePath, ctx));
1000
+ const serializeOnly = isRequestOnly;
970
1001
  if (deserializeOnly) deserializeOnlyModels.add(model.name);
1002
+ if (serializeOnly) serializeOnlyModels.add(model.name);
971
1003
 
972
1004
  if (deserializeOnly) {
973
1005
  serializerImports.push(
974
1006
  `import { deserialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
975
1007
  );
1008
+ } else if (serializeOnly) {
1009
+ serializerImports.push(`import { serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`);
976
1010
  } else {
977
1011
  serializerImports.push(
978
1012
  `import { deserialize${domainName}, serialize${domainName} } from '${relativeImport(testPath, serializerPath)}';`,
@@ -1009,6 +1043,15 @@ function generateSerializerTests(spec: ApiSpec, ctx: EmitterContext): GeneratedF
1009
1043
  lines.push(' expect(deserialized).toBeDefined();');
1010
1044
  lines.push(' });');
1011
1045
  lines.push('});');
1046
+ } else if (serializeOnlyModels.has(model.name)) {
1047
+ // Serialize-only test for request-body-only models without a deserializer.
1048
+ lines.push(`describe('${domainName}Serializer', () => {`);
1049
+ lines.push(" it('serializes correctly', () => {");
1050
+ lines.push(` const fixture = ${fixtureName} as ${wireName};`);
1051
+ lines.push(` const serialized = serialize${domainName}(fixture as any);`);
1052
+ lines.push(' expect(serialized).toBeDefined();');
1053
+ lines.push(' });');
1054
+ lines.push('});');
1012
1055
  } else {
1013
1056
  // Round-trip test
1014
1057
  lines.push(`describe('${domainName}Serializer', () => {`);