@workos/oagen-emitters 0.0.1 → 0.2.1

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 (49) hide show
  1. package/.github/workflows/release-please.yml +9 -1
  2. package/.husky/commit-msg +0 -0
  3. package/.husky/pre-commit +1 -0
  4. package/.husky/pre-push +1 -0
  5. package/.oxfmtrc.json +8 -1
  6. package/.prettierignore +1 -0
  7. package/.release-please-manifest.json +3 -0
  8. package/.vscode/settings.json +3 -0
  9. package/CHANGELOG.md +61 -0
  10. package/README.md +2 -2
  11. package/dist/index.d.mts +7 -0
  12. package/dist/index.d.mts.map +1 -0
  13. package/dist/index.mjs +4070 -0
  14. package/dist/index.mjs.map +1 -0
  15. package/package.json +14 -18
  16. package/release-please-config.json +11 -0
  17. package/smoke/sdk-dotnet.ts +17 -3
  18. package/smoke/sdk-elixir.ts +17 -3
  19. package/smoke/sdk-go.ts +21 -4
  20. package/smoke/sdk-kotlin.ts +23 -4
  21. package/smoke/sdk-node.ts +15 -3
  22. package/smoke/sdk-ruby.ts +17 -3
  23. package/smoke/sdk-rust.ts +16 -3
  24. package/src/node/client.ts +521 -206
  25. package/src/node/common.ts +74 -4
  26. package/src/node/config.ts +1 -0
  27. package/src/node/enums.ts +53 -9
  28. package/src/node/errors.ts +82 -3
  29. package/src/node/fixtures.ts +87 -16
  30. package/src/node/index.ts +66 -10
  31. package/src/node/manifest.ts +4 -2
  32. package/src/node/models.ts +251 -124
  33. package/src/node/naming.ts +107 -3
  34. package/src/node/resources.ts +1162 -108
  35. package/src/node/serializers.ts +512 -52
  36. package/src/node/tests.ts +650 -110
  37. package/src/node/type-map.ts +89 -11
  38. package/src/node/utils.ts +426 -113
  39. package/test/node/client.test.ts +1083 -20
  40. package/test/node/enums.test.ts +73 -4
  41. package/test/node/errors.test.ts +4 -21
  42. package/test/node/models.test.ts +499 -5
  43. package/test/node/naming.test.ts +14 -7
  44. package/test/node/resources.test.ts +1568 -9
  45. package/test/node/serializers.test.ts +241 -5
  46. package/tsconfig.json +2 -3
  47. package/{tsup.config.ts → tsdown.config.ts} +1 -1
  48. package/dist/index.d.ts +0 -5
  49. package/dist/index.js +0 -2158
@@ -1,27 +1,437 @@
1
- import type { Service, Operation, EmitterContext, GeneratedFile } from '@workos/oagen';
2
- import { planOperation, toPascalCase } from '@workos/oagen';
1
+ // @oagen-ignore: Operation.async all TypeScript SDK methods are async by nature
2
+
3
+ import type { Service, Operation, EmitterContext, GeneratedFile, TypeRef, Model } from '@workos/oagen';
4
+ import { planOperation, toPascalCase, toCamelCase } from '@workos/oagen';
3
5
  import type { OperationPlan } from '@workos/oagen';
4
6
  import { mapTypeRef } from './type-map.js';
5
7
  import {
6
8
  fieldName,
9
+ wireFieldName,
7
10
  fileName,
8
- serviceDirName,
11
+ resolveServiceDir,
9
12
  resolveMethodName,
10
13
  resolveInterfaceName,
11
14
  resolveServiceName,
12
- buildServiceNameMap,
13
15
  wireInterfaceName,
14
16
  } from './naming.js';
