@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/src/php/client.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { ApiSpec, Service, EmitterContext, GeneratedFile } from '@workos/oagen';
2
2
  import { toPascalCase, toCamelCase } from '@workos/oagen';
3
- import { className, servicePropertyName } from './naming.js';
3
+ import { className, servicePropertyName, buildExportedClassNameSet, resolveServiceTarget } from './naming.js';
4
4
  import { getMountTarget } from '../shared/resolved-ops.js';
5
5
  import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
6
6
 
@@ -61,11 +61,12 @@ export function buildServiceAccessPaths(services: Service[], ctx: EmitterContext
61
61
 
62
62
  function deduplicateByMount(services: Service[], ctx: EmitterContext): { name: string; propName: string }[] {
63
63
  const seen = new Map<string, { name: string; propName: string }>();
64
+ const exportedClasses = buildExportedClassNameSet(ctx);
64
65
  for (const service of services) {
65
66
  const target = getMountTarget(service, ctx);
66
67
  if (!seen.has(target)) {
67
68
  seen.set(target, {
68
- name: className(target),
69
+ name: className(resolveServiceTarget(target, exportedClasses)),
69
70
  propName: servicePropertyName(target),
70
71
  });
71
72
  }
package/src/php/naming.ts CHANGED
@@ -2,6 +2,10 @@ import type { Service, Operation, EmitterContext, Enum } from '@workos/oagen';
2
2
  import { toPascalCase, toCamelCase, toSnakeCase } from '@workos/oagen';
3
3
  import { buildResolvedLookup, lookupMethodName } from '../shared/resolved-ops.js';
4
4
  import { stripUrnPrefix, applyAcronymFixes } 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
  /** Namespace grouping result (shared with client.ts). */
7
11
  export interface NamespaceGroup {
@@ -90,6 +94,24 @@ export function className(name: string): string {
90
94
  return result;
91
95
  }
92
96
 
97
+ /**
98
+ * Build the set of model + enum class names exported by the SDK. Used to
99
+ * detect collisions with operation-client class names — a colliding service
100
+ * gets a `Service` suffix appended.
101
+ */
102
+ export function buildExportedClassNameSet(ctx: EmitterContext): Set<string> {
103
+ return buildExportedClassNameSetShared(ctx, className);
104
+ }
105
+
106
+ /**
107
+ * Resolve a service's mount-target identifier, appending `Service` on
108
+ * collision with an exported model/enum class. Used in `\Service\…` files
109
+ * to avoid `use WorkOS\Resource\X; class X` PHP fatal errors.
110
+ */
111
+ export function resolveServiceTarget(target: string, exportedClasses: Set<string>): string {
112
+ return resolveServiceTargetShared(target, exportedClasses, className);
113
+ }
114
+
93
115
  /** PascalCase file name (without extension) — PSR-4 convention. */
94
116
  export function fileName(name: string): string {
95
117
  return className(name);
@@ -1,7 +1,7 @@
1
1
  import type { Service, Operation, Model, EmitterContext, GeneratedFile, ResolvedOperation } from '@workos/oagen';
2
2
  import { planOperation, toCamelCase, toPascalCase } from '@workos/oagen';
3
3
  import { mapTypeRef, mapTypeRefForPHPDoc } from './type-map.js';
4
- import { className, fieldName, resolveMethodName } from './naming.js';
4
+ import { className, fieldName, resolveMethodName, buildExportedClassNameSet, resolveServiceTarget } from './naming.js';
5
5
  import { isListWrapperModel } from './models.js';
6
6
  import {
7
7
  groupByMount,
@@ -43,9 +43,10 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
43
43
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
44
44
  : services.map((s) => ({ name: className(s.name), operations: s.operations }));
45
45
 
46
+ const exportedClasses = buildExportedClassNameSet(ctx);
46
47
  for (const { name: mountName, operations } of entries) {
47
48
  if (operations.length === 0) continue;
48
- const resourceName = className(mountName);
49
+ const resourceName = className(resolveServiceTarget(mountName, exportedClasses));
49
50
  const mergedService: Service = { name: mountName, operations };
50
51
  const lines: string[] = [];
51
52
 
@@ -1,6 +1,14 @@
1
1
  import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
2
  import { toPascalCase } from '@workos/oagen';
3
- import { className, resolveServiceDir, servicePropertyName, buildMountDirMap, dirToModule } from './naming.js';
3
+ import {
4
+ className,
5
+ resolveServiceDir,
6
+ servicePropertyName,
7
+ buildMountDirMap,
8
+ dirToModule,
9
+ buildExportedClassNameSet,
10
+ resolveServiceTarget,
11
+ } from './naming.js';
4
12
  import { resolveResourceClassName, collectParameterGroupClassNames } from './resources.js';
5
13
  import { getMountTarget, groupByMount } from '../shared/resolved-ops.js';
6
14
  import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
@@ -149,9 +157,10 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
149
157
 
150
158
  // Import resource classes (both sync and async)
151
159
  const serviceDirMap = buildMountDirMap(ctx);
160
+ const exportedClasses = buildExportedClassNameSet(ctx);
152
161
  for (const service of topLevelServices) {
153
162
  const resolvedName = resolveResourceClassName(service, ctx);
154
- const clsName = className(resolvedName);
163
+ const clsName = className(resolveServiceTarget(resolvedName, exportedClasses));
155
164
  const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
156
165
  const importLine = `from .${dirToModule(dirName)}._resource import ${clsName}, Async${clsName}`;
157
166
  if (importLine.length > 88) {
@@ -185,7 +194,7 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
185
194
  const generatedProps = new Set<string>();
186
195
  for (const service of topLevelServices) {
187
196
  const resolvedName = resolveResourceClassName(service, ctx);
188
- const clsName = className(resolvedName);
197
+ const clsName = className(resolveServiceTarget(resolvedName, exportedClasses));
189
198
  const prop = servicePropertyName(resolvedName);
190
199
  const readable = clsName.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
191
200
  lines.push('');
@@ -208,7 +217,7 @@ function generateWorkOSClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
208
217
  const asyncGeneratedProps = new Set<string>();
209
218
  for (const service of topLevelServices) {
210
219
  const resolvedName = resolveResourceClassName(service, ctx);
211
- const clsName = className(resolvedName);
220
+ const clsName = className(resolveServiceTarget(resolvedName, exportedClasses));
212
221
  const prop = servicePropertyName(resolvedName);
213
222
  const readable = clsName.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/([A-Z]+)([A-Z][a-z])/g, '$1 $2');
214
223
  lines.push('');
@@ -255,6 +264,7 @@ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
255
264
  const files: GeneratedFile[] = [];
256
265
  const topLevel = deduplicateByMount(spec.services, ctx);
257
266
  const serviceDirMap = buildMountDirMap(ctx);
267
+ const exportedClasses = buildExportedClassNameSet(ctx);
258
268
 
259
269
  // Build a map from mount target -> operations so we can discover parameter
260
270
  // group dataclasses that need re-exporting from __init__.py.
@@ -262,6 +272,7 @@ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
262
272
 
263
273
  for (const service of topLevel) {
264
274
  const resolvedName = resolveResourceClassName(service, ctx);
275
+ const clsName = className(resolveServiceTarget(resolvedName, exportedClasses));
265
276
  const dirName = serviceDirMap.get(service.name) ?? resolveServiceDir(resolvedName);
266
277
  const lines: string[] = [];
267
278
 
@@ -274,7 +285,7 @@ function generateServiceInits(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
274
285
  // public re-exports. Otherwise consumers importing
275
286
  // `from workos.user_management import RoleSingle` get a private-import
276
287
  // warning under strict mode. Models barrel uses the same convention.
277
- const resourceImports = [resolvedName, `Async${resolvedName}`, ...groupClassNames];
288
+ const resourceImports = [clsName, `Async${clsName}`, ...groupClassNames];
278
289
  const aliasedImports = resourceImports.map((n) => `${n} as ${n}`);
279
290
  lines.push(`from ._resource import ${aliasedImports.join(', ')}`);
280
291
  lines.push('from .models import *');
@@ -2,6 +2,10 @@ import type { Operation, Service, EmitterContext } from '@workos/oagen';
2
2
  import { toPascalCase, toSnakeCase } from '@workos/oagen';
3
3
  import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
4
4
  import { stripUrnPrefix, applyAcronymFixes } 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
  /**
7
11
  * Python class names that collide with builtins or typing imports.
@@ -136,6 +140,27 @@ export function resolveMethodName(op: Operation, _service: Service, ctx: Emitter
136
140
  return toSnakeCase(op.name);
137
141
  }
138
142
 
143
+ /**
144
+ * Build the set of model + enum class names exported by the SDK. Used to
145
+ * detect collisions with operation-client class names — a colliding service
146
+ * gets a `Service` suffix appended.
147
+ */
148
+ export function buildExportedClassNameSet(ctx: EmitterContext): Set<string> {
149
+ return buildExportedClassNameSetShared(ctx, className);
150
+ }
151
+
152
+ /**
153
+ * Resolve a service's mount-target identifier, appending `Service` on
154
+ * collision with an exported model/enum class. Feeds `className`/`fileName`
155
+ * so the class declaration, file, and any qualified references stay aligned.
156
+ *
157
+ * Accessor names (`servicePropertyName`) intentionally use the RAW target —
158
+ * `client.organization_membership` reads better than the suffixed form.
159
+ */
160
+ export function resolveServiceTarget(target: string, exportedClasses: Set<string>): string {
161
+ return resolveServiceTargetShared(target, exportedClasses, className);
162
+ }
163
+
139
164
  /** Resolve the SDK class name for a service, using resolved operations' mountOn. */
140
165
  export function resolveClassName(service: Service, ctx: EmitterContext): string {
141
166
  // Use resolved ops mountOn as canonical class name (flat pattern like PHP)
@@ -23,6 +23,8 @@ import {
23
23
  buildMountDirMap,
24
24
  dirToModule,
25
25
  relativeImportPrefix,
26
+ buildExportedClassNameSet,
27
+ resolveServiceTarget,
26
28
  } from './naming.js';
27
29
  import {
28
30
  buildResolvedLookup,
@@ -1012,10 +1014,11 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
1012
1014
  ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations }))
1013
1015
  : services.map((s) => ({ name: resolveClassName(s, ctx), operations: s.operations }));
1014
1016
 
1017
+ const exportedClasses = buildExportedClassNameSet(ctx);
1015
1018
  for (const { name: mountName, operations: allOperations } of entries) {
1016
1019
  if (allOperations.length === 0) continue;
1017
1020
  const dirName = moduleName(mountName);
1018
- const resourceClassName = className(mountName);
1021
+ const resourceClassName = className(resolveServiceTarget(mountName, exportedClasses));
1019
1022
  const importPrefix = relativeImportPrefix(dirName);
1020
1023
 
1021
1024
  const lines: string[] = [];
@@ -1,6 +1,14 @@
1
1
  import type { ApiSpec, EmitterContext, GeneratedFile, Service, Model, Enum } from '@workos/oagen';
2
2
  import { assignModelsToServices } from '@workos/oagen';
3
- import { servicePropertyName, resolveClassName, className, fileName, buildMountDirMap } from './naming.js';
3
+ import {
4
+ servicePropertyName,
5
+ resolveClassName,
6
+ className,
7
+ fileName,
8
+ buildMountDirMap,
9
+ buildExportedClassNameSet,
10
+ resolveServiceTarget,
11
+ } from './naming.js';
4
12
  import { classifyUnassignedModel } from './models.js';
5
13
  import { getMountTarget } from '../shared/resolved-ops.js';
6
14
  import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
@@ -89,8 +97,12 @@ function buildInflectionMap(spec: ApiSpec, ctx: EmitterContext): Map<string, str
89
97
 
90
98
  inflections.set('workos', 'WorkOS');
91
99
 
100
+ const exportedClasses = buildExportedClassNameSet(ctx);
92
101
  for (const service of buildTopLevelServices(spec, ctx)) {
93
- const target = getMountTarget(service, ctx) || resolveClassName(service, ctx);
102
+ const target = resolveServiceTarget(
103
+ getMountTarget(service, ctx) || resolveClassName(service, ctx),
104
+ exportedClasses,
105
+ );
94
106
  const cls = className(target);
95
107
  const file = fileName(target);
96
108
  if (rubyCamelize(file) !== cls) inflections.set(file, cls);
@@ -203,10 +215,11 @@ function generateClientClass(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
203
215
  lines.push(' class Client < BaseClient');
204
216
 
205
217
  const topLevelServices = buildTopLevelServices(spec, ctx);
218
+ const exportedClasses = buildExportedClassNameSet(ctx);
206
219
  for (const service of topLevelServices) {
207
- const target = getMountTarget(service, ctx) || resolveClassName(service, ctx);
208
- const cls = className(target);
209
- const prop = servicePropertyName(target);
220
+ const rawTarget = getMountTarget(service, ctx) || resolveClassName(service, ctx);
221
+ const cls = className(resolveServiceTarget(rawTarget, exportedClasses));
222
+ const prop = servicePropertyName(rawTarget);
210
223
  lines.push('');
211
224
  lines.push(` def ${prop}`);
212
225
  lines.push(` @${prop} ||= WorkOS::${cls}.new(self)`);
@@ -7,9 +7,14 @@ import { servicePropertyName } from './naming.js';
7
7
  * Uses each resolved operation's actual mountOn (not the service default) so
8
8
  * operations remounted via operationHints land on the correct service prop.
9
9
  * Split operations emit one entry per wrapper (keyed by wrapper name + variant).
10
+ *
11
+ * The accessor (`service` field) uses the raw mountOn — accessor names stay
12
+ * unsuffixed even when the underlying service class gets a `Service` suffix
13
+ * on collision.
10
14
  */
11
15
  export function buildOperationsMap(spec: ApiSpec, ctx: EmitterContext): OperationsMap {
12
16
  void spec;
17
+ void ctx;
13
18
  const manifest: OperationsMap = {};
14
19
 
15
20
  for (const r of ctx.resolvedOperations ?? []) {
@@ -2,6 +2,10 @@ import type { Operation, Service, EmitterContext } from '@workos/oagen';
2
2
  import { toPascalCase, toSnakeCase } from '@workos/oagen';
3
3
  import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
4
4
  import { stripUrnPrefix, applyAcronymFixes } 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
  /**
7
11
  * Ruby class names that collide with core classes. When a model name resolves
@@ -117,6 +121,32 @@ export function moduleName(name: string): string {
117
121
  return toSnakeCase(name);
118
122
  }
119
123
 
124
+ /**
125
+ * Build the set of model + enum Ruby class names that the SDK exposes under
126
+ * `WorkOS::`. Used to detect collisions with operation-client class names —
127
+ * a colliding service gets a `Service` suffix (`OrganizationMembershipService`)
128
+ * so it doesn't shadow the model class under Zeitwerk's collapsed namespace.
129
+ */
130
+ export function buildExportedClassNameSet(ctx: EmitterContext): Set<string> {
131
+ return buildExportedClassNameSetShared(ctx, className);
132
+ }
133
+
134
+ /**
135
+ * Resolve a service's mount-target identifier, appending `Service` on
136
+ * collision with an exported model/enum class name. The returned PascalCase
137
+ * value feeds `className`/`fileName` to derive matching class + file names
138
+ * (e.g. `OrganizationMembershipService` / `organization_membership_service`).
139
+ *
140
+ * Accessor names (`servicePropertyName`) intentionally use the RAW target —
141
+ * `client.organization_membership` is more readable than the suffixed form.
142
+ *
143
+ * The directory used by `loader.collapse` (the model home) likewise uses the
144
+ * raw target.
145
+ */
146
+ export function resolveServiceTarget(target: string, exportedClasses: Set<string>): string {
147
+ return resolveServiceTargetShared(target, exportedClasses, className);
148
+ }
149
+
120
150
  /**
121
151
  * PascalCase class name for a parameter-group variant. Mirrors the Python
122
152
  * convention: group "password" + variant "plaintext" → `PasswordPlaintext`.
package/src/ruby/rbi.ts CHANGED
@@ -7,6 +7,9 @@ import {
7
7
  safeParamName,
8
8
  scopedGroupVariantClassName,
9
9
  resolveMethodName,
10
+ servicePropertyName,
11
+ buildExportedClassNameSet,
12
+ resolveServiceTarget,
10
13
  } from './naming.js';
11
14
  import {
12
15
  buildResolvedLookup,
@@ -129,9 +132,11 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
129
132
  if (isListWrapperModel(m)) listWrapperModels.set(m.name, m);
130
133
  }
131
134
  const groupOwners = buildGroupOwnerMap(ctx);
135
+ const exportedClasses = buildExportedClassNameSet(ctx);
132
136
 
133
137
  for (const [mountTarget, group] of groups) {
134
- const cls = className(mountTarget);
138
+ const resolvedTarget = resolveServiceTarget(mountTarget, exportedClasses);
139
+ const cls = className(resolvedTarget);
135
140
  const lines: string[] = [];
136
141
  lines.push('# typed: strong');
137
142
  lines.push('');
@@ -143,6 +148,9 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
143
148
  // layout in lib/workos/<service>.rb.
144
149
  const variants = collectVariantsForMountTarget(ctx, spec.models as Model[], mountTarget);
145
150
  for (const v of variants) {
151
+ // Rewrite mountTarget to the (possibly pluralized) service class so the
152
+ // RBI's `WorkOS::<Service>::<Variant>` reference matches the runtime.
153
+ v.mountTarget = resolvedTarget;
146
154
  for (const line of emitInlineVariantRbi(v)) lines.push(line);
147
155
  lines.push('');
148
156
  }
@@ -183,7 +191,8 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
183
191
  if (!owner) {
184
192
  throw new Error(`No owner mount target found for parameter group '${group.name}'`);
185
193
  }
186
- const variants = group.variants.map((v) => scopedGroupVariantClassName(owner, group.name, v.name));
194
+ const resolvedOwner = resolveServiceTarget(owner, exportedClasses);
195
+ const variants = group.variants.map((v) => scopedGroupVariantClassName(resolvedOwner, group.name, v.name));
187
196
  if (variants.length === 1) return variants[0];
188
197
  return `T.any(${variants.join(', ')})`;
189
198
  };
@@ -267,7 +276,7 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
267
276
  lines.push('end');
268
277
 
269
278
  files.push({
270
- path: `rbi/workos/${fileName(mountTarget)}.rbi`,
279
+ path: `rbi/workos/${fileName(resolvedTarget)}.rbi`,
271
280
  content: lines.join('\n'),
272
281
  integrateTarget: true,
273
282
  overwriteExisting: true,
@@ -283,11 +292,9 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
283
292
  lines.push(' class Client < BaseClient');
284
293
 
285
294
  for (const [mountTarget] of groups) {
286
- const cls = className(mountTarget);
287
- const prop = mountTarget
288
- .replace(/-/g, '_')
289
- .replace(/[A-Z]/g, (ch) => `_${ch.toLowerCase()}`)
290
- .replace(/^_/, '');
295
+ const resolvedTarget = resolveServiceTarget(mountTarget, exportedClasses);
296
+ const cls = className(resolvedTarget);
297
+ const prop = servicePropertyName(mountTarget);
291
298
  lines.push(` sig { returns(WorkOS::${cls}) }`);
292
299
  lines.push(` def ${prop}; end`);
293
300
  lines.push('');
@@ -8,6 +8,8 @@ import {
8
8
  safeParamName,
9
9
  resolveMethodName,
10
10
  scopedGroupVariantClassName,
11
+ buildExportedClassNameSet,
12
+ resolveServiceTarget,
11
13
  } from './naming.js';
12
14
  import { mapTypeRefForYard } from './type-map.js';
13
15
  import {
@@ -48,10 +50,12 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
48
50
  // dispatcher and YARD `@param` reference resolves variant classes through
49
51
  // this map so cross-resource references stay consistent.
50
52
  const groupOwners = buildGroupOwnerMap(ctx);
53
+ const exportedClasses = buildExportedClassNameSet(ctx);
51
54
 
52
55
  for (const [mountTarget, group] of groups) {
53
- const cls = className(mountTarget);
54
- const file = fileName(mountTarget);
56
+ const resolvedTarget = resolveServiceTarget(mountTarget, exportedClasses);
57
+ const cls = className(resolvedTarget);
58
+ const file = fileName(resolvedTarget);
55
59
 
56
60
  const operations = group.operations;
57
61
  if (operations.length === 0) continue;
@@ -103,6 +107,7 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
103
107
  listWrapperModels,
104
108
  requires,
105
109
  groupOwners,
110
+ exportedClasses,
106
111
  });
107
112
  methodBodies.push(body);
108
113
 
@@ -182,6 +187,7 @@ function emitMethod(args: {
182
187
  listWrapperModels: Map<string, Model>;
183
188
  requires: Set<string>;
184
189
  groupOwners: Map<string, string>;
190
+ exportedClasses: Set<string>;
185
191
  }): string {
186
192
  const {
187
193
  op,
@@ -195,6 +201,7 @@ function emitMethod(args: {
195
201
  listWrapperModels,
196
202
  requires,
197
203
  groupOwners,
204
+ exportedClasses,
198
205
  } = args;
199
206
  void enumNames;
200
207
 
@@ -204,7 +211,7 @@ function emitMethod(args: {
204
211
  if (!owner) {
205
212
  throw new Error(`No owner mount target found for parameter group '${group.name}'`);
206
213
  }
207
- return scopedGroupVariantClassName(owner, group.name, variantName);
214
+ return scopedGroupVariantClassName(resolveServiceTarget(owner, exportedClasses), group.name, variantName);
208
215
  };
209
216
 
210
217
  const plan = planOperation(op);
package/src/ruby/tests.ts CHANGED
@@ -7,6 +7,8 @@ import {
7
7
  scopedGroupVariantClassName,
8
8
  servicePropertyName,
9
9
  resolveMethodName,
10
+ buildExportedClassNameSet,
11
+ resolveServiceTarget,
10
12
  } from './naming.js';
11
13
  import {
12
14
  buildResolvedLookup,
@@ -38,11 +40,13 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
38
40
 
39
41
  const lookup = buildResolvedLookup(ctx);
40
42
  const groupOwners = buildGroupOwnerMap(ctx);
43
+ const exportedClasses = buildExportedClassNameSet(ctx);
41
44
 
42
45
  for (const [mountTarget, group] of groups) {
43
- const cls = className(mountTarget);
46
+ const resolvedTarget = resolveServiceTarget(mountTarget, exportedClasses);
47
+ const cls = className(resolvedTarget);
44
48
  const prop = servicePropertyName(mountTarget);
45
- const file = fileName(mountTarget);
49
+ const file = fileName(resolvedTarget);
46
50
 
47
51
  const lines: string[] = [];
48
52
  lines.push(`require 'test_helper'`);
@@ -85,7 +89,7 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
85
89
 
86
90
  const resolved = lookupResolved(op, lookup);
87
91
  const hiddenParams = buildHiddenParams(resolved);
88
- const callArgs = buildCallArgsStub(op, modelByName, hiddenParams, groupOwners, models);
92
+ const callArgs = buildCallArgsStub(op, modelByName, hiddenParams, groupOwners, models, exportedClasses);
89
93
  const bodyMatcher = buildBodyMatcher(op, modelByName, hiddenParams, models);
90
94
 
91
95
  // Collect method info for the parameterized 401 test (T20).
@@ -118,7 +122,15 @@ export function generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile
118
122
  for (let vi = 1; vi < group.variants.length; vi++) {
119
123
  const variant = group.variants[vi];
120
124
  const overrides = new Map<string, number>([[group.name, vi]]);
121
- const variantCallArgs = buildCallArgsStub(op, modelByName, hiddenParams, groupOwners, models, overrides);
125
+ const variantCallArgs = buildCallArgsStub(
126
+ op,
127
+ modelByName,
128
+ hiddenParams,
129
+ groupOwners,
130
+ models,
131
+ exportedClasses,
132
+ overrides,
133
+ );
122
134
  const variantBodyMatcher = buildBodyMatcher(op, modelByName, hiddenParams, models, overrides);
123
135
  const suffix = `with_${fieldName(group.name)}_${fieldName(variant.name)}`;
124
136
  lines.push('');
@@ -338,6 +350,7 @@ function buildCallArgsStub(
338
350
  hiddenParams: Set<string>,
339
351
  groupOwners: Map<string, string>,
340
352
  models: Model[],
353
+ exportedClasses: Set<string>,
341
354
  variantOverrides: Map<string, number> = new Map(),
342
355
  ): string {
343
356
  const parts: string[] = [];
@@ -406,7 +419,11 @@ function buildCallArgsStub(
406
419
  if (!owner) {
407
420
  throw new Error(`No owner mount target found for parameter group '${group.name}'`);
408
421
  }
409
- const variantClass = scopedGroupVariantClassName(owner, group.name, variant.name);
422
+ const variantClass = scopedGroupVariantClassName(
423
+ resolveServiceTarget(owner, exportedClasses),
424
+ group.name,
425
+ variant.name,
426
+ );
410
427
  const fieldStubs = variant.parameters
411
428
  .map((p) => `${fieldName(p.name)}: ${stubValueFor(pickVariantParamType(p.type, bodyFieldTypes.get(p.name)))}`)
412
429
  .join(', ');
@@ -0,0 +1,56 @@
1
+ import type { EmitterContext, Model, Enum } from '@workos/oagen';
2
+ import { isListWrapperModel, isListMetadataModel } from './model-utils.js';
3
+
4
+ /**
5
+ * Suffix applied to an operation-client class name when it collides with an
6
+ * exported model/enum class name in the same SDK namespace. Standardized
7
+ * across emitters so colliding services look the same in every language —
8
+ * e.g. `OrganizationMembershipService` regardless of language.
9
+ *
10
+ * Languages whose operation clients already carry a unique suffix (Go's
11
+ * `…Service`, Rust's `…Api`, .NET's `…Service`) skip this helper entirely.
12
+ */
13
+ export const SERVICE_COLLISION_SUFFIX = 'Service';
14
+
15
+ /**
16
+ * Build the set of model + enum class names that the SDK exports under its
17
+ * top-level namespace. Each emitter passes its own `classNameFn` so the
18
+ * comparison happens on the language-specific class-name form (e.g. Ruby's
19
+ * `RoleList`, Python's `RoleList`).
20
+ *
21
+ * List-wrapper and list-metadata models are excluded — they aren't exposed
22
+ * as user-facing types.
23
+ */
24
+ export function buildExportedClassNameSet(ctx: EmitterContext, classNameFn: (name: string) => string): Set<string> {
25
+ const out = new Set<string>();
26
+ for (const model of ctx.spec.models as Model[]) {
27
+ if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
28
+ out.add(classNameFn(model.name));
29
+ }
30
+ for (const enumDef of ctx.spec.enums as Enum[]) {
31
+ out.add(classNameFn(enumDef.name));
32
+ }
33
+ return out;
34
+ }
35
+
36
+ /**
37
+ * Resolve the PascalCase mount-target identifier for an operation client,
38
+ * appending `Service` when the un-suffixed class name would shadow an
39
+ * exported model or enum.
40
+ *
41
+ * Operates on the PascalCase target (the mount-target string the IR carries),
42
+ * so the returned value feeds cleanly into each language's `className` and
43
+ * `fileName` helpers — e.g. `OrganizationMembership` → `OrganizationMembershipService`,
44
+ * then `fileName` → `organization_membership_service` / `organization-membership-service`.
45
+ *
46
+ * The accessor on the client (`client.organization_membership`) is intentionally
47
+ * NOT suffixed — callers should keep using the raw target for `servicePropertyName`
48
+ * so the accessor reads naturally.
49
+ */
50
+ export function resolveServiceTarget(
51
+ target: string,
52
+ exportedClasses: Set<string>,
53
+ classNameFn: (name: string) => string,
54
+ ): string {
55
+ return exportedClasses.has(classNameFn(target)) ? `${target}${SERVICE_COLLISION_SUFFIX}` : target;
56
+ }
@@ -590,4 +590,60 @@ describe('generateSerializers', () => {
590
590
  expect(barrel!.overwriteExisting).toBe(true);
591
591
  }
592
592
  });
593
+
594
+ it('omits deserialize half for request-body-only models', () => {
595
+ // `CreateOrganization` is sent as a POST body but the operation responds
596
+ // with a separate `Organization` model. The deserializer for the request
597
+ // model would be dead code AND would silently misbehave if called (the
598
+ // response wire shape doesn't match), so it shouldn't be emitted.
599
+ const models: Model[] = [
600
+ {
601
+ name: 'Organization',
602
+ fields: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
603
+ },
604
+ {
605
+ name: 'CreateOrganization',
606
+ fields: [{ name: 'name', type: { kind: 'primitive', type: 'string' }, required: true }],
607
+ },
608
+ ];
609
+
610
+ const spec: ApiSpec = {
611
+ ...emptySpec,
612
+ models,
613
+ services: [
614
+ {
615
+ name: 'Organizations',
616
+ operations: [
617
+ {
618
+ name: 'createOrganization',
619
+ httpMethod: 'post',
620
+ path: '/organizations',
621
+ pathParams: [],
622
+ queryParams: [],
623
+ headerParams: [],
624
+ response: { kind: 'model', name: 'Organization' },
625
+ requestBody: { kind: 'model', name: 'CreateOrganization' },
626
+ errors: [],
627
+ injectIdempotencyKey: false,
628
+ },
629
+ ],
630
+ },
631
+ ],
632
+ };
633
+
634
+ const ctxWithModels: EmitterContext = { ...ctx, spec };
635
+ const result = generateSerializers(models, ctxWithModels);
636
+
637
+ const createSerializer = result.find((f) => f.path.endsWith('create-organization.serializer.ts'));
638
+ expect(createSerializer).toBeDefined();
639
+ expect(createSerializer!.content).toContain('export const serializeCreateOrganization');
640
+ expect(createSerializer!.content).not.toContain('export const deserializeCreateOrganization');
641
+
642
+ // Response-side model still gets both halves.
643
+ const orgSerializer = result.find(
644
+ (f) => f.path.endsWith('organization.serializer.ts') && !f.path.includes('create'),
645
+ );
646
+ expect(orgSerializer).toBeDefined();
647
+ expect(orgSerializer!.content).toContain('export const deserializeOrganization');
648
+ });
593
649
  });