@workos/oagen-emitters 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.oxfmtrc.json +8 -1
  3. package/.release-please-manifest.json +1 -1
  4. package/CHANGELOG.md +15 -0
  5. package/README.md +129 -0
  6. package/dist/index.d.mts +10 -1
  7. package/dist/index.d.mts.map +1 -1
  8. package/dist/index.mjs +11943 -2728
  9. package/dist/index.mjs.map +1 -1
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +298 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +137 -46
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-php.ts +28 -26
  23. package/smoke/sdk-python.ts +5 -2
  24. package/smoke/sdk-ruby.ts +17 -3
  25. package/smoke/sdk-rust.ts +16 -3
  26. package/src/go/client.ts +141 -0
  27. package/src/go/enums.ts +196 -0
  28. package/src/go/fixtures.ts +212 -0
  29. package/src/go/index.ts +81 -0
  30. package/src/go/manifest.ts +36 -0
  31. package/src/go/models.ts +254 -0
  32. package/src/go/naming.ts +191 -0
  33. package/src/go/resources.ts +827 -0
  34. package/src/go/tests.ts +751 -0
  35. package/src/go/type-map.ts +82 -0
  36. package/src/go/wrappers.ts +261 -0
  37. package/src/index.ts +3 -0
  38. package/src/node/client.ts +167 -122
  39. package/src/node/enums.ts +13 -4
  40. package/src/node/errors.ts +42 -233
  41. package/src/node/field-plan.ts +726 -0
  42. package/src/node/fixtures.ts +15 -5
  43. package/src/node/index.ts +65 -16
  44. package/src/node/models.ts +264 -96
  45. package/src/node/naming.ts +52 -25
  46. package/src/node/resources.ts +621 -172
  47. package/src/node/sdk-errors.ts +41 -0
  48. package/src/node/tests.ts +71 -27
  49. package/src/node/type-map.ts +4 -2
  50. package/src/node/utils.ts +56 -64
  51. package/src/node/wrappers.ts +151 -0
  52. package/src/php/client.ts +171 -0
  53. package/src/php/enums.ts +67 -0
  54. package/src/php/errors.ts +9 -0
  55. package/src/php/fixtures.ts +181 -0
  56. package/src/php/index.ts +96 -0
  57. package/src/php/manifest.ts +36 -0
  58. package/src/php/models.ts +310 -0
  59. package/src/php/naming.ts +298 -0
  60. package/src/php/resources.ts +561 -0
  61. package/src/php/tests.ts +533 -0
  62. package/src/php/type-map.ts +90 -0
  63. package/src/php/utils.ts +18 -0
  64. package/src/php/wrappers.ts +151 -0
  65. package/src/python/client.ts +337 -0
  66. package/src/python/enums.ts +313 -0
  67. package/src/python/fixtures.ts +196 -0
  68. package/src/python/index.ts +95 -0
  69. package/src/python/manifest.ts +38 -0
  70. package/src/python/models.ts +688 -0
  71. package/src/python/naming.ts +209 -0
  72. package/src/python/resources.ts +1322 -0
  73. package/src/python/tests.ts +1335 -0
  74. package/src/python/type-map.ts +93 -0
  75. package/src/python/wrappers.ts +191 -0
  76. package/src/shared/model-utils.ts +255 -0
  77. package/src/shared/naming-utils.ts +107 -0
  78. package/src/shared/non-spec-services.ts +54 -0
  79. package/src/shared/resolved-ops.ts +109 -0
  80. package/src/shared/wrapper-utils.ts +59 -0
  81. package/test/go/client.test.ts +92 -0
  82. package/test/go/enums.test.ts +132 -0
  83. package/test/go/errors.test.ts +9 -0
  84. package/test/go/models.test.ts +265 -0
  85. package/test/go/resources.test.ts +408 -0
  86. package/test/go/tests.test.ts +143 -0
  87. package/test/node/client.test.ts +199 -94
  88. package/test/node/enums.test.ts +75 -3
  89. package/test/node/errors.test.ts +2 -41
  90. package/test/node/models.test.ts +109 -20
  91. package/test/node/naming.test.ts +37 -4
  92. package/test/node/resources.test.ts +662 -30
  93. package/test/node/serializers.test.ts +36 -7
  94. package/test/node/type-map.test.ts +11 -0
  95. package/test/php/client.test.ts +94 -0
  96. package/test/php/enums.test.ts +173 -0
  97. package/test/php/errors.test.ts +9 -0
  98. package/test/php/models.test.ts +497 -0
  99. package/test/php/resources.test.ts +644 -0
  100. package/test/php/tests.test.ts +118 -0
  101. package/test/python/client.test.ts +200 -0
  102. package/test/python/enums.test.ts +228 -0
  103. package/test/python/errors.test.ts +16 -0
  104. package/test/python/manifest.test.ts +74 -0
  105. package/test/python/models.test.ts +716 -0
  106. package/test/python/resources.test.ts +617 -0
  107. package/test/python/tests.test.ts +202 -0
  108. package/src/node/common.ts +0 -273
  109. package/src/node/config.ts +0 -71
  110. package/src/node/serializers.ts +0 -744
@@ -1,22 +1,45 @@
1
1
  // @oagen-ignore: Operation.async — all TypeScript SDK methods are async by nature
2
2
 
3
- import type { Service, Operation, EmitterContext, GeneratedFile, TypeRef, Model } from '@workos/oagen';
4
- import { planOperation, toPascalCase } from '@workos/oagen';
3
+ import type {
4
+ Service,
5
+ Operation,
6
+ EmitterContext,
7
+ GeneratedFile,
8
+ TypeRef,
9
+ Model,
10
+ ResolvedOperation,
11
+ } from '@workos/oagen';
12
+ import { planOperation, toPascalCase, toCamelCase } from '@workos/oagen';
5
13
  import type { OperationPlan } from '@workos/oagen';
6
14
  import { mapTypeRef } from './type-map.js';
7
15
  import {
8
16
  fieldName,
9
17
  wireFieldName,
10
18
  fileName,
11
- serviceDirName,
19
+ resolveServiceDir,
12
20
  resolveMethodName,
13
21
  resolveInterfaceName,
14
22
  resolveServiceName,
15
23
  wireInterfaceName,
16
24
  } from './naming.js';
17
- import { docComment, createServiceDirResolver, isServiceCoveredByExisting, uncoveredOperations } from './utils.js';
25
+ import {
26
+ docComment,
27
+ createServiceDirResolver,
28
+ isServiceCoveredByExisting,
29
+ hasMethodsAbsentFromBaseline,
30
+ uncoveredOperations,
31
+ } from './utils.js';
18
32
  import { assignEnumsToServices } from './enums.js';
