@workos/oagen-emitters 0.2.1 → 0.4.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 (136) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +13 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +14549 -3385
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/dotnet.md +336 -0
  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 +328 -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 +45 -12
  18. package/smoke/sdk-go.ts +116 -42
  19. package/smoke/sdk-php.ts +28 -26
  20. package/smoke/sdk-python.ts +5 -2
  21. package/src/dotnet/client.ts +89 -0
  22. package/src/dotnet/enums.ts +323 -0
  23. package/src/dotnet/fixtures.ts +236 -0
  24. package/src/dotnet/index.ts +246 -0
  25. package/src/dotnet/manifest.ts +36 -0
  26. package/src/dotnet/models.ts +344 -0
  27. package/src/dotnet/naming.ts +330 -0
  28. package/src/dotnet/resources.ts +622 -0
  29. package/src/dotnet/tests.ts +693 -0
  30. package/src/dotnet/type-map.ts +201 -0
  31. package/src/dotnet/wrappers.ts +186 -0
  32. package/src/go/client.ts +141 -0
  33. package/src/go/enums.ts +196 -0
  34. package/src/go/fixtures.ts +212 -0
  35. package/src/go/index.ts +84 -0
  36. package/src/go/manifest.ts +36 -0
  37. package/src/go/models.ts +254 -0
  38. package/src/go/naming.ts +179 -0
  39. package/src/go/resources.ts +827 -0
  40. package/src/go/tests.ts +751 -0
  41. package/src/go/type-map.ts +82 -0
  42. package/src/go/wrappers.ts +261 -0
  43. package/src/index.ts +4 -0
  44. package/src/kotlin/client.ts +53 -0
  45. package/src/kotlin/enums.ts +162 -0
  46. package/src/kotlin/index.ts +92 -0
  47. package/src/kotlin/manifest.ts +55 -0
  48. package/src/kotlin/models.ts +395 -0
  49. package/src/kotlin/naming.ts +223 -0
  50. package/src/kotlin/overrides.ts +25 -0
  51. package/src/kotlin/resources.ts +667 -0
  52. package/src/kotlin/tests.ts +1019 -0
  53. package/src/kotlin/type-map.ts +123 -0
  54. package/src/kotlin/wrappers.ts +168 -0
  55. package/src/node/client.ts +128 -115
  56. package/src/node/enums.ts +9 -0
  57. package/src/node/errors.ts +37 -232
  58. package/src/node/field-plan.ts +726 -0
  59. package/src/node/fixtures.ts +9 -1
  60. package/src/node/index.ts +3 -9
  61. package/src/node/models.ts +178 -21
  62. package/src/node/naming.ts +49 -111
  63. package/src/node/resources.ts +527 -397
  64. package/src/node/sdk-errors.ts +41 -0
  65. package/src/node/tests.ts +69 -19
  66. package/src/node/type-map.ts +4 -2
  67. package/src/node/utils.ts +13 -71
  68. package/src/node/wrappers.ts +151 -0
  69. package/src/php/client.ts +179 -0
  70. package/src/php/enums.ts +67 -0
  71. package/src/php/errors.ts +9 -0
  72. package/src/php/fixtures.ts +181 -0
  73. package/src/php/index.ts +96 -0
  74. package/src/php/manifest.ts +36 -0
  75. package/src/php/models.ts +310 -0
  76. package/src/php/naming.ts +279 -0
  77. package/src/php/resources.ts +636 -0
  78. package/src/php/tests.ts +609 -0
  79. package/src/php/type-map.ts +90 -0
  80. package/src/php/utils.ts +18 -0
  81. package/src/php/wrappers.ts +152 -0
  82. package/src/python/client.ts +345 -0
  83. package/src/python/enums.ts +313 -0
  84. package/src/python/fixtures.ts +196 -0
  85. package/src/python/index.ts +95 -0
  86. package/src/python/manifest.ts +38 -0
  87. package/src/python/models.ts +688 -0
  88. package/src/python/naming.ts +189 -0
  89. package/src/python/resources.ts +1322 -0
  90. package/src/python/tests.ts +1335 -0
  91. package/src/python/type-map.ts +93 -0
  92. package/src/python/wrappers.ts +191 -0
  93. package/src/shared/model-utils.ts +472 -0
  94. package/src/shared/naming-utils.ts +154 -0
  95. package/src/shared/non-spec-services.ts +54 -0
  96. package/src/shared/resolved-ops.ts +109 -0
  97. package/src/shared/wrapper-utils.ts +70 -0
  98. package/test/dotnet/client.test.ts +121 -0
  99. package/test/dotnet/enums.test.ts +193 -0
  100. package/test/dotnet/errors.test.ts +9 -0
  101. package/test/dotnet/manifest.test.ts +82 -0
  102. package/test/dotnet/models.test.ts +260 -0
  103. package/test/dotnet/resources.test.ts +255 -0
  104. package/test/dotnet/tests.test.ts +202 -0
  105. package/test/go/client.test.ts +92 -0
  106. package/test/go/enums.test.ts +132 -0
  107. package/test/go/errors.test.ts +9 -0
  108. package/test/go/models.test.ts +265 -0
  109. package/test/go/resources.test.ts +408 -0
  110. package/test/go/tests.test.ts +143 -0
  111. package/test/kotlin/models.test.ts +135 -0
  112. package/test/kotlin/tests.test.ts +176 -0
  113. package/test/node/client.test.ts +92 -12
  114. package/test/node/enums.test.ts +2 -0
  115. package/test/node/errors.test.ts +2 -41
  116. package/test/node/models.test.ts +2 -0
  117. package/test/node/naming.test.ts +23 -0
  118. package/test/node/resources.test.ts +315 -84
  119. package/test/node/serializers.test.ts +3 -1
  120. package/test/node/type-map.test.ts +11 -0
  121. package/test/php/client.test.ts +95 -0
  122. package/test/php/enums.test.ts +173 -0
  123. package/test/php/errors.test.ts +9 -0
  124. package/test/php/models.test.ts +497 -0
  125. package/test/php/resources.test.ts +682 -0
  126. package/test/php/tests.test.ts +185 -0
  127. package/test/python/client.test.ts +200 -0
  128. package/test/python/enums.test.ts +228 -0
  129. package/test/python/errors.test.ts +16 -0
  130. package/test/python/manifest.test.ts +74 -0
  131. package/test/python/models.test.ts +716 -0
  132. package/test/python/resources.test.ts +617 -0
  133. package/test/python/tests.test.ts +202 -0
  134. package/src/node/common.ts +0 -273
  135. package/src/node/config.ts +0 -71
  136. 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,16 @@ 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';
