@workos/oagen-emitters 0.3.0 → 0.5.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 (128) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +15 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +12 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -12737
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +336 -0
  19. package/oagen.config.ts +5 -343
  20. package/package.json +10 -34
  21. package/smoke/sdk-dotnet.ts +45 -12
  22. package/src/dotnet/client.ts +89 -0
  23. package/src/dotnet/enums.ts +323 -0
  24. package/src/dotnet/fixtures.ts +236 -0
  25. package/src/dotnet/index.ts +248 -0
  26. package/src/dotnet/manifest.ts +36 -0
  27. package/src/dotnet/models.ts +320 -0
  28. package/src/dotnet/naming.ts +368 -0
  29. package/src/dotnet/resources.ts +943 -0
  30. package/src/dotnet/tests.ts +713 -0
  31. package/src/dotnet/type-map.ts +228 -0
  32. package/src/dotnet/wrappers.ts +197 -0
  33. package/src/go/client.ts +35 -3
  34. package/src/go/enums.ts +4 -0
  35. package/src/go/index.ts +15 -7
  36. package/src/go/models.ts +6 -1
  37. package/src/go/naming.ts +5 -17
  38. package/src/go/resources.ts +534 -73
  39. package/src/go/tests.ts +39 -3
  40. package/src/go/type-map.ts +8 -3
  41. package/src/go/wrappers.ts +79 -21
  42. package/src/index.ts +15 -0
  43. package/src/kotlin/client.ts +58 -0
  44. package/src/kotlin/enums.ts +189 -0
  45. package/src/kotlin/index.ts +92 -0
  46. package/src/kotlin/manifest.ts +55 -0
  47. package/src/kotlin/models.ts +486 -0
  48. package/src/kotlin/naming.ts +229 -0
  49. package/src/kotlin/overrides.ts +25 -0
  50. package/src/kotlin/resources.ts +998 -0
  51. package/src/kotlin/tests.ts +1133 -0
  52. package/src/kotlin/type-map.ts +123 -0
  53. package/src/kotlin/wrappers.ts +168 -0
  54. package/src/node/client.ts +84 -7
  55. package/src/node/field-plan.ts +12 -14
  56. package/src/node/fixtures.ts +39 -3
  57. package/src/node/index.ts +1 -0
  58. package/src/node/models.ts +281 -37
  59. package/src/node/resources.ts +319 -95
  60. package/src/node/tests.ts +108 -29
  61. package/src/node/type-map.ts +1 -31
  62. package/src/node/utils.ts +96 -6
  63. package/src/node/wrappers.ts +31 -1
  64. package/src/php/client.ts +11 -3
  65. package/src/php/models.ts +0 -33
  66. package/src/php/naming.ts +2 -21
  67. package/src/php/resources.ts +275 -19
  68. package/src/php/tests.ts +118 -18
  69. package/src/php/type-map.ts +16 -2
  70. package/src/php/wrappers.ts +7 -2
  71. package/src/plugin.ts +50 -0
  72. package/src/python/client.ts +50 -32
  73. package/src/python/enums.ts +35 -10
  74. package/src/python/index.ts +35 -27
  75. package/src/python/models.ts +139 -2
  76. package/src/python/naming.ts +2 -22
  77. package/src/python/resources.ts +234 -17
  78. package/src/python/tests.ts +260 -16
  79. package/src/python/type-map.ts +16 -2
  80. package/src/ruby/client.ts +238 -0
  81. package/src/ruby/enums.ts +149 -0
  82. package/src/ruby/index.ts +93 -0
  83. package/src/ruby/manifest.ts +35 -0
  84. package/src/ruby/models.ts +360 -0
  85. package/src/ruby/naming.ts +187 -0
  86. package/src/ruby/rbi.ts +313 -0
  87. package/src/ruby/resources.ts +799 -0
  88. package/src/ruby/tests.ts +459 -0
  89. package/src/ruby/type-map.ts +97 -0
  90. package/src/ruby/wrappers.ts +161 -0
  91. package/src/shared/model-utils.ts +357 -16
  92. package/src/shared/naming-utils.ts +83 -0
  93. package/src/shared/non-spec-services.ts +13 -0
  94. package/src/shared/resolved-ops.ts +75 -1
  95. package/src/shared/wrapper-utils.ts +12 -1
  96. package/test/dotnet/client.test.ts +121 -0
  97. package/test/dotnet/enums.test.ts +193 -0
  98. package/test/dotnet/errors.test.ts +9 -0
  99. package/test/dotnet/manifest.test.ts +82 -0
  100. package/test/dotnet/models.test.ts +258 -0
  101. package/test/dotnet/resources.test.ts +387 -0
  102. package/test/dotnet/tests.test.ts +202 -0
  103. package/test/entrypoint.test.ts +89 -0
  104. package/test/go/client.test.ts +6 -6
  105. package/test/go/resources.test.ts +156 -7
  106. package/test/kotlin/models.test.ts +135 -0
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/kotlin/tests.test.ts +176 -0
  109. package/test/node/client.test.ts +74 -0
  110. package/test/node/models.test.ts +134 -1
  111. package/test/node/resources.test.ts +343 -34
  112. package/test/node/utils.test.ts +140 -0
  113. package/test/php/client.test.ts +2 -1
  114. package/test/php/models.test.ts +5 -4
  115. package/test/php/resources.test.ts +103 -0
  116. package/test/php/tests.test.ts +67 -0
  117. package/test/plugin.test.ts +50 -0
  118. package/test/python/client.test.ts +56 -0
  119. package/test/python/models.test.ts +99 -0
  120. package/test/python/resources.test.ts +294 -0
  121. package/test/python/tests.test.ts +91 -0
  122. package/test/ruby/client.test.ts +81 -0
  123. package/test/ruby/resources.test.ts +386 -0
  124. package/test/shared/resolved-ops.test.ts +122 -0
  125. package/tsdown.config.ts +1 -1
  126. package/dist/index.mjs.map +0 -1
  127. package/scripts/generate-php.js +0 -13
  128. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -40,6 +40,7 @@ import {
40
40
  getOpInferFromClient,
41
41
  } from '../shared/resolved-ops.js';