19
33
  import { unwrapListModel } from './fixtures.js';
34
+ import { buildNodeStatusExceptions } from './sdk-errors.js';
35
+ import {
36
+ buildResolvedLookup,
37
+ lookupResolved,
38
+ groupByMount,
39
+ getOpDefaults,
40
+ getOpInferFromClient,
41
+ } from '../shared/resolved-ops.js';
42
+ import { generateWrapperMethods, collectWrapperResponseModels } from './wrappers.js';
20
43
 
21
44
  /**
22
45
  * Check whether the baseline (hand-written) class has a constructor compatible
@@ -57,15 +80,9 @@ export function resolveResourceClassName(service: Service, ctx: EmitterContext):
57
80
  /** Standard pagination query params handled by PaginationOptions — not imported individually. */
58
81
  const PAGINATION_PARAM_NAMES = new Set(['limit', 'before', 'after', 'order']);
59
82
 
60
- /** Map HTTP status codes to their corresponding exception class names for @throws docs. */
61
- const STATUS_TO_EXCEPTION_NAME: Record<number, string> = {
62
- 400: 'BadRequestException',
63
- 401: 'UnauthorizedException',
64
- 404: 'NotFoundException',
65
- 409: 'ConflictException',
66
- 422: 'UnprocessableEntityException',
67
- 429: 'RateLimitExceededException',
68
- };
83
+ /** Map HTTP status codes to their corresponding exception class names for @throws docs.
84
+ * Built from sdk.errors.statusCodeMap with Node-specific naming overrides. */
85
+ const STATUS_TO_EXCEPTION_NAME: Record<number, string> = buildNodeStatusExceptions();
69
86
 
70
87
  /**
71
88
  * Compute the options interface name for a paginated method.
@@ -85,28 +102,135 @@ function httpMethodNeedsBody(method: string): boolean {
85
102
  return method === 'post' || method === 'put' || method === 'patch';
86
103
  }
87
104
 
105
+ // ---------------------------------------------------------------------------
106
+ // Method-name deduplication helpers
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /** Split a camelCase/PascalCase name into lowercase word parts. */
110
+ function splitCamelWords(name: string): string[] {
111
+ const parts: string[] = [];
112
+ let start = 0;
113
+ for (let i = 1; i < name.length; i++) {
114
+ if (name[i] >= 'A' && name[i] <= 'Z') {
115
+ parts.push(name.slice(start, i).toLowerCase());
116
+ start = i;
117
+ }
118
+ }
119
+ parts.push(name.slice(start).toLowerCase());
120
+ return parts;
121
+ }
122
+
123
+ /** Naive singularize: strip trailing 's' unless it ends in 'ss'. */
124
+ function singularize(word: string): string {
125
+ return word.endsWith('s') && !word.endsWith('ss') ? word.slice(0, -1) : word;
126
+ }
127
+
128
+ /**
129
+ * Deduplicate method names within the plans array.
130
+ *
131
+ * When `disambiguateOperationNames()` in `@workos/oagen` fails (e.g., for
132
+ * single-segment paths like `/organizations`), two operations can resolve to
133
+ * the same method name. Disambiguate by appending a path-derived suffix.
134
+ */
135
+ function deduplicateMethodNames(
136
+ plans: Array<{ op: Operation; plan: OperationPlan; method: string }>,
137
+ _ctx: EmitterContext,
138
+ ): void {
139
+ const nameCount = new Map<string, number>();
140
+ for (const p of plans) {
141
+ nameCount.set(p.method, (nameCount.get(p.method) ?? 0) + 1);
142
+ }
143
+
144
+ for (const [name, count] of nameCount) {
145
+ if (count <= 1) continue;
146
+ const dupes = plans.filter((p) => p.method === name);
147
+
148
+ // If all duplicates are on the SAME base path (different HTTP methods),
149
+ // trust the names — they represent the same resource.
150
+ const basePaths = new Set(dupes.map((d) => d.op.path.replace(/\/\{[^}]+\}$/, '')));
151
+ if (basePaths.size <= 1) continue;
152
+
153
+ // Disambiguate: keep the name for the plan whose path best matches,
154
+ // append path suffix for the others.
155
+ // Score: how many words in the method name appear in the path segments
156
+ const nameWords = new Set(splitCamelWords(name).map(singularize));
157
+ const scored = dupes.map((d) => {
158
+ const pathWords = d.op.path
159
+ .split('/')
160
+ .filter((s) => s && !s.startsWith('{'))
161
+ .flatMap((s) => s.split('_'))
162
+ .map(singularize);
163
+ const overlap = pathWords.filter((w) => nameWords.has(w)).length;
164
+ return { plan: d, score: overlap };
165
+ });
166
+ scored.sort((a, b) => b.score - a.score);
167
+
168
+ // The best-scoring plan keeps the name; others get disambiguated
169
+ for (let i = 1; i < scored.length; i++) {
170
+ const dupe = scored[i].plan;
171
+ const segments = dupe.op.path.split('/').filter((s) => s && !s.startsWith('{'));
172
+ // Use first segment as suffix (the resource collection name)
173
+ const suffix = segments[0] ?? '';
174
+ if (suffix) {
175
+ dupe.method = toCamelCase(`${name}_${suffix}`);
176
+ }
177
+ }
178
+
179
+ // If still colliding after suffix, append index
180
+ const stillDuped = new Map<string, typeof dupes>();
181
+ for (const dupe of dupes) {
182
+ const group = stillDuped.get(dupe.method) ?? [];
183
+ group.push(dupe);
184
+ stillDuped.set(dupe.method, group);
185
+ }
186
+ for (const [, group] of stillDuped) {
187
+ if (group.length <= 1) continue;
188
+ for (let i = 1; i < group.length; i++) {
189
+ group[i].method = `${group[i].method}${i + 1}`;
190
+ }
191
+ }
192
+ }
193
+ }
194
+
88
195
  export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
89
196
  if (services.length === 0) return [];
90
197
  const files: GeneratedFile[] = [];
91
198
 