43
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
26
44
 
27
45
  /**
28
46
  * Check whether the baseline (hand-written) class has a constructor compatible
@@ -63,15 +81,9 @@ export function resolveResourceClassName(service: Service, ctx: EmitterContext):
63
81
  /** Standard pagination query params handled by PaginationOptions — not imported individually. */
64
82
  const PAGINATION_PARAM_NAMES = new Set(['limit', 'before', 'after', 'order']);
65
83
 
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
- };
84
+ /** Map HTTP status codes to their corresponding exception class names for @throws docs.
85
+ * Built from sdk.errors.statusCodeMap with Node-specific naming overrides. */
86
+ const STATUS_TO_EXCEPTION_NAME: Record<number, string> = buildNodeStatusExceptions();
75
87
 
76
88
  /**
77
89
  * Compute the options interface name for a paginated method.
@@ -92,18 +104,9 @@ function httpMethodNeedsBody(method: string): boolean {
92
104
  }
93
105
 
94
106
  // ---------------------------------------------------------------------------
95
- // Method-name reconciliation helpers
107
+ // Method-name deduplication helpers
96
108
  // ---------------------------------------------------------------------------
97
109
 
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
110
  /** Split a camelCase/PascalCase name into lowercase word parts. */
108
111
  function splitCamelWords(name: string): string[] {
109
112
  const parts: string[] = [];
@@ -123,209 +126,6 @@ function singularize(word: string): string {
123
126
  return word.endsWith('s') && !word.endsWith('ss') ? word.slice(0, -1) : word;
124
127
  }
125
128
 
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
129
  /**
330
130
  * Deduplicate method names within the plans array.
331
131
  *
@@ -393,11 +193,75 @@ function deduplicateMethodNames(
393
193
  }
394
194
  }
395
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
+
396
252
  export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
397
253
  if (services.length === 0) return [];
398
254
  const files: GeneratedFile[] = [];
399
255
 
400
- for (const service of services) {
256
+ // Group services by mount target to avoid file path collisions when
257
+ // multiple IR services mount to the same resource class.
258
+ const mountGroups = groupByMount(ctx);
259
+ const mergedServices: Service[] =
260
+ mountGroups.size > 0 ? [...mountGroups].map(([name, group]) => ({ name, operations: group.operations })) : services;
261
+
262
+ const topLevelEnumNames = new Set(ctx.spec.enums.map((e) => e.name));
263
+
264
+ for (const service of mergedServices) {
401
265
  if (isServiceCoveredByExisting(service, ctx)) {
402
266
  // Fully covered: generate with ALL operations so the merger's docstring
403
267
  // refresh pass can update JSDoc on existing methods.
@@ -406,6 +270,9 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
406
270
  // remove skipIfExists so the merger adds the new methods.
407
271
  if (hasMethodsAbsentFromBaseline(service, ctx)) {
408
272
  delete file.skipIfExists;
273
+ // Suppress auto-generated header — the file is a merge target
274
+ // containing hand-written code, not a fully generated file.
275
+ file.headerPlacement = 'skip';
409
276
  }
410
277
  files.push(file);
411
278
  continue;
@@ -420,12 +287,29 @@ export function generateResources(services: Service[], ctx: EmitterContext): Gen
420
287
  // merger adds new methods AND refreshes existing JSDoc.
421
288
  const file = generateResourceClass(service, ctx);
422
289
  delete file.skipIfExists;
290
+ // Suppress auto-generated header — the file is a merge target
291
+ // containing hand-written code, not a fully generated file.
292
+ file.headerPlacement = 'skip';
423
293
  files.push(file);
424
294
  } else {
425
- files.push(generateResourceClass(service, ctx));
295
+ // Purely oagen-managed: no baseline class exists, so the file is owned
296
+ // end-to-end by the emitter. Remove skipIfExists so regeneration always
297
+ // overwrites — emitter improvements (serializer dispatch, JSDoc, etc.)
298
+ // must propagate without manual intervention.
299
+ const file = generateResourceClass(service, ctx);
300
+ delete file.skipIfExists;
301
+ files.push(file);
426
302
  }
427
303
  }
428
304
 
305
+ // Emit paginated list options interfaces AFTER the resource classes so
306
+ // tests and manifest ordering that index `files[0]` as the class stay
307
+ // stable. Placing them under `interfaces/` lets the per-service barrel
308
+ // pick them up automatically.
309
+ for (const service of mergedServices) {
310
+ files.push(...generatePaginatedOptionsInterfaces(service, ctx, topLevelEnumNames));
311
+ }
312
+
429
313
  return files;
430
314
  }
431
315
 
@@ -441,10 +325,8 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
441
325
  method: resolveMethodName(op, service, ctx),
442
326
  }));
443
327
 
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);
328
+ // Resolved operations already produce correct method names via the
329
+ // centralized hint map no per-emitter reconciliation needed.
448
330
 
449
331
  // Deduplicate method names within the class (e.g., two operations both
450
332
  // resolving to "create" for different paths).
@@ -458,18 +340,23 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
458
340
  // order, JSDoc comments get attached to the wrong methods (list↔create,
459
341
  // add↔set swaps). Sorting by the overlay's method order ensures the
460
342
  // generated output matches the existing file's method order.
343
+ //
344
+ // We build the order from HTTP operation keys (e.g., "GET /organizations")
345
+ // rather than method names, because resolveMethodName may return a different
346
+ // name than the overlay's methodName (e.g., when the hint map overrides it),
347
+ // causing the lookup to fail and the sort to produce wrong order.
461
348
  if (ctx.overlayLookup?.methodByOperation) {
462
- const methodOrder = new Map<string, number>();
349
+ const httpKeyOrder = new Map<string, number>();
463
350
  let pos = 0;
464
- for (const [, info] of ctx.overlayLookup.methodByOperation) {
465
- if (!methodOrder.has(info.methodName)) {
466
- methodOrder.set(info.methodName, pos++);
467
- }
351
+ for (const [httpKey] of ctx.overlayLookup.methodByOperation) {
352
+ httpKeyOrder.set(httpKey, pos++);
468
353
  }
469
- if (methodOrder.size > 0) {
354
+ if (httpKeyOrder.size > 0) {
470
355
  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;
356
+ const aKey = `${a.op.httpMethod.toUpperCase()} ${a.op.path}`;
357
+ const bKey = `${b.op.httpMethod.toUpperCase()} ${b.op.path}`;
358
+ const aPos = httpKeyOrder.get(aKey) ?? Number.MAX_SAFE_INTEGER;
359
+ const bPos = httpKeyOrder.get(bKey) ?? Number.MAX_SAFE_INTEGER;
473
360
  return aPos - bPos;
474
361
  });
475
362
  }
@@ -478,13 +365,32 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
478
365
  const hasPaginated = plans.some((p) => p.plan.isPaginated);
479
366
  const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
480
367
 
368
+ // When merging into an existing class, the merger keeps baseline method
369
+ // bodies but may add imports from the generated code. To avoid orphaned
370
+ // imports for types used only by baseline methods (whose bodies are kept
371
+ // intact), skip model collection for methods that already exist.
372
+ const baselineMethodSet = new Set<string>();
373
+ const baselineClass = ctx.apiSurface?.classes?.[serviceClass];
374
+ if (baselineClass?.methods) {
375
+ for (const name of Object.keys(baselineClass.methods)) {
376
+ baselineMethodSet.add(name);
377
+ }
378
+ }
379
+
481
380
  // Collect models for imports — only include models that are actually used
482
381
  // in method signatures (not all union variants from the spec)
483
382
  const responseModels = new Set<string>();
484
383
  const requestModels = new Set<string>();
485
384
  const paramEnums = new Set<string>();
486
385
  const paramModels = new Set<string>();
487
- for (const { op, plan } of plans) {
386
+ for (const { op, plan, method } of plans) {
387
+ // Skip imports for methods that already exist in the baseline class.
388
+ // The merger keeps baseline method bodies, so their imports are already
389
+ // present in the existing file. Including them here would create
390
+ // orphaned imports when the generated return type differs from the
391
+ // baseline's (e.g., generated `List` vs baseline `RoleList`).
392
+ if (baselineMethodSet.has(method)) continue;
393
+
488
394
  if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
489
395
  // For paginated operations, import the item type (e.g., Connection)
490
396
  // rather than the list wrapper type (e.g., ConnectionList).
@@ -531,6 +437,28 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
531
437
  collectParamTypeRefs(param.type, paramEnums, paramModels);
532
438
  }
533
439
  }
440
+
441
+ // Collect response models from union split wrappers so their types and
442
+ // deserializers are imported alongside the primary operation models.
443
+ // Also collect models referenced in wrapper param signatures (e.g.,
444
+ // `redirect_uris: RedirectUriInput[]`) — otherwise the wrapper emits a
445
+ // reference to a type it never imported.
446
+ const resolvedLookup = buildResolvedLookup(ctx);
447
+ for (const { op, method } of plans) {
448
+ if (baselineMethodSet.has(method)) continue;
449
+ const resolved = lookupResolved(op, resolvedLookup);
450
+ if (resolved) {
451
+ for (const name of collectWrapperResponseModels(resolved)) {
452
+ responseModels.add(name);
453
+ }
454
+ for (const wrapper of resolved.wrappers ?? []) {
455
+ for (const { field } of resolveWrapperParams(wrapper, ctx)) {
456
+ if (field) collectParamTypeRefs(field.type, paramEnums, paramModels);
457
+ }
458
+ }
459
+ }
460
+ }
461
+
534
462
  const allModels = new Set([...responseModels, ...requestModels, ...paramModels]);
535
463
 
536
464
  const lines: string[] = [];
@@ -543,6 +471,17 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
543
471
  lines.push("import { createPaginatedList } from '../common/utils/fetch-and-deserialize';");
544
472
  }
545
473
 
474
+ // Paginated list options live in their own interface files so they're
475
+ // picked up by the per-service barrel (and flow through to the root
476
+ // package barrel). Import them here rather than declaring inline.
477
+ for (const { op, plan, method } of plans) {
478
+ if (!plan.isPaginated) continue;
479
+ const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
480
+ if (extraParams.length === 0) continue;
481
+ const optionsName = paginatedOptionsName(method, resolvedName);
482
+ lines.push(`import type { ${optionsName} } from './interfaces/${fileName(optionsName)}.interface';`);
483
+ }
484
+
546
485
  // Check if any operation needs PostOptions (idempotent POST or custom encoding)
547
486
  const hasIdempotentPost = plans.some((p) => p.plan.isIdempotentPost);
548
487
  const hasCustomEncoding = plans.some(
@@ -646,21 +585,58 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
646
585
 
647
586
  lines.push('');
648
587
 
649
- // Options interfaces for operations with query params.
650
- // Paginated operations extend PaginationOptions; non-paginated operations get standalone interfaces.
588
+ // Per-operation helpers (wire-format option serializers etc.) emitted
589
+ // alongside the resource class. The options interfaces themselves live
590
+ // in separate files under `interfaces/` so the per-service barrel can
591
+ // re-export them; see the earlier import block at the top of the file.
651
592
  for (const { op, plan, method } of plans) {
652
593
  if (plan.isPaginated) {
653
594
  const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
654
595
  if (extraParams.length > 0) {
655
596
  const optionsName = paginatedOptionsName(method, resolvedName);
656
- // Always generate the options interface locally in the resource file.
657
- // Previously we skipped generation when a baseline interface with a matching
658
- // name existed, but the baseline interface may live in a different module
659
- // (e.g., `user-management/` vs `user-management-users/`) and would not be
660
- // available without an import. Generating locally is safe and avoids
661
- // cross-module import resolution issues.
662
- lines.push(`export interface ${optionsName} extends PaginationOptions {`);
663
- for (const param of extraParams) {
597
+
598
+ // When any extension param has a camelCase domain name that differs
599
+ // from its snake_case wire name, emit a serializer that translates
600
+ // the user-facing options into the wire query shape. Without this,
601
+ // the query string uses camelCase keys (e.g. `organizationId=...`)
602
+ // that the API silently ignores — the filter becomes a no-op.
603
+ const needsWireSerializer = extraParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
604
+ if (needsWireSerializer) {
605
+ const serializerName = `serialize${optionsName}`;
606
+ lines.push(`const ${serializerName} = (options: ${optionsName}): PaginationOptions => {`);
607
+ // Pagination fields pass through unchanged (limit/before/after/order
608
+ // share spelling in both cases). Spread first so that wire-named
609
+ // extension fields land on top and the camelCase keys don't also
610
+ // leak into the query string.
611
+ lines.push(' const wire: Record<string, unknown> = {');
612
+ for (const p of PAGINATION_PARAM_NAMES) {
613
+ lines.push(` ${p}: options.${p},`);
614
+ }
615
+ lines.push(' };');
616
+ for (const param of extraParams) {
617
+ const camel = fieldName(param.name);
618
+ const snake = wireFieldName(param.name);
619
+ lines.push(` if (options.${camel} !== undefined) wire.${snake} = options.${camel};`);
620
+ }
621
+ lines.push(' return wire as PaginationOptions;');
622
+ lines.push('};');
623
+ lines.push('');
624
+ }
625
+ }
626
+ } else if (!plan.isPaginated && !plan.hasBody && !plan.isDelete && op.queryParams.length > 0) {
627
+ // Non-paginated GET or void methods with query params get a typed options interface
628
+ // instead of falling back to Record<string, unknown>.
629
+ // Filter out hidden params (defaults and inferFromClient fields)
630
+ const resolved = lookupResolved(op, resolvedLookup);
631
+ const opHiddenParams = new Set<string>([
632
+ ...Object.keys(getOpDefaults(resolved)),
633
+ ...getOpInferFromClient(resolved),
634
+ ]);
635
+ const visibleParams = op.queryParams.filter((p) => !opHiddenParams.has(p.name));
636
+ if (visibleParams.length > 0) {
637
+ const optionsName = toPascalCase(method) + 'Options';
638
+ lines.push(`export interface ${optionsName} {`);
639
+ for (const param of visibleParams) {
664
640
  const opt = !param.required ? '?' : '';
665
641
  if (param.description || param.deprecated) {
666
642
  const parts: string[] = [];
@@ -673,23 +649,6 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
673
649
  lines.push('}');
674
650
  lines.push('');
675
651
  }
676
- } else if (!plan.isPaginated && !plan.hasBody && !plan.isDelete && op.queryParams.length > 0) {
677
- // Non-paginated GET or void methods with query params get a typed options interface
678
- // 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));
688
- }
689
- lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
690
- }
691
- lines.push('}');
692
- lines.push('');
693
652
  }
694
653
  }
695
654
 
@@ -702,7 +661,13 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
702
661
 
703
662
  for (const { op, plan, method } of plans) {
704
663
  lines.push('');
705
- lines.push(...renderMethod(op, plan, method, service, ctx, modelMap, specEnumNames));
664
+ const resolved = lookupResolved(op, resolvedLookup);
665
+ lines.push(...renderMethod(op, plan, method, service, ctx, modelMap, specEnumNames, resolved));
666
+
667
+ // Emit union split wrapper methods (typed convenience methods for each variant)
668
+ if (resolved?.wrappers && resolved.wrappers.length > 0) {
669
+ lines.push(...generateWrapperMethods(resolved, ctx));
670
+ }
706
671
  }
707
672
 
708
673
  lines.push('}');
@@ -718,12 +683,20 @@ function renderMethod(
718
683
  ctx: EmitterContext,
719
684
  modelMap: Map<string, Model>,
720
685
  specEnumNames?: Set<string>,
686
+ resolvedOp?: ResolvedOperation,
721
687
  ): string[] {
722
688
  const lines: string[] = [];
723
689
  const responseModel = plan.responseModelName ? resolveInterfaceName(plan.responseModelName, ctx) : null;
724
690
 
725
691
  const pathStr = buildPathStr(op);
726
692
 
693
+ // Build the set of params hidden from the method signature
694
+ // (injected from client config or as constant defaults)
695
+ const hiddenParams = new Set<string>([
696
+ ...Object.keys(getOpDefaults(resolvedOp)),
697
+ ...getOpInferFromClient(resolvedOp),
698
+ ]);
699
+
727
700
  // Build set of valid param names to filter @param tags.
728
701
  // Prefer the overlay (existing method signature) if available;
729
702
  // otherwise compute from what the render path will actually include.
@@ -739,119 +712,136 @@ function renderMethod(
739
712
  for (const p of op.pathParams) actualParams.add(fieldName(p.name));
740
713
  if (plan.hasBody) actualParams.add('payload');
741
714
  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) {
715
+ // renderGetMethod/renderVoidMethod add options when there are visible non-paginated query params
716
+ const visibleQueryCount = op.queryParams.filter((q) => !hiddenParams.has(q.name)).length;
717
+ if (!plan.isPaginated && visibleQueryCount > 0 && !plan.isDelete) {
744
718
  actualParams.add('options');
745
719
  }
746
720
  validParamNames = actualParams;
747
721
  }
748
722
 
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)`);
723
+ // Always generate JSDoc for all methods (both existing and new).
724
+ // The merger matches docstrings by member name — if we skip JSDoc for
725
+ // existing methods, previously misplaced docstrings can never be corrected.
726
+ // Hand-written docs (e.g., @deprecated, PKCE flow descriptions) are
727
+ // preserved by the merger's @deprecated-preservation and @oagen-ignore
728
+ // mechanisms instead.
729
+ {
730
+ const docParts: string[] = [];
731
+ if (op.description) docParts.push(op.description);
732
+ for (const param of op.pathParams) {
733
+ const paramName = fieldName(param.name);
734
+ if (validParamNames && !validParamNames.has(paramName)) continue;
735
+ const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
736
+ if (param.description) {
737
+ docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
738
+ } else if (param.deprecated) {
739
+ docParts.push(`@param ${paramName} - (deprecated)`);
740
+ }
741
+ if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
742
+ if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
759
743
  }
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)`);
744
+ // Document query params for non-paginated operations
745
+ if (!plan.isPaginated) {
746
+ // Only document query params if the method will have an options parameter
747
+ if (validParamNames && (validParamNames.has('options') || overlayMethod)) {
748
+ for (const param of op.queryParams) {
749
+ if (hiddenParams.has(param.name)) continue;
750
+ const paramName = `options.${fieldName(param.name)}`;
751
+ if (validParamNames && !validParamNames.has('options') && !validParamNames.has(fieldName(param.name)))
752
+ continue;
753
+ const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
754
+ if (param.description) {
755
+ docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
756
+ } else if (param.deprecated) {
757
+ docParts.push(`@param ${paramName} - (deprecated)`);
758
+ }
759
+ if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
760
+ if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
775
761
  }
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
762
  }
779
763
  }
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.';
764
+ // Skip header and cookie params in JSDoc — they are not exposed in the method signature.
765
+ // The SDK handles headers and cookies internally, so documenting them would be misleading.
766
+ // Document payload parameter when there is a request body
767
+ if (plan.hasBody) {
768
+ const bodyInfo = extractRequestBodyType(op, ctx);
769
+ if (bodyInfo?.kind === 'model') {
770
+ const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
771
+ let payloadDesc: string;
772
+ if (bodyModel?.description) {
773
+ payloadDesc = `@param payload - ${bodyModel.description}`;
774
+ } else if (bodyModel) {
775
+ // When the model lacks a description, list its required fields to help
776
+ // callers understand what must be provided.
777
+ const requiredFieldNames = bodyModel.fields.filter((f) => f.required).map((f) => fieldName(f.name));
778
+ payloadDesc =
779
+ requiredFieldNames.length > 0
780
+ ? `@param payload - Object containing ${requiredFieldNames.join(', ')}.`
781
+ : '@param payload - The request body.';
782
+ } else {
783
+ payloadDesc = '@param payload - The request body.';
784
+ }
785
+ docParts.push(payloadDesc);
799
786
  } else {
800
- payloadDesc = '@param payload - The request body.';
787
+ docParts.push('@param payload - The request body.');
801
788
  }
802
- docParts.push(payloadDesc);
803
- } else {
804
- docParts.push('@param payload - The request body.');
805
789
  }
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;
790
+ // Document options parameter for paginated operations
791
+ if (plan.isPaginated) {
792
+ docParts.push('@param options - Pagination and filter options.');
793
+ } else if (op.queryParams.filter((q) => !hiddenParams.has(q.name)).length > 0) {
794
+ docParts.push('@param options - Additional query options.');
822
795
  }
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}`);
796
+ // @returns for the primary response model.
797
+ // When an overlay method exists, prefer its return type so the JSDoc
798
+ // matches the actual TypeScript signature (the overlay may use a
799
+ // different model name than the OpenAPI schema).
800
+ if (overlayMethod?.returnType) {
801
+ docParts.push(`@returns {${overlayMethod.returnType}}`);
802
+ } else if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
803
+ // Unwrap list wrapper models to match the actual return type — the method returns
804
+ // AutoPaginatable<ItemType>, not the list wrapper.
805
+ let itemRawName = op.pagination.itemType.name;
806
+ const pModel = modelMap.get(itemRawName);
807
+ if (pModel) {
808
+ const unwrapped = unwrapListModel(pModel, modelMap);
809
+ if (unwrapped) itemRawName = unwrapped.name;
810
+ }
811
+ const itemTypeName = resolveInterfaceName(itemRawName, ctx);
812
+ docParts.push(`@returns {Promise<AutoPaginatable<${itemTypeName}>>}`);
813
+ } else if (responseModel) {
814
+ const returnTypeDoc = plan.isArrayResponse ? `${responseModel}[]` : responseModel;
815
+ docParts.push(`@returns {Promise<${returnTypeDoc}>}`);
816
+ } else {
817
+ docParts.push('@returns {Promise<void>}');
835
818
  }
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);
819
+ // @throws for error responses
820
+ for (const err of op.errors) {
821
+ const exceptionName = STATUS_TO_EXCEPTION_NAME[err.statusCode];
822
+ if (exceptionName) {
823
+ docParts.push(`@throws {${exceptionName}} ${err.statusCode}`);
845
824
  }
846
825
  }
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}`);
826
+ if (op.deprecated) docParts.push('@deprecated');
827
+
828
+ if (docParts.length > 0) {
829
+ // Flatten all parts, splitting multiline descriptions into individual lines
830
+ const allLines: string[] = [];
831
+ for (const part of docParts) {
832
+ for (const line of part.split('\n')) {
833
+ allLines.push(line);
834
+ }
835
+ }
836
+ if (allLines.length === 1) {
837
+ lines.push(` /** ${allLines[0]} */`);
838
+ } else {
839
+ lines.push(' /**');
840
+ for (const line of allLines) {
841
+ lines.push(line === '' ? ' *' : ` * ${line}`);
842
+ }
843
+ lines.push(' */');
853
844
  }
854
- lines.push(' */');
855
845
  }
856
846
  }
857
847
 
@@ -895,9 +885,9 @@ function renderMethod(
895
885
  } else if (plan.hasBody && responseModel) {
896
886
  renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx, specEnumNames);
897
887
  } else if (responseModel) {
898
- renderGetMethod(lines, op, plan, method, responseModel, pathStr, specEnumNames);
888
+ renderGetMethod(lines, op, plan, method, responseModel, pathStr, specEnumNames, resolvedOp);
899
889
  } else {
900
- renderVoidMethod(lines, op, plan, method, pathStr, ctx, specEnumNames);
890
+ renderVoidMethod(lines, op, plan, method, pathStr, ctx, specEnumNames, resolvedOp);
901
891
  }
902
892
 
903
893
  // Defensive: if no render function produced a method body, emit a stub
@@ -925,13 +915,18 @@ function renderPaginatedMethod(
925
915
  ): void {
926
916
  const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
927
917
  const optionsType = extraParams.length > 0 ? paginatedOptionsName(method, resolvedServiceName) : 'PaginationOptions';
918
+ // When any extension param has a camelCase/snake_case divergence, the
919
+ // resource file emits a `serialize<OptionsName>` helper — pass it to
920
+ // createPaginatedList so the wire query uses snake_case keys.
921
+ const needsWireSerializer = extraParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
922
+ const serializerArg = needsWireSerializer ? `, serialize${optionsType}` : '';
928
923
 
929
924
  const pathParams = buildPathParams(op, specEnumNames);
930
925
  const allParams = pathParams ? `${pathParams}, options?: ${optionsType}` : `options?: ${optionsType}`;
931
926
 
932
927
  lines.push(` async ${method}(${allParams}): Promise<AutoPaginatable<${itemType}, ${optionsType}>> {`);
933
928
  lines.push(
934
- ` return createPaginatedList<${wireInterfaceName(itemType)}, ${itemType}, ${optionsType}>(this.workos, ${pathStr}, deserialize${itemType}, options);`,
929
+ ` return createPaginatedList<${wireInterfaceName(itemType)}, ${itemType}, ${optionsType}>(this.workos, ${pathStr}, deserialize${itemType}, options${serializerArg});`,
935
930
  );
936
931
  lines.push(' }');
937
932
  }
@@ -1040,16 +1035,22 @@ function renderBodyMethod(
1040
1035
  const encodingOption = encoding && encoding !== 'json' ? `, encoding: '${encoding}' as const` : '';
1041
1036
  const hasCustomEncoding = encodingOption !== '';
1042
1037
 
1043
- lines.push(` async ${method}(${paramsStr}): Promise<${responseModel}> {`);
1038
+ const returnType = plan.isArrayResponse ? `${responseModel}[]` : responseModel;
1039
+ const wireType = plan.isArrayResponse ? `${wireInterfaceName(responseModel)}[]` : wireInterfaceName(responseModel);
1040
+ const returnExpr = plan.isArrayResponse
1041
+ ? `data.map(deserialize${responseModel})`
1042
+ : `deserialize${responseModel}(data)`;
1043
+
1044
+ lines.push(` async ${method}(${paramsStr}): Promise<${returnType}> {`);
1044
1045
  if (plan.isIdempotentPost) {
1045
1046
  if (hasCustomEncoding) {
1046
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1047
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
1047
1048
  lines.push(` ${pathStr},`);
1048
1049
  lines.push(` ${bodyExpr},`);
1049
1050
  lines.push(` { ...requestOptions${encodingOption} },`);
1050
1051
  lines.push(' );');
1051
1052
  } else {
1052
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1053
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
1053
1054
  lines.push(` ${pathStr},`);
1054
1055
  lines.push(` ${bodyExpr},`);
1055
1056
  lines.push(' requestOptions,');
@@ -1057,19 +1058,19 @@ function renderBodyMethod(
1057
1058
  }
1058
1059
  } else {
1059
1060
  if (hasCustomEncoding) {
1060
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1061
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
1061
1062
  lines.push(` ${pathStr},`);
1062
1063
  lines.push(` ${bodyExpr},`);
1063
1064
  lines.push(` { ${encodingOption.slice(2)} },`);
1064
1065
  lines.push(' );');
1065
1066
  } else {
1066
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1067
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(`);
1067
1068
  lines.push(` ${pathStr},`);
1068
1069
  lines.push(` ${bodyExpr},`);
1069
1070
  lines.push(' );');
1070
1071
  }
1071
1072
  }
1072
- lines.push(` return deserialize${responseModel}(data);`);
1073
+ lines.push(` return ${returnExpr};`);
1073
1074
  lines.push(' }');
1074
1075
  }