42
42
  import { generateWrapperMethods, collectWrapperResponseModels } from './wrappers.js';
43
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
43
44
 
44
45
  /**
45
46
  * Check whether the baseline (hand-written) class has a constructor compatible
@@ -192,6 +193,62 @@ function deduplicateMethodNames(
192
193
  }
193
194
  }
194
195
 
196
+ /**
197
+ * Emit one interface file per paginated list operation that has extension
198
+ * query params. Placing the options interface under `interfaces/` lets the
199
+ * per-service barrel pick it up via `export * from './interfaces'`, which
200
+ * is what the root `src/index.ts` re-exports. When the interface was
201
+ * declared inline in the resource file, it was unreachable from the barrel
202
+ * and callers couldn't import the type by name from the package root.
203
+ */
204
+ function generatePaginatedOptionsInterfaces(
205
+ service: Service,
206
+ ctx: EmitterContext,
207
+ specEnumNames: Set<string>,
208
+ ): GeneratedFile[] {
209
+ const files: GeneratedFile[] = [];
210
+ const resolvedName = resolveResourceClassName(service, ctx);
211
+ const serviceDir = resolveServiceDir(resolvedName);
212
+
213
+ const plans = service.operations.map((op) => ({
214
+ op,
215
+ plan: planOperation(op),
216
+ method: resolveMethodName(op, service, ctx),
217
+ }));
218
+
219
+ for (const { op, plan, method } of plans) {
220
+ if (!plan.isPaginated) continue;
221
+ const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
222
+ if (extraParams.length === 0) continue;
223
+
224
+ const optionsName = paginatedOptionsName(method, resolvedName);
225
+ const filePath = `src/${serviceDir}/interfaces/${fileName(optionsName)}.interface.ts`;
226
+
227
+ const lines: string[] = [];
228
+ lines.push("import type { PaginationOptions } from '../../common/interfaces/pagination-options.interface';");
229
+ lines.push('');
230
+ lines.push(`export interface ${optionsName} extends PaginationOptions {`);
231
+ for (const param of extraParams) {
232
+ const opt = !param.required ? '?' : '';
233
+ if (param.description || param.deprecated) {
234
+ const parts: string[] = [];
235
+ if (param.description) parts.push(param.description);
236
+ if (param.deprecated) parts.push('@deprecated');
237
+ lines.push(...docComment(parts.join('\n'), 2));
238
+ }
239
+ lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
240
+ }
241
+ lines.push('}');
242
+
243
+ files.push({
244
+ path: filePath,
245
+ content: lines.join('\n'),
246
+ });
247
+ }
248
+
249
+ return files;
250
+ }
251
+
195
252
  export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
