@workos/oagen-emitters 0.6.7 → 0.6.8

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-Bk0xWTQC.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-Cmg_LFtm.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.6.7",
3
+ "version": "0.6.8",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
package/src/ruby/index.ts CHANGED
@@ -30,7 +30,8 @@ export const rubyEmitter: Emitter = {
30
30
  language: 'ruby',
31
31
 
32
32
  generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
33
- return ensureTrailingNewlines(generateModels(models, ctx));
33
+ const modelFiles = generateModels(models, ctx);
34
+ return ensureTrailingNewlines(modelFiles);
34
35
  },
35
36
 
36
37
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
@@ -54,7 +55,8 @@ export const rubyEmitter: Emitter = {
54
55
  },
55
56
 
56
57
  generateTypeSignatures(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
57
- return ensureTrailingNewlines(generateRbiFiles(spec, ctx));
58
+ const rbiFiles = generateRbiFiles(spec, ctx);
59
+ return ensureTrailingNewlines(rbiFiles);
58
60
  },
59
61
 
60
62
  generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
@@ -117,6 +117,29 @@ export function moduleName(name: string): string {
117
117
  return toSnakeCase(name);
118
118
  }
119
119
 
120
+ /**
121
+ * PascalCase class name for a parameter-group variant. Mirrors the Python
122
+ * convention: group "password" + variant "plaintext" → `PasswordPlaintext`.
123
+ * Used as the Ruby constant under the WorkOS module.
124
+ */
125
+ export function groupVariantClassName(groupName: string, variantName: string): string {
126
+ return className(`${groupName}_${variantName}`);
127
+ }
128
+
129
+ /** snake_case Zeitwerk-compatible filename for a parameter-group variant. */
130
+ export function groupVariantFileName(groupName: string, variantName: string): string {
131
+ return fileName(`${groupName}_${variantName}`);
132
+ }
133
+
134
+ /**
135
+ * Fully-qualified Ruby constant for a parameter-group variant scoped under
136
+ * its owning resource module — e.g. "WorkOS::UserManagement::PasswordPlaintext".
137
+ * Mirrors Python's `workos.user_management.PasswordPlaintext` namespacing.
138
+ */
139
+ export function scopedGroupVariantClassName(mountTarget: string, groupName: string, variantName: string): string {
140
+ return `WorkOS::${className(mountTarget)}::${groupVariantClassName(groupName, variantName)}`;
141
+ }
142
+
120
143
  /** snake_case property name for service accessors on the client. */