92
- for (const service of services) {
199
+ // Group services by mount target to avoid file path collisions when
200
+ // multiple IR services mount to the same resource class.
201
+ const mountGroups = groupByMount(ctx);
202
+ const mergedServices: Service[] =
203
+ mountGroups.size > 0 ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations })) : services;
204
+
205
+ for (const service of mergedServices) {
93
206
  if (isServiceCoveredByExisting(service, ctx)) {
94
- // Fully covered skip entirely
207
+ // Fully covered: generate with ALL operations so the merger's docstring
208
+ // refresh pass can update JSDoc on existing methods.
209
+ const file = generateResourceClass(service, ctx);
210
+ // When the baseline class is missing methods for some operations,
211
+ // remove skipIfExists so the merger adds the new methods.
212
+ if (hasMethodsAbsentFromBaseline(service, ctx)) {
213
+ delete file.skipIfExists;
214
+ // Suppress auto-generated header — the file is a merge target
215
+ // containing hand-written code, not a fully generated file.
216
+ file.headerPlacement = 'skip';
217
+ }
218
+ files.push(file);
95
219
  continue;
96
220
  }
97
221
 
98
- // Check for partial coverage: some operations covered, some not.
99
- // Generate methods only for uncovered operations.
100
222
  const ops = uncoveredOperations(service, ctx);
101
223
  if (ops.length === 0) continue;
102
224
 
103
225
  if (ops.length < service.operations.length) {
104
- // Partial coverage: create a service with only uncovered operations.
105
- // Remove skipIfExists so the merger can add these new methods to the
106
- // existing class file (otherwise uncovered operations are silently lost).
107
- const partialService = { ...service, operations: ops };
108
- const file = generateResourceClass(partialService, ctx);
226
+ // Partial coverage: generate with ALL operations so JSDoc is available
227
+ // for both covered and uncovered methods. Remove skipIfExists so the
228
+ // merger adds new methods AND refreshes existing JSDoc.
229
+ const file = generateResourceClass(service, ctx);
109
230
  delete file.skipIfExists;
231
+ // Suppress auto-generated header — the file is a merge target
232
+ // containing hand-written code, not a fully generated file.
233
+ file.headerPlacement = 'skip';
110
234
  files.push(file);
111
235
  } else {
112
236
  files.push(generateResourceClass(service, ctx));
@@ -118,7 +242,7 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
118
242
 
119
243
  function generateResourceClass(service: Service, ctx: EmitterContext): GeneratedFile {
120
244
  const resolvedName = resolveResourceClassName(service, ctx);
121
- const serviceDir = serviceDirName(resolvedName);
245
+ const serviceDir = resolveServiceDir(resolvedName);
122
246
  const serviceClass = resolvedName;
123
247
  const resourcePath = `src/${serviceDir}/${fileName(resolvedName)}.ts`;
124
248
 
@@ -128,6 +252,13 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
128
252
  method: resolveMethodName(op, service, ctx),
129
253
  }));
130
254
 
255
+ // Resolved operations already produce correct method names via the
256
+ // centralized hint map — no per-emitter reconciliation needed.
257
+
258
+ // Deduplicate method names within the class (e.g., two operations both
259
+ // resolving to "create" for different paths).
260
+ deduplicateMethodNames(plans, ctx);
261
+
131
262
  // Sort plans to match the existing file's method order.
132
263
  // When the merger integrates generated content with existing files, its
133
264
  // URL-fingerprint fallback (pass 2) matches by position among methods that
@@ -136,18 +267,23 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
136
267
  // order, JSDoc comments get attached to the wrong methods (list↔create,
137
268
  // add↔set swaps). Sorting by the overlay's method order ensures the
138
269
  // generated output matches the existing file's method order.
270
+ //
271
+ // We build the order from HTTP operation keys (e.g., "GET /organizations")
272
+ // rather than method names, because resolveMethodName may return a different
273
+ // name than the overlay's methodName (e.g., when the hint map overrides it),
274
+ // causing the lookup to fail and the sort to produce wrong order.
139
275
  if (ctx.overlayLookup?.methodByOperation) {
140
- const methodOrder = new Map<string, number>();
276
+ const httpKeyOrder = new Map<string, number>();
141
277
  let pos = 0;
142
- for (const [, info] of ctx.overlayLookup.methodByOperation) {
143
- if (!methodOrder.has(info.methodName)) {
144
- methodOrder.set(info.methodName, pos++);
145
- }
278
+ for (const [httpKey] of ctx.overlayLookup.methodByOperation) {
279
+ httpKeyOrder.set(httpKey, pos++);
146
280
  }
147
- if (methodOrder.size > 0) {
281
+ if (httpKeyOrder.size > 0) {
148
282
  plans.sort((a, b) => {
149
- const aPos = methodOrder.get(a.method) ?? Number.MAX_SAFE_INTEGER;
150
- const bPos = methodOrder.get(b.method) ?? Number.MAX_SAFE_INTEGER;
283
+ const aKey = `${a.op.httpMethod.toUpperCase()} ${a.op.path}`;
284
+ const bKey = `${b.op.httpMethod.toUpperCase()} ${b.op.path}`;
285
+ const aPos = httpKeyOrder.get(aKey) ?? Number.MAX_SAFE_INTEGER;
286
+ const bPos = httpKeyOrder.get(bKey) ?? Number.MAX_SAFE_INTEGER;
151
287
  return aPos - bPos;
152
288
  });
153
289
  }
@@ -156,13 +292,32 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
156
292
  const hasPaginated = plans.some((p) => p.plan.isPaginated);
157
293
  const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
158
294
 
295
+ // When merging into an existing class, the merger keeps baseline method
296
+ // bodies but may add imports from the generated code. To avoid orphaned
297
+ // imports for types used only by baseline methods (whose bodies are kept
298
+ // intact), skip model collection for methods that already exist.
299
+ const baselineMethodSet = new Set<string>();
300
+ const baselineClass = ctx.apiSurface?.classes?.[serviceClass];
301
+ if (baselineClass?.methods) {
302
+ for (const name of Object.keys(baselineClass.methods)) {
303
+ baselineMethodSet.add(name);
304
+ }
305
+ }
306
+
159
307
  // Collect models for imports — only include models that are actually used
160
308
  // in method signatures (not all union variants from the spec)
161
309
  const responseModels = new Set<string>();
162
310
  const requestModels = new Set<string>();
163
311
  const paramEnums = new Set<string>();
164
312
  const paramModels = new Set<string>();
165
- for (const { op, plan } of plans) {
313
+ for (const { op, plan, method } of plans) {
314
+ // Skip imports for methods that already exist in the baseline class.
315
+ // The merger keeps baseline method bodies, so their imports are already
316
+ // present in the existing file. Including them here would create
317
+ // orphaned imports when the generated return type differs from the
318
+ // baseline's (e.g., generated `List` vs baseline `RoleList`).
319
+ if (baselineMethodSet.has(method)) continue;
320
+
166
321
  if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
167
322
  // For paginated operations, import the item type (e.g., Connection)
168
323
  // rather than the list wrapper type (e.g., ConnectionList).
@@ -192,11 +347,10 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
192
347
  requestModels.add(name);
193
348
  }
194
349
  } else {
195
- // Non-discriminated union: import variant models as domain types only.
196
- // Without a discriminator we can't statically dispatch serialization,
197
- // so the payload is passed through as-is.
350
+ // Non-discriminated union: import variant models with serializers so we
351
+ // can dispatch to the correct serializer at runtime via field guards.
198
352
  for (const name of bodyInfo.modelNames) {
199
- paramModels.add(name);
353
+ requestModels.add(name);
200
354
  }
201
355
  }
202
356
  }
@@ -210,6 +364,20 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
210
364
  collectParamTypeRefs(param.type, paramEnums, paramModels);
211
365
  }
212
366
  }
