@workos/oagen-emitters 0.2.1 → 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 (103) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +8 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +10 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +11893 -3226
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/go.md +338 -0
  10. package/docs/sdk-architecture/php.md +315 -0
  11. package/docs/sdk-architecture/python.md +511 -0
  12. package/oagen.config.ts +298 -2
  13. package/package.json +9 -5
  14. package/scripts/generate-php.js +13 -0
  15. package/scripts/git-push-with-published-oagen.sh +21 -0
  16. package/smoke/sdk-go.ts +116 -42
  17. package/smoke/sdk-php.ts +28 -26
  18. package/smoke/sdk-python.ts +5 -2
  19. package/src/go/client.ts +141 -0
  20. package/src/go/enums.ts +196 -0
  21. package/src/go/fixtures.ts +212 -0
  22. package/src/go/index.ts +81 -0
  23. package/src/go/manifest.ts +36 -0
  24. package/src/go/models.ts +254 -0
  25. package/src/go/naming.ts +191 -0
  26. package/src/go/resources.ts +827 -0
  27. package/src/go/tests.ts +751 -0
  28. package/src/go/type-map.ts +82 -0
  29. package/src/go/wrappers.ts +261 -0
  30. package/src/index.ts +3 -0
  31. package/src/node/client.ts +78 -115
  32. package/src/node/enums.ts +9 -0
  33. package/src/node/errors.ts +37 -232
  34. package/src/node/field-plan.ts +726 -0
  35. package/src/node/fixtures.ts +9 -1
  36. package/src/node/index.ts +2 -9
  37. package/src/node/models.ts +178 -21
  38. package/src/node/naming.ts +49 -111
  39. package/src/node/resources.ts +374 -364
  40. package/src/node/sdk-errors.ts +41 -0
  41. package/src/node/tests.ts +32 -12
  42. package/src/node/type-map.ts +4 -2
  43. package/src/node/utils.ts +13 -71
  44. package/src/node/wrappers.ts +151 -0
  45. package/src/php/client.ts +171 -0
  46. package/src/php/enums.ts +67 -0
  47. package/src/php/errors.ts +9 -0
  48. package/src/php/fixtures.ts +181 -0
  49. package/src/php/index.ts +96 -0
  50. package/src/php/manifest.ts +36 -0
  51. package/src/php/models.ts +310 -0
  52. package/src/php/naming.ts +298 -0
  53. package/src/php/resources.ts +561 -0
  54. package/src/php/tests.ts +533 -0
  55. package/src/php/type-map.ts +90 -0
  56. package/src/php/utils.ts +18 -0
  57. package/src/php/wrappers.ts +151 -0
  58. package/src/python/client.ts +337 -0
  59. package/src/python/enums.ts +313 -0
  60. package/src/python/fixtures.ts +196 -0
  61. package/src/python/index.ts +95 -0
  62. package/src/python/manifest.ts +38 -0
  63. package/src/python/models.ts +688 -0
  64. package/src/python/naming.ts +209 -0
  65. package/src/python/resources.ts +1322 -0
  66. package/src/python/tests.ts +1335 -0
  67. package/src/python/type-map.ts +93 -0
  68. package/src/python/wrappers.ts +191 -0
  69. package/src/shared/model-utils.ts +255 -0
  70. package/src/shared/naming-utils.ts +107 -0
  71. package/src/shared/non-spec-services.ts +54 -0
  72. package/src/shared/resolved-ops.ts +109 -0
  73. package/src/shared/wrapper-utils.ts +59 -0
  74. package/test/go/client.test.ts +92 -0
  75. package/test/go/enums.test.ts +132 -0
  76. package/test/go/errors.test.ts +9 -0
  77. package/test/go/models.test.ts +265 -0
  78. package/test/go/resources.test.ts +408 -0
  79. package/test/go/tests.test.ts +143 -0
  80. package/test/node/client.test.ts +18 -12
  81. package/test/node/enums.test.ts +2 -0
  82. package/test/node/errors.test.ts +2 -41
  83. package/test/node/models.test.ts +2 -0
  84. package/test/node/naming.test.ts +23 -0
  85. package/test/node/resources.test.ts +99 -69
  86. package/test/node/serializers.test.ts +3 -1
  87. package/test/node/type-map.test.ts +11 -0
  88. package/test/php/client.test.ts +94 -0
  89. package/test/php/enums.test.ts +173 -0
  90. package/test/php/errors.test.ts +9 -0
  91. package/test/php/models.test.ts +497 -0
  92. package/test/php/resources.test.ts +644 -0
  93. package/test/php/tests.test.ts +118 -0
  94. package/test/python/client.test.ts +200 -0
  95. package/test/python/enums.test.ts +228 -0
  96. package/test/python/errors.test.ts +16 -0
  97. package/test/python/manifest.test.ts +74 -0
  98. package/test/python/models.test.ts +716 -0
  99. package/test/python/resources.test.ts +617 -0
  100. package/test/python/tests.test.ts +202 -0
  101. package/src/node/common.ts +0 -273
  102. package/src/node/config.ts +0 -71
  103. package/src/node/serializers.ts +0 -746