15
- import { collectModelRefs, assignModelsToServices, docComment } from './utils.js';
17
+ import {
18
+ docComment,
19
+ createServiceDirResolver,
20
+ isServiceCoveredByExisting,
21
+ hasMethodsAbsentFromBaseline,
22
+ uncoveredOperations,
23
+ } from './utils.js';
24
+ import { assignEnumsToServices } from './enums.js';
25
+ import { unwrapListModel } from './fixtures.js';
26
+
27
+ /**
28
+ * Check whether the baseline (hand-written) class has a constructor compatible
29
+ * with the generated pattern `constructor(private readonly workos: WorkOS)`.
30
+ * Returns true when no baseline exists (fresh generation) or when compatible.
31
+ */
32
+ export function hasCompatibleConstructor(className: string, ctx: EmitterContext): boolean {
33
+ const baselineClass = ctx.apiSurface?.classes?.[className];
34
+ if (!baselineClass) return true; // No baseline — fresh generation
35
+ const params = baselineClass.constructorParams;
36
+ if (!params || params.length === 0) return true; // No-arg constructor is compatible
37
+ // Compatible if there is a single `workos` param whose type contains "WorkOS"
38
+ return params.some((p) => p.name === 'workos' && p.type.includes('WorkOS'));
39
+ }
40
+
41
+ /**
42
+ * Resolve the resource class name for a service, accounting for constructor
43
+ * compatibility with the baseline class.
44
+ *
45
+ * When the overlay-resolved class has an incompatible constructor (e.g., a
46
+ * hand-written `Webhooks` class that takes `CryptoProvider` instead of `WorkOS`),
47
+ * falls back to the IR name (`toPascalCase(service.name)`). If the IR name
48
+ * collides with the overlay name, appends an `Endpoints` suffix.
49
+ */
50
+ export function resolveResourceClassName(service: Service, ctx: EmitterContext): string {
51
+ const overlayName = resolveServiceName(service, ctx);
52
+ if (hasCompatibleConstructor(overlayName, ctx)) {
53
+ return overlayName;
54
+ }
55
+ // Incompatible constructor — fall back to IR name
56
+ const irName = toPascalCase(service.name);
57
+ if (irName === overlayName) {
58
+ return irName + 'Endpoints';
59
+ }
60
+ return irName;
61
+ }
62
+
63
+ /** Standard pagination query params handled by PaginationOptions — not imported individually. */
64
+ const PAGINATION_PARAM_NAMES = new Set(['limit', 'before', 'after', 'order']);
65
+
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
+ };
75
+
76
+ /**
77
+ * Compute the options interface name for a paginated method.
78
+ * When the method name is simply "list", prefix with the service name to avoid
79
+ * naming collisions at barrel-export level (e.g. "ConnectionsListOptions"
80
+ * instead of the generic "ListOptions").
81
+ */
82
+ function paginatedOptionsName(method: string, resolvedServiceName: string): string {
83
+ if (method === 'list') {
84
+ return `${toPascalCase(resolvedServiceName)}ListOptions`;
85
+ }
86
+ return toPascalCase(method) + 'Options';
87
+ }
88
+
89
+ /** HTTP methods that require a body argument even when the spec has no request body. */
90
+ function httpMethodNeedsBody(method: string): boolean {
91
+ return method === 'post' || method === 'put' || method === 'patch';
92
+ }
93
+
94
+ // ---------------------------------------------------------------------------
95
+ // Method-name reconciliation helpers
96
+ // ---------------------------------------------------------------------------
97
+
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
+ /** Split a camelCase/PascalCase name into lowercase word parts. */
108
+ function splitCamelWords(name: string): string[] {
109
+ const parts: string[] = [];
110
+ let start = 0;
111
+ for (let i = 1; i < name.length; i++) {
112
+ if (name[i] >= 'A' && name[i] <= 'Z') {
113
+ parts.push(name.slice(start, i).toLowerCase());
114
+ start = i;
115
+ }
116
+ }
117
+ parts.push(name.slice(start).toLowerCase());
118
+ return parts;
119
+ }
120
+
121
+ /** Naive singularize: strip trailing 's' unless it ends in 'ss'. */
122
+ function singularize(word: string): string {
123
+ return word.endsWith('s') && !word.endsWith('ss') ? word.slice(0, -1) : word;
124
+ }
125
+
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
+ /**
330
+ * Deduplicate method names within the plans array.
331
+ *
332
+ * When `disambiguateOperationNames()` in `@workos/oagen` fails (e.g., for
333
+ * single-segment paths like `/organizations`), two operations can resolve to
334
+ * the same method name. Disambiguate by appending a path-derived suffix.
335
+ */
336
+ function deduplicateMethodNames(
337
+ plans: Array<{ op: Operation; plan: OperationPlan; method: string }>,
338
+ _ctx: EmitterContext,
339
+ ): void {
340
+ const nameCount = new Map<string, number>();
341
+ for (const p of plans) {
342
+ nameCount.set(p.method, (nameCount.get(p.method) ?? 0) + 1);
343
+ }
344
+
345
+ for (const [name, count] of nameCount) {
346
+ if (count <= 1) continue;
347
+ const dupes = plans.filter((p) => p.method === name);
348
+
349
+ // If all duplicates are on the SAME base path (different HTTP methods),
350
+ // trust the names — they represent the same resource.
351
+ const basePaths = new Set(dupes.map((d) => d.op.path.replace(/\/\{[^}]+\}$/, '')));
352
+ if (basePaths.size <= 1) continue;
353
+
354
+ // Disambiguate: keep the name for the plan whose path best matches,
355
+ // append path suffix for the others.
356
+ // Score: how many words in the method name appear in the path segments
357
+ const nameWords = new Set(splitCamelWords(name).map(singularize));
358
+ const scored = dupes.map((d) => {
359
+ const pathWords = d.op.path
360
+ .split('/')
361
+ .filter((s) => s && !s.startsWith('{'))
362
+ .flatMap((s) => s.split('_'))
363
+ .map(singularize);
364
+ const overlap = pathWords.filter((w) => nameWords.has(w)).length;
365
+ return { plan: d, score: overlap };
366
+ });
367
+ scored.sort((a, b) => b.score - a.score);
368
+
369
+ // The best-scoring plan keeps the name; others get disambiguated
370
+ for (let i = 1; i < scored.length; i++) {
371
+ const dupe = scored[i].plan;
372
+ const segments = dupe.op.path.split('/').filter((s) => s && !s.startsWith('{'));
373
+ // Use first segment as suffix (the resource collection name)
374
+ const suffix = segments[0] ?? '';
375
+ if (suffix) {
376
+ dupe.method = toCamelCase(`${name}_${suffix}`);
377
+ }
378
+ }
379
+
380
+ // If still colliding after suffix, append index
381
+ const stillDuped = new Map<string, typeof dupes>();
382
+ for (const dupe of dupes) {
383
+ const group = stillDuped.get(dupe.method) ?? [];
384
+ group.push(dupe);
385
+ stillDuped.set(dupe.method, group);
386
+ }
387
+ for (const [, group] of stillDuped) {
388
+ if (group.length <= 1) continue;
389
+ for (let i = 1; i < group.length; i++) {
390
+ group[i].method = `${group[i].method}${i + 1}`;
391
+ }
392
+ }
393
+ }
394
+ }
16
395
 
17
396
  export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
18
397
  if (services.length === 0) return [];
19
- return services.map((service) => generateResourceClass(service, ctx));
398
+ const files: GeneratedFile[] = [];
399
+
400
+ for (const service of services) {
401
+ if (isServiceCoveredByExisting(service, ctx)) {
402
+ // Fully covered: generate with ALL operations so the merger's docstring
403
+ // refresh pass can update JSDoc on existing methods.
404
+ const file = generateResourceClass(service, ctx);
405
+ // When the baseline class is missing methods for some operations,
406
+ // remove skipIfExists so the merger adds the new methods.
407
+ if (hasMethodsAbsentFromBaseline(service, ctx)) {
408
+ delete file.skipIfExists;
409
+ }
410
+ files.push(file);
411
+ continue;
412
+ }
413
+
414
+ const ops = uncoveredOperations(service, ctx);
415
+ if (ops.length === 0) continue;
416
+
417
+ if (ops.length < service.operations.length) {
418
+ // Partial coverage: generate with ALL operations so JSDoc is available
419
+ // for both covered and uncovered methods. Remove skipIfExists so the
420
+ // merger adds new methods AND refreshes existing JSDoc.
421
+ const file = generateResourceClass(service, ctx);
422
+ delete file.skipIfExists;
423
+ files.push(file);
424
+ } else {
425
+ files.push(generateResourceClass(service, ctx));
426
+ }
427
+ }
428
+
429
+ return files;
20
430
  }
21
431
 
22
432
  function generateResourceClass(service: Service, ctx: EmitterContext): GeneratedFile {
23
- const resolvedName = resolveServiceName(service, ctx);
24
- const serviceDir = serviceDirName(resolvedName);
433
+ const resolvedName = resolveResourceClassName(service, ctx);
434
+ const serviceDir = resolveServiceDir(resolvedName);
25
435
  const serviceClass = resolvedName;
26
436
  const resourcePath = `src/${serviceDir}/${fileName(resolvedName)}.ts`;
27
437
 
@@ -31,20 +441,97 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
31
441
  method: resolveMethodName(op, service, ctx),
32
442
  }));
33
443
 
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);
448
+
449
+ // Deduplicate method names within the class (e.g., two operations both
450
+ // resolving to "create" for different paths).
451
+ deduplicateMethodNames(plans, ctx);
452
+
453
+ // Sort plans to match the existing file's method order.
454
+ // When the merger integrates generated content with existing files, its
455
+ // URL-fingerprint fallback (pass 2) matches by position among methods that
456
+ // share the same endpoint path. If the spec lists POST before GET for a
457
+ // path (common in OpenAPI) but the existing class has them in a different
458
+ // order, JSDoc comments get attached to the wrong methods (list↔create,
459
+ // add↔set swaps). Sorting by the overlay's method order ensures the
460
+ // generated output matches the existing file's method order.
461
+ if (ctx.overlayLookup?.methodByOperation) {
462
+ const methodOrder = new Map<string, number>();
463
+ let pos = 0;
464
+ for (const [, info] of ctx.overlayLookup.methodByOperation) {
465
+ if (!methodOrder.has(info.methodName)) {
466
+ methodOrder.set(info.methodName, pos++);
467
+ }
468
+ }
469
+ if (methodOrder.size > 0) {
470
+ 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;
473
+ return aPos - bPos;
474
+ });
475
+ }
476
+ }
477
+
34
478
  const hasPaginated = plans.some((p) => p.plan.isPaginated);
479
+ const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
35
480
 
36
- // Collect models for imports
481
+ // Collect models for imports — only include models that are actually used
482
+ // in method signatures (not all union variants from the spec)
37
483
  const responseModels = new Set<string>();