1075
1076
 
@@ -1081,35 +1082,109 @@ function renderGetMethod(
1081
1082
  responseModel: string,
1082
1083
  pathStr: string,
1083
1084
  specEnumNames?: Set<string>,
1085
+ resolvedOp?: ResolvedOperation,
1084
1086
  ): void {
1087
+ const hiddenParams = new Set<string>([
1088
+ ...Object.keys(getOpDefaults(resolvedOp)),
1089
+ ...getOpInferFromClient(resolvedOp),
1090
+ ]);
1091
+
1085
1092
  const params = buildPathParams(op, specEnumNames);
1086
- const hasQuery = op.queryParams.length > 0 && !plan.isPaginated;
1087
- const optionsType = hasQuery ? toPascalCase(method) + 'Options' : null;
1093
+ const visibleQueryParams = op.queryParams.filter((p) => !hiddenParams.has(p.name));
1094
+ const hasVisibleQuery = visibleQueryParams.length > 0 && !plan.isPaginated;
1095
+ const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
1096
+ const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
1097
+ const hasInjected = hasDefaults || hasInferred;
1098
+ const hasQuery = (op.queryParams.length > 0 && !plan.isPaginated) || hasInjected;
1099
+ const optionsType = hasVisibleQuery ? toPascalCase(method) + 'Options' : null;
1100
+
1101
+ const allParams = optionsType
1102
+ ? params
1103
+ ? `${params}, options?: ${optionsType}`
1104
+ : `options?: ${optionsType}`
1105
+ : params;
1106
+
1107
+ const returnType = plan.isArrayResponse ? `${responseModel}[]` : responseModel;
1108
+ const wireType = plan.isArrayResponse ? `${wireInterfaceName(responseModel)}[]` : wireInterfaceName(responseModel);
1109
+ const returnExpr = plan.isArrayResponse
1110
+ ? `data.map(deserialize${responseModel})`
1111
+ : `deserialize${responseModel}(data)`;
1112
+
1113
+ lines.push(` async ${method}(${allParams}): Promise<${returnType}> {`);
1114
+ if (hasQuery) {
1115
+ if (hasInjected) {
1116
+ // Build the query object with visible params, defaults, and inferred fields
1117
+ const queryParts: string[] = [];
1118
+
1119
+ // Regular visible query params (from options)
1120
+ if (hasVisibleQuery) {
1121
+ for (const param of visibleQueryParams) {
1122
+ const camel = fieldName(param.name);
1123
+ const snake = wireFieldName(param.name);
1124
+ if (camel === snake) {
1125
+ if (param.required) {
1126
+ queryParts.push(`${camel}: options.${camel}`);
1127
+ } else {
1128
+ queryParts.push(`...(options?.${camel} !== undefined && { ${camel}: options.${camel} })`);
1129
+ }
1130
+ } else {
1131
+ if (param.required) {
1132
+ queryParts.push(`${snake}: options.${camel}`);
1133
+ } else {
1134
+ queryParts.push(`...(options?.${camel} !== undefined && { ${snake}: options.${camel} })`);
1135
+ }
1136
+ }
1137
+ }
1138
+ }
1088
1139
 
1089
- const allParams = hasQuery ? (params ? `${params}, options?: ${optionsType}` : `options?: ${optionsType}`) : params;
1140
+ // Constant defaults (e.g., response_type: 'code')
1141
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
1142
+ queryParts.push(`${key}: ${tsLiteral(value)}`);
1143
+ }
1090
1144
 
1091
- lines.push(` async ${method}(${allParams}): Promise<${responseModel}> {`);
1092
- 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(' });');
1145
+ // Inferred fields from client config (e.g., client_id from this.workos.options.clientId)
1146
+ for (const field of getOpInferFromClient(resolvedOp)) {
1147
+ queryParts.push(`${field}: ${clientFieldExpression(field)}`);
1148
+ }
1149
+
1150
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, {`);
1151
+ lines.push(` query: { ${queryParts.join(', ')} },`);
1152
+ lines.push(' });');
1153
+ } else {
1154
+ const queryExpr = renderQueryExpr(visibleQueryParams);
1155
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, {`);
1156
+ lines.push(` query: ${queryExpr},`);
1157
+ lines.push(' });');
1158
+ }
1099
1159
  } else if (httpMethodNeedsBody(op.httpMethod)) {
1100
1160
  // PUT/PATCH/POST require a body argument even when the spec has no request body
1101
- lines.push(
1102
- ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {});`,
1103
- );
1161
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, {});`);
1104
1162
  } else {
1105
- lines.push(
1106
- ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr});`,
1107
- );
1163
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr});`);
1108
1164
  }