121
144
  export function servicePropertyName(name: string): string {
122
145
  return toSnakeCase(name);
@@ -0,0 +1,221 @@
1
+ import type { EmitterContext, TypeRef, Model } from '@workos/oagen';
2
+ import { className, fieldName, groupVariantClassName } from './naming.js';
3
+ import { mapTypeRefForYard } from './type-map.js';
4
+ import { collectBodyFieldTypes, groupByMount } from '../shared/resolved-ops.js';
5
+
6
+ /**
7
+ * Sorbet type string for a TypeRef. Mirrors `mapSorbetType` in rbi.ts but
8
+ * lives here so the parameter-groups module is self-contained.
9
+ */
10
+ function mapSorbetType(ref: TypeRef): string {
11
+ switch (ref.kind) {
12
+ case 'primitive':
13
+ switch (ref.type) {
14
+ case 'string':
15
+ return 'String';
16
+ case 'integer':
17
+ return 'Integer';
18
+ case 'number':
19
+ return 'Float';
20
+ case 'boolean':
21
+ return 'T::Boolean';
22
+ case 'unknown':
23
+ return 'T.untyped';
24
+ }
25
+ break;
26
+ case 'array':
27
+ return `T::Array[${mapSorbetType(ref.items)}]`;
28
+ case 'model':
29
+ return `WorkOS::${className(ref.name)}`;
30
+ case 'enum':
31
+ return 'String';
32
+ case 'nullable':
33
+ return `T.nilable(${mapSorbetType(ref.inner)})`;
34
+ case 'literal':
35
+ if (typeof ref.value === 'string') return 'String';
36
+ if (ref.value === null) return 'NilClass';
37
+ if (typeof ref.value === 'number') return Number.isInteger(ref.value) ? 'Integer' : 'Float';
38
+ return 'T::Boolean';
39
+ case 'union': {
40
+ const variants = ref.variants.map((v) => mapSorbetType(v));
41
+ const unique = [...new Set(variants)];
42
+ if (unique.length === 1) return unique[0];
43
+ return `T.any(${unique.join(', ')})`;
44
+ }
45
+ case 'map':
46
+ return `T::Hash[String, ${mapSorbetType(ref.valueType)}]`;
47
+ }
48
+ return 'T.untyped';
49
+ }
50
+
51
+ export interface CollectedVariant {
52
+ className: string;
53
+ groupName: string;
54
+ variantName: string;
55
+ /** PascalCase mount target this variant is scoped under (e.g. "UserManagement"). */
56
+ mountTarget: string;
57
+ parameters: { name: string; type: TypeRef }[];
58
+ }
59
+
60
+ /**
61
+ * Build a stable groupName -> mountTarget map. Each parameter group is owned
62
+ * by exactly one resource module — Ruby variant classes are inlined into
63
+ * `WorkOS::<MountTarget>::<Variant>` (matching Python's per-resource layout),
64
+ * so a dispatcher in another resource that references the same group still
65
+ * resolves to a single canonical class.
66
+ *
67
+ * Mount targets are visited in alphabetical order so first-wins is
68
+ * deterministic across runs. In the current spec no group is shared across
69
+ * mount targets; if one ever is, the alphabetically-first owner gets the
70
+ * class and other dispatchers reference it by full path.
71
+ */
72
+ export function buildGroupOwnerMap(ctx: EmitterContext): Map<string, string> {
73
+ const owner = new Map<string, string>();
74
+ const groups = groupByMount(ctx);
75
+ const sortedTargets = [...groups.keys()].sort();
76
+ for (const target of sortedTargets) {
77
+ const g = groups.get(target);
78
+ if (!g) continue;
79
+ for (const op of g.operations) {
80
+ for (const grp of op.parameterGroups ?? []) {
81
+ if (!owner.has(grp.name)) owner.set(grp.name, target);
82
+ }
83
+ }
84
+ }
85
+ return owner;
86
+ }
87
+
88
+ /**
89
+ * Collect all variant classes a given mount target owns. Variants are
90
+ * inlined into the resource file (and its RBI counterpart) — Zeitwerk's
91
+ * collapse convention means subdirectories under `lib/workos/<service>/`
92
+ * don't add a namespace level, so files there can't define
93
+ * `WorkOS::<Service>::<Variant>`. Inline definitions sidestep that.
94
+ *
95
+ * Variant parameter types are taken from the IR's leaf type. When the IR's
96
+ * leaf is a bare primitive but the request body model has a richer type
97
+ * (array/enum/model/map), we fall back to the body type to recover fidelity
98
+ * the IR drops. Body nullability is stripped — when a parameter group is
99
+ * optional, the body field for the group becomes nullable, but within a
100
+ * variant the leaf is always required (selecting the variant means passing it).
101
+ */
102
+ export function collectVariantsForMountTarget(
103
+ ctx: EmitterContext,
104
+ models: Model[],
105
+ mountTarget: string,
106
+ ): CollectedVariant[] {
107
+ const owner = buildGroupOwnerMap(ctx);
108
+ const seen = new Set<string>();
109
+ const out: CollectedVariant[] = [];
110
+ const groups = groupByMount(ctx);
111
+ const g = groups.get(mountTarget);
112
+ if (!g) return out;
113
+ for (const op of g.operations) {
114
+ const bodyFieldTypes = collectBodyFieldTypes(op, models);
115
+ for (const group of op.parameterGroups ?? []) {
116
+ if (owner.get(group.name) !== mountTarget) continue;
117
+ for (const variant of group.variants) {
118
+ const cls = groupVariantClassName(group.name, variant.name);
119
+ if (seen.has(cls)) continue;
120
+ seen.add(cls);
121
+ out.push({
122
+ className: cls,
123
+ groupName: group.name,
124
+ variantName: variant.name,
125
+ mountTarget,
126
+ parameters: variant.parameters.map((p) => ({
127
+ name: p.name,
128
+ type: pickVariantParamType(p.type, bodyFieldTypes.get(p.name)),
129
+ })),
130
+ });
131
+ }
132
+ }
133
+ }
134
+ return out;
135
+ }
136
+
137
+ /**
138
+ * Pick the type for a variant leaf parameter.
139
+ *
140
+ * Prefer the IR's leaf type. Use the body model's type only when the IR is a
141
+ * bare primitive but the body has a structured type — that's the original
142
+ * fidelity-recovery case the body fallback was added for. Strip any outer
143
+ * nullable from the body type, since body nullability reflects the parent
144
+ * group's optionality, not the leaf's required-ness within the variant.
145
+ *
146
+ * Exported so the test emitter can recover the same type the variant class
147
+ * declares — IR primitives for fields like `role_slugs` would otherwise stub
148
+ * as `"stub"` strings instead of the `["stub"]` arrays the class accepts.
149
+ */
150
+ export function pickVariantParamType(irType: TypeRef, bodyType: TypeRef | undefined): TypeRef {
151
+ if (!bodyType) return irType;
152
+ const unwrappedBody = bodyType.kind === 'nullable' ? bodyType.inner : bodyType;
153
+ const bodyIsStructured =
154
+ unwrappedBody.kind === 'array' ||
155
+ unwrappedBody.kind === 'enum' ||
156
+ unwrappedBody.kind === 'model' ||
157
+ unwrappedBody.kind === 'map';
158
+ if (irType.kind === 'primitive' && bodyIsStructured) return unwrappedBody;
159
+ return irType;
160
+ }
161
+
162
+ function readableName(name: string): string {
163
+ return name.replace(/_/g, ' ');
164
+ }
165
+
166
+ /**
167
+ * Render the inline `Data.define` block for a single variant, indented for
168
+ * inclusion inside a `class <Service>` body. Returns an array of lines with
169
+ * 4-space indent (the resource file's class members are 4-space indented).
170
+ */
171
+ export function emitInlineVariantClass(v: CollectedVariant): string[] {
172
+ const lines: string[] = [];
173
+ lines.push(` # Identifies the ${readableName(v.groupName)} (${readableName(v.variantName)} variant).`);
174
+ lines.push(' #');
175
+ for (const p of v.parameters) {
176
+ const yardType = mapTypeRefForYard(p.type);
177
+ lines.push(` # @!attribute [r] ${fieldName(p.name)}`);
178
+ lines.push(` # @return [${yardType}]`);
179
+ }
180
+ if (v.parameters.length === 0) {
181
+ lines.push(` ${v.className} = Data.define`);
182
+ } else {
183
+ const fields = v.parameters.map((p) => `:${fieldName(p.name)}`).join(', ');
184
+ lines.push(` ${v.className} = Data.define(${fields})`);
185
+ }
186
+ return lines;
187
+ }
188
+
189
+ /**
190
+ * Render the inline RBI `class` block for a single variant, indented for
191
+ * inclusion inside a `class <Service>` body in a service .rbi file. Returns
192
+ * lines with 4-space indent.
193
+ */
194
+ export function emitInlineVariantRbi(v: CollectedVariant): string[] {
195
+ const lines: string[] = [];
196
+ const fqcn = `WorkOS::${v.mountTarget}::${v.className}`;
197
+ lines.push(` class ${v.className}`);
198
+ for (const p of v.parameters) {
199
+ lines.push(` sig { returns(${mapSorbetType(p.type)}) }`);
200
+ lines.push(` def ${fieldName(p.name)}; end`);
201
+ lines.push('');
202
+ }
203
+ if (v.parameters.length === 0) {
204
+ lines.push(` sig { returns(${fqcn}) }`);
205
+ lines.push(` def self.new; end`);
206
+ } else {
207
+ lines.push(' sig do');
208
+ lines.push(' params(');
209
+ for (let i = 0; i < v.parameters.length; i++) {
210
+ const p = v.parameters[i];
211
+ const sep = i === v.parameters.length - 1 ? '' : ',';
212
+ lines.push(` ${fieldName(p.name)}: ${mapSorbetType(p.type)}${sep}`);
213
+ }
214
+ lines.push(` ).returns(${fqcn})`);
215
+ lines.push(' end');
216
+ const kwargs = v.parameters.map((p) => `${fieldName(p.name)}:`).join(', ');
217
+ lines.push(` def self.new(${kwargs}); end`);
218
+ }
219
+ lines.push(' end');
220
+ return lines;
221
+ }
package/src/ruby/rbi.ts CHANGED
@@ -1,6 +1,13 @@
1
1
  import type { ApiSpec, EmitterContext, GeneratedFile, TypeRef, Model } from '@workos/oagen';
2
2
  import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
- import { className, fieldName, fileName, safeParamName, resolveMethodName } from './naming.js';
3
+ import {
4
+ className,
5
+ fieldName,
6
+ fileName,
7
+ safeParamName,
8
+ scopedGroupVariantClassName,
9
+ resolveMethodName,
10
+ } from './naming.js';
4
11
  import {
5
12
  buildResolvedLookup,
6
13
  groupByMount,
@@ -9,6 +16,7 @@ import {
9
16
  collectGroupedParamNames,
10
17
  } from '../shared/resolved-ops.js';
11
18
  import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
19
+ import { buildGroupOwnerMap, collectVariantsForMountTarget, emitInlineVariantRbi } from './parameter-groups.js';
12
20
 
13
21
  /**
14
22
  * Map an IR TypeRef to a Sorbet type string for RBI files.
@@ -120,6 +128,7 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
120
128
  for (const m of spec.models as Model[]) {
121
129
  if (isListWrapperModel(m)) listWrapperModels.set(m.name, m);
122
130
  }
131
+ const groupOwners = buildGroupOwnerMap(ctx);
123
132
 
124
133
  for (const [mountTarget, group] of groups) {
125
134
  const cls = className(mountTarget);
@@ -129,6 +138,15 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
129
138
  lines.push('module WorkOS');
130
139
  lines.push(` class ${cls}`);
131
140
 
141
+ // Inline parameter-group variant RBI blocks owned by this mount target.
142
+ // Mirrors the runtime `class ... PasswordPlaintext = Data.define(...) end`
143
+ // layout in lib/workos/<service>.rb.
144
+ const variants = collectVariantsForMountTarget(ctx, spec.models as Model[], mountTarget);
145
+ for (const v of variants) {
146
+ for (const line of emitInlineVariantRbi(v)) lines.push(line);
147
+ lines.push('');
148
+ }
149
+
132
150
  lines.push(' sig { params(client: WorkOS::BaseClient).void }');
133
151
  lines.push(' def initialize(client); end');
134
152
  lines.push('');
@@ -153,9 +171,26 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
153
171
  const hiddenParams = buildHiddenParams(resolved);
154
172
  const groupedParamNames = collectGroupedParamNames(op);
155
173
  const queryParams = (op.queryParams ?? []).filter((q) => !groupedParamNames.has(q.name));
156
- const bodyFields = getRequestBodyFieldsFlat(op, hiddenParams, modelByName);
174
+ // Drop body fields that collide with a parameter-group name; the group
175
+ // dispatcher kwarg handles them. See ruby/resources.ts for the matching
176
+ // filter on the runtime side.
177
+ const bodyFields = getRequestBodyFieldsFlat(op, hiddenParams, modelByName).filter(
178
+ (f) => !groupedParamNames.has(f.name),
179
+ );
180
+ const parameterGroups = op.parameterGroups ?? [];
181
+ const groupSorbetType = (group: (typeof parameterGroups)[number]): string => {
182
+ const owner = groupOwners.get(group.name);
183
+ if (!owner) {
184
+ throw new Error(`No owner mount target found for parameter group '${group.name}'`);
185
+ }
186
+ const variants = group.variants.map((v) => scopedGroupVariantClassName(owner, group.name, v.name));
187
+ if (variants.length === 1) return variants[0];
188
+ return `T.any(${variants.join(', ')})`;
189
+ };
157
190
 
158
- // Build parameter list for sig
191
+ // Build parameter list for sig. Order mirrors the runtime emitter:
192
+ // path → required body → required query → required groups → optional
193
+ // body → optional query → optional groups → request_options.
159
194
  const sigParams: string[] = [];
160
195
  const seen = new Set<string>();
161
196
 
@@ -181,6 +216,13 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
181
216
  seen.add(n);
182
217
  sigParams.push(`${n}: ${mapSorbetType(q.type)}`);
183
218
  }
219
+ for (const group of parameterGroups) {
220
+ if (group.optional) continue;
221
+ const n = fieldName(group.name);
222
+ if (seen.has(n)) continue;
223
+ seen.add(n);
224
+ sigParams.push(`${n}: ${groupSorbetType(group)}`);
225
+ }
184
226
  for (const f of bodyFields) {
185
227
  if (hiddenParams.has(f.name)) continue;
186
228
  if (f.required) continue;
@@ -197,6 +239,13 @@ export function generateRbiFiles(spec: ApiSpec, ctx: EmitterContext): GeneratedF
197
239
  seen.add(n);
198
240
  sigParams.push(`${n}: T.nilable(${unwrapNilable(mapSorbetType(q.type))})`);
199
241
  }
242
+ for (const group of parameterGroups) {
243
+ if (!group.optional) continue;
244
+ const n = fieldName(group.name);
245
+ if (seen.has(n)) continue;
246
+ seen.add(n);
247
+ sigParams.push(`${n}: T.nilable(${groupSorbetType(group)})`);
248
+ }
200
249
  sigParams.push('request_options: T::Hash[Symbol, T.untyped]');
201
250
 
202
251
  // Return type
@@ -1,6 +1,14 @@
1
1
  import type { Service, EmitterContext, GeneratedFile, Operation, TypeRef, Parameter, Model } from '@workos/oagen';
2
2
  import { planOperation } from '@workos/oagen';
3
- import { className, fieldName, fileName, methodName, safeParamName, resolveMethodName } from './naming.js';
3
+ import {
4
+ className,
5
+ fieldName,
6
+ fileName,
7
+ methodName,
8
+ safeParamName,
9
+ resolveMethodName,
10
+ scopedGroupVariantClassName,
11
+ } from './naming.js';
4
12
  import { mapTypeRefForYard } from './type-map.js';
5
13
  import {
6
14
  buildResolvedLookup,
@@ -13,6 +21,7 @@ import {
13
21
  } from '../shared/resolved-ops.js';
14
22
  import { isListWrapperModel } from '../shared/model-utils.js';
15
23
  import { generateWrapperMethods, collectWrapperResponseModels } from './wrappers.js';
24
+ import { buildGroupOwnerMap, collectVariantsForMountTarget, emitInlineVariantClass } from './parameter-groups.js';
16
25
 
17
26
  /**
18
27
  * Generate Ruby resource (service) classes from IR services.
@@ -35,6 +44,11 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
35
44
  if (isListWrapperModel(m)) listWrapperModels.set(m.name, m);
36
45
  }
37
46
 
47
+ // Resolve groupName -> owner mountTarget once per generation pass; every
48
+ // dispatcher and YARD `@param` reference resolves variant classes through
49
+ // this map so cross-resource references stay consistent.
50
+ const groupOwners = buildGroupOwnerMap(ctx);
51
+
38
52
  for (const [mountTarget, group] of groups) {
39
53
  const cls = className(mountTarget);
40
54
  const file = fileName(mountTarget);
@@ -88,6 +102,7 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
88
102
  modelByName,
89
103
  listWrapperModels,
90
104
  requires,
105
+ groupOwners,
91
106
  });
92
107
  methodBodies.push(body);
93
108
 
@@ -121,6 +136,18 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
121
136
  }
122
137
  lines.push('module WorkOS');
123
138
  lines.push(` class ${cls}`);
139
+
140
+ // Inline parameter-group variant classes owned by this mount target.
141
+ // Zeitwerk's `loader.collapse` flattens `lib/workos/<service>/` so files
142
+ // there can't define `WorkOS::<Service>::*` constants — variants must
143
+ // live inside the service's own file. Matches Python's per-resource
144
+ // dataclass layout.
145
+ const variants = collectVariantsForMountTarget(ctx, ctx.spec.models as Model[], mountTarget);
146
+ for (const v of variants) {
147
+ for (const line of emitInlineVariantClass(v)) lines.push(line);
148
+ lines.push('');
149
+ }
150
+
124
151
  lines.push(' def initialize(client)');
125
152
  lines.push(' @client = client');
126
153
  lines.push(' end');
@@ -154,6 +181,7 @@ function emitMethod(args: {
154
181
  modelByName: Map<string, Model>;
155
182
  listWrapperModels: Map<string, Model>;
156
183
  requires: Set<string>;
184
+ groupOwners: Map<string, string>;
157
185
  }): string {
158
186
  const {
159
187
  op,
@@ -166,9 +194,19 @@ function emitMethod(args: {
166
194
  modelByName,
167
195
  listWrapperModels,
168
196
  requires,
197
+ groupOwners,
169
198
  } = args;
170
199
  void enumNames;
171
200
 
201
+ /** Fully-qualified Ruby constant for a variant (e.g. WorkOS::UserManagement::PasswordPlaintext). */
202
+ const variantClassRef = (group: { name: string }, variantName: string): string => {
203
+ const owner = groupOwners.get(group.name);
204
+ if (!owner) {
205
+ throw new Error(`No owner mount target found for parameter group '${group.name}'`);
206
+ }
207
+ return scopedGroupVariantClassName(owner, group.name, variantName);
208
+ };
209
+
172
210
  const plan = planOperation(op);
173
211
  const lines: string[] = [];
174
212
 
@@ -177,8 +215,11 @@ function emitMethod(args: {
177
215
  const groupedParamNames = collectGroupedParamNames(op);
178
216
  const queryParams = (op.queryParams ?? []).filter((q) => !groupedParamNames.has(q.name));
179
217
 
180
- // Request body params: if body is a model, expand its fields.
181
- const bodyFields = getRequestBodyFields(op, hiddenParams, modelByName);
218
+ // Request body params: if body is a model, expand its fields. Drop any field
219
+ // whose name is also a parameter-group name — those are dispatched by the
220
+ // group kwarg below, so emitting them as flat kwargs would shadow the group
221
+ // and cause `String#[Symbol]` TypeErrors when the dispatcher reads `:type`.
222
+ const bodyFields = getRequestBodyFields(op, hiddenParams, modelByName).filter((f) => !groupedParamNames.has(f.name));
182
223
 
183
224
  // Detect path/body name collisions and build a rename map for body fields.
184
225
  // When a body field's snake_case name matches a path param, prefix with "body_"
@@ -269,7 +310,16 @@ function emitMethod(args: {
269
310
  sigParts.push('request_options: {}');
270
311
 
271
312
  // YARD docs.
272
- const doc = buildYardDoc(op, pathParams, queryParams, bodyFields, hiddenParams, bodyFieldRenames, listWrapperModels);
313
+ const doc = buildYardDoc(
314
+ op,
315
+ pathParams,
316
+ queryParams,
317
+ bodyFields,
318
+ hiddenParams,
319
+ bodyFieldRenames,
320
+ listWrapperModels,
321
+ variantClassRef,
322
+ );
273
323
  for (const line of doc) lines.push(` ${line}`);
274
324
 
275
325
  // Signature.
@@ -309,30 +359,41 @@ function emitMethod(args: {
309
359
  const groupsGoToQuery = hasGroups && !hasBodyMethod;
310
360
  const hasQuery = qEntries.length > 0 || groupsGoToQuery;
311
361
  if (hasQuery) {
362
+ // Skip `.compact` when no entry can be nil — required kwargs are always
363
+ // passed (Ruby raises ArgumentError otherwise), so the literal has no nil
364
+ // values to drop. Group dispatch happens after the literal is built and
365
+ // doesn't contribute potentially-nil entries either.
366
+ const queryHasNilable = qEntries.some((q) => !q.required);
367
+ const queryCompact = queryHasNilable ? '.compact' : '';
312
368
  lines.push(' params = {');
313
369
  for (let i = 0; i < qEntries.length; i++) {
314
370
  const q = qEntries[i];
315
371
  const sep = i === qEntries.length - 1 && !groupsGoToQuery ? '' : ',';
316
372
  lines.push(` ${rubyStringLit(q.name)} => ${safeParamName(q.name)}${sep}`);
317
373
  }
318
- lines.push(' }.compact');
374
+ lines.push(` }${queryCompact}`);
319
375
 
320
376
  if (groupsGoToQuery) {
321
- // Parameter group dispatch: merge grouped params into the query hash
377
+ // Parameter group dispatch: callers pass a typed variant class instance
378
+ // (e.g. `WorkOS::ParentResourceById`); we pattern-match on its class
379
+ // and forward its readers into the query hash.
322
380
  for (const group of op.parameterGroups ?? []) {
323
381
  const prop = fieldName(group.name);
324
382
  if (group.optional) {
325
383
  lines.push(` if ${prop}`);
326
- lines.push(` case ${prop}[:type]`);
384
+ lines.push(` case ${prop}`);
327
385
  } else {
328
- lines.push(` case ${prop}[:type]`);
386
+ lines.push(` case ${prop}`);
329
387
  }
330
388
  for (const variant of group.variants) {
331
- lines.push(` when ${rubyStringLit(variant.name)}`);
389
+ const variantClass = variantClassRef(group, variant.name);
390
+ lines.push(` when ${variantClass}`);
332
391
  for (const p of variant.parameters) {
333
- lines.push(` params[${rubyStringLit(p.name)}] = ${prop}[:${fieldName(p.name)}]`);
392
+ lines.push(` params[${rubyStringLit(p.name)}] = ${prop}.${fieldName(p.name)}`);
334
393
  }
335
394
  }
395
+ lines.push(` else`);
396
+ lines.push(` raise ArgumentError, ${dispatchErrorLiteral(group, prop, variantClassRef)}`);
336
397
  lines.push(' end');
337
398
  if (group.optional) {
338
399
  lines.push(' end');
@@ -341,8 +402,12 @@ function emitMethod(args: {
341
402
  }
342
403
  }
343
404
 
344
- // Request body
345
- const hasBody = bodyFields.length > 0 && !['get', 'head'].includes(method_http);
405
+ // Request body. Emit when there are non-group body fields OR a parameter
406
+ // group dispatches into the body the latter case matters when an
407
+ // operation's body is exclusively managed by a group (e.g.
408
+ // update_organization_membership's `role`), where filtering the group's
409
+ // leaves leaves bodyFields empty but the request still needs a payload.
410
+ const hasBody = (bodyFields.length > 0 && !['get', 'head'].includes(method_http)) || (hasGroups && hasBodyMethod);
346
411
 
347
412
  if (hasBody) {
348
413
  const bodyEntries: string[] = [];
@@ -355,35 +420,44 @@ function emitMethod(args: {
355
420
  const optKey = fc === 'client_secret' ? 'api_key' : fc;
356
421
  bodyEntries.push(`${rubyStringLit(fc)} => (request_options[:${optKey}] || @client.${clientProp})`);
357
422
  }
423
+ // Track whether any literal entry can be nil — defaults/inferFromClient
424
+ // resolve to non-nil values, so only optional body kwargs are nilable.
425
+ let bodyHasNilable = false;
358
426
  for (const f of bodyFields) {
359
427
  if (hiddenParams.has(f.name)) continue;
360
428
  bodyEntries.push(`${rubyStringLit(f.name)} => ${bodyKwargName(f.name)}`);
429
+ if (!f.required) bodyHasNilable = true;
361
430
  }
431
+ const bodyCompact = bodyHasNilable ? '.compact' : '';
362
432
  lines.push(' body = {');
363
433
  for (let i = 0; i < bodyEntries.length; i++) {
364
434
  const sep = i === bodyEntries.length - 1 ? '' : ',';
365
435
  lines.push(` ${bodyEntries[i]}${sep}`);
366
436
  }
367
- lines.push(' }.compact');
437
+ lines.push(` }${bodyCompact}`);
368
438
 
369
439
  // Parameter group dispatch into body for POST/PUT/PATCH so sensitive
370
440
  // fields (passwords, role slugs) never leak into the URL query string.
371
441
  // DELETE groups are already handled via query above (groupsGoToQuery).
442
+ // Callers pass a typed variant class instance and we pattern-match on it.
372
443
  if (hasGroups && hasBodyMethod) {
373
444
  for (const group of op.parameterGroups ?? []) {
374
445
  const prop = fieldName(group.name);
375
446
  if (group.optional) {
376
447
  lines.push(` if ${prop}`);
377
- lines.push(` case ${prop}[:type]`);
448
+ lines.push(` case ${prop}`);
378
449
  } else {
379
- lines.push(` case ${prop}[:type]`);
450
+ lines.push(` case ${prop}`);
380
451
  }
381
452
  for (const variant of group.variants) {
382
- lines.push(` when ${rubyStringLit(variant.name)}`);
453
+ const variantClass = variantClassRef(group, variant.name);
454
+ lines.push(` when ${variantClass}`);
383
455
  for (const p of variant.parameters) {
384
- lines.push(` body[${rubyStringLit(p.name)}] = ${prop}[:${fieldName(p.name)}]`);
456
+ lines.push(` body[${rubyStringLit(p.name)}] = ${prop}.${fieldName(p.name)}`);
385
457
  }
386
458
  }
459
+ lines.push(` else`);
460
+ lines.push(` raise ArgumentError, ${dispatchErrorLiteral(group, prop, variantClassRef)}`);
387
461
  lines.push(' end');
388
462
  if (group.optional) {
389
463
  lines.push(' end');
@@ -719,8 +793,9 @@ function buildYardDoc(
719
793
  queryParams: Parameter[],
720
794
  bodyFields: { name: string; required: boolean; type: TypeRef; description?: string; deprecated?: boolean }[],
721
795
  hiddenParams: Set<string>,
722
- bodyFieldRenames?: Map<string, string>,
723
- listWrapperModels?: Map<string, Model>,
796
+ bodyFieldRenames: Map<string, string> | undefined,
797
+ listWrapperModels: Map<string, Model> | undefined,
798
+ variantClassRef: (group: { name: string }, variantName: string) => string,
724
799
  ): string[] {
725
800
  const lines: string[] = [];
726
801
  const summary = op.description ?? `${op.httpMethod.toUpperCase()} ${op.path}`;
@@ -764,6 +839,16 @@ function buildYardDoc(
764
839
  const deprecatedPrefix = q.deprecated ? '(deprecated) ' : '';
765
840
  lines.push(`# @param ${n} [${type}${suffix}] ${deprecatedPrefix}${oneLine(q.description)}`.trim());
766
841
  }
842
+ // Parameter group kwargs: the type bracket lists the variant classes the
843
+ // caller may pass; no extra prose needed since YARD already renders them.
844
+ for (const group of op.parameterGroups ?? []) {
845
+ const n = fieldName(group.name);
846
+ if (emittedParamNames.has(n)) continue;
847
+ emittedParamNames.add(n);
848
+ const variantTypes = group.variants.map((v) => variantClassRef(group, v.name)).join(', ');
849
+ const suffix = group.optional ? ', nil' : '';
850
+ lines.push(`# @param ${n} [${variantTypes}${suffix}] Identifies the ${group.name.replace(/_/g, ' ')}.`);
851
+ }
767
852
  lines.push(`# @param request_options [Hash] (see WorkOS::Types::RequestOptions)`);
768
853
 
769
854
  // Return type: void for unknown-primitive, ListStruct for list wrappers and
@@ -797,3 +882,17 @@ void methodName;
797
882
  function rubyStringLit(s: string): string {
798
883
  return `'${s.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
799
884
  }
885
+
886
+ /**
887
+ * Build a Ruby double-quoted string expression for the `else raise ArgumentError`
888
+ * arm of a parameter-group dispatcher. Lists the expected variant classes and
889
+ * interpolates the actual class of the value the caller passed.
890
+ */
891
+ function dispatchErrorLiteral(
892
+ group: { name: string; variants: { name: string }[] },
893
+ prop: string,
894
+ variantClassRef: (group: { name: string }, variantName: string) => string,
895
+ ): string {
896
+ const expected = group.variants.map((v) => variantClassRef(group, v.name)).join(', ');
897
+ return `"expected ${prop} to be one of: ${expected}, got #{${prop}.class}"`;
898
+ }