38
484
  const requestModels = new Set<string>();
485
+ const paramEnums = new Set<string>();
486
+ const paramModels = new Set<string>();
39
487
  for (const { op, plan } of plans) {
40
- if (plan.responseModelName) responseModels.add(plan.responseModelName);
41
- if (op.requestBody) {
42
- for (const name of collectModelRefs(op.requestBody)) {
43
- requestModels.add(name);
488
+ if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
489
+ // For paginated operations, import the item type (e.g., Connection)
490
+ // rather than the list wrapper type (e.g., ConnectionList).
491
+ // fetchAndDeserialize handles the list envelope internally.
492
+ // The IR's itemType may point to a list wrapper model — unwrap to the actual item.
493
+ let itemName = op.pagination.itemType.name;
494
+ const itemModel = modelMap.get(itemName);
495
+ if (itemModel) {
496
+ const unwrapped = unwrapListModel(itemModel, modelMap);
497
+ if (unwrapped) {
498
+ itemName = unwrapped.name;
499
+ }
500
+ }
501
+ responseModels.add(itemName);
502
+ } else if (plan.responseModelName) {
503
+ responseModels.add(plan.responseModelName);
504
+ }
505
+ // Import request body model(s) — handles both single models and union variants.
506
+ const bodyInfo = extractRequestBodyType(op, ctx);
507
+ if (bodyInfo?.kind === 'model') {
508
+ requestModels.add(bodyInfo.name);
509
+ } else if (bodyInfo?.kind === 'union') {
510
+ if (bodyInfo.discriminator) {
511
+ // Discriminated union: import variant models with serializers so we can
512
+ // dispatch to the correct serializer at runtime based on the discriminator.
513
+ for (const name of bodyInfo.modelNames) {
514
+ requestModels.add(name);
515
+ }
516
+ } else {
517
+ // Non-discriminated union: import variant models with serializers so we
518
+ // can dispatch to the correct serializer at runtime via field guards.
519
+ for (const name of bodyInfo.modelNames) {
520
+ requestModels.add(name);
521
+ }
44
522
  }
45
523
  }
524
+ // Collect types referenced in query and path parameters.
525
+ // For paginated operations, skip standard pagination params (limit, before, after, order)
526
+ // since they're handled by PaginationOptions and don't need explicit imports.
527
+ const queryParams = plan.isPaginated
528
+ ? op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name))
529
+ : op.queryParams;
530
+ for (const param of [...queryParams, ...op.pathParams]) {
531
+ collectParamTypeRefs(param.type, paramEnums, paramModels);
532
+ }
46
533
  }
47
- const allModels = new Set([...responseModels, ...requestModels]);
534
+ const allModels = new Set([...responseModels, ...requestModels, ...paramModels]);
48
535
 
49
536
  const lines: string[] = [];
50
537
 
@@ -52,63 +539,126 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
52
539
  lines.push("import type { WorkOS } from '../workos';");
53
540
  if (hasPaginated) {
54
541
  lines.push("import type { PaginationOptions } from '../common/interfaces/pagination-options.interface';");
55
- lines.push("import { AutoPaginatable } from '../common/utils/pagination';");
56
- lines.push("import { fetchAndDeserialize } from '../common/utils/fetch-and-deserialize';");
542
+ lines.push("import type { AutoPaginatable } from '../common/utils/pagination';");
543
+ lines.push("import { createPaginatedList } from '../common/utils/fetch-and-deserialize';");
57
544
  }
58
545
 
59
- // Check if any operation is an idempotent POST
546
+ // Check if any operation needs PostOptions (idempotent POST or custom encoding)
60
547
  const hasIdempotentPost = plans.some((p) => p.plan.isIdempotentPost);
61
- if (hasIdempotentPost) {
548
+ const hasCustomEncoding = plans.some(
549
+ (p) => p.op.requestBodyEncoding && p.op.requestBodyEncoding !== 'json' && p.plan.hasBody,
550
+ );
551
+ if (hasIdempotentPost || hasCustomEncoding) {
62
552
  lines.push("import type { PostOptions } from '../common/interfaces/post-options.interface';");
63
553
  }
64
554
 
65
555
  // Compute model-to-service mapping for correct cross-service import paths
66
- const modelToService = assignModelsToServices(ctx.spec.models, ctx.spec.services);
67
- const serviceNameMap = buildServiceNameMap(ctx.spec.services, ctx);
68
- const resolveDir = (irService: string | undefined) =>
69
- irService ? serviceDirName(serviceNameMap.get(irService) ?? irService) : 'common';
556
+ const { modelToService, resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
557
+
558
+ // Wire (Response) types are only needed for models used as response types in method signatures.
559
+ // Request models and param models only need the domain type.
560
+ const usedWireTypes = new Set<string>();
561
+ for (const name of responseModels) {
562
+ usedWireTypes.add(resolveInterfaceName(name, ctx));
563
+ }
70
564
 
565
+ // Track imported resolved names to prevent duplicate type name collisions
566
+ const importedTypeNames = new Set<string>();
71
567
  for (const name of allModels) {
72
568
  const resolved = resolveInterfaceName(name, ctx);
569
+ if (importedTypeNames.has(resolved)) continue; // Skip duplicate resolved names
570
+ importedTypeNames.add(resolved);
73
571
  const modelDir = modelToService.get(name);
74
572
  const modelServiceDir = resolveDir(modelDir);
75
573
  const relPath =
76
574
  modelServiceDir === serviceDir
77
575
  ? `./interfaces/${fileName(name)}.interface`
78
576
  : `../${modelServiceDir}/interfaces/${fileName(name)}.interface`;
79
- lines.push(`import type { ${resolved}, ${wireInterfaceName(resolved)} } from '${relPath}';`);
577
+ if (usedWireTypes.has(resolved)) {
578
+ lines.push(`import type { ${resolved}, ${wireInterfaceName(resolved)} } from '${relPath}';`);
579
+ } else {
580
+ lines.push(`import type { ${resolved} } from '${relPath}';`);
581
+ }
80
582
  }
81
583
 
584
+ // Collect serializer imports by module path so we can merge deserialize and
585
+ // serialize imports from the same module into a single import statement.
586
+ const serializerImportsByPath = new Map<string, string[]>();
587
+
588
+ const importedDeserializers = new Set<string>();
82
589
  for (const name of responseModels) {
83
590
  const resolved = resolveInterfaceName(name, ctx);
591
+ if (importedDeserializers.has(resolved)) continue;
592
+ importedDeserializers.add(resolved);
84
593
  const modelDir = modelToService.get(name);
85
594
  const modelServiceDir = resolveDir(modelDir);
86
595
  const relPath =
87
596
  modelServiceDir === serviceDir
88
597
  ? `./serializers/${fileName(name)}.serializer`
89
598
  : `../${modelServiceDir}/serializers/${fileName(name)}.serializer`;
90
- lines.push(`import { deserialize${resolved} } from '${relPath}';`);
599
+ const existing = serializerImportsByPath.get(relPath) ?? [];
600
+ existing.push(`deserialize${resolved}`);
601
+ serializerImportsByPath.set(relPath, existing);
91
602
  }
92
603
 
604
+ const importedSerializers = new Set<string>();
93
605
  for (const name of requestModels) {
94
606
  const resolved = resolveInterfaceName(name, ctx);
607
+ if (importedSerializers.has(resolved)) continue;
608
+ importedSerializers.add(resolved);
95
609
  const modelDir = modelToService.get(name);
96
610
  const modelServiceDir = resolveDir(modelDir);
97
611
  const relPath =
98
612
  modelServiceDir === serviceDir
99
613
  ? `./serializers/${fileName(name)}.serializer`
100
614
  : `../${modelServiceDir}/serializers/${fileName(name)}.serializer`;
101
- lines.push(`import { serialize${resolved} } from '${relPath}';`);
615
+ const existing = serializerImportsByPath.get(relPath) ?? [];
616
+ existing.push(`serialize${resolved}`);
617
+ serializerImportsByPath.set(relPath, existing);
618
+ }
619
+
620
+ // Emit merged serializer imports
621
+ for (const [relPath, specifiers] of serializerImportsByPath) {
622
+ lines.push(`import { ${specifiers.join(', ')} } from '${relPath}';`);
623
+ }
624
+
625
+ // Build a set of global enum names — used to distinguish named enums (with files)
626
+ // from inline enums (no file, must be rendered as string literal unions).
627
+ const specEnumNames = new Set(ctx.spec.enums.map((e) => e.name));
628
+
629
+ // Import enum types referenced in query/path parameters.
630
+ // Only import enums that actually exist in the spec's global enums list —
631
+ // inline string unions may have kind 'enum' but no corresponding file.
632
+ if (paramEnums.size > 0) {
633
+ const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services);
634
+ for (const name of paramEnums) {
635
+ if (allModels.has(name)) continue; // Already imported as a model
636
+ if (!specEnumNames.has(name)) continue; // No file generated for this enum
637
+ const enumDir = enumToService.get(name);
638
+ const enumServiceDir = resolveDir(enumDir);
639
+ const relPath =
640
+ enumServiceDir === serviceDir
641
+ ? `./interfaces/${fileName(name)}.interface`
642
+ : `../${enumServiceDir}/interfaces/${fileName(name)}.interface`;
643
+ lines.push(`import type { ${name} } from '${relPath}';`);
644
+ }
102
645
  }
103
646
 
104
647
  lines.push('');
105
648
 
106
- // List options interfaces for paginated operations with extra query params
649
+ // Options interfaces for operations with query params.
650
+ // Paginated operations extend PaginationOptions; non-paginated operations get standalone interfaces.
107
651
  for (const { op, plan, method } of plans) {
108
652
  if (plan.isPaginated) {
109
- const extraParams = op.queryParams.filter((p) => !['limit', 'before', 'after', 'order'].includes(p.name));
653
+ const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
110
654
  if (extraParams.length > 0) {
111
- const optionsName = toPascalCase(method) + 'Options';
655
+ 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.
112
662
  lines.push(`export interface ${optionsName} extends PaginationOptions {`);
113
663
  for (const param of extraParams) {
114
664
  const opt = !param.required ? '?' : '';
@@ -118,11 +668,28 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
118
668
  if (param.deprecated) parts.push('@deprecated');
119
669
  lines.push(...docComment(parts.join('\n'), 2));
120
670
  }
121
- lines.push(` ${fieldName(param.name)}${opt}: ${mapTypeRef(param.type)};`);
671
+ lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
122
672
  }
123
673
  lines.push('}');
124
674
  lines.push('');
125
675
  }
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('');
126
693
  }
127
694
  }