@@ -1,6 +1,14 @@
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';
3
+ import type {
4
+ Service,
5
+ Operation,
6
+ EmitterContext,
7
+ GeneratedFile,
8
+ TypeRef,
9
+ Model,
10
+ ResolvedOperation,
11
+ } from '@workos/oagen';
4
12
  import { planOperation, toPascalCase, toCamelCase } from '@workos/oagen';
5
13
  import type { OperationPlan } from '@workos/oagen';
6
14
  import { mapTypeRef } from './type-map.js';
@@ -23,6 +31,15 @@ import {
23
31
  } from './utils.js';
24
32
  import { assignEnumsToServices } from './enums.js';
25
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';
26
43
 
27
44
  /**
28
45
  * Check whether the baseline (hand-written) class has a constructor compatible
@@ -63,15 +80,9 @@ export function resolveResourceClassName(service: Service, ctx: EmitterContext):
63
80
  /** Standard pagination query params handled by PaginationOptions — not imported individually. */
64
81
  const PAGINATION_PARAM_NAMES = new Set(['limit', 'before', 'after', 'order']);
65
82
 
66
- /** Map HTTP status codes to their corresponding exception class names for @throws docs. */
67
- const STATUS_TO_EXCEPTION_NAME: Record<number, string> = {
68
- 400: 'BadRequestException',
69
- 401: 'UnauthorizedException',
70
- 404: 'NotFoundException',
71
- 409: 'ConflictException',
72
- 422: 'UnprocessableEntityException',
73
- 429: 'RateLimitExceededException',
74
- };
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();
75
86
 
76
87
  /**
77
88
  * Compute the options interface name for a paginated method.
@@ -92,18 +103,9 @@ function httpMethodNeedsBody(method: string): boolean {
92
103
  }
93
104
 
94
105
  // ---------------------------------------------------------------------------
95
- // Method-name reconciliation helpers
106
+ // Method-name deduplication helpers
96
107
  // ---------------------------------------------------------------------------
97
108
 
98
- /** Map HTTP methods to expected CRUD verb prefixes for method name matching. */
99
- const HTTP_VERB_PREFIXES: Record<string, string[]> = {
100
- get: ['list', 'get', 'fetch', 'retrieve', 'find'],
101
- post: ['create', 'add', 'insert', 'send'],
102
- put: ['set', 'update', 'replace', 'put'],
103
- patch: ['update', 'patch', 'modify'],
104
- delete: ['delete', 'remove', 'revoke'],
105
- };
106
-
107
109
  /** Split a camelCase/PascalCase name into lowercase word parts. */