367
+
368
+ // Collect response models from union split wrappers so their types and
369
+ // deserializers are imported alongside the primary operation models.
370
+ const resolvedLookup = buildResolvedLookup(ctx);
371
+ for (const { op, method } of plans) {
372
+ if (baselineMethodSet.has(method)) continue;
373
+ const resolved = lookupResolved(op, resolvedLookup);
374
+ if (resolved) {
375
+ for (const name of collectWrapperResponseModels(resolved)) {
376
+ responseModels.add(name);
377
+ }
378
+ }
379
+ }
380
+
213
381
  const allModels = new Set([...responseModels, ...requestModels, ...paramModels]);
214
382
 
215
383
  const lines: string[] = [];
@@ -355,20 +523,29 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
355
523
  } else if (!plan.isPaginated && !plan.hasBody && !plan.isDelete && op.queryParams.length > 0) {
356
524
  // Non-paginated GET or void methods with query params get a typed options interface
357
525
  // instead of falling back to Record<string, unknown>.
358
- const optionsName = toPascalCase(method) + 'Options';
359
- lines.push(`export interface ${optionsName} {`);
360
- for (const param of op.queryParams) {
361
- const opt = !param.required ? '?' : '';
362
- if (param.description || param.deprecated) {
363
- const parts: string[] = [];
364
- if (param.description) parts.push(param.description);
365
- if (param.deprecated) parts.push('@deprecated');
366
- lines.push(...docComment(parts.join('\n'), 2));
526
+ // Filter out hidden params (defaults and inferFromClient fields)
527
+ const resolved = lookupResolved(op, resolvedLookup);
528
+ const opHiddenParams = new Set<string>([
529
+ ...Object.keys(getOpDefaults(resolved)),
530
+ ...getOpInferFromClient(resolved),
531
+ ]);
532
+ const visibleParams = op.queryParams.filter((p) => !opHiddenParams.has(p.name));
533
+ if (visibleParams.length > 0) {
534
+ const optionsName = toPascalCase(method) + 'Options';
535
+ lines.push(`export interface ${optionsName} {`);
536
+ for (const param of visibleParams) {
537
+ const opt = !param.required ? '?' : '';
538
+ if (param.description || param.deprecated) {
539
+ const parts: string[] = [];
540
+ if (param.description) parts.push(param.description);
541
+ if (param.deprecated) parts.push('@deprecated');
542
+ lines.push(...docComment(parts.join('\n'), 2));
543
+ }
544
+ lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
367
545
  }
368
- lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
546
+ lines.push('}');
547
+ lines.push('');
369
548
  }
370
- lines.push('}');
371
- lines.push('');
372
549
  }
373
550
  }
374
551
 
@@ -381,7 +558,13 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
381
558
 
382
559
  for (const { op, plan, method } of plans) {
383
560
  lines.push('');
384
- lines.push(...renderMethod(op, plan, method, service, ctx, modelMap, specEnumNames));
561
+ const resolved = lookupResolved(op, resolvedLookup);
562
+ lines.push(...renderMethod(op, plan, method, service, ctx, modelMap, specEnumNames, resolved));
563
+
564
+ // Emit union split wrapper methods (typed convenience methods for each variant)
565
+ if (resolved?.wrappers && resolved.wrappers.length > 0) {
566
+ lines.push(...generateWrapperMethods(resolved, ctx));
567
+ }
385
568
  }
386
569
 
387
570
  lines.push('}');
@@ -397,12 +580,20 @@ function renderMethod(
397
580
  ctx: EmitterContext,
398
581
  modelMap: Map<string, Model>,
399
582
  specEnumNames?: Set<string>,
583
+ resolvedOp?: ResolvedOperation,
400
584
  ): string[] {
401
585
  const lines: string[] = [];
402
586
  const responseModel = plan.responseModelName ? resolveInterfaceName(plan.responseModelName, ctx) : null;
403
587
 
404
588
  const pathStr = buildPathStr(op);
405
589
 
590
+ // Build the set of params hidden from the method signature
591
+ // (injected from client config or as constant defaults)
592
+ const hiddenParams = new Set<string>([
593
+ ...Object.keys(getOpDefaults(resolvedOp)),
594
+ ...getOpInferFromClient(resolvedOp),
595
+ ]);
596
+
406
597
  // Build set of valid param names to filter @param tags.
407
598
  // Prefer the overlay (existing method signature) if available;
408
599
  // otherwise compute from what the render path will actually include.
@@ -418,119 +609,135 @@ function renderMethod(
418
609
  for (const p of op.pathParams) actualParams.add(fieldName(p.name));
419
610
  if (plan.hasBody) actualParams.add('payload');
420
611
  if (plan.isPaginated) actualParams.add('options');
421
- // renderGetMethod adds options when there are non-paginated query params
422
- if (!plan.isPaginated && op.queryParams.length > 0 && !plan.isDelete && responseModel) {
612
+ // renderGetMethod/renderVoidMethod add options when there are visible non-paginated query params
613
+ const visibleQueryCount = op.queryParams.filter((q) => !hiddenParams.has(q.name)).length;
614
+ if (!plan.isPaginated && visibleQueryCount > 0 && !plan.isDelete) {
423
615
  actualParams.add('options');
424
616
  }
425
617
  validParamNames = actualParams;
426
618
  }
427
619
 
428
- const docParts: string[] = [];
429
- if (op.description) docParts.push(op.description);
430
- for (const param of op.pathParams) {
431
- const paramName = fieldName(param.name);
432
- if (validParamNames && !validParamNames.has(paramName)) continue;
433
- const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
434
- if (param.description) {
435
- docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
436
- } else if (param.deprecated) {
437
- docParts.push(`@param ${paramName} - (deprecated)`);
438
- }
439
- if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
440
- if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
441
- }
442
- // Document query params for non-paginated operations
443
- if (!plan.isPaginated) {
444
- // Only document query params if the method will have an options parameter
445
- if (validParamNames && (validParamNames.has('options') || overlayMethod)) {
446
- for (const param of op.queryParams) {
447
- const paramName = `options.${fieldName(param.name)}`;
448
- if (validParamNames && !validParamNames.has('options') && !validParamNames.has(fieldName(param.name))) continue;
449
- const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
450
- if (param.description) {
451
- docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
452
- } else if (param.deprecated) {
453
- docParts.push(`@param ${paramName} - (deprecated)`);
620
+ // Always generate JSDoc for all methods (both existing and new).
621
+ // The merger matches docstrings by member name — if we skip JSDoc for
622
+ // existing methods, previously misplaced docstrings can never be corrected.
623
+ // Hand-written docs (e.g., @deprecated, PKCE flow descriptions) are
624
+ // preserved by the merger's @deprecated-preservation and @oagen-ignore
625
+ // mechanisms instead.
626
+ {
627
+ const docParts: string[] = [];
628
+ if (op.description) docParts.push(op.description);
629
+ for (const param of op.pathParams) {
630
+ const paramName = fieldName(param.name);
631
+ if (validParamNames && !validParamNames.has(paramName)) continue;
632
+ const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
633
+ if (param.description) {
634
+ docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
635
+ } else if (param.deprecated) {
636
+ docParts.push(`@param ${paramName} - (deprecated)`);
637
+ }
638
+ if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
639
+ if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
640
+ }
641
+ // Document query params for non-paginated operations
642
+ if (!plan.isPaginated) {
643
+ // Only document query params if the method will have an options parameter
644
+ if (validParamNames && (validParamNames.has('options') || overlayMethod)) {
645
+ for (const param of op.queryParams) {
646
+ if (hiddenParams.has(param.name)) continue;
647
+ const paramName = `options.${fieldName(param.name)}`;
648
+ if (validParamNames && !validParamNames.has('options') && !validParamNames.has(fieldName(param.name)))
649
+ continue;
650
+ const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
651
+ if (param.description) {
652
+ docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
653
+ } else if (param.deprecated) {
654
+ docParts.push(`@param ${paramName} - (deprecated)`);
655
+ }
656
+ if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
657
+ if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
454
658
  }
455
- if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
456
- if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
457
659
  }
458
660
  }
459
- }
460
- // Skip header and cookie params in JSDoc they are not exposed in the method signature.
461
- // The SDK handles headers and cookies internally, so documenting them would be misleading.
462
- // Document payload parameter when there is a request body
463
- if (plan.hasBody) {
464
- const bodyInfo = extractRequestBodyType(op, ctx);
465
- if (bodyInfo?.kind === 'model') {
466
- const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
467
- let payloadDesc: string;
468
- if (bodyModel?.description) {
469
- payloadDesc = `@param payload - ${bodyModel.description}`;
470
- } else if (bodyModel) {
471
- // When the model lacks a description, list its required fields to help
472
- // callers understand what must be provided.
473
- const requiredFieldNames = bodyModel.fields.filter((f) => f.required).map((f) => fieldName(f.name));
474
- payloadDesc =
475
- requiredFieldNames.length > 0
476
- ? `@param payload - Object containing ${requiredFieldNames.join(', ')}.`
477
- : '@param payload - The request body.';
661
+ // Skip header and cookie params in JSDoc — they are not exposed in the method signature.
662
+ // The SDK handles headers and cookies internally, so documenting them would be misleading.
663
+ // Document payload parameter when there is a request body
664
+ if (plan.hasBody) {
665
+ const bodyInfo = extractRequestBodyType(op, ctx);
666
+ if (bodyInfo?.kind === 'model') {
667
+ const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
668
+ let payloadDesc: string;
669
+ if (bodyModel?.description) {
670
+ payloadDesc = `@param payload - ${bodyModel.description}`;
671
+ } else if (bodyModel) {
672
+ // When the model lacks a description, list its required fields to help
673
+ // callers understand what must be provided.
674
+ const requiredFieldNames = bodyModel.fields.filter((f) => f.required).map((f) => fieldName(f.name));
675
+ payloadDesc =
676
+ requiredFieldNames.length > 0
677
+ ? `@param payload - Object containing ${requiredFieldNames.join(', ')}.`
678
+ : '@param payload - The request body.';
679
+ } else {
680
+ payloadDesc = '@param payload - The request body.';
681
+ }
682
+ docParts.push(payloadDesc);
478
683
  } else {
479
- payloadDesc = '@param payload - The request body.';
684
+ docParts.push('@param payload - The request body.');
480
685
  }
481
- docParts.push(payloadDesc);
686
+ }
687
+ // Document options parameter for paginated operations
688
+ if (plan.isPaginated) {
689
+ docParts.push('@param options - Pagination and filter options.');
690
+ } else if (op.queryParams.filter((q) => !hiddenParams.has(q.name)).length > 0) {
691
+ docParts.push('@param options - Additional query options.');
692
+ }
693
+ // @returns for the primary response model.
694
+ // When an overlay method exists, prefer its return type so the JSDoc
695
+ // matches the actual TypeScript signature (the overlay may use a
696
+ // different model name than the OpenAPI schema).
697
+ if (overlayMethod?.returnType) {
698
+ docParts.push(`@returns {${overlayMethod.returnType}}`);
699
+ } else if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
700
+ // Unwrap list wrapper models to match the actual return type — the method returns
701
+ // AutoPaginatable<ItemType>, not the list wrapper.
702
+ let itemRawName = op.pagination.itemType.name;
703
+ const pModel = modelMap.get(itemRawName);
704
+ if (pModel) {
705
+ const unwrapped = unwrapListModel(pModel, modelMap);
706
+ if (unwrapped) itemRawName = unwrapped.name;
707
+ }
708
+ const itemTypeName = resolveInterfaceName(itemRawName, ctx);
709
+ docParts.push(`@returns {Promise<AutoPaginatable<${itemTypeName}>>}`);
710
+ } else if (responseModel) {
711
+ docParts.push(`@returns {Promise<${responseModel}>}`);
482
712
  } else {
483
- docParts.push('@param payload - The request body.');
484
- }
485
- }
486
- // Document options parameter for paginated operations
487
- if (plan.isPaginated) {
488
- docParts.push('@param options - Pagination and filter options.');
489
- } else if (op.queryParams.length > 0) {
490
- docParts.push('@param options - Additional query options.');
491
- }
492
- // @returns for the primary response model (use item type for paginated operations).
493
- // Unwrap list wrapper models to match the actual return type — the method returns
494
- // AutoPaginatable<ItemType>, not the list wrapper.
495
- if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
496
- let itemRawName = op.pagination.itemType.name;
497
- const pModel = modelMap.get(itemRawName);
498
- if (pModel) {
499
- const unwrapped = unwrapListModel(pModel, modelMap);
500
- if (unwrapped) itemRawName = unwrapped.name;
501
- }
502
- const itemTypeName = resolveInterfaceName(itemRawName, ctx);
503
- docParts.push(`@returns {AutoPaginatable<${itemTypeName}>}`);
504
- } else if (responseModel) {
505
- docParts.push(`@returns {${responseModel}}`);
506
- } else {
507
- docParts.push('@returns {void}');
508
- }
509
- // @throws for error responses
510
- for (const err of op.errors) {
511
- const exceptionName = STATUS_TO_EXCEPTION_NAME[err.statusCode];
512
- if (exceptionName) {
513
- docParts.push(`@throws {${exceptionName}} ${err.statusCode}`);
713
+ docParts.push('@returns {Promise<void>}');
514
714
  }
515
- }
516
- if (op.deprecated) docParts.push('@deprecated');
517
-
518
- if (docParts.length > 0) {
519
- // Flatten all parts, splitting multiline descriptions into individual lines
520
- const allLines: string[] = [];
521
- for (const part of docParts) {
522
- for (const line of part.split('\n')) {
523
- allLines.push(line);
715
+ // @throws for error responses
716
+ for (const err of op.errors) {
717
+ const exceptionName = STATUS_TO_EXCEPTION_NAME[err.statusCode];
718
+ if (exceptionName) {
719
+ docParts.push(`@throws {${exceptionName}} ${err.statusCode}`);
524
720
  }
525
721
  }
526
- if (allLines.length === 1) {
527
- lines.push(` /** ${allLines[0]} */`);
528
- } else {
529
- lines.push(' /**');
530
- for (const line of allLines) {
531
- lines.push(line === '' ? ' *' : ` * ${line}`);
722
+ if (op.deprecated) docParts.push('@deprecated');
723
+
724
+ if (docParts.length > 0) {
725
+ // Flatten all parts, splitting multiline descriptions into individual lines
726
+ const allLines: string[] = [];
727
+ for (const part of docParts) {
728
+ for (const line of part.split('\n')) {
729
+ allLines.push(line);
730
+ }
731
+ }
732
+ if (allLines.length === 1) {
733
+ lines.push(` /** ${allLines[0]} */`);
734
+ } else {
735
+ lines.push(' /**');
736
+ for (const line of allLines) {
737
+ lines.push(line === '' ? ' *' : ` * ${line}`);
738
+ }
739
+ lines.push(' */');
532
740
  }
533
- lines.push(' */');
534
741
  }
535
742
  }
536
743
 
@@ -574,9 +781,9 @@ function renderMethod(
574
781
  } else if (plan.hasBody && responseModel) {
575
782
  renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx, specEnumNames);
576
783
  } else if (responseModel) {
577
- renderGetMethod(lines, op, plan, method, responseModel, pathStr, specEnumNames);
784
+ renderGetMethod(lines, op, plan, method, responseModel, pathStr, specEnumNames, resolvedOp);
578
785
  } else {
579
- renderVoidMethod(lines, op, plan, method, pathStr, ctx, specEnumNames);
786
+ renderVoidMethod(lines, op, plan, method, pathStr, ctx, specEnumNames, resolvedOp);
580
787
  }
581
788
 
582
789
  // Defensive: if no render function produced a method body, emit a stub
@@ -649,7 +856,7 @@ function renderDeleteWithBodyMethod(
649
856
  if (bodyInfo.discriminator) {
650
857
  bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
651
858
  } else {
652
- bodyExpr = 'payload';
859
+ bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
653
860
  }
654
861
  } else {
655
862
  requestType = 'Record<string, unknown>';
@@ -688,12 +895,9 @@ function renderBodyMethod(
688
895
  } else if (bodyInfo?.kind === 'union') {
689
896
  requestType = bodyInfo.typeStr;
690
897
  if (bodyInfo.discriminator) {
691
- // Discriminated union: dispatch to the correct serializer at runtime.
692
898
  bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
693
899
  } else {
694
- // Non-discriminated union: cannot statically dispatch —
695
- // pass the payload directly (caller provides the correct shape).
696
- bodyExpr = 'payload';
900
+ bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
697
901
  }
698
902
  } else {
699
903
  requestType = 'Record<string, unknown>';
@@ -763,21 +967,78 @@ function renderGetMethod(
763
967
  responseModel: string,
764
968
  pathStr: string,
765
969
  specEnumNames?: Set<string>,
970
+ resolvedOp?: ResolvedOperation,
766
971
  ): void {
767
- const params = buildPathParams(op, specEnumNames);
768
- const hasQuery = op.queryParams.length > 0 && !plan.isPaginated;
769
- const optionsType = hasQuery ? toPascalCase(method) + 'Options' : null;
972
+ const hiddenParams = new Set<string>([
973
+ ...Object.keys(getOpDefaults(resolvedOp)),
974
+ ...getOpInferFromClient(resolvedOp),
975
+ ]);
770
976
 
771
- const allParams = hasQuery ? (params ? `${params}, options?: ${optionsType}` : `options?: ${optionsType}`) : params;
977
+ const params = buildPathParams(op, specEnumNames);
978
+ const visibleQueryParams = op.queryParams.filter((p) => !hiddenParams.has(p.name));
979
+ const hasVisibleQuery = visibleQueryParams.length > 0 && !plan.isPaginated;
980
+ const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
981
+ const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
982
+ const hasInjected = hasDefaults || hasInferred;
983
+ const hasQuery = (op.queryParams.length > 0 && !plan.isPaginated) || hasInjected;
984
+ const optionsType = hasVisibleQuery ? toPascalCase(method) + 'Options' : null;
985
+
986
+ const allParams = optionsType
987
+ ? params
988
+ ? `${params}, options?: ${optionsType}`
989
+ : `options?: ${optionsType}`
990
+ : params;
772
991
 
773
992
  lines.push(` async ${method}(${allParams}): Promise<${responseModel}> {`);
774
993
  if (hasQuery) {
775
- const queryExpr = renderQueryExpr(op.queryParams);
776
- lines.push(
777
- ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
778
- );
779
- lines.push(` query: ${queryExpr},`);
780
- lines.push(' });');
994
+ if (hasInjected) {
995
+ // Build the query object with visible params, defaults, and inferred fields
996
+ const queryParts: string[] = [];
997
+
998
+ // Regular visible query params (from options)
999
+ if (hasVisibleQuery) {
1000
+ for (const param of visibleQueryParams) {
1001
+ const camel = fieldName(param.name);
1002
+ const snake = wireFieldName(param.name);
1003
+ if (camel === snake) {
1004
+ if (param.required) {
1005
+ queryParts.push(`${camel}: options.${camel}`);
1006
+ } else {
1007
+ queryParts.push(`...(options?.${camel} !== undefined && { ${camel}: options.${camel} })`);
1008
+ }
1009
+ } else {
1010
+ if (param.required) {
1011
+ queryParts.push(`${snake}: options.${camel}`);
1012
+ } else {
1013
+ queryParts.push(`...(options?.${camel} !== undefined && { ${snake}: options.${camel} })`);
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+
1019
+ // Constant defaults (e.g., response_type: 'code')
1020
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
1021
+ queryParts.push(`${key}: ${tsLiteral(value)}`);
1022
+ }
1023
+
1024
+ // Inferred fields from client config (e.g., client_id from this.workos.options.clientId)
1025
+ for (const field of getOpInferFromClient(resolvedOp)) {
1026
+ queryParts.push(`${field}: ${clientFieldExpression(field)}`);
1027
+ }
1028
+
1029
+ lines.push(
1030
+ ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
1031
+ );
1032
+ lines.push(` query: { ${queryParts.join(', ')} },`);
1033
+ lines.push(' });');
1034
+ } else {
1035
+ const queryExpr = renderQueryExpr(visibleQueryParams);
1036
+ lines.push(
1037
+ ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
1038
+ );
1039
+ lines.push(` query: ${queryExpr},`);
1040
+ lines.push(' });');
1041
+ }
781
1042
  } else if (httpMethodNeedsBody(op.httpMethod)) {
782
1043
  // PUT/PATCH/POST require a body argument even when the spec has no request body
783
1044
  lines.push(
@@ -792,6 +1053,25 @@ function renderGetMethod(
792
1053
  lines.push(' }');
793
1054
  }
794
1055
 
1056
+ /** Convert a JS value to a TypeScript literal. */
1057
+ function tsLiteral(value: string | number | boolean): string {
1058
+ if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`;
1059
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
1060
+ return String(value);
1061
+ }
1062
+
1063
+ /** Get the TypeScript expression for reading a client config field. */
1064
+ function clientFieldExpression(field: string): string {
1065
+ switch (field) {
1066
+ case 'client_id':
1067
+ return 'this.workos.options.clientId';
1068
+ case 'client_secret':
1069
+ return 'this.workos.key';
1070
+ default:
1071
+ return `this.workos.${toCamelCase(field)}`;
1072
+ }
1073
+ }
1074
+
795
1075
  function renderVoidMethod(
796
1076
  lines: string[],
797
1077
  op: Operation,
@@ -800,10 +1080,21 @@ function renderVoidMethod(
800
1080
  pathStr: string,
801
1081
  ctx: EmitterContext,
802
1082
  specEnumNames?: Set<string>,
1083
+ resolvedOp?: ResolvedOperation,
803
1084
  ): void {
1085
+ const hiddenParams = new Set<string>([
1086
+ ...Object.keys(getOpDefaults(resolvedOp)),
1087
+ ...getOpInferFromClient(resolvedOp),
1088
+ ]);
1089
+
804
1090
  const params = buildPathParams(op, specEnumNames);
805
- const hasQuery = op.queryParams.length > 0 && !plan.hasBody;
806
- const optionsType = hasQuery ? toPascalCase(method) + 'Options' : null;
1091
+ const visibleQueryParams = op.queryParams.filter((p) => !hiddenParams.has(p.name));
1092
+ const hasVisibleQuery = visibleQueryParams.length > 0 && !plan.hasBody;
1093
+ const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
1094
+ const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
1095
+ const hasInjected = hasDefaults || hasInferred;
1096
+ const hasQuery = hasVisibleQuery || (hasInjected && !plan.hasBody);
1097
+ const optionsType = hasVisibleQuery ? toPascalCase(method) + 'Options' : null;
807
1098
 
808
1099
  let bodyParam = '';
809
1100
  let bodyExpr = 'payload';
@@ -818,7 +1109,7 @@ function renderVoidMethod(
818
1109
  if (bodyInfo.discriminator) {
819
1110
  bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
820
1111
  } else {
821
- bodyExpr = 'payload';
1112
+ bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
822
1113
  }
823
1114
  } else {
824
1115
  bodyParam = 'payload: Record<string, unknown>';
@@ -836,10 +1127,47 @@ function renderVoidMethod(
836
1127
  if (plan.hasBody) {
837
1128
  lines.push(` await this.workos.${op.httpMethod}(${pathStr}, ${bodyExpr});`);
838
1129
  } else if (hasQuery) {
839
- const queryExpr = renderQueryExpr(op.queryParams);
840
- lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {`);
841
- lines.push(` query: ${queryExpr},`);
842
- lines.push(' });');
1130
+ if (hasInjected) {
1131
+ // Build query object with visible params, defaults, and inferred fields
1132
+ const queryParts: string[] = [];
1133
+
1134
+ if (hasVisibleQuery) {
1135
+ for (const param of visibleQueryParams) {
1136
+ const camel = fieldName(param.name);
1137
+ const snake = wireFieldName(param.name);
1138
+ if (camel === snake) {
1139
+ if (param.required) {
1140
+ queryParts.push(`${camel}: options.${camel}`);
1141
+ } else {
1142
+ queryParts.push(`...(options?.${camel} !== undefined && { ${camel}: options.${camel} })`);
1143
+ }
1144
+ } else {
1145
+ if (param.required) {
1146
+ queryParts.push(`${snake}: options.${camel}`);
1147
+ } else {
1148
+ queryParts.push(`...(options?.${camel} !== undefined && { ${snake}: options.${camel} })`);
1149
+ }
1150
+ }
1151
+ }
1152
+ }
1153
+
1154
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
1155
+ queryParts.push(`${key}: ${tsLiteral(value)}`);
1156
+ }
1157
+
1158
+ for (const field of getOpInferFromClient(resolvedOp)) {
1159
+ queryParts.push(`${field}: ${clientFieldExpression(field)}`);
1160
+ }
1161
+
1162
+ lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {`);
1163
+ lines.push(` query: { ${queryParts.join(', ')} },`);
1164
+ lines.push(' });');
1165
+ } else {
1166
+ const queryExpr = renderQueryExpr(visibleQueryParams);
1167
+ lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {`);
1168
+ lines.push(` query: ${queryExpr},`);
1169
+ lines.push(' });');
1170
+ }
843
1171
  } else if (httpMethodNeedsBody(op.httpMethod)) {
844
1172
  lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {});`);
845
1173
  } else {
@@ -940,6 +1268,122 @@ function renderUnionBodySerializer(
940
1268
  return `(() => { switch ((payload as any).${prop}) { ${cases.join('; ')}; default: return payload } })()`;
941
1269
  }
942
1270
 
1271
+ /**
1272
+ * Generate an IIFE expression that dispatches to the correct serializer for a
1273
+ * non-discriminated union request body. Inspects model fields to find a
1274
+ * required field unique to each variant and uses `'field' in payload` guards.
1275
+ * Falls back to `payload` only when no variant can be distinguished.
1276
+ */
1277
+ function renderNonDiscriminatedUnionBodySerializer(modelNames: string[], ctx: EmitterContext): string {
1278
+ const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
1279
+
1280
+ // Try to detect an implicit discriminator: a required field present in all
1281
+ // variants whose type is `kind: 'literal'` with a distinct value per variant.
1282
+ // This covers oneOf unions where each variant has e.g. `grant_type: 'authorization_code'`.
1283
+ const implicitDisc = detectImplicitDiscriminator(modelNames, modelMap);
1284
+ if (implicitDisc) {
1285
+ return renderUnionBodySerializer(implicitDisc, ctx);
1286
+ }
1287
+
1288
+ // Collect required field names per model (using camelCase domain names).
1289
+ const requiredFieldsByModel = new Map<string, Set<string>>();
1290
+ for (const name of modelNames) {
1291
+ const model = modelMap.get(name);
1292
+ if (!model) return 'payload';
1293
+ requiredFieldsByModel.set(name, new Set(model.fields.filter((f) => f.required).map((f) => fieldName(f.name))));
1294
+ }
1295
+
1296
+ // For each model, find a required field that no other model has.
1297
+ const guards: Array<{ modelName: string; field: string }> = [];
1298
+ let fallbackModel: string | undefined;
1299
+
1300
+ for (const name of modelNames) {
1301
+ const myFields = requiredFieldsByModel.get(name)!;
1302
+ let uniqueField: string | undefined;
1303
+ for (const field of myFields) {
1304
+ const isUnique = modelNames.every((other) => other === name || !requiredFieldsByModel.get(other)?.has(field));
1305
+ if (isUnique) {
1306
+ uniqueField = field;
1307
+ break;
1308
+ }
1309
+ }
1310
+ if (uniqueField) {
1311
+ guards.push({ modelName: name, field: uniqueField });
1312
+ } else if (!fallbackModel) {
1313
+ fallbackModel = name;
1314
+ } else {
1315
+ // Multiple models with no unique field — can't dispatch
1316
+ return 'payload';
1317
+ }
1318
+ }
1319
+
1320
+ if (guards.length === 0) return 'payload';
1321
+
1322
+ const parts: string[] = [];
1323
+ for (const { modelName, field } of guards) {
1324
+ const resolved = resolveInterfaceName(modelName, ctx);
1325
+ parts.push(`if ('${field}' in payload) return serialize${resolved}(payload as any)`);
1326
+ }
1327
+ if (fallbackModel) {
1328
+ const resolved = resolveInterfaceName(fallbackModel, ctx);
1329
+ parts.push(`return serialize${resolved}(payload as any)`);
1330
+ } else {
1331
+ parts.push('return payload');
1332
+ }
1333
+
1334
+ return `(() => { ${parts.join('; ')} })()`;
1335
+ }
1336
+
1337
+ /**
1338
+ * Detect an implicit discriminator from literal-typed fields.
1339
+ * Returns a discriminator descriptor if all variants share a required field
1340
+ * whose type is `kind: 'literal'` with a distinct value per variant.
1341
+ */
1342
+ function detectImplicitDiscriminator(
1343
+ modelNames: string[],
1344
+ modelMap: Map<string, Model>,
1345
+ ): { property: string; mapping: Record<string, string> } | null {
1346
+ if (modelNames.length < 2) return null;
1347
+
1348
+ const firstModel = modelMap.get(modelNames[0]);
1349
+ if (!firstModel) return null;
1350
+
1351
+ // Candidate fields: required fields with literal type in the first model.
1352
+ const candidates = firstModel.fields.filter((f) => f.required && f.type.kind === 'literal');
1353
+
1354
+ for (const candidate of candidates) {
1355
+ const mapping: Record<string, string> = {};
1356
+ const values = new Set<string | number | boolean | null>();
1357
+ let valid = true;
1358
+
1359
+ for (const name of modelNames) {
1360
+ const model = modelMap.get(name);
1361
+ if (!model) {
1362
+ valid = false;
1363
+ break;
1364
+ }
1365
+ const field = model.fields.find((f) => f.name === candidate.name);
1366
+ if (!field || !field.required || field.type.kind !== 'literal') {
1367
+ valid = false;
1368
+ break;
1369
+ }
1370
+ const val = field.type.value;
1371
+ if (values.has(val)) {
1372
+ valid = false;
1373
+ break;
1374
+ } // duplicate value
1375
+ values.add(val);
1376
+ mapping[String(val)] = name;
1377
+ }
1378
+
1379
+ if (valid && Object.keys(mapping).length === modelNames.length) {
1380
+ return { property: candidate.name, mapping };
1381
+ }
1382
+ }
1383
+
1384
+ return null;
1385
+ }
1386
+
943
1387
  /** Return type for extractRequestBodyType when the body is a union. */
944
1388
  interface UnionBodyInfo {
945
1389
  kind: 'union';
@@ -961,7 +1405,12 @@ function extractRequestBodyType(
961
1405
  }
962
1406
  if (modelNames.length > 0) {
963
1407
  const typeStr = modelNames.map((n) => resolveInterfaceName(n, ctx)).join(' | ');
964
- return { kind: 'union', typeStr, modelNames, discriminator: op.requestBody.discriminator };
1408
+ return {
1409
+ kind: 'union',
1410
+ typeStr,
1411
+ modelNames,
1412
+ discriminator: op.requestBody.discriminator,
1413
+ };
965
1414
  }
966
1415
  }
967
1416
  return null;