128
695
 
@@ -135,7 +702,7 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
135
702
 
136
703
  for (const { op, plan, method } of plans) {
137
704
  lines.push('');
138
- lines.push(...renderMethod(op, plan, method, service, ctx));
705
+ lines.push(...renderMethod(op, plan, method, service, ctx, modelMap, specEnumNames));
139
706
  }
140
707
 
141
708
  lines.push('}');
@@ -149,29 +716,125 @@ function renderMethod(
149
716
  method: string,
150
717
  service: Service,
151
718
  ctx: EmitterContext,
719
+ modelMap: Map<string, Model>,
720
+ specEnumNames?: Set<string>,
152
721
  ): string[] {
153
722
  const lines: string[] = [];
154
723
  const responseModel = plan.responseModelName ? resolveInterfaceName(plan.responseModelName, ctx) : null;
155
724
 
156
- // Path interpolation: replace {param} with ${param}
157
- const interpolatedPath = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
158
- const usesTemplate = interpolatedPath.includes('${');
159
- const pathStr = usesTemplate ? `\`${interpolatedPath}\`` : `'${op.path}'`;
725
+ const pathStr = buildPathStr(op);
726
+
727
+ // Build set of valid param names to filter @param tags.
728
+ // Prefer the overlay (existing method signature) if available;
729
+ // otherwise compute from what the render path will actually include.
730
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
731
+ const overlayMethod = ctx.overlayLookup?.methodByOperation?.get(httpKey);
732
+ let validParamNames: Set<string> | null = null;
733
+ if (overlayMethod) {
734
+ validParamNames = new Set(overlayMethod.params.map((p) => p.name));
735
+ } else {
736
+ // Compute actual params based on render path to avoid documenting params
737
+ // that won't appear in the method signature
738
+ const actualParams = new Set<string>();
739
+ for (const p of op.pathParams) actualParams.add(fieldName(p.name));
740
+ if (plan.hasBody) actualParams.add('payload');
741
+ 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) {
744
+ actualParams.add('options');
745
+ }
746
+ validParamNames = actualParams;
747
+ }
160
748
 
161
749
  const docParts: string[] = [];
162
750
  if (op.description) docParts.push(op.description);
163
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) ' : '';
164
755
  if (param.description) {
165
- docParts.push(`@param ${fieldName(param.name)} - ${param.description}`);
756
+ docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
757
+ } else if (param.deprecated) {
758
+ docParts.push(`@param ${paramName} - (deprecated)`);
166
759
  }
760
+ if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
761
+ if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
167
762
  }