108
110
  function splitCamelWords(name: string): string[] {
109
111
  const parts: string[] = [];
@@ -123,209 +125,6 @@ function singularize(word: string): string {
123
125
  return word.endsWith('s') && !word.endsWith('ss') ? word.slice(0, -1) : word;
124
126
  }
125
127
 
126
- /**
127
- * Batch-reconcile generated method names against the api-surface class methods.
128
- *
129
- * When the overlay doesn't map an operation (missing from previous manifest or
130
- * fuzzy-matching failed), the emitter falls back to the spec-derived name which
131
- * often doesn't match the hand-written SDK. This function uses three passes
132
- * with progressive exclusion to find the best surface match:
133
- *
134
- * 1. **Overlay-resolved** — already correct, mark as taken.
135
- * 2. **Word-set match** — same content words regardless of order (handles
136
- * `listRolesOrganizations` ↔ `listOrganizationRoles`).
137
- * 3. **Path-context match** — all surface-method content words appear in the
138
- * operation's URL path segments (handles `findById` ↔ `getResource`).
139
- *
140
- * After each pass, matched surface methods are removed from the pool so that
141
- * ambiguous cases (e.g., `listEnvironmentRoles` vs `listOrganizationRoles`)
142
- * resolve by elimination.
143
- */
144
- function reconcileMethodNames(
145
- plans: Array<{ op: Operation; plan: OperationPlan; method: string }>,
146
- service: Service,
147
- ctx: EmitterContext,
148
- ): void {
149
- const className = resolveResourceClassName(service, ctx);
150
- const classMethods = ctx.apiSurface?.classes?.[className]?.methods;
151
- if (!classMethods) return;
152
-
153
- const available = new Set(Object.keys(classMethods));
154
- const resolved = new Map<(typeof plans)[number], string>();
155
-
156
- // Exclude surface methods that are overlay-mapped by OTHER services'
157
- // operations. This prevents the reconciliation from stealing methods
158
- // that belong to a different service (e.g., `createPermission` belongs
159
- // to the Permissions service, not the Authorization service).
160
- const thisServicePaths = new Set(service.operations.map((op) => op.path));
161
- if (ctx.overlayLookup?.methodByOperation) {
162
- for (const [httpKey, info] of ctx.overlayLookup.methodByOperation) {
163
- if (info.className !== className) continue; // different class
164
- const path = httpKey.split(' ')[1];
165
- if (thisServicePaths.has(path)) continue; // same service
166
- // This method is mapped to an operation in a different service
167
- available.delete(info.methodName);
168
- }
169
- }
170
-
171
- // Determine which plans are already overlay-resolved
172
- const overlayResolved = new Set<(typeof plans)[number]>();
173
- for (const plan of plans) {
174
- const httpKey = `${plan.op.httpMethod.toUpperCase()} ${plan.op.path}`;
175
- if (ctx.overlayLookup?.methodByOperation?.get(httpKey)) {
176
- overlayResolved.add(plan);
177
- if (available.has(plan.method)) {
178
- resolved.set(plan, plan.method);
179
- available.delete(plan.method);
180
- }
181
- }
182
- }
183
-
184
- // Helper: check verb compatibility.
185
- // When specVerb is provided, require the surface method to use the same verb
186
- // subgroup (e.g., "list" operations only match "list" methods, not "get").
187
- const verbMatches = (methodName: string, httpMethod: string, specVerb?: string): boolean => {
188
- const prefixes = HTTP_VERB_PREFIXES[httpMethod] ?? [];
189
- const lower = methodName.toLowerCase();
190
- if (!prefixes.some((p) => lower.startsWith(p))) return false;
191
- if (specVerb) {
192
- const surfaceVerb = splitCamelWords(methodName)[0];
193
- // "list" is a distinct verb subgroup from "get/find/fetch/retrieve"
194
- if (specVerb === 'list' && surfaceVerb !== 'list') return false;
195
- if (specVerb !== 'list' && surfaceVerb === 'list') return false;
196
- }
197
- return true;
198
- };
199
-
200
- // Pass 1: Word-set matching (handles word-order differences)
201
- for (const plan of plans) {
202
- if (resolved.has(plan)) continue;
203
- const specVerb = splitCamelWords(plan.method)[0];
204
- const specWords = splitCamelWords(plan.method).slice(1).map(singularize); // skip verb
205
- const specSet = new Set(specWords);
206
- if (specSet.size === 0) continue;
207
-
208
- let match: string | null = null;
209
- for (const name of available) {
210
- if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
211
- const methodWords = splitCamelWords(name).slice(1).map(singularize);
212
- if (methodWords.length !== specWords.length) continue;
213
- const methodSet = new Set(methodWords);
214
- if (specSet.size === methodSet.size && [...specSet].every((w) => methodSet.has(w))) {
215
- if (match !== null) {
216
- match = null; // ambiguous — more than one match
217
- break;
218
- }
219
- match = name;
220
- }
221
- }
222
- if (match) {
223
- resolved.set(plan, match);
224
- available.delete(match);
225
- }
226
- }
227
-
228
- // Pass 2: Path-context matching (handles different naming e.g., findById → getResource)
229
- // To avoid false matches (e.g., `createPermission` matching a role-permission path
230
- // just because "permission" appears somewhere in the URL), require that the method's
231
- // content words reference the LEAF resource of the path, not just any segment.
232
- for (const plan of plans) {
233
- if (resolved.has(plan)) continue;
234
- const specVerb = splitCamelWords(plan.method)[0];
235
- const pathSegments = plan.op.path.split('/').filter((s) => s && !s.startsWith('{'));
236
- const pathWords = new Set(pathSegments.flatMap((s) => s.split('_')).map(singularize));
237
- // The "leaf" words come from the last non-param segment — the most specific resource.
238
- let bestMatch: string | null = null;
239
- let bestLen = 0;
240
- let ambiguous = false;
241
- for (const name of available) {
242
- if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
243
- const methodWords = splitCamelWords(name).slice(1).map(singularize);
244
- if (methodWords.length === 0) continue;
245
- // All method content words must appear in path context
246
- if (!methodWords.every((w) => pathWords.has(w))) continue;
247
- // For paths with 3+ non-param segments (nested sub-resources like
248
- // /authorization/roles/{slug}/permissions), require at least 2 content
249
- // words. A single-word method like `createPermission` should only match
250
- // a top-level path like /authorization/permissions, not a nested one.
251
- if (methodWords.length < 2 && pathSegments.length > 2) continue;
252
- if (methodWords.length > bestLen) {
253
- bestMatch = name;
254
- bestLen = methodWords.length;
255
- ambiguous = false;
256
- } else if (methodWords.length === bestLen) {
257
- ambiguous = true;
258
- }
259
- }
260
- if (bestMatch && !ambiguous) {
261
- resolved.set(plan, bestMatch);
262
- available.delete(bestMatch);
263
- }
264
- }
265
-
266
- // Pass 3: Retry word-set and path-context for still-unresolved plans
267
- // (earlier passes may have eliminated ambiguous candidates)
268
- for (const plan of plans) {
269
- if (resolved.has(plan)) continue;
270
- const specVerb = splitCamelWords(plan.method)[0];
271
-
272
- // Retry word-set
273
- const specWords = splitCamelWords(plan.method).slice(1).map(singularize);
274
- const specSet = new Set(specWords);
275
- let match: string | null = null;
276
- for (const name of available) {
277
- if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
278
- const methodWords = splitCamelWords(name).slice(1).map(singularize);
279
- if (methodWords.length !== specWords.length) continue;
280
- const methodSet = new Set(methodWords);
281
- if (specSet.size === methodSet.size && [...specSet].every((w) => methodSet.has(w))) {
282
- if (match !== null) {
283
- match = null;
284
- break;
285
- }
286
- match = name;
287
- }
288
- }
289
- if (match) {
290
- resolved.set(plan, match);
291
- available.delete(match);
292
- continue;
293
- }
294
-
295
- // Retry path-context (with leaf-word check)
296
- const pathSegments = plan.op.path.split('/').filter((s) => s && !s.startsWith('{'));
297
- const pathWords = new Set(pathSegments.flatMap((s) => s.split('_')).map(singularize));
298
- let bestMatch: string | null = null;
299
- let bestLen = 0;
300
- let ambiguous = false;
301
- for (const name of available) {
302
- if (!verbMatches(name, plan.op.httpMethod, specVerb)) continue;
303
- const methodWords = splitCamelWords(name).slice(1).map(singularize);
304
- if (methodWords.length === 0) continue;
305
- if (!methodWords.every((w) => pathWords.has(w))) continue;
306
- if (methodWords.length < 2 && pathSegments.length > 2) continue;
307
- if (methodWords.length > bestLen) {
308
- bestMatch = name;
309
- bestLen = methodWords.length;
310
- ambiguous = false;
311
- } else if (methodWords.length === bestLen) {
312
- ambiguous = true;
313
- }
314
- }
315
- if (bestMatch && !ambiguous) {
316
- resolved.set(plan, bestMatch);
317
- available.delete(bestMatch);
318
- }
319
- }
320
-
321
- // Apply resolved names (only for non-overlay plans)
322
- for (const plan of plans) {
323
- if (overlayResolved.has(plan)) continue;
324
- const name = resolved.get(plan);
325
- if (name) plan.method = name;
326
- }
327
- }
328
-
329
128
  /**
330
129
  * Deduplicate method names within the plans array.
331
130
  *
@@ -397,7 +196,13 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
397
196
  if (services.length === 0) return [];
398
197
  const files: GeneratedFile[] = [];
399
198
 
400
- 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) {
401
206
  if (isServiceCoveredByExisting(service, ctx)) {
402
207
  // Fully covered: generate with ALL operations so the merger's docstring
403
208
  // refresh pass can update JSDoc on existing methods.
@@ -406,6 +211,9 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
406
211
  // remove skipIfExists so the merger adds the new methods.
407
212
  if (hasMethodsAbsentFromBaseline(service, ctx)) {
408
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';
409
217
  }
410
218
  files.push(file);
411
219
  continue;
@@ -420,6 +228,9 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
420
228
  // merger adds new methods AND refreshes existing JSDoc.
421
229
  const file = generateResourceClass(service, ctx);
422
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';
423
234
  files.push(file);
424
235
  } else {
425
236
  files.push(generateResourceClass(service, ctx));
@@ -441,10 +252,8 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
441
252
  method: resolveMethodName(op, service, ctx),
442
253
  }));
443
254
 
444
- // Reconcile method names against the api-surface class methods.
445
- // This fixes cases where the overlay is missing mappings and the
446
- // spec-derived name doesn't match the hand-written SDK name.
447
- reconcileMethodNames(plans, service, ctx);
255
+ // Resolved operations already produce correct method names via the
256
+ // centralized hint map no per-emitter reconciliation needed.
448
257
 
449
258
  // Deduplicate method names within the class (e.g., two operations both
450
259
  // resolving to "create" for different paths).
@@ -458,18 +267,23 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
458
267
  // order, JSDoc comments get attached to the wrong methods (list↔create,
459
268
  // add↔set swaps). Sorting by the overlay's method order ensures the
460
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.
461
275
  if (ctx.overlayLookup?.methodByOperation) {
462
- const methodOrder = new Map<string, number>();
276
+ const httpKeyOrder = new Map<string, number>();
463
277
  let pos = 0;
464
- for (const [, info] of ctx.overlayLookup.methodByOperation) {
465
- if (!methodOrder.has(info.methodName)) {
466
- methodOrder.set(info.methodName, pos++);
467
- }
278
+ for (const [httpKey] of ctx.overlayLookup.methodByOperation) {
279
+ httpKeyOrder.set(httpKey, pos++);
468
280
  }
469
- if (methodOrder.size > 0) {
281
+ if (httpKeyOrder.size > 0) {
470
282
  plans.sort((a, b) => {
471
- const aPos = methodOrder.get(a.method) ?? Number.MAX_SAFE_INTEGER;
472
- 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;
473
287
  return aPos - bPos;
474
288
  });
475
289
  }
@@ -478,13 +292,32 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
478
292
  const hasPaginated = plans.some((p) => p.plan.isPaginated);
479
293
  const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
480
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
+
481
307
  // Collect models for imports — only include models that are actually used
482
308
  // in method signatures (not all union variants from the spec)
483
309
  const responseModels = new Set<string>();
484
310
  const requestModels = new Set<string>();
485
311
  const paramEnums = new Set<string>();
486
312
  const paramModels = new Set<string>();
487
- 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
+
488
321
  if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
489
322
  // For paginated operations, import the item type (e.g., Connection)
490
323
  // rather than the list wrapper type (e.g., ConnectionList).
@@ -531,6 +364,20 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
531
364
  collectParamTypeRefs(param.type, paramEnums, paramModels);
532
365
  }
533
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
+
534
381
  const allModels = new Set([...responseModels, ...requestModels, ...paramModels]);
535
382
 
536
383
  const lines: string[] = [];
@@ -676,20 +523,29 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
676
523
  } else if (!plan.isPaginated && !plan.hasBody && !plan.isDelete && op.queryParams.length > 0) {
677
524
  // Non-paginated GET or void methods with query params get a typed options interface
678
525
  // instead of falling back to Record<string, unknown>.
679
- const optionsName = toPascalCase(method) + 'Options';
680
- lines.push(`export interface ${optionsName} {`);
681
- for (const param of op.queryParams) {
682
- const opt = !param.required ? '?' : '';
683
- if (param.description || param.deprecated) {
684
- const parts: string[] = [];
685
- if (param.description) parts.push(param.description);
686
- if (param.deprecated) parts.push('@deprecated');
687
- 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)};`);
688
545
  }
689
- lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
546
+ lines.push('}');
547
+ lines.push('');
690
548
  }
691
- lines.push('}');
692
- lines.push('');
693
549
  }
694
550
  }
695
551
 
@@ -702,7 +558,13 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
702
558
 
703
559
  for (const { op, plan, method } of plans) {
704
560
  lines.push('');
705
- 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
+ }
706
568
  }
707
569
 
708
570
  lines.push('}');
@@ -718,12 +580,20 @@ function renderMethod(
718
580
  ctx: EmitterContext,
719
581
  modelMap: Map<string, Model>,
720
582
  specEnumNames?: Set<string>,
583
+ resolvedOp?: ResolvedOperation,
721
584
  ): string[] {
722
585
  const lines: string[] = [];
723
586
  const responseModel = plan.responseModelName ? resolveInterfaceName(plan.responseModelName, ctx) : null;
724
587
 
725
588
  const pathStr = buildPathStr(op);
726
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
+
727
597
  // Build set of valid param names to filter @param tags.
728
598
  // Prefer the overlay (existing method signature) if available;
729
599
  // otherwise compute from what the render path will actually include.
@@ -739,119 +609,135 @@ function renderMethod(
739
609
  for (const p of op.pathParams) actualParams.add(fieldName(p.name));
740
610
  if (plan.hasBody) actualParams.add('payload');
741
611
  if (plan.isPaginated) actualParams.add('options');
742
- // renderGetMethod adds options when there are non-paginated query params
743
- 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) {
744
615
  actualParams.add('options');
745
616
  }
746
617
  validParamNames = actualParams;
747
618
  }
748
619
 
749
- const docParts: string[] = [];
750
- if (op.description) docParts.push(op.description);
751
- for (const param of op.pathParams) {
752
- const paramName = fieldName(param.name);
753
- if (validParamNames && !validParamNames.has(paramName)) continue;
754
- const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
755
- if (param.description) {
756
- docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
757
- } else if (param.deprecated) {
758
- 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)}`);
759
640
  }
760
- if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
761
- if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
762
- }
763
- // Document query params for non-paginated operations
764
- if (!plan.isPaginated) {
765
- // Only document query params if the method will have an options parameter
766
- if (validParamNames && (validParamNames.has('options') || overlayMethod)) {
767
- for (const param of op.queryParams) {
768
- const paramName = `options.${fieldName(param.name)}`;
769
- if (validParamNames && !validParamNames.has('options') && !validParamNames.has(fieldName(param.name))) continue;
770
- const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
771
- if (param.description) {
772
- docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
773
- } else if (param.deprecated) {
774
- docParts.push(`@param ${paramName} - (deprecated)`);
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)}`);
775
658
  }
776
- if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
777
- if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
778
659
  }
779
660
  }
780
- }
781
- // Skip header and cookie params in JSDoc they are not exposed in the method signature.
782
- // The SDK handles headers and cookies internally, so documenting them would be misleading.
783
- // Document payload parameter when there is a request body
784
- if (plan.hasBody) {
785
- const bodyInfo = extractRequestBodyType(op, ctx);
786
- if (bodyInfo?.kind === 'model') {
787
- const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
788
- let payloadDesc: string;
789
- if (bodyModel?.description) {
790
- payloadDesc = `@param payload - ${bodyModel.description}`;
791
- } else if (bodyModel) {
792
- // When the model lacks a description, list its required fields to help
793
- // callers understand what must be provided.
794
- const requiredFieldNames = bodyModel.fields.filter((f) => f.required).map((f) => fieldName(f.name));
795
- payloadDesc =
796
- requiredFieldNames.length > 0
797
- ? `@param payload - Object containing ${requiredFieldNames.join(', ')}.`
798
- : '@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);
799
683
  } else {
800
- payloadDesc = '@param payload - The request body.';
684
+ docParts.push('@param payload - The request body.');
801
685
  }
802
- docParts.push(payloadDesc);
803
- } else {
804
- docParts.push('@param payload - The request body.');
805
686
  }
806
- }
807
- // Document options parameter for paginated operations
808
- if (plan.isPaginated) {
809
- docParts.push('@param options - Pagination and filter options.');
810
- } else if (op.queryParams.length > 0) {
811
- docParts.push('@param options - Additional query options.');
812
- }
813
- // @returns for the primary response model (use item type for paginated operations).
814
- // Unwrap list wrapper models to match the actual return type — the method returns
815
- // AutoPaginatable<ItemType>, not the list wrapper.
816
- if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
817
- let itemRawName = op.pagination.itemType.name;
818
- const pModel = modelMap.get(itemRawName);
819
- if (pModel) {
820
- const unwrapped = unwrapListModel(pModel, modelMap);
821
- if (unwrapped) itemRawName = unwrapped.name;
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.');
822
692
  }
823
- const itemTypeName = resolveInterfaceName(itemRawName, ctx);
824
- docParts.push(`@returns {AutoPaginatable<${itemTypeName}>}`);
825
- } else if (responseModel) {
826
- docParts.push(`@returns {${responseModel}}`);
827
- } else {
828
- docParts.push('@returns {void}');
829
- }
830
- // @throws for error responses
831
- for (const err of op.errors) {
832
- const exceptionName = STATUS_TO_EXCEPTION_NAME[err.statusCode];
833
- if (exceptionName) {
834
- docParts.push(`@throws {${exceptionName}} ${err.statusCode}`);
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}>}`);
712
+ } else {
713
+ docParts.push('@returns {Promise<void>}');
835
714
  }
836
- }
837
- if (op.deprecated) docParts.push('@deprecated');
838
-
839
- if (docParts.length > 0) {
840
- // Flatten all parts, splitting multiline descriptions into individual lines
841
- const allLines: string[] = [];
842
- for (const part of docParts) {
843
- for (const line of part.split('\n')) {
844
- 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}`);
845
720
  }
846
721
  }
847
- if (allLines.length === 1) {
848
- lines.push(` /** ${allLines[0]} */`);
849
- } else {
850
- lines.push(' /**');
851
- for (const line of allLines) {
852
- 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(' */');
853
740
  }
854
- lines.push(' */');
855
741
  }
856
742
  }
857
743
 
@@ -895,9 +781,9 @@ function renderMethod(
895
781
  } else if (plan.hasBody && responseModel) {
896
782
  renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx, specEnumNames);
897
783
  } else if (responseModel) {
898
- renderGetMethod(lines, op, plan, method, responseModel, pathStr, specEnumNames);
784
+ renderGetMethod(lines, op, plan, method, responseModel, pathStr, specEnumNames, resolvedOp);
899
785
  } else {
900
- renderVoidMethod(lines, op, plan, method, pathStr, ctx, specEnumNames);
786
+ renderVoidMethod(lines, op, plan, method, pathStr, ctx, specEnumNames, resolvedOp);
901
787
  }
902
788
 
903
789
  // Defensive: if no render function produced a method body, emit a stub
@@ -1081,21 +967,78 @@ function renderGetMethod(
1081
967
  responseModel: string,
1082
968
  pathStr: string,
1083
969
  specEnumNames?: Set<string>,
970
+ resolvedOp?: ResolvedOperation,
1084
971
  ): void {
1085
- const params = buildPathParams(op, specEnumNames);
1086
- const hasQuery = op.queryParams.length > 0 && !plan.isPaginated;
1087
- const optionsType = hasQuery ? toPascalCase(method) + 'Options' : null;
972
+ const hiddenParams = new Set<string>([
973
+ ...Object.keys(getOpDefaults(resolvedOp)),
974
+ ...getOpInferFromClient(resolvedOp),
975
+ ]);
1088
976
 
1089
- 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;
1090
991
 
1091
992
  lines.push(` async ${method}(${allParams}): Promise<${responseModel}> {`);
1092
993
  if (hasQuery) {
1093
- const queryExpr = renderQueryExpr(op.queryParams);
1094
- lines.push(
1095
- ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
1096
- );
1097
- lines.push(` query: ${queryExpr},`);
1098
- 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
+ }
1099
1042
  } else if (httpMethodNeedsBody(op.httpMethod)) {
1100
1043
  // PUT/PATCH/POST require a body argument even when the spec has no request body
1101
1044
  lines.push(
@@ -1110,6 +1053,25 @@ function renderGetMethod(
1110
1053
  lines.push(' }');
1111
1054
  }
1112
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
+
1113
1075
  function renderVoidMethod(
1114
1076
  lines: string[],
1115
1077
  op: Operation,
@@ -1118,10 +1080,21 @@ function renderVoidMethod(
1118
1080
  pathStr: string,
1119
1081
  ctx: EmitterContext,
1120
1082
  specEnumNames?: Set<string>,
1083
+ resolvedOp?: ResolvedOperation,
1121
1084
  ): void {
1085
+ const hiddenParams = new Set<string>([
1086
+ ...Object.keys(getOpDefaults(resolvedOp)),
1087
+ ...getOpInferFromClient(resolvedOp),
1088
+ ]);
1089
+
1122
1090
  const params = buildPathParams(op, specEnumNames);
1123
- const hasQuery = op.queryParams.length > 0 && !plan.hasBody;
1124
- 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;
1125
1098
 
1126
1099
  let bodyParam = '';
1127
1100
  let bodyExpr = 'payload';
@@ -1154,10 +1127,47 @@ function renderVoidMethod(
1154
1127
  if (plan.hasBody) {
1155
1128
  lines.push(` await this.workos.${op.httpMethod}(${pathStr}, ${bodyExpr});`);
1156
1129
  } else if (hasQuery) {
1157
- const queryExpr = renderQueryExpr(op.queryParams);
1158
- lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {`);
1159
- lines.push(` query: ${queryExpr},`);
1160
- 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
+ }
1161
1171
  } else if (httpMethodNeedsBody(op.httpMethod)) {
1162
1172
  lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {});`);
1163
1173
  } else {