196
253
  if (services.length === 0) return [];
197
254
  const files: GeneratedFile[] = [];
@@ -202,19 +259,19 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
202
259
  const mergedServices: Service[] =
203
260
  mountGroups.size > 0 ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations })) : services;
204
261
 
262
+ const topLevelEnumNames = new Set(ctx.spec.enums.map((e) => e.name));
263
+
205
264
  for (const service of mergedServices) {
206
265
  if (isServiceCoveredByExisting(service, ctx)) {
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';
266
+ if (!hasMethodsAbsentFromBaseline(service, ctx)) {
267
+ continue; // Fully covered, no new methods -- skip entirely
217
268
  }
269
+ // Partially covered -- has new methods that need to be added.
270
+ const file = generateResourceClass(service, ctx);
271
+ delete file.skipIfExists;
272
+ // Suppress auto-generated header — the file is a merge target
273
+ // containing hand-written code, not a fully generated file.
274
+ file.headerPlacement = 'skip';
218
275
  files.push(file);
219
276
  continue;
220
277
  }
@@ -233,10 +290,25 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
233
290
  file.headerPlacement = 'skip';
234
291
  files.push(file);
235
292
  } else {
236
- files.push(generateResourceClass(service, ctx));
293
+ // Purely oagen-managed: no baseline class exists, so the file is owned
294
+ // end-to-end by the emitter. Remove skipIfExists so regeneration always
295
+ // overwrites — emitter improvements (serializer dispatch, JSDoc, etc.)
296
+ // must propagate without manual intervention.
297
+ const file = generateResourceClass(service, ctx);
298
+ delete file.skipIfExists;
299
+ files.push(file);
237
300
  }
238
301
  }
239
302
 
303
+ // Emit paginated list options interfaces AFTER the resource classes so
304
+ // tests and manifest ordering that index `files[0]` as the class stay
305
+ // stable. Placing them under `interfaces/` lets the per-service barrel
306
+ // pick them up automatically.
307
+ for (const service of mergedServices) {
308
+ if (isServiceCoveredByExisting(service, ctx) && !hasMethodsAbsentFromBaseline(service, ctx)) continue;
309
+ files.push(...generatePaginatedOptionsInterfaces(service, ctx, topLevelEnumNames));
310
+ }
311
+
240
312
  return files;
241
313
  }
242
314
 
@@ -311,11 +383,19 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
311
383
  const paramEnums = new Set<string>();
312
384
  const paramModels = new Set<string>();