1109
- lines.push(` return deserialize${responseModel}(data);`);
1165
+ lines.push(` return ${returnExpr};`);
1110
1166
  lines.push(' }');
1111
1167
  }
1112
1168
 
1169
+ /** Convert a JS value to a TypeScript literal. */
1170
+ function tsLiteral(value: string | number | boolean): string {
1171
+ if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`;
1172
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
1173
+ return String(value);
1174
+ }
1175
+
1176
+ /** Get the TypeScript expression for reading a client config field. */
1177
+ function clientFieldExpression(field: string): string {
1178
+ switch (field) {
1179
+ case 'client_id':
1180
+ return 'this.workos.options.clientId';
1181
+ case 'client_secret':
1182
+ return 'this.workos.key';
1183
+ default:
1184
+ return `this.workos.${toCamelCase(field)}`;
1185
+ }
1186
+ }
1187
+
1113
1188
  function renderVoidMethod(
1114
1189
  lines: string[],
1115
1190
  op: Operation,
@@ -1118,10 +1193,21 @@ function renderVoidMethod(
1118
1193
  pathStr: string,
1119
1194
  ctx: EmitterContext,
1120
1195
  specEnumNames?: Set<string>,
1196
+ resolvedOp?: ResolvedOperation,
1121
1197
  ): void {
1198
+ const hiddenParams = new Set<string>([
1199
+ ...Object.keys(getOpDefaults(resolvedOp)),
1200
+ ...getOpInferFromClient(resolvedOp),
1201
+ ]);
1202
+
1122
1203
  const params = buildPathParams(op, specEnumNames);
1123
- const hasQuery = op.queryParams.length > 0 && !plan.hasBody;
1124
- const optionsType = hasQuery ? toPascalCase(method) + 'Options' : null;
1204
+ const visibleQueryParams = op.queryParams.filter((p) => !hiddenParams.has(p.name));
1205
+ const hasVisibleQuery = visibleQueryParams.length > 0 && !plan.hasBody;
1206
+ const hasDefaults = Object.keys(getOpDefaults(resolvedOp)).length > 0;
1207
+ const hasInferred = getOpInferFromClient(resolvedOp).length > 0;
1208
+ const hasInjected = hasDefaults || hasInferred;
1209
+ const hasQuery = hasVisibleQuery || (hasInjected && !plan.hasBody);
1210
+ const optionsType = hasVisibleQuery ? toPascalCase(method) + 'Options' : null;
1125
1211
 
1126
1212
  let bodyParam = '';
1127
1213
  let bodyExpr = 'payload';
@@ -1154,10 +1240,47 @@ function renderVoidMethod(
1154
1240
  if (plan.hasBody) {
1155
1241
  lines.push(` await this.workos.${op.httpMethod}(${pathStr}, ${bodyExpr});`);
1156
1242
  } 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(' });');
1243
+ if (hasInjected) {
1244
+ // Build query object with visible params, defaults, and inferred fields
1245
+ const queryParts: string[] = [];
1246
+
1247
+ if (hasVisibleQuery) {
1248
+ for (const param of visibleQueryParams) {
1249
+ const camel = fieldName(param.name);
1250
+ const snake = wireFieldName(param.name);
1251
+ if (camel === snake) {
1252
+ if (param.required) {
1253
+ queryParts.push(`${camel}: options.${camel}`);
1254
+ } else {
1255
+ queryParts.push(`...(options?.${camel} !== undefined && { ${camel}: options.${camel} })`);
1256
+ }
1257
+ } else {
1258
+ if (param.required) {
1259
+ queryParts.push(`${snake}: options.${camel}`);
1260
+ } else {
1261
+ queryParts.push(`...(options?.${camel} !== undefined && { ${snake}: options.${camel} })`);
1262
+ }
1263
+ }
1264
+ }
1265
+ }
1266
+
1267
+ for (const [key, value] of Object.entries(getOpDefaults(resolvedOp))) {
1268
+ queryParts.push(`${key}: ${tsLiteral(value)}`);
1269
+ }
1270
+
1271
+ for (const field of getOpInferFromClient(resolvedOp)) {
1272
+ queryParts.push(`${field}: ${clientFieldExpression(field)}`);
1273
+ }
1274
+
1275
+ lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {`);
1276
+ lines.push(` query: { ${queryParts.join(', ')} },`);
1277
+ lines.push(' });');
1278
+ } else {
1279
+ const queryExpr = renderQueryExpr(visibleQueryParams);
1280
+ lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {`);
1281
+ lines.push(` query: ${queryExpr},`);
1282
+ lines.push(' });');
1283
+ }
1161
1284
  } else if (httpMethodNeedsBody(op.httpMethod)) {
1162
1285
  lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {});`);
1163
1286
  } else {
@@ -1253,9 +1376,16 @@ function renderUnionBodySerializer(
1253
1376
  const cases: string[] = [];
1254
1377
  for (const [value, modelName] of Object.entries(disc.mapping)) {
1255
1378
  const resolved = resolveInterfaceName(modelName, ctx);
1256
- cases.push(`case '${value}': return serialize${resolved}(payload as any)`);
1379
+ // Switch on a typed discriminator narrows `payload` to the variant, so the
1380
+ // serializer call type-checks without any casts.
1381
+ cases.push(`case '${value}': return serialize${resolved}(payload)`);
1257
1382
  }
1258
- return `(() => { switch ((payload as any).${prop}) { ${cases.join('; ')}; default: return payload } })()`;
1383
+ // Assign `payload` to `never` in the default branch to get a compile-time
1384
+ // exhaustiveness check — if a new variant is added to the union but not to
1385
+ // the switch, the build fails here. At runtime, we still throw so an
1386
+ // unknown discriminator slipping through via `as any` fails loudly rather
1387
+ // than silently forwarding camelCase to the API.
1388
+ return `(() => { switch (payload.${prop}) { ${cases.join('; ')}; default: { const _unknown: never = payload; throw new Error(\`Unknown ${prop}: \${(_unknown as { ${prop}?: unknown }).${prop}}\`) } } })()`;
1259
1389
  }
1260
1390
 
1261
1391
  /**