168
- if (op.deprecated) docParts.push('@deprecated');
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)`);
775
+ }
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
+ }
779
+ }
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.';
799
+ } else {
800
+ payloadDesc = '@param payload - The request body.';
801
+ }
802
+ docParts.push(payloadDesc);
803
+ } else {
804
+ docParts.push('@param payload - The request body.');
805
+ }
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;
822
+ }
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
169
831
  for (const err of op.errors) {
170
- const exceptionName = statusToExceptionName(err.statusCode);
832
+ const exceptionName = STATUS_TO_EXCEPTION_NAME[err.statusCode];
171
833
  if (exceptionName) {
172
834
  docParts.push(`@throws {${exceptionName}} ${err.statusCode}`);
173
835
  }
174
836
  }
837
+ if (op.deprecated) docParts.push('@deprecated');
175
838
 
176
839
  if (docParts.length > 0) {
177
840
  // Flatten all parts, splitting multiline descriptions into individual lines
@@ -192,22 +855,59 @@ function renderMethod(
192
855
  }
193
856
  }
194
857
 
195
- if (plan.isPaginated) {
196
- if (!responseModel) {
197
- console.warn(
198
- `[oagen] Warning: Skipping paginated method "${method}" (${op.httpMethod.toUpperCase()} ${op.path}) — response has no named model. Ensure the spec uses a $ref for paginated item types.`,
858
+ const preDecisionCount = lines.length;
859
+
860
+ if (plan.isPaginated && op.pagination && op.httpMethod === 'get') {
861
+ // For paginated operations, use the item type from pagination metadata
862
+ // (e.g., Connection) rather than the list wrapper type (e.g., ConnectionList).
863
+ // Unwrap list wrapper models to get the actual item type name.
864
+ let paginatedItemRawName = op.pagination.itemType.kind === 'model' ? op.pagination.itemType.name : null;
865
+ if (paginatedItemRawName) {
866
+ const pModel = modelMap.get(paginatedItemRawName);
867
+ if (pModel) {
868
+ const unwrapped = unwrapListModel(pModel, modelMap);
869
+ if (unwrapped) {
870
+ paginatedItemRawName = unwrapped.name;
871
+ }
872
+ }
873
+ }
874
+ const paginatedItemType = paginatedItemRawName ? resolveInterfaceName(paginatedItemRawName, ctx) : responseModel;
875
+ if (paginatedItemType) {
876
+ const resolvedServiceNameForPaginated = resolveServiceName(service, ctx);
877
+ renderPaginatedMethod(
878
+ lines,
879
+ op,
880
+ plan,
881
+ method,
882
+ paginatedItemType,
883
+ pathStr,
884
+ resolvedServiceNameForPaginated,
885
+ specEnumNames,
199
886
  );
200
- return lines;
201
887
  }
202
- renderPaginatedMethod(lines, op, plan, method, responseModel);
888
+ } else if (plan.isPaginated && plan.hasBody && responseModel) {
889
+ // Non-GET paginated operation (e.g., PUT with list response) — treat as body method
890
+ renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx, specEnumNames);
891
+ } else if (plan.isDelete && plan.hasBody) {
892
+ renderDeleteWithBodyMethod(lines, op, plan, method, pathStr, ctx, specEnumNames);
203
893
  } else if (plan.isDelete) {
204
- renderDeleteMethod(lines, op, plan, method, pathStr);
894
+ renderDeleteMethod(lines, op, plan, method, pathStr, specEnumNames);
205
895
  } else if (plan.hasBody && responseModel) {
206
- renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx);
896
+ renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx, specEnumNames);
207
897
  } else if (responseModel) {
208
- renderGetMethod(lines, op, plan, method, responseModel, pathStr);
898
+ renderGetMethod(lines, op, plan, method, responseModel, pathStr, specEnumNames);
209
899
  } else {
210
- renderVoidMethod(lines, op, plan, method, pathStr);
900
+ renderVoidMethod(lines, op, plan, method, pathStr, ctx, specEnumNames);
901
+ }
902
+
903
+ // Defensive: if no render function produced a method body, emit a stub
904
+ if (lines.length === preDecisionCount) {
905
+ const params = buildPathParams(op, specEnumNames);
906
+ lines.push(` async ${method}(${params}): Promise<void> {`);
907
+ lines.push(
908
+ ` await this.workos.${op.httpMethod}(${pathStr}${httpMethodNeedsBody(op.httpMethod) ? ', {}' : ''});`,
909
+ );
910
+ lines.push(' }');
211
911
  }
212
912
 
213
913
  return lines;
@@ -219,29 +919,20 @@ function renderPaginatedMethod(
219
919
  plan: OperationPlan,
220
920
  method: string,
221
921
  itemType: string,
922
+ pathStr: string,
923
+ resolvedServiceName: string,
924
+ specEnumNames?: Set<string>,
222
925
  ): void {
223
- const extraParams = op.queryParams.filter((p) => !['limit', 'before', 'after', 'order'].includes(p.name));
224
- const optionsType = extraParams.length > 0 ? toPascalCase(method) + 'Options' : 'PaginationOptions';
926
+ const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
927
+ const optionsType = extraParams.length > 0 ? paginatedOptionsName(method, resolvedServiceName) : 'PaginationOptions';
225
928
 
226
- const pathStr = buildPathStr(op);
929
+ const pathParams = buildPathParams(op, specEnumNames);
930
+ const allParams = pathParams ? `${pathParams}, options?: ${optionsType}` : `options?: ${optionsType}`;
227
931
 
228
- lines.push(` async ${method}(options?: ${optionsType}): Promise<AutoPaginatable<${itemType}, ${optionsType}>> {`);
229
- lines.push(' return new AutoPaginatable(');
230
- lines.push(` await fetchAndDeserialize<${wireInterfaceName(itemType)}, ${itemType}>(`);
231
- lines.push(' this.workos,');
232
- lines.push(` ${pathStr},`);
233
- lines.push(` deserialize${itemType},`);
234
- lines.push(' options,');
235
- lines.push(' ),');
236
- lines.push(' (params) =>');
237
- lines.push(` fetchAndDeserialize<${wireInterfaceName(itemType)}, ${itemType}>(`);
238
- lines.push(' this.workos,');
239
- lines.push(` ${pathStr},`);
240
- lines.push(` deserialize${itemType},`);
241
- lines.push(' params,');
242
- lines.push(' ),');
243
- lines.push(' options,');
244
- lines.push(' );');
932
+ lines.push(` async ${method}(${allParams}): Promise<AutoPaginatable<${itemType}, ${optionsType}>> {`);
933
+ lines.push(
934
+ ` return createPaginatedList<${wireInterfaceName(itemType)}, ${itemType}, ${optionsType}>(this.workos, ${pathStr}, deserialize${itemType}, options);`,
935
+ );
245
936
  lines.push(' }');
246
937
  }
247
938
 
@@ -251,13 +942,54 @@ function renderDeleteMethod(
251
942
  plan: OperationPlan,
252
943
  method: string,
253
944
  pathStr: string,
945
+ specEnumNames?: Set<string>,
254
946
  ): void {
255
- const params = buildPathParams(op);
947
+ const params = buildPathParams(op, specEnumNames);
256
948
  lines.push(` async ${method}(${params}): Promise<void> {`);
257
949
  lines.push(` await this.workos.delete(${pathStr});`);
258
950
  lines.push(' }');
259
951
  }
260
952
 
953
+ function renderDeleteWithBodyMethod(
954
+ lines: string[],
955
+ op: Operation,
956
+ plan: OperationPlan,
957
+ method: string,
958
+ pathStr: string,
959
+ ctx: EmitterContext,
960
+ specEnumNames?: Set<string>,
961
+ ): void {
962
+ const bodyInfo = extractRequestBodyType(op, ctx);
963
+ let requestType: string;
964
+ let bodyExpr: string;
965
+ if (bodyInfo?.kind === 'model') {
966
+ requestType = resolveInterfaceName(bodyInfo.name, ctx);
967
+ bodyExpr = `serialize${requestType}(payload)`;
968
+ } else if (bodyInfo?.kind === 'union') {
969
+ requestType = bodyInfo.typeStr;
970
+ if (bodyInfo.discriminator) {
971
+ bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
972
+ } else {
973
+ bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
974
+ }
975
+ } else {
976
+ requestType = 'Record<string, unknown>';
977
+ bodyExpr = 'payload';
978
+ }
979
+
980
+ const paramParts: string[] = [];
981
+ for (const param of op.pathParams) {
982
+ paramParts.push(
983
+ `${fieldName(param.name)}: ${specEnumNames ? mapParamType(param.type, specEnumNames) : mapTypeRef(param.type)}`,
984
+ );
985
+ }
986
+ paramParts.push(`payload: ${requestType}`);
987
+
988
+ lines.push(` async ${method}(${paramParts.join(', ')}): Promise<void> {`);
989
+ lines.push(` await this.workos.deleteWithBody(${pathStr}, ${bodyExpr});`);
990
+ lines.push(' }');
991
+ }
992
+
261
993
  function renderBodyMethod(
262
994
  lines: string[],
263
995
  op: Operation,
@@ -266,15 +998,33 @@ function renderBodyMethod(
266
998
  responseModel: string,
267
999
  pathStr: string,
268
1000
  ctx: EmitterContext,
1001
+ specEnumNames?: Set<string>,
269
1002
  ): void {
270
- const requestBodyModel = extractRequestBodyModelName(op);
271
- const requestType = requestBodyModel ? resolveInterfaceName(requestBodyModel, ctx) : 'any';
1003
+ const bodyInfo = extractRequestBodyType(op, ctx);
1004
+ let requestType: string;
1005
+ let bodyExpr: string;
1006
+ if (bodyInfo?.kind === 'model') {
1007
+ requestType = resolveInterfaceName(bodyInfo.name, ctx);
1008
+ bodyExpr = `serialize${requestType}(payload)`;
1009
+ } else if (bodyInfo?.kind === 'union') {
1010
+ requestType = bodyInfo.typeStr;
1011
+ if (bodyInfo.discriminator) {
1012
+ bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
1013
+ } else {
1014
+ bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
1015
+ }
1016
+ } else {
1017
+ requestType = 'Record<string, unknown>';
1018
+ bodyExpr = 'payload';
1019
+ }
272
1020
 
273
1021
  const paramParts: string[] = [];
274
1022
 
275
1023
  // Always pass path params as individual parameters (matches existing SDK pattern)
276
1024
  for (const param of op.pathParams) {
277
- paramParts.push(`${fieldName(param.name)}: ${mapTypeRef(param.type)}`);
1025
+ paramParts.push(
1026
+ `${fieldName(param.name)}: ${specEnumNames ? mapParamType(param.type, specEnumNames) : mapTypeRef(param.type)}`,
1027
+ );
278
1028
  }
279
1029
 
280
1030
  paramParts.push(`payload: ${requestType}`);
@@ -284,20 +1034,40 @@ function renderBodyMethod(
284
1034
  }
285
1035
 
286
1036
  const paramsStr = paramParts.join(', ');
287
- const bodyExpr = requestBodyModel && requestType !== 'any' ? `serialize${requestType}(payload)` : 'payload';
1037
+
1038
+ // Fix 2: Pass encoding option when requestBodyEncoding is non-json
1039
+ const encoding = op.requestBodyEncoding;
1040
+ const encodingOption = encoding && encoding !== 'json' ? `, encoding: '${encoding}' as const` : '';
1041
+ const hasCustomEncoding = encodingOption !== '';
288
1042
 
289
1043
  lines.push(` async ${method}(${paramsStr}): Promise<${responseModel}> {`);
290
1044
  if (plan.isIdempotentPost) {
291
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
292
- lines.push(` ${pathStr},`);
293
- lines.push(` ${bodyExpr},`);
294
- lines.push(' requestOptions,');
295
- lines.push(' );');
1045
+ if (hasCustomEncoding) {
1046
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1047
+ lines.push(` ${pathStr},`);
1048
+ lines.push(` ${bodyExpr},`);
1049
+ lines.push(` { ...requestOptions${encodingOption} },`);
1050
+ lines.push(' );');
1051
+ } else {
1052
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1053
+ lines.push(` ${pathStr},`);
1054
+ lines.push(` ${bodyExpr},`);
1055
+ lines.push(' requestOptions,');
1056
+ lines.push(' );');
1057
+ }
296
1058
  } else {
297
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
298
- lines.push(` ${pathStr},`);
299
- lines.push(` ${bodyExpr},`);
300
- lines.push(' );');
1059
+ if (hasCustomEncoding) {
1060
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1061
+ lines.push(` ${pathStr},`);
1062
+ lines.push(` ${bodyExpr},`);
1063
+ lines.push(` { ${encodingOption.slice(2)} },`);
1064
+ lines.push(' );');
1065
+ } else {
1066
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
1067
+ lines.push(` ${pathStr},`);
1068
+ lines.push(` ${bodyExpr},`);
1069
+ lines.push(' );');
1070
+ }
301
1071
  }
302
1072
  lines.push(` return deserialize${responseModel}(data);`);
303
1073
  lines.push(' }');
@@ -310,23 +1080,27 @@ function renderGetMethod(
310
1080
  method: string,
311
1081
  responseModel: string,
312
1082
  pathStr: string,
1083
+ specEnumNames?: Set<string>,
313
1084
  ): void {
314
- const params = buildPathParams(op);
1085
+ const params = buildPathParams(op, specEnumNames);
315
1086
  const hasQuery = op.queryParams.length > 0 && !plan.isPaginated;
1087
+ const optionsType = hasQuery ? toPascalCase(method) + 'Options' : null;
316
1088
 
317
- const allParams = hasQuery
318
- ? params
319
- ? `${params}, options?: Record<string, any>`
320
- : 'options?: Record<string, any>'
321
- : params;
1089
+ const allParams = hasQuery ? (params ? `${params}, options?: ${optionsType}` : `options?: ${optionsType}`) : params;
322
1090
 
323
1091
  lines.push(` async ${method}(${allParams}): Promise<${responseModel}> {`);
324
1092
  if (hasQuery) {
1093
+ const queryExpr = renderQueryExpr(op.queryParams);
325
1094
  lines.push(
326
1095
  ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
327
1096
  );
328
- lines.push(' query: options,');
1097
+ lines.push(` query: ${queryExpr},`);
329
1098
  lines.push(' });');
1099
+ } else if (httpMethodNeedsBody(op.httpMethod)) {
1100
+ // 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
+ );
330
1104
  } else {
331
1105
  lines.push(
332
1106
  ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr});`,
@@ -336,45 +1110,325 @@ function renderGetMethod(
336
1110
  lines.push(' }');
337
1111
  }
338
1112
 
339
- function renderVoidMethod(lines: string[], op: Operation, plan: OperationPlan, method: string, pathStr: string): void {
340
- const params = buildPathParams(op);
341
- const allParams = plan.hasBody ? (params ? `${params}, payload: any` : 'payload: any') : params;
1113
+ function renderVoidMethod(
1114
+ lines: string[],
1115
+ op: Operation,
1116
+ plan: OperationPlan,
1117
+ method: string,
1118
+ pathStr: string,
1119
+ ctx: EmitterContext,
1120
+ specEnumNames?: Set<string>,
1121
+ ): void {
1122
+ const params = buildPathParams(op, specEnumNames);
1123
+ const hasQuery = op.queryParams.length > 0 && !plan.hasBody;
1124
+ const optionsType = hasQuery ? toPascalCase(method) + 'Options' : null;
1125
+
1126
+ let bodyParam = '';
1127
+ let bodyExpr = 'payload';
1128
+ if (plan.hasBody) {
1129
+ const bodyInfo = extractRequestBodyType(op, ctx);
1130
+ if (bodyInfo?.kind === 'model') {
1131
+ const requestType = resolveInterfaceName(bodyInfo.name, ctx);
1132
+ bodyParam = `payload: ${requestType}`;
1133
+ bodyExpr = `serialize${requestType}(payload)`;
1134
+ } else if (bodyInfo?.kind === 'union') {
1135
+ bodyParam = `payload: ${bodyInfo.typeStr}`;
1136
+ if (bodyInfo.discriminator) {
1137
+ bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
1138
+ } else {
1139
+ bodyExpr = renderNonDiscriminatedUnionBodySerializer(bodyInfo.modelNames, ctx);
1140
+ }
1141
+ } else {
1142
+ bodyParam = 'payload: Record<string, unknown>';
1143
+ bodyExpr = 'payload';
1144
+ }
1145
+ }
1146
+
1147
+ const paramParts: string[] = [];
1148
+ if (params) paramParts.push(params);
1149
+ if (bodyParam) paramParts.push(bodyParam);
1150
+ if (optionsType) paramParts.push(`options?: ${optionsType}`);
1151
+ const allParams = paramParts.join(', ');
342
1152
 
343
1153
  lines.push(` async ${method}(${allParams}): Promise<void> {`);
344
1154
  if (plan.hasBody) {
345
- lines.push(` await this.workos.${op.httpMethod}(${pathStr}, payload);`);
1155
+ lines.push(` await this.workos.${op.httpMethod}(${pathStr}, ${bodyExpr});`);
1156
+ } 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(' });');
1161
+ } else if (httpMethodNeedsBody(op.httpMethod)) {
1162
+ lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {});`);
346
1163
  } else {
347
1164
  lines.push(` await this.workos.${op.httpMethod}(${pathStr});`);
348
1165
  }
349
1166
  lines.push(' }');
350
1167
  }
351
1168
 
1169
+ /**
1170
+ * Generate an inline query serialization expression that maps camelCase option
1171
+ * keys to their snake_case wire equivalents. When all keys already match
1172
+ * (camel === snake), returns 'options' as-is for brevity.
1173
+ */
1174
+ function renderQueryExpr(queryParams: { name: string; required: boolean }[]): string {
1175
+ // Check if any key actually needs conversion
1176
+ const needsConversion = queryParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
1177
+ if (!needsConversion) return 'options';
1178
+
1179
+ const parts: string[] = [];
1180
+ for (const param of queryParams) {
1181
+ const camel = fieldName(param.name);
1182
+ const snake = wireFieldName(param.name);
1183
+ if (param.required) {
1184
+ parts.push(`${snake}: options.${camel}`);
1185
+ } else {
1186
+ parts.push(`...(options.${camel} !== undefined && { ${snake}: options.${camel} })`);
1187
+ }
1188
+ }
1189
+ return `options ? { ${parts.join(', ')} } : undefined`;
1190
+ }
1191
+
352
1192
  function buildPathStr(op: Operation): string {
353
1193
  const interpolated = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
354
1194
  return interpolated.includes('${') ? `\`${interpolated}\`` : `'${op.path}'`;
355
1195
  }
356
1196
 
357
- function buildPathParams(op: Operation): string {
358
- return op.pathParams.map((p) => `${fieldName(p.name)}: ${mapTypeRef(p.type)}`).join(', ');
1197
+ function buildPathParams(op: Operation, specEnumNames?: Set<string>): string {
1198
+ // Start with declared path params
1199
+ const declaredNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
1200
+ const params = op.pathParams.map((p) => {
1201
+ const type = specEnumNames ? mapParamType(p.type, specEnumNames) : mapTypeRef(p.type);
1202
+ return `${fieldName(p.name)}: ${type}`;
1203
+ });
1204
+
1205
+ // Detect path template variables not in declared pathParams and add them as string params.
1206
+ // This handles cases where the spec path has {param} but pathParams is incomplete.
1207
+ const templateVars = [...op.path.matchAll(/\{(\w+)\}/g)].map(([, name]) => fieldName(name));
1208
+ for (const varName of templateVars) {
1209
+ if (!declaredNames.has(varName)) {
1210
+ params.push(`${varName}: string`);
1211
+ }
1212
+ }
1213
+
1214
+ return params.join(', ');
359
1215
  }
360
1216
 
361
- function extractRequestBodyModelName(op: Operation): string | null {
362
- if (!op.requestBody) return null;
363
- if (op.requestBody.kind === 'model') return op.requestBody.name;
1217
+ /**
1218
+ * Walk a parameter's type tree and collect enum/model names for imports.
1219
+ * Handles arrays and nullable wrappers that may contain nested enums/models.
1220
+ */
1221
+ function collectParamTypeRefs(type: TypeRef, enums: Set<string>, models: Set<string>): void {
1222
+ switch (type.kind) {
1223
+ case 'enum':
1224
+ enums.add(type.name);
1225
+ break;
1226
+ case 'model':
1227
+ models.add(type.name);
1228
+ break;
1229
+ case 'array':
1230
+ collectParamTypeRefs(type.items, enums, models);
1231
+ break;
1232
+ case 'nullable':
1233
+ collectParamTypeRefs(type.inner, enums, models);
1234
+ break;
1235
+ }
1236
+ }
1237
+
1238
+ /**
1239
+ * Extract request body type info, supporting both single models and union types.
1240
+ * Returns structured info so callers can handle imports and serialization appropriately.
1241
+ */
1242
+ /**
1243
+ * Generate an IIFE expression that dispatches to the correct serializer for a
1244
+ * discriminated union request body. Switches on the camelCase discriminator
1245
+ * property of the domain object and calls the appropriate serialize function
1246
+ * for each mapped model variant.
1247
+ */
1248
+ function renderUnionBodySerializer(
1249
+ disc: { property: string; mapping: Record<string, string> },
1250
+ ctx: EmitterContext,
1251
+ ): string {
1252
+ const prop = fieldName(disc.property);
1253
+ const cases: string[] = [];
1254
+ for (const [value, modelName] of Object.entries(disc.mapping)) {
1255
+ const resolved = resolveInterfaceName(modelName, ctx);
1256
+ cases.push(`case '${value}': return serialize${resolved}(payload as any)`);
1257
+ }
1258
+ return `(() => { switch ((payload as any).${prop}) { ${cases.join('; ')}; default: return payload } })()`;
1259
+ }
1260
+
1261
+ /**
1262
+ * Generate an IIFE expression that dispatches to the correct serializer for a
1263
+ * non-discriminated union request body. Inspects model fields to find a
1264
+ * required field unique to each variant and uses `'field' in payload` guards.
1265
+ * Falls back to `payload` only when no variant can be distinguished.
1266
+ */
1267
+ function renderNonDiscriminatedUnionBodySerializer(modelNames: string[], ctx: EmitterContext): string {
1268
+ const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
1269
+
1270
+ // Try to detect an implicit discriminator: a required field present in all
1271
+ // variants whose type is `kind: 'literal'` with a distinct value per variant.
1272
+ // This covers oneOf unions where each variant has e.g. `grant_type: 'authorization_code'`.
1273
+ const implicitDisc = detectImplicitDiscriminator(modelNames, modelMap);
1274
+ if (implicitDisc) {
1275
+ return renderUnionBodySerializer(implicitDisc, ctx);
1276
+ }
1277
+
1278
+ // Collect required field names per model (using camelCase domain names).
1279
+ const requiredFieldsByModel = new Map<string, Set<string>>();
1280
+ for (const name of modelNames) {
1281
+ const model = modelMap.get(name);
1282
+ if (!model) return 'payload';
1283
+ requiredFieldsByModel.set(name, new Set(model.fields.filter((f) => f.required).map((f) => fieldName(f.name))));
1284
+ }
1285
+
1286
+ // For each model, find a required field that no other model has.
1287
+ const guards: Array<{ modelName: string; field: string }> = [];
1288
+ let fallbackModel: string | undefined;
1289
+
1290
+ for (const name of modelNames) {
1291
+ const myFields = requiredFieldsByModel.get(name)!;
1292
+ let uniqueField: string | undefined;
1293
+ for (const field of myFields) {
1294
+ const isUnique = modelNames.every((other) => other === name || !requiredFieldsByModel.get(other)?.has(field));
1295
+ if (isUnique) {
1296
+ uniqueField = field;
1297
+ break;
1298
+ }
1299
+ }
1300
+ if (uniqueField) {
1301
+ guards.push({ modelName: name, field: uniqueField });
1302
+ } else if (!fallbackModel) {
1303
+ fallbackModel = name;
1304
+ } else {
1305
+ // Multiple models with no unique field — can't dispatch
1306
+ return 'payload';
1307
+ }
1308
+ }
1309
+
1310
+ if (guards.length === 0) return 'payload';
1311
+
1312
+ const parts: string[] = [];
1313
+ for (const { modelName, field } of guards) {
1314
+ const resolved = resolveInterfaceName(modelName, ctx);
1315
+ parts.push(`if ('${field}' in payload) return serialize${resolved}(payload as any)`);
1316
+ }
1317
+ if (fallbackModel) {
1318
+ const resolved = resolveInterfaceName(fallbackModel, ctx);
1319
+ parts.push(`return serialize${resolved}(payload as any)`);
1320
+ } else {
1321
+ parts.push('return payload');
1322
+ }
1323
+
1324
+ return `(() => { ${parts.join('; ')} })()`;
1325
+ }
1326
+
1327
+ /**
1328
+ * Detect an implicit discriminator from literal-typed fields.
1329
+ * Returns a discriminator descriptor if all variants share a required field
1330
+ * whose type is `kind: 'literal'` with a distinct value per variant.
1331
+ */
1332
+ function detectImplicitDiscriminator(
1333
+ modelNames: string[],
1334
+ modelMap: Map<string, Model>,
1335
+ ): { property: string; mapping: Record<string, string> } | null {
1336
+ if (modelNames.length < 2) return null;
1337
+
1338
+ const firstModel = modelMap.get(modelNames[0]);
1339
+ if (!firstModel) return null;
1340
+
1341
+ // Candidate fields: required fields with literal type in the first model.
1342
+ const candidates = firstModel.fields.filter((f) => f.required && f.type.kind === 'literal');
1343
+
1344
+ for (const candidate of candidates) {
1345
+ const mapping: Record<string, string> = {};
1346
+ const values = new Set<string | number | boolean | null>();
1347
+ let valid = true;
1348
+
1349
+ for (const name of modelNames) {
1350
+ const model = modelMap.get(name);
1351
+ if (!model) {
1352
+ valid = false;
1353
+ break;
1354
+ }
1355
+ const field = model.fields.find((f) => f.name === candidate.name);
1356
+ if (!field || !field.required || field.type.kind !== 'literal') {
1357
+ valid = false;
1358
+ break;
1359
+ }
1360
+ const val = field.type.value;
1361
+ if (values.has(val)) {
1362
+ valid = false;
1363
+ break;
1364
+ } // duplicate value
1365
+ values.add(val);
1366
+ mapping[String(val)] = name;
1367
+ }
1368
+
1369
+ if (valid && Object.keys(mapping).length === modelNames.length) {
1370
+ return { property: candidate.name, mapping };
1371
+ }
1372
+ }
1373
+
364
1374
  return null;
365
1375
  }
366
1376
 
367
- const STATUS_TO_EXCEPTION: Record<number, string> = {
368
- 400: 'BadRequestException',
369
- 401: 'UnauthorizedException',
370
- 404: 'NotFoundException',
371
- 409: 'ConflictException',
372
- 422: 'UnprocessableEntityException',
373
- 429: 'RateLimitExceededException',
374
- };
1377
+ /** Return type for extractRequestBodyType when the body is a union. */
1378
+ interface UnionBodyInfo {
1379
+ kind: 'union';
1380
+ typeStr: string;
1381
+ modelNames: string[];
1382
+ discriminator?: { property: string; mapping: Record<string, string> };
1383
+ }
375
1384
 
376
- function statusToExceptionName(statusCode: number): string | null {
377
- if (STATUS_TO_EXCEPTION[statusCode]) return STATUS_TO_EXCEPTION[statusCode];
378
- if (statusCode >= 500) return 'GenericServerException';
1385
+ function extractRequestBodyType(
1386
+ op: Operation,
1387
+ ctx: EmitterContext,
1388
+ ): { kind: 'model'; name: string } | UnionBodyInfo | null {
1389
+ if (!op.requestBody) return null;
1390
+ if (op.requestBody.kind === 'model') return { kind: 'model', name: op.requestBody.name };
1391
+ if (op.requestBody.kind === 'union') {
1392
+ const modelNames: string[] = [];
1393
+ for (const variant of op.requestBody.variants) {
1394
+ if (variant.kind === 'model') modelNames.push(variant.name);
1395
+ }
1396
+ if (modelNames.length > 0) {
1397
+ const typeStr = modelNames.map((n) => resolveInterfaceName(n, ctx)).join(' | ');
1398
+ return {
1399
+ kind: 'union',
1400
+ typeStr,
1401
+ modelNames,
1402
+ discriminator: op.requestBody.discriminator,
1403
+ };
1404
+ }
1405
+ }
379
1406
  return null;
380
1407
  }
1408
+
1409
+ /**
1410
+ * Map a parameter type to a TypeScript type string, handling inline enums
1411
+ * that don't have corresponding global enum definitions. These would
1412
+ * otherwise emit bare names like `Type` or `Action` that are never imported.
1413
+ *
1414
+ * Recursively handles container types (arrays, nullable) so that inline
1415
+ * enums nested inside e.g. `array<enum>` are also inlined as string literal unions.
1416
+ */
1417
+ function mapParamType(type: TypeRef, specEnumNames: Set<string>): string {
1418
+ if (type.kind === 'enum' && !specEnumNames.has(type.name)) {
1419
+ // Inline enum with no generated file — render values as string literal union
1420
+ if (type.values && type.values.length > 0) {
1421
+ return type.values.map((v: string | number) => (typeof v === 'string' ? `'${v}'` : String(v))).join(' | ');
1422
+ }
1423
+ return 'string';
1424
+ }
1425
+ if (type.kind === 'array') {
1426
+ const inner = mapParamType(type.items, specEnumNames);
1427
+ // Parenthesize union types when used as array element type
1428
+ return inner.includes(' | ') ? `(${inner})[]` : `${inner}[]`;
1429
+ }
1430
+ if (type.kind === 'nullable') {
1431
+ return `${mapParamType(type.inner, specEnumNames)} | null`;
1432
+ }
1433
+ return mapTypeRef(type);
1434
+ }