313
385
  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`).
386
+ // Always collect param type refs for enums inline options interfaces
387
+ // are generated for all methods (including baseline ones), so their
388
+ // type dependencies must always be imported.
389
+ const queryParams = plan.isPaginated
390
+ ? op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name))
391
+ : op.queryParams;
392
+ for (const param of [...queryParams, ...op.pathParams]) {
393
+ collectParamTypeRefs(param.type, paramEnums, paramModels);
394
+ }
395
+
396
+ // Skip response/request model imports for methods that already exist in
397
+ // the baseline class. The merger keeps baseline method bodies, so their
398
+ // imports are already present in the existing file.
319
399
  if (baselineMethodSet.has(method)) continue;
320
400
 
321
401
  if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
@@ -354,19 +434,13 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
354
434
  }
355
435
  }
356
436
  }
357
- // Collect types referenced in query and path parameters.
358
- // For paginated operations, skip standard pagination params (limit, before, after, order)
359
- // since they're handled by PaginationOptions and don't need explicit imports.
360
- const queryParams = plan.isPaginated
361
- ? op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name))
362
- : op.queryParams;
363
- for (const param of [...queryParams, ...op.pathParams]) {
364
- collectParamTypeRefs(param.type, paramEnums, paramModels);
365
- }
366
437
  }
367
438
 
368
439
  // Collect response models from union split wrappers so their types and
369
440
  // deserializers are imported alongside the primary operation models.
441
+ // Also collect models referenced in wrapper param signatures (e.g.,
442
+ // `redirect_uris: RedirectUriInput[]`) — otherwise the wrapper emits a
443
+ // reference to a type it never imported.
370
444
  const resolvedLookup = buildResolvedLookup(ctx);
371
445
  for (const { op, method } of plans) {
372
446
  if (baselineMethodSet.has(method)) continue;
@@ -375,6 +449,11 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
375
449
  for (const name of collectWrapperResponseModels(resolved)) {
376
450
  responseModels.add(name);
377
451
  }
452
+ for (const wrapper of resolved.wrappers ?? []) {
453
+ for (const { field } of resolveWrapperParams(wrapper, ctx)) {
454
+ if (field) collectParamTypeRefs(field.type, paramEnums, paramModels);
455
+ }
456
+ }
378
457
  }
379
458
  }
380
459
 
@@ -386,8 +465,19 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
386
465
  lines.push("import type { WorkOS } from '../workos';");
387
466
  if (hasPaginated) {
388
467
  lines.push("import type { PaginationOptions } from '../common/interfaces/pagination-options.interface';");
389
- lines.push("import type { AutoPaginatable } from '../common/utils/pagination';");
390
- lines.push("import { createPaginatedList } from '../common/utils/fetch-and-deserialize';");
468
+ lines.push("import { AutoPaginatable } from '../common/utils/pagination';");
469
+ lines.push("import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';");
470
+ }
471
+
472
+ // Paginated list options live in their own interface files so they're
473
+ // picked up by the per-service barrel (and flow through to the root
474
+ // package barrel). Import them here rather than declaring inline.
475
+ for (const { op, plan, method } of plans) {
476
+ if (!plan.isPaginated) continue;
477
+ const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
478
+ if (extraParams.length === 0) continue;
479
+ const optionsName = paginatedOptionsName(method, resolvedName);
480
+ lines.push(`import type { ${optionsName} } from './interfaces/${fileName(optionsName)}.interface';`);
391
481
  }
392
482
 
393
483
  // Check if any operation needs PostOptions (idempotent POST or custom encoding)
@@ -493,32 +583,43 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
493
583
 
494
584
  lines.push('');
495
585
 
496
- // Options interfaces for operations with query params.
497
- // Paginated operations extend PaginationOptions; non-paginated operations get standalone interfaces.
586
+ // Per-operation helpers (wire-format option serializers etc.) emitted
587
+ // alongside the resource class. The options interfaces themselves live
588
+ // in separate files under `interfaces/` so the per-service barrel can
589
+ // re-export them; see the earlier import block at the top of the file.
498
590
  for (const { op, plan, method } of plans) {
499
591
  if (plan.isPaginated) {
500
592
  const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
501
593
  if (extraParams.length > 0) {
502
594
  const optionsName = paginatedOptionsName(method, resolvedName);
503
- // Always generate the options interface locally in the resource file.
504
- // Previously we skipped generation when a baseline interface with a matching
505
- // name existed, but the baseline interface may live in a different module
506
- // (e.g., `user-management/` vs `user-management-users/`) and would not be
507
- // available without an import. Generating locally is safe and avoids
508
- // cross-module import resolution issues.
509
- lines.push(`export interface ${optionsName} extends PaginationOptions {`);
510
- for (const param of extraParams) {
511
- const opt = !param.required ? '?' : '';
512
- if (param.description || param.deprecated) {
513
- const parts: string[] = [];
514
- if (param.description) parts.push(param.description);
515
- if (param.deprecated) parts.push('@deprecated');
516
- lines.push(...docComment(parts.join('\n'), 2));
595
+
596
+ // When any extension param has a camelCase domain name that differs
597
+ // from its snake_case wire name, emit a serializer that translates
598
+ // the user-facing options into the wire query shape. Without this,
599
+ // the query string uses camelCase keys (e.g. `organizationId=...`)
600
+ // that the API silently ignores — the filter becomes a no-op.
601
+ const needsWireSerializer = extraParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
602
+ if (needsWireSerializer) {
603
+ const serializerName = `serialize${optionsName}`;
604
+ lines.push(`const ${serializerName} = (options: ${optionsName}): PaginationOptions => {`);
605
+ // Pagination fields pass through unchanged (limit/before/after/order
606
+ // share spelling in both cases). Spread first so that wire-named
607
+ // extension fields land on top and the camelCase keys don't also
608
+ // leak into the query string.
609
+ lines.push(' const wire: Record<string, unknown> = {');
610
+ for (const p of PAGINATION_PARAM_NAMES) {
611
+ lines.push(` ${p}: options.${p},`);
517
612
  }
518
- lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
613
+ lines.push(' };');
614
+ for (const param of extraParams) {
615
+ const camel = fieldName(param.name);
616
+ const snake = wireFieldName(param.name);
617
+ lines.push(` if (options.${camel} !== undefined) wire.${snake} = options.${camel};`);
618
+ }
619
+ lines.push(' return wire as PaginationOptions;');
620
+ lines.push('};');
621
+ lines.push('');
519
622
  }
520
- lines.push('}');
521
- lines.push('');
522
623
  }
523
624
  } else if (!plan.isPaginated && !plan.hasBody && !plan.isDelete && op.queryParams.length > 0) {
524
625
  // Non-paginated GET or void methods with query params get a typed options interface
@@ -628,12 +729,16 @@ function renderMethod(
628
729
  if (op.description) docParts.push(op.description);
629
730
  for (const param of op.pathParams) {
630
731
  const paramName = fieldName(param.name);
631
- if (validParamNames && !validParamNames.has(paramName)) continue;
732
+ // When the overlay folds a path param into the options object,
733
+ // document it as @param options.paramName instead of top-level.
734
+ const folded = validParamNames && !validParamNames.has(paramName) && validParamNames.has('options');
735
+ if (validParamNames && !validParamNames.has(paramName) && !folded) continue;
736
+ const docName = folded ? `options.${paramName}` : paramName;
632
737
  const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
633
738
  if (param.description) {
634
- docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
739
+ docParts.push(`@param ${docName} - ${deprecatedPrefix}${param.description}`);
635
740
  } else if (param.deprecated) {
636
- docParts.push(`@param ${paramName} - (deprecated)`);
741
+ docParts.push(`@param ${docName} - (deprecated)`);
637
742
  }
638
743
  if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
639
744
  if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
@@ -660,36 +765,122 @@ function renderMethod(
660
765
  }
661
766
  // Skip header and cookie params in JSDoc — they are not exposed in the method signature.
662
767
  // The SDK handles headers and cookies internally, so documenting them would be misleading.
663
- // Document payload parameter when there is a request body
768
+ // Document payload/body parameter when there is a request body.
769
+ // Detect the actual param name from the overlay — the hand-written SDK may
770
+ // fold the body into an 'options' param instead of the generated 'payload'.
771
+ let bodyDocParamName: string | null = null;
664
772
  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.';
773
+ let bodyParamName = 'payload';
774
+ let overlayHadUnusableName = false;
775
+ if (validParamNames && !validParamNames.has('payload') && overlayMethod) {
776
+ const pathNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
777
+ const candidates = overlayMethod.params.filter((p) => !pathNames.has(p.name) && p.name !== 'requestOptions');
778
+ // Filter out destructuring artifacts (e.g., __0) from extracted param names
779
+ const isUsableName = (name: string) => !/^__\d+$/.test(name);
780
+ if (candidates.length === 1 && isUsableName(candidates[0].name)) {
781
+ bodyParamName = candidates[0].name;
782
+ } else if (candidates.length === 1 && !isUsableName(candidates[0].name)) {
783
+ overlayHadUnusableName = true;
784
+ } else if (candidates.length > 1) {
785
+ // Multiple non-path params match by type against the request body model
786
+ const bodyInfo = extractRequestBodyType(op, ctx);
787
+ if (bodyInfo?.kind === 'model') {
788
+ const bodyTypeName = resolveInterfaceName(bodyInfo.name, ctx);
789
+ const typeMatch = candidates.find((p) => p.type === bodyTypeName && isUsableName(p.name));
790
+ if (typeMatch) {
791
+ bodyParamName = typeMatch.name;
792
+ } else {
793
+ // Type names diverge (overlay vs spec). Fall back to matching
794
+ // overlay param names against body model field names so each
795
+ // extracted param gets documented individually.
796
+ const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
797
+ if (bodyModel) {
798
+ const fieldMap = new Map(bodyModel.fields.map((f) => [fieldName(f.name), f]));
799
+ for (const candidate of candidates) {
800
+ const matchingField = fieldMap.get(candidate.name);
801
+ if (matchingField?.description) {
802
+ docParts.push(`@param ${candidate.name} - ${matchingField.description}`);
803
+ if (matchingField.example !== undefined)
804
+ docParts.push(`@example ${JSON.stringify(matchingField.example)}`);
805
+ }
806
+ }
807
+ bodyDocParamName = '__multi__'; // prevent duplicate body doc below
808
+ }
809
+ }
810
+ }
811
+ }
812
+ }
813
+
814
+ // When the overlay had an unusable param name (e.g., destructured __0),
815
+ // force documentation under 'payload' so the body isn't silently dropped.
816
+ if (
817
+ bodyDocParamName !== '__multi__' &&
818
+ (overlayHadUnusableName || !validParamNames || validParamNames.has(bodyParamName))
819
+ ) {
820
+ bodyDocParamName = bodyParamName;
821
+ const bodyInfo = extractRequestBodyType(op, ctx);
822
+ if (bodyInfo?.kind === 'model') {
823
+ const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
824
+ let bodyDesc: string;
825
+ if (bodyModel?.description) {
826
+ bodyDesc = `@param ${bodyParamName} - ${bodyModel.description}`;
827
+ } else if (bodyModel) {
828
+ // When the model lacks a description, list its required fields to help
829
+ // callers understand what must be provided.
830
+ const requiredFieldNames = bodyModel.fields.filter((f) => f.required).map((f) => fieldName(f.name));
831
+ bodyDesc =
832
+ requiredFieldNames.length > 0
833
+ ? `@param ${bodyParamName} - Object containing ${requiredFieldNames.join(', ')}.`
834
+ : `@param ${bodyParamName} - The request body.`;
835
+ } else {
836
+ bodyDesc = `@param ${bodyParamName} - The request body.`;
837
+ }
838
+ docParts.push(bodyDesc);
839
+
840
+ // When the body is folded into an options-style param (not 'payload'),
841
+ // expand individual fields so IDEs surface per-field documentation.
842
+ if (bodyParamName !== 'payload' && bodyModel) {
843
+ for (const bField of bodyModel.fields) {
844
+ const fName = `${bodyParamName}.${fieldName(bField.name)}`;
845
+ const deprecatedPrefix = bField.deprecated ? '(deprecated) ' : '';
846
+ if (bField.description) {
847
+ docParts.push(`@param ${fName} - ${deprecatedPrefix}${bField.description}`);
848
+ } else if (bField.deprecated) {
849
+ docParts.push(`@param ${fName} - (deprecated)`);
850
+ }
851
+ if (bField.example !== undefined) docParts.push(`@example ${JSON.stringify(bField.example)}`);
852
+ }
853
+ }
679
854
  } else {
680
- payloadDesc = '@param payload - The request body.';
855
+ docParts.push(`@param ${bodyParamName} - The request body.`);
681
856
  }
682
- docParts.push(payloadDesc);
683
- } else {
684
- docParts.push('@param payload - The request body.');
685
857
  }
686
858
  }
687
859
  // Document options parameter for paginated operations
860
+ const hasOptionsSummary = () => docParts.some((p) => /^@param options(\s|$)/.test(p));
688
861
  if (plan.isPaginated) {
689
862
  docParts.push('@param options - Pagination and filter options.');
690
- } else if (op.queryParams.filter((q) => !hiddenParams.has(q.name)).length > 0) {
863
+ } else if (!hasOptionsSummary() && op.queryParams.filter((q) => !hiddenParams.has(q.name)).length > 0) {
691
864
  docParts.push('@param options - Additional query options.');
692
865
  }
866
+ // Ensure an @param options summary exists when there are @param options.xxx
867
+ // entries (from folded path/body params) or when the overlay exposes an
868
+ // options param that wasn't otherwise documented.
869
+ if (validParamNames?.has('options') && !hasOptionsSummary()) {
870
+ const hasOptionsDotEntries = docParts.some((p) => p.startsWith('@param options.'));
871
+ if (hasOptionsDotEntries || overlayMethod) {
872
+ docParts.push('@param options - The request options.');
873
+ }
874
+ }
875
+ // Reorder: ensure @param options summary comes before any @param options.xxx entries
876
+ {
877
+ const summaryIdx = docParts.findIndex((p) => /^@param options(\s|$)/.test(p));
878
+ const firstDotIdx = docParts.findIndex((p) => p.startsWith('@param options.'));
879
+ if (summaryIdx > firstDotIdx && firstDotIdx >= 0) {
880
+ const [summary] = docParts.splice(summaryIdx, 1);
881
+ docParts.splice(firstDotIdx, 0, summary);
882
+ }
883
+ }
693
884
  // @returns for the primary response model.
694
885
  // When an overlay method exists, prefer its return type so the JSDoc
695
886
  // matches the actual TypeScript signature (the overlay may use a
@@ -708,7 +899,8 @@ function renderMethod(
708
899
  const itemTypeName = resolveInterfaceName(itemRawName, ctx);
709
900
  docParts.push(`@returns {Promise<AutoPaginatable<${itemTypeName}>>}`);
710
901
  } else if (responseModel) {
711
- docParts.push(`@returns {Promise<${responseModel}>}`);
902
+ const returnTypeDoc = plan.isArrayResponse ? `${responseModel}[]` : responseModel;
903
+ docParts.push(`@returns {Promise<${returnTypeDoc}>}`);
712
904
  } else {
713
905
  docParts.push('@returns {Promise<void>}');
714
906
  }
@@ -811,14 +1003,35 @@ function renderPaginatedMethod(
811
1003
  ): void {
812
1004
  const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
813
1005
  const optionsType = extraParams.length > 0 ? paginatedOptionsName(method, resolvedServiceName) : 'PaginationOptions';
1006
+ // When any extension param has a camelCase/snake_case divergence, the
1007
+ // resource file emits a `serialize<OptionsName>` helper — pass it to
1008
+ // createPaginatedList so the wire query uses snake_case keys.
1009
+ const needsWireSerializer = extraParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
1010
+ const serializerArg = needsWireSerializer ? `, serialize${optionsType}` : '';
814
1011
 
815
1012
  const pathParams = buildPathParams(op, specEnumNames);
816
1013
  const allParams = pathParams ? `${pathParams}, options?: ${optionsType}` : `options?: ${optionsType}`;
817
1014
 
1015
+ const wireType = wireInterfaceName(itemType);
1016
+ const serializeCall = serializerArg ? `options ? serialize${optionsType}(options) : undefined` : 'options';
1017
+
818
1018
  lines.push(` async ${method}(${allParams}): Promise<AutoPaginatable<${itemType}, ${optionsType}>> {`);
819
- lines.push(
820
- ` return createPaginatedList<${wireInterfaceName(itemType)}, ${itemType}, ${optionsType}>(this.workos, ${pathStr}, deserialize${itemType}, options);`,
821
- );
1019
+ lines.push(` return new AutoPaginatable(`);
1020
+ lines.push(` await fetchAndDeserialize<${wireType}, ${itemType}>(`);
1021
+ lines.push(` this.workos,`);
1022
+ lines.push(` ${pathStr},`);
1023
+ lines.push(` deserialize${itemType},`);
1024
+ lines.push(` ${serializeCall},`);
1025
+ lines.push(` ),`);
1026
+ lines.push(` (params) =>`);
1027
+ lines.push(` fetchAndDeserialize<${wireType}, ${itemType}>(`);
1028
+ lines.push(` this.workos,`);
1029
+ lines.push(` ${pathStr},`);
1030
+ lines.push(` deserialize${itemType},`);
1031
+ lines.push(` params,`);
1032
+ lines.push(` ),`);
1033
+ lines.push(` options,`);
1034
+ lines.push(` );`);
822
1035
  lines.push(' }');
823
1036
  }
824
1037
 
@@ -926,16 +1139,22 @@ function renderBodyMethod(
926
1139
  const encodingOption = encoding && encoding !== 'json' ? `, encoding: '${encoding}' as const` : '';
927
1140
  const hasCustomEncoding = encodingOption !== '';
928
1141
 
929
- lines.push(` async ${method}(${paramsStr}): Promise<${responseModel}> {`);
1142
+ const returnType = plan.isArrayResponse ? `${responseModel}[]` : responseModel;
1143
+ const wireType = plan.isArrayResponse ? `${wireInterfaceName(responseModel)}[]` : wireInterfaceName(responseModel);
1144
+ const returnExpr = plan.isArrayResponse
1145
+ ? `data.map(deserialize${responseModel})`
1146
+ : `deserialize${responseModel}(data)`;
1147
+
1148
+ lines.push(` async ${method}(${paramsStr}): Promise<${returnType}> {`);
930
1149
  if (plan.isIdempotentPost) {
931
1150
  if (hasCustomEncoding) {
932
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1151
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
933
1152
  lines.push(` ${pathStr},`);
934
1153
  lines.push(` ${bodyExpr},`);
935
1154
  lines.push(` { ...requestOptions${encodingOption} },`);
936
1155
  lines.push(' );');
937
1156
  } else {
938
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1157
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
939
1158
  lines.push(` ${pathStr},`);
940
1159
  lines.push(` ${bodyExpr},`);
941
1160
  lines.push(' requestOptions,');
@@ -943,19 +1162,19 @@ function renderBodyMethod(
943
1162
  }
944
1163
  } else {
945
1164
  if (hasCustomEncoding) {
946
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1165
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
947
1166
  lines.push(` ${pathStr},`);
948
1167
  lines.push(` ${bodyExpr},`);
949
1168
  lines.push(` { ${encodingOption.slice(2)} },`);
950
1169
  lines.push(' );');
951
1170
  } else {
952
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1171
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
953
1172
  lines.push(` ${pathStr},`);
954
1173
  lines.push(` ${bodyExpr},`);
955
1174
  lines.push(' );');
956
1175
  }
957
1176
  }
958
- lines.push(` return deserialize${responseModel}(data);`);
1177
+ lines.push(` return ${returnExpr};`);
959
1178
  lines.push(' }');
960
1179
  }
961
1180
 
@@ -989,7 +1208,13 @@ function renderGetMethod(
989
1208
  : `options?: ${optionsType}`
990
1209
  : params;
991
1210
 
992
- lines.push(` async ${method}(${allParams}): Promise<${responseModel}> {`);
1211
+ const returnType = plan.isArrayResponse ? `${responseModel}[]` : responseModel;
1212
+ const wireType = plan.isArrayResponse ? `${wireInterfaceName(responseModel)}[]` : wireInterfaceName(responseModel);
1213
+ const returnExpr = plan.isArrayResponse
1214
+ ? `data.map(deserialize${responseModel})`
1215
+ : `deserialize${responseModel}(data)`;
1216
+
1217
+ lines.push(` async ${method}(${allParams}): Promise<${returnType}> {`);
993
1218
  if (hasQuery) {
994
1219
  if (hasInjected) {
995
1220
  // Build the query object with visible params, defaults, and inferred fields
@@ -1026,30 +1251,22 @@ function renderGetMethod(
1026
1251
  queryParts.push(`${field}: ${clientFieldExpression(field)}`);
1027
1252
  }
1028
1253
 
1029
- lines.push(
1030
- ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
1031
- );
1254
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, {`);
1032
1255
  lines.push(` query: { ${queryParts.join(', ')} },`);
1033
1256
  lines.push(' });');
1034
1257
  } else {
1035
1258
  const queryExpr = renderQueryExpr(visibleQueryParams);
1036
- lines.push(
1037
- ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
1038
- );
1259
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, {`);
1039
1260
  lines.push(` query: ${queryExpr},`);
1040
1261
  lines.push(' });');
1041
1262
  }
1042
1263
  } else if (httpMethodNeedsBody(op.httpMethod)) {
1043
1264
  // PUT/PATCH/POST require a body argument even when the spec has no request body
1044
- lines.push(
1045
- ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {});`,
1046
- );
1265
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, {});`);
1047
1266
  } else {
1048
- lines.push(
1049
- ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr});`,
1050
- );
1267
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr});`);
1051
1268
  }
1052
- lines.push(` return deserialize${responseModel}(data);`);
1269
+ lines.push(` return ${returnExpr};`);
1053
1270
  lines.push(' }');
1054
1271
  }
1055
1272
 
@@ -1263,9 +1480,16 @@ function renderUnionBodySerializer(
1263
1480
  const cases: string[] = [];
1264
1481
  for (const [value, modelName] of Object.entries(disc.mapping)) {
1265
1482
  const resolved = resolveInterfaceName(modelName, ctx);
1266
- cases.push(`case '${value}': return serialize${resolved}(payload as any)`);
1483
+ // Switch on a typed discriminator narrows `payload` to the variant, so the
1484
+ // serializer call type-checks without any casts.
1485
+ cases.push(`case '${value}': return serialize${resolved}(payload)`);
1267
1486
  }
1268
- return `(() => { switch ((payload as any).${prop}) { ${cases.join('; ')}; default: return payload } })()`;
1487
+ // Assign `payload` to `never` in the default branch to get a compile-time
1488
+ // exhaustiveness check — if a new variant is added to the union but not to
1489
+ // the switch, the build fails here. At runtime, we still throw so an
1490
+ // unknown discriminator slipping through via `as any` fails loudly rather
1491
+ // than silently forwarding camelCase to the API.
1492
+ return `(() => { switch (payload.${prop}) { ${cases.join('; ')}; default: { const _unknown: never = payload; throw new Error(\`Unknown ${prop}: \${(_unknown as { ${prop}?: unknown }).${prop}}\`) } } })()`;
1269
1493
  }
1270
1494
 
1271
1495
  /**