@workos/oagen-emitters 0.0.1 → 0.2.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 (41) 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/.prettierignore +1 -0
  6. package/.release-please-manifest.json +3 -0
  7. package/.vscode/settings.json +3 -0
  8. package/CHANGELOG.md +54 -0
  9. package/README.md +2 -2
  10. package/dist/index.d.mts +7 -0
  11. package/dist/index.d.mts.map +1 -0
  12. package/dist/index.mjs +3522 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/package.json +14 -18
  15. package/release-please-config.json +11 -0
  16. package/src/node/client.ts +437 -204
  17. package/src/node/common.ts +74 -4
  18. package/src/node/config.ts +1 -0
  19. package/src/node/enums.ts +50 -6
  20. package/src/node/errors.ts +78 -3
  21. package/src/node/fixtures.ts +84 -15
  22. package/src/node/index.ts +2 -2
  23. package/src/node/manifest.ts +4 -2
  24. package/src/node/models.ts +195 -79
  25. package/src/node/naming.ts +16 -1
  26. package/src/node/resources.ts +721 -106
  27. package/src/node/serializers.ts +510 -52
  28. package/src/node/tests.ts +621 -105
  29. package/src/node/type-map.ts +89 -11
  30. package/src/node/utils.ts +377 -114
  31. package/test/node/client.test.ts +979 -15
  32. package/test/node/enums.test.ts +0 -1
  33. package/test/node/errors.test.ts +4 -21
  34. package/test/node/models.test.ts +409 -2
  35. package/test/node/naming.test.ts +0 -3
  36. package/test/node/resources.test.ts +964 -7
  37. package/test/node/serializers.test.ts +212 -3
  38. package/tsconfig.json +2 -3
  39. package/{tsup.config.ts → tsdown.config.ts} +1 -1
  40. package/dist/index.d.ts +0 -5
  41. package/dist/index.js +0 -2158
@@ -1,26 +1,123 @@
1
- import type { Service, Operation, EmitterContext, GeneratedFile } 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';
2
4
  import { planOperation, toPascalCase } 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
11
  serviceDirName,
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 { docComment, createServiceDirResolver, isServiceCoveredByExisting, uncoveredOperations } from './utils.js';
18
+ import { assignEnumsToServices } from './enums.js';
19
+ import { unwrapListModel } from './fixtures.js';
20
+
21
+ /**
22
+ * Check whether the baseline (hand-written) class has a constructor compatible
23
+ * with the generated pattern `constructor(private readonly workos: WorkOS)`.
24
+ * Returns true when no baseline exists (fresh generation) or when compatible.
25
+ */
26
+ export function hasCompatibleConstructor(className: string, ctx: EmitterContext): boolean {
27
+ const baselineClass = ctx.apiSurface?.classes?.[className];
28
+ if (!baselineClass) return true; // No baseline — fresh generation
29
+ const params = baselineClass.constructorParams;
30
+ if (!params || params.length === 0) return true; // No-arg constructor is compatible
31
+ // Compatible if there is a single `workos` param whose type contains "WorkOS"
32
+ return params.some((p) => p.name === 'workos' && p.type.includes('WorkOS'));
33
+ }
34
+
35
+ /**
36
+ * Resolve the resource class name for a service, accounting for constructor
37
+ * compatibility with the baseline class.
38
+ *
39
+ * When the overlay-resolved class has an incompatible constructor (e.g., a
40
+ * hand-written `Webhooks` class that takes `CryptoProvider` instead of `WorkOS`),
41
+ * falls back to the IR name (`toPascalCase(service.name)`). If the IR name
42
+ * collides with the overlay name, appends an `Endpoints` suffix.
43
+ */
44
+ export function resolveResourceClassName(service: Service, ctx: EmitterContext): string {
45
+ const overlayName = resolveServiceName(service, ctx);
46
+ if (hasCompatibleConstructor(overlayName, ctx)) {
47
+ return overlayName;
48
+ }
49
+ // Incompatible constructor — fall back to IR name
50
+ const irName = toPascalCase(service.name);
51
+ if (irName === overlayName) {
52
+ return irName + 'Endpoints';
53
+ }
54
+ return irName;
55
+ }
56
+
57
+ /** Standard pagination query params handled by PaginationOptions — not imported individually. */
58
+ const PAGINATION_PARAM_NAMES = new Set(['limit', 'before', 'after', 'order']);
59
+
60
+ /** Map HTTP status codes to their corresponding exception class names for @throws docs. */
61
+ const STATUS_TO_EXCEPTION_NAME: Record<number, string> = {
62
+ 400: 'BadRequestException',
63
+ 401: 'UnauthorizedException',
64
+ 404: 'NotFoundException',
65
+ 409: 'ConflictException',
66
+ 422: 'UnprocessableEntityException',
67
+ 429: 'RateLimitExceededException',
68
+ };
69
+
70
+ /**
71
+ * Compute the options interface name for a paginated method.
72
+ * When the method name is simply "list", prefix with the service name to avoid
73
+ * naming collisions at barrel-export level (e.g. "ConnectionsListOptions"
74
+ * instead of the generic "ListOptions").
75
+ */
76
+ function paginatedOptionsName(method: string, resolvedServiceName: string): string {
77
+ if (method === 'list') {
78
+ return `${toPascalCase(resolvedServiceName)}ListOptions`;
79
+ }
80
+ return toPascalCase(method) + 'Options';
81
+ }
82
+
83
+ /** HTTP methods that require a body argument even when the spec has no request body. */
84
+ function httpMethodNeedsBody(method: string): boolean {
85
+ return method === 'post' || method === 'put' || method === 'patch';
86
+ }
16
87
 
17
88
  export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
18
89
  if (services.length === 0) return [];
19
- return services.map((service) => generateResourceClass(service, ctx));
90
+ const files: GeneratedFile[] = [];
91
+
92
+ for (const service of services) {
93
+ if (isServiceCoveredByExisting(service, ctx)) {
94
+ // Fully covered — skip entirely
95
+ continue;
96
+ }
97
+
98
+ // Check for partial coverage: some operations covered, some not.
99
+ // Generate methods only for uncovered operations.
100
+ const ops = uncoveredOperations(service, ctx);
101
+ if (ops.length === 0) continue;
102
+
103
+ if (ops.length < service.operations.length) {
104
+ // Partial coverage: create a service with only uncovered operations.
105
+ // Remove skipIfExists so the merger can add these new methods to the
106
+ // existing class file (otherwise uncovered operations are silently lost).
107
+ const partialService = { ...service, operations: ops };
108
+ const file = generateResourceClass(partialService, ctx);
109
+ delete file.skipIfExists;
110
+ files.push(file);
111
+ } else {
112
+ files.push(generateResourceClass(service, ctx));
113
+ }
114
+ }
115
+
116
+ return files;
20
117
  }
21
118
 
22
119
  function generateResourceClass(service: Service, ctx: EmitterContext): GeneratedFile {
23
- const resolvedName = resolveServiceName(service, ctx);
120
+ const resolvedName = resolveResourceClassName(service, ctx);
24
121
  const serviceDir = serviceDirName(resolvedName);
25
122
  const serviceClass = resolvedName;
26
123
  const resourcePath = `src/${serviceDir}/${fileName(resolvedName)}.ts`;
@@ -31,20 +128,89 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
31
128
  method: resolveMethodName(op, service, ctx),
32
129
  }));
33
130
 
131
+ // Sort plans to match the existing file's method order.
132
+ // When the merger integrates generated content with existing files, its
133
+ // URL-fingerprint fallback (pass 2) matches by position among methods that
134
+ // share the same endpoint path. If the spec lists POST before GET for a
135
+ // path (common in OpenAPI) but the existing class has them in a different
136
+ // order, JSDoc comments get attached to the wrong methods (list↔create,
137
+ // add↔set swaps). Sorting by the overlay's method order ensures the
138
+ // generated output matches the existing file's method order.
139
+ if (ctx.overlayLookup?.methodByOperation) {
140
+ const methodOrder = new Map<string, number>();
141
+ let pos = 0;
142
+ for (const [, info] of ctx.overlayLookup.methodByOperation) {
143
+ if (!methodOrder.has(info.methodName)) {
144
+ methodOrder.set(info.methodName, pos++);
145
+ }
146
+ }
147
+ if (methodOrder.size > 0) {
148
+ plans.sort((a, b) => {
149
+ const aPos = methodOrder.get(a.method) ?? Number.MAX_SAFE_INTEGER;
150
+ const bPos = methodOrder.get(b.method) ?? Number.MAX_SAFE_INTEGER;
151
+ return aPos - bPos;
152
+ });
153
+ }
154
+ }
155
+
34
156
  const hasPaginated = plans.some((p) => p.plan.isPaginated);
157
+ const modelMap = new Map(ctx.spec.models.map((m) => [m.name, m]));
35
158
 
36
- // Collect models for imports
159
+ // Collect models for imports — only include models that are actually used
160
+ // in method signatures (not all union variants from the spec)
37
161
  const responseModels = new Set<string>();
38
162
  const requestModels = new Set<string>();
163
+ const paramEnums = new Set<string>();
164
+ const paramModels = new Set<string>();
39
165
  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);
166
+ if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
167
+ // For paginated operations, import the item type (e.g., Connection)
168
+ // rather than the list wrapper type (e.g., ConnectionList).
169
+ // fetchAndDeserialize handles the list envelope internally.
170
+ // The IR's itemType may point to a list wrapper model — unwrap to the actual item.
171
+ let itemName = op.pagination.itemType.name;
172
+ const itemModel = modelMap.get(itemName);
173
+ if (itemModel) {
174
+ const unwrapped = unwrapListModel(itemModel, modelMap);
175
+ if (unwrapped) {
176
+ itemName = unwrapped.name;
177
+ }
178
+ }
179
+ responseModels.add(itemName);
180
+ } else if (plan.responseModelName) {
181
+ responseModels.add(plan.responseModelName);
182
+ }
183
+ // Import request body model(s) — handles both single models and union variants.
184
+ const bodyInfo = extractRequestBodyType(op, ctx);
185
+ if (bodyInfo?.kind === 'model') {
186
+ requestModels.add(bodyInfo.name);
187
+ } else if (bodyInfo?.kind === 'union') {
188
+ if (bodyInfo.discriminator) {
189
+ // Discriminated union: import variant models with serializers so we can
190
+ // dispatch to the correct serializer at runtime based on the discriminator.
191
+ for (const name of bodyInfo.modelNames) {
192
+ requestModels.add(name);
193
+ }
194
+ } else {
195
+ // Non-discriminated union: import variant models as domain types only.
196
+ // Without a discriminator we can't statically dispatch serialization,
197
+ // so the payload is passed through as-is.
198
+ for (const name of bodyInfo.modelNames) {
199
+ paramModels.add(name);
200
+ }
44
201
  }
45
202
  }
203
+ // Collect types referenced in query and path parameters.
204
+ // For paginated operations, skip standard pagination params (limit, before, after, order)
205
+ // since they're handled by PaginationOptions and don't need explicit imports.
206
+ const queryParams = plan.isPaginated
207
+ ? op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name))
208
+ : op.queryParams;
209
+ for (const param of [...queryParams, ...op.pathParams]) {
210
+ collectParamTypeRefs(param.type, paramEnums, paramModels);
211
+ }
46
212
  }
47
- const allModels = new Set([...responseModels, ...requestModels]);
213
+ const allModels = new Set([...responseModels, ...requestModels, ...paramModels]);
48
214
 
49
215
  const lines: string[] = [];
50
216
 
@@ -52,63 +218,126 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
52
218
  lines.push("import type { WorkOS } from '../workos';");
53
219
  if (hasPaginated) {
54
220
  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';");
221
+ lines.push("import type { AutoPaginatable } from '../common/utils/pagination';");
222
+ lines.push("import { createPaginatedList } from '../common/utils/fetch-and-deserialize';");
57
223
  }
58
224
 
59
- // Check if any operation is an idempotent POST
225
+ // Check if any operation needs PostOptions (idempotent POST or custom encoding)
60
226
  const hasIdempotentPost = plans.some((p) => p.plan.isIdempotentPost);
61
- if (hasIdempotentPost) {
227
+ const hasCustomEncoding = plans.some(
228
+ (p) => p.op.requestBodyEncoding && p.op.requestBodyEncoding !== 'json' && p.plan.hasBody,
229
+ );
230
+ if (hasIdempotentPost || hasCustomEncoding) {
62
231
  lines.push("import type { PostOptions } from '../common/interfaces/post-options.interface';");
63
232
  }
64
233
 
65
234
  // 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';
235
+ const { modelToService, resolveDir } = createServiceDirResolver(ctx.spec.models, ctx.spec.services, ctx);
70
236
 
237
+ // Wire (Response) types are only needed for models used as response types in method signatures.
238
+ // Request models and param models only need the domain type.
239
+ const usedWireTypes = new Set<string>();
240
+ for (const name of responseModels) {
241
+ usedWireTypes.add(resolveInterfaceName(name, ctx));
242
+ }
243
+
244
+ // Track imported resolved names to prevent duplicate type name collisions
245
+ const importedTypeNames = new Set<string>();
71
246
  for (const name of allModels) {
72
247
  const resolved = resolveInterfaceName(name, ctx);
248
+ if (importedTypeNames.has(resolved)) continue; // Skip duplicate resolved names
249
+ importedTypeNames.add(resolved);
73
250
  const modelDir = modelToService.get(name);
74
251
  const modelServiceDir = resolveDir(modelDir);
75
252
  const relPath =
76
253
  modelServiceDir === serviceDir
77
254
  ? `./interfaces/${fileName(name)}.interface`
78
255
  : `../${modelServiceDir}/interfaces/${fileName(name)}.interface`;
79
- lines.push(`import type { ${resolved}, ${wireInterfaceName(resolved)} } from '${relPath}';`);
256
+ if (usedWireTypes.has(resolved)) {
257
+ lines.push(`import type { ${resolved}, ${wireInterfaceName(resolved)} } from '${relPath}';`);
258
+ } else {
259
+ lines.push(`import type { ${resolved} } from '${relPath}';`);
260
+ }
80
261
  }
81
262
 
263
+ // Collect serializer imports by module path so we can merge deserialize and
264
+ // serialize imports from the same module into a single import statement.
265
+ const serializerImportsByPath = new Map<string, string[]>();
266
+
267
+ const importedDeserializers = new Set<string>();
82
268
  for (const name of responseModels) {
83
269
  const resolved = resolveInterfaceName(name, ctx);
270
+ if (importedDeserializers.has(resolved)) continue;
271
+ importedDeserializers.add(resolved);
84
272
  const modelDir = modelToService.get(name);
85
273
  const modelServiceDir = resolveDir(modelDir);
86
274
  const relPath =
87
275
  modelServiceDir === serviceDir
88
276
  ? `./serializers/${fileName(name)}.serializer`
89
277
  : `../${modelServiceDir}/serializers/${fileName(name)}.serializer`;
90
- lines.push(`import { deserialize${resolved} } from '${relPath}';`);
278
+ const existing = serializerImportsByPath.get(relPath) ?? [];
279
+ existing.push(`deserialize${resolved}`);
280
+ serializerImportsByPath.set(relPath, existing);
91
281
  }
92
282
 
283
+ const importedSerializers = new Set<string>();
93
284
  for (const name of requestModels) {
94
285
  const resolved = resolveInterfaceName(name, ctx);
286
+ if (importedSerializers.has(resolved)) continue;
287
+ importedSerializers.add(resolved);
95
288
  const modelDir = modelToService.get(name);
96
289
  const modelServiceDir = resolveDir(modelDir);
97
290
  const relPath =
98
291
  modelServiceDir === serviceDir
99
292
  ? `./serializers/${fileName(name)}.serializer`
100
293
  : `../${modelServiceDir}/serializers/${fileName(name)}.serializer`;
101
- lines.push(`import { serialize${resolved} } from '${relPath}';`);
294
+ const existing = serializerImportsByPath.get(relPath) ?? [];
295
+ existing.push(`serialize${resolved}`);
296
+ serializerImportsByPath.set(relPath, existing);
297
+ }
298
+
299
+ // Emit merged serializer imports
300
+ for (const [relPath, specifiers] of serializerImportsByPath) {
301
+ lines.push(`import { ${specifiers.join(', ')} } from '${relPath}';`);
302
+ }
303
+
304
+ // Build a set of global enum names — used to distinguish named enums (with files)
305
+ // from inline enums (no file, must be rendered as string literal unions).
306
+ const specEnumNames = new Set(ctx.spec.enums.map((e) => e.name));
307
+
308
+ // Import enum types referenced in query/path parameters.
309
+ // Only import enums that actually exist in the spec's global enums list —
310
+ // inline string unions may have kind 'enum' but no corresponding file.
311
+ if (paramEnums.size > 0) {
312
+ const enumToService = assignEnumsToServices(ctx.spec.enums, ctx.spec.services);
313
+ for (const name of paramEnums) {
314
+ if (allModels.has(name)) continue; // Already imported as a model
315
+ if (!specEnumNames.has(name)) continue; // No file generated for this enum
316
+ const enumDir = enumToService.get(name);
317
+ const enumServiceDir = resolveDir(enumDir);
318
+ const relPath =
319
+ enumServiceDir === serviceDir
320
+ ? `./interfaces/${fileName(name)}.interface`
321
+ : `../${enumServiceDir}/interfaces/${fileName(name)}.interface`;
322
+ lines.push(`import type { ${name} } from '${relPath}';`);
323
+ }
102
324
  }
103
325
 
104
326
  lines.push('');
105
327
 
106
- // List options interfaces for paginated operations with extra query params
328
+ // Options interfaces for operations with query params.
329
+ // Paginated operations extend PaginationOptions; non-paginated operations get standalone interfaces.
107
330
  for (const { op, plan, method } of plans) {
108
331
  if (plan.isPaginated) {
109
- const extraParams = op.queryParams.filter((p) => !['limit', 'before', 'after', 'order'].includes(p.name));
332
+ const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
110
333
  if (extraParams.length > 0) {
111
- const optionsName = toPascalCase(method) + 'Options';
334
+ const optionsName = paginatedOptionsName(method, resolvedName);
335
+ // Always generate the options interface locally in the resource file.
336
+ // Previously we skipped generation when a baseline interface with a matching
337
+ // name existed, but the baseline interface may live in a different module
338
+ // (e.g., `user-management/` vs `user-management-users/`) and would not be
339
+ // available without an import. Generating locally is safe and avoids
340
+ // cross-module import resolution issues.
112
341
  lines.push(`export interface ${optionsName} extends PaginationOptions {`);
113
342
  for (const param of extraParams) {
114
343
  const opt = !param.required ? '?' : '';
@@ -118,11 +347,28 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
118
347
  if (param.deprecated) parts.push('@deprecated');
119
348
  lines.push(...docComment(parts.join('\n'), 2));
120
349
  }
121
- lines.push(` ${fieldName(param.name)}${opt}: ${mapTypeRef(param.type)};`);
350
+ lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
122
351
  }
123
352
  lines.push('}');
124
353
  lines.push('');
125
354
  }
355
+ } else if (!plan.isPaginated && !plan.hasBody && !plan.isDelete && op.queryParams.length > 0) {
356
+ // Non-paginated GET or void methods with query params get a typed options interface
357
+ // instead of falling back to Record<string, unknown>.
358
+ const optionsName = toPascalCase(method) + 'Options';
359
+ lines.push(`export interface ${optionsName} {`);
360
+ for (const param of op.queryParams) {
361
+ const opt = !param.required ? '?' : '';
362
+ if (param.description || param.deprecated) {
363
+ const parts: string[] = [];
364
+ if (param.description) parts.push(param.description);
365
+ if (param.deprecated) parts.push('@deprecated');
366
+ lines.push(...docComment(parts.join('\n'), 2));
367
+ }
368
+ lines.push(` ${fieldName(param.name)}${opt}: ${mapParamType(param.type, specEnumNames)};`);
369
+ }
370
+ lines.push('}');
371
+ lines.push('');
126
372
  }
127
373
  }
128
374
 
@@ -135,7 +381,7 @@ function generateResourceClass(service: Service, ctx: EmitterContext): Generated
135
381
 
136
382
  for (const { op, plan, method } of plans) {
137
383
  lines.push('');
138
- lines.push(...renderMethod(op, plan, method, service, ctx));
384
+ lines.push(...renderMethod(op, plan, method, service, ctx, modelMap, specEnumNames));
139
385
  }
140
386
 
141
387
  lines.push('}');
@@ -149,29 +395,125 @@ function renderMethod(
149
395
  method: string,
150
396
  service: Service,
151
397
  ctx: EmitterContext,
398
+ modelMap: Map<string, Model>,
399
+ specEnumNames?: Set<string>,
152
400
  ): string[] {
153
401
  const lines: string[] = [];
154
402
  const responseModel = plan.responseModelName ? resolveInterfaceName(plan.responseModelName, ctx) : null;
155
403
 
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}'`;
404
+ const pathStr = buildPathStr(op);
405
+
406
+ // Build set of valid param names to filter @param tags.
407
+ // Prefer the overlay (existing method signature) if available;
408
+ // otherwise compute from what the render path will actually include.
409
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
410
+ const overlayMethod = ctx.overlayLookup?.methodByOperation?.get(httpKey);
411
+ let validParamNames: Set<string> | null = null;
412
+ if (overlayMethod) {
413
+ validParamNames = new Set(overlayMethod.params.map((p) => p.name));
414
+ } else {
415
+ // Compute actual params based on render path to avoid documenting params
416
+ // that won't appear in the method signature
417
+ const actualParams = new Set<string>();
418
+ for (const p of op.pathParams) actualParams.add(fieldName(p.name));
419
+ if (plan.hasBody) actualParams.add('payload');
420
+ if (plan.isPaginated) actualParams.add('options');
421
+ // renderGetMethod adds options when there are non-paginated query params
422
+ if (!plan.isPaginated && op.queryParams.length > 0 && !plan.isDelete && responseModel) {
423
+ actualParams.add('options');
424
+ }
425
+ validParamNames = actualParams;
426
+ }
160
427
 
161
428
  const docParts: string[] = [];
162
429
  if (op.description) docParts.push(op.description);
163
430
  for (const param of op.pathParams) {
431
+ const paramName = fieldName(param.name);
432
+ if (validParamNames && !validParamNames.has(paramName)) continue;
433
+ const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
164
434
  if (param.description) {
165
- docParts.push(`@param ${fieldName(param.name)} - ${param.description}`);
435
+ docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
436
+ } else if (param.deprecated) {
437
+ docParts.push(`@param ${paramName} - (deprecated)`);
166
438
  }
439
+ if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
440
+ if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
167
441
  }
168
- if (op.deprecated) docParts.push('@deprecated');
442
+ // Document query params for non-paginated operations
443
+ if (!plan.isPaginated) {
444
+ // Only document query params if the method will have an options parameter
445
+ if (validParamNames && (validParamNames.has('options') || overlayMethod)) {
446
+ for (const param of op.queryParams) {
447
+ const paramName = `options.${fieldName(param.name)}`;
448
+ if (validParamNames && !validParamNames.has('options') && !validParamNames.has(fieldName(param.name))) continue;
449
+ const deprecatedPrefix = param.deprecated ? '(deprecated) ' : '';
450
+ if (param.description) {
451
+ docParts.push(`@param ${paramName} - ${deprecatedPrefix}${param.description}`);
452
+ } else if (param.deprecated) {
453
+ docParts.push(`@param ${paramName} - (deprecated)`);
454
+ }
455
+ if (param.default !== undefined) docParts.push(`@default ${JSON.stringify(param.default)}`);
456
+ if (param.example !== undefined) docParts.push(`@example ${JSON.stringify(param.example)}`);
457
+ }
458
+ }
459
+ }
460
+ // Skip header and cookie params in JSDoc — they are not exposed in the method signature.
461
+ // The SDK handles headers and cookies internally, so documenting them would be misleading.
462
+ // Document payload parameter when there is a request body
463
+ if (plan.hasBody) {
464
+ const bodyInfo = extractRequestBodyType(op, ctx);
465
+ if (bodyInfo?.kind === 'model') {
466
+ const bodyModel = ctx.spec.models.find((m) => m.name === bodyInfo.name);
467
+ let payloadDesc: string;
468
+ if (bodyModel?.description) {
469
+ payloadDesc = `@param payload - ${bodyModel.description}`;
470
+ } else if (bodyModel) {
471
+ // When the model lacks a description, list its required fields to help
472
+ // callers understand what must be provided.
473
+ const requiredFieldNames = bodyModel.fields.filter((f) => f.required).map((f) => fieldName(f.name));
474
+ payloadDesc =
475
+ requiredFieldNames.length > 0
476
+ ? `@param payload - Object containing ${requiredFieldNames.join(', ')}.`
477
+ : '@param payload - The request body.';
478
+ } else {
479
+ payloadDesc = '@param payload - The request body.';
480
+ }
481
+ docParts.push(payloadDesc);
482
+ } else {
483
+ docParts.push('@param payload - The request body.');
484
+ }
485
+ }
486
+ // Document options parameter for paginated operations
487
+ if (plan.isPaginated) {
488
+ docParts.push('@param options - Pagination and filter options.');
489
+ } else if (op.queryParams.length > 0) {
490
+ docParts.push('@param options - Additional query options.');
491
+ }
492
+ // @returns for the primary response model (use item type for paginated operations).
493
+ // Unwrap list wrapper models to match the actual return type — the method returns
494
+ // AutoPaginatable<ItemType>, not the list wrapper.
495
+ if (plan.isPaginated && op.pagination?.itemType.kind === 'model') {
496
+ let itemRawName = op.pagination.itemType.name;
497
+ const pModel = modelMap.get(itemRawName);
498
+ if (pModel) {
499
+ const unwrapped = unwrapListModel(pModel, modelMap);
500
+ if (unwrapped) itemRawName = unwrapped.name;
501
+ }
502
+ const itemTypeName = resolveInterfaceName(itemRawName, ctx);
503
+ docParts.push(`@returns {AutoPaginatable<${itemTypeName}>}`);
504
+ } else if (responseModel) {
505
+ docParts.push(`@returns {${responseModel}}`);
506
+ } else {
507
+ docParts.push('@returns {void}');
508
+ }
509
+ // @throws for error responses
169
510
  for (const err of op.errors) {
170
- const exceptionName = statusToExceptionName(err.statusCode);
511
+ const exceptionName = STATUS_TO_EXCEPTION_NAME[err.statusCode];
171
512
  if (exceptionName) {
172
513
  docParts.push(`@throws {${exceptionName}} ${err.statusCode}`);
173
514
  }
174
515
  }
516
+ if (op.deprecated) docParts.push('@deprecated');
175
517
 
176
518
  if (docParts.length > 0) {
177
519
  // Flatten all parts, splitting multiline descriptions into individual lines
@@ -192,22 +534,59 @@ function renderMethod(
192
534
  }
193
535
  }
194
536
 
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.`,
537
+ const preDecisionCount = lines.length;
538
+
539
+ if (plan.isPaginated && op.pagination && op.httpMethod === 'get') {
540
+ // For paginated operations, use the item type from pagination metadata
541
+ // (e.g., Connection) rather than the list wrapper type (e.g., ConnectionList).
542
+ // Unwrap list wrapper models to get the actual item type name.
543
+ let paginatedItemRawName = op.pagination.itemType.kind === 'model' ? op.pagination.itemType.name : null;
544
+ if (paginatedItemRawName) {
545
+ const pModel = modelMap.get(paginatedItemRawName);
546
+ if (pModel) {
547
+ const unwrapped = unwrapListModel(pModel, modelMap);
548
+ if (unwrapped) {
549
+ paginatedItemRawName = unwrapped.name;
550
+ }
551
+ }
552
+ }
553
+ const paginatedItemType = paginatedItemRawName ? resolveInterfaceName(paginatedItemRawName, ctx) : responseModel;
554
+ if (paginatedItemType) {
555
+ const resolvedServiceNameForPaginated = resolveServiceName(service, ctx);
556
+ renderPaginatedMethod(
557
+ lines,
558
+ op,
559
+ plan,
560
+ method,
561
+ paginatedItemType,
562
+ pathStr,
563
+ resolvedServiceNameForPaginated,
564
+ specEnumNames,
199
565
  );
200
- return lines;
201
566
  }
202
- renderPaginatedMethod(lines, op, plan, method, responseModel);
567
+ } else if (plan.isPaginated && plan.hasBody && responseModel) {
568
+ // Non-GET paginated operation (e.g., PUT with list response) — treat as body method
569
+ renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx, specEnumNames);
570
+ } else if (plan.isDelete && plan.hasBody) {
571
+ renderDeleteWithBodyMethod(lines, op, plan, method, pathStr, ctx, specEnumNames);
203
572
  } else if (plan.isDelete) {
204
- renderDeleteMethod(lines, op, plan, method, pathStr);
573
+ renderDeleteMethod(lines, op, plan, method, pathStr, specEnumNames);
205
574
  } else if (plan.hasBody && responseModel) {
206
- renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx);
575
+ renderBodyMethod(lines, op, plan, method, responseModel, pathStr, ctx, specEnumNames);
207
576
  } else if (responseModel) {
208
- renderGetMethod(lines, op, plan, method, responseModel, pathStr);
577
+ renderGetMethod(lines, op, plan, method, responseModel, pathStr, specEnumNames);
209
578
  } else {
210
- renderVoidMethod(lines, op, plan, method, pathStr);
579
+ renderVoidMethod(lines, op, plan, method, pathStr, ctx, specEnumNames);
580
+ }
581
+
582
+ // Defensive: if no render function produced a method body, emit a stub
583
+ if (lines.length === preDecisionCount) {
584
+ const params = buildPathParams(op, specEnumNames);
585
+ lines.push(` async ${method}(${params}): Promise<void> {`);
586
+ lines.push(
587
+ ` await this.workos.${op.httpMethod}(${pathStr}${httpMethodNeedsBody(op.httpMethod) ? ', {}' : ''});`,
588
+ );
589
+ lines.push(' }');
211
590
  }
212
591
 
213
592
  return lines;
@@ -219,29 +598,20 @@ function renderPaginatedMethod(
219
598
  plan: OperationPlan,
220
599
  method: string,
221
600
  itemType: string,
601
+ pathStr: string,
602
+ resolvedServiceName: string,
603
+ specEnumNames?: Set<string>,
222
604
  ): 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';
605
+ const extraParams = op.queryParams.filter((p) => !PAGINATION_PARAM_NAMES.has(p.name));
606
+ const optionsType = extraParams.length > 0 ? paginatedOptionsName(method, resolvedServiceName) : 'PaginationOptions';
225
607
 
226
- const pathStr = buildPathStr(op);
608
+ const pathParams = buildPathParams(op, specEnumNames);
609
+ const allParams = pathParams ? `${pathParams}, options?: ${optionsType}` : `options?: ${optionsType}`;
227
610
 
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(' );');
611
+ lines.push(` async ${method}(${allParams}): Promise<AutoPaginatable<${itemType}, ${optionsType}>> {`);
612
+ lines.push(
613
+ ` return createPaginatedList<${wireInterfaceName(itemType)}, ${itemType}, ${optionsType}>(this.workos, ${pathStr}, deserialize${itemType}, options);`,
614
+ );
245
615
  lines.push(' }');
246
616
  }
247
617
 
@@ -251,13 +621,54 @@ function renderDeleteMethod(
251
621
  plan: OperationPlan,
252
622
  method: string,
253
623
  pathStr: string,
624
+ specEnumNames?: Set<string>,
254
625
  ): void {
255
- const params = buildPathParams(op);
626
+ const params = buildPathParams(op, specEnumNames);
256
627
  lines.push(` async ${method}(${params}): Promise<void> {`);
257
628
  lines.push(` await this.workos.delete(${pathStr});`);
258
629
  lines.push(' }');
259
630
  }
260
631
 
632
+ function renderDeleteWithBodyMethod(
633
+ lines: string[],
634
+ op: Operation,
635
+ plan: OperationPlan,
636
+ method: string,
637
+ pathStr: string,
638
+ ctx: EmitterContext,
639
+ specEnumNames?: Set<string>,
640
+ ): void {
641
+ const bodyInfo = extractRequestBodyType(op, ctx);
642
+ let requestType: string;
643
+ let bodyExpr: string;
644
+ if (bodyInfo?.kind === 'model') {
645
+ requestType = resolveInterfaceName(bodyInfo.name, ctx);
646
+ bodyExpr = `serialize${requestType}(payload)`;
647
+ } else if (bodyInfo?.kind === 'union') {
648
+ requestType = bodyInfo.typeStr;
649
+ if (bodyInfo.discriminator) {
650
+ bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
651
+ } else {
652
+ bodyExpr = 'payload';
653
+ }
654
+ } else {
655
+ requestType = 'Record<string, unknown>';
656
+ bodyExpr = 'payload';
657
+ }
658
+
659
+ const paramParts: string[] = [];
660
+ for (const param of op.pathParams) {
661
+ paramParts.push(
662
+ `${fieldName(param.name)}: ${specEnumNames ? mapParamType(param.type, specEnumNames) : mapTypeRef(param.type)}`,
663
+ );
664
+ }
665
+ paramParts.push(`payload: ${requestType}`);
666
+
667
+ lines.push(` async ${method}(${paramParts.join(', ')}): Promise<void> {`);
668
+ lines.push(` await this.workos.deleteWithBody(${pathStr}, ${bodyExpr});`);
669
+ lines.push(' }');
670
+ }
671
+
261
672
  function renderBodyMethod(
262
673
  lines: string[],
263
674
  op: Operation,
@@ -266,15 +677,36 @@ function renderBodyMethod(
266
677
  responseModel: string,
267
678
  pathStr: string,
268
679
  ctx: EmitterContext,
680
+ specEnumNames?: Set<string>,
269
681
  ): void {
270
- const requestBodyModel = extractRequestBodyModelName(op);
271
- const requestType = requestBodyModel ? resolveInterfaceName(requestBodyModel, ctx) : 'any';
682
+ const bodyInfo = extractRequestBodyType(op, ctx);
683
+ let requestType: string;
684
+ let bodyExpr: string;
685
+ if (bodyInfo?.kind === 'model') {
686
+ requestType = resolveInterfaceName(bodyInfo.name, ctx);
687
+ bodyExpr = `serialize${requestType}(payload)`;
688
+ } else if (bodyInfo?.kind === 'union') {
689
+ requestType = bodyInfo.typeStr;
690
+ if (bodyInfo.discriminator) {
691
+ // Discriminated union: dispatch to the correct serializer at runtime.
692
+ bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
693
+ } else {
694
+ // Non-discriminated union: cannot statically dispatch —
695
+ // pass the payload directly (caller provides the correct shape).
696
+ bodyExpr = 'payload';
697
+ }
698
+ } else {
699
+ requestType = 'Record<string, unknown>';
700
+ bodyExpr = 'payload';
701
+ }
272
702
 
273
703
  const paramParts: string[] = [];
274
704
 
275
705
  // Always pass path params as individual parameters (matches existing SDK pattern)
276
706
  for (const param of op.pathParams) {
277
- paramParts.push(`${fieldName(param.name)}: ${mapTypeRef(param.type)}`);
707
+ paramParts.push(
708
+ `${fieldName(param.name)}: ${specEnumNames ? mapParamType(param.type, specEnumNames) : mapTypeRef(param.type)}`,
709
+ );
278
710
  }
279
711
 
280
712
  paramParts.push(`payload: ${requestType}`);
@@ -284,20 +716,40 @@ function renderBodyMethod(
284
716
  }
285
717
 
286
718
  const paramsStr = paramParts.join(', ');
287
- const bodyExpr = requestBodyModel && requestType !== 'any' ? `serialize${requestType}(payload)` : 'payload';
719
+
720
+ // Fix 2: Pass encoding option when requestBodyEncoding is non-json
721
+ const encoding = op.requestBodyEncoding;
722
+ const encodingOption = encoding && encoding !== 'json' ? `, encoding: '${encoding}' as const` : '';
723
+ const hasCustomEncoding = encodingOption !== '';
288
724
 
289
725
  lines.push(` async ${method}(${paramsStr}): Promise<${responseModel}> {`);
290
726
  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(' );');
727
+ if (hasCustomEncoding) {
728
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
729
+ lines.push(` ${pathStr},`);
730
+ lines.push(` ${bodyExpr},`);
731
+ lines.push(` { ...requestOptions${encodingOption} },`);
732
+ lines.push(' );');
733
+ } else {
734
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
735
+ lines.push(` ${pathStr},`);
736
+ lines.push(` ${bodyExpr},`);
737
+ lines.push(' requestOptions,');
738
+ lines.push(' );');
739
+ }
296
740
  } else {
297
- lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
298
- lines.push(` ${pathStr},`);
299
- lines.push(` ${bodyExpr},`);
300
- lines.push(' );');
741
+ if (hasCustomEncoding) {
742
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
743
+ lines.push(` ${pathStr},`);
744
+ lines.push(` ${bodyExpr},`);
745
+ lines.push(` { ${encodingOption.slice(2)} },`);
746
+ lines.push(' );');
747
+ } else {
748
+ lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(`);
749
+ lines.push(` ${pathStr},`);
750
+ lines.push(` ${bodyExpr},`);
751
+ lines.push(' );');
752
+ }
301
753
  }
302
754
  lines.push(` return deserialize${responseModel}(data);`);
303
755
  lines.push(' }');
@@ -310,23 +762,27 @@ function renderGetMethod(
310
762
  method: string,
311
763
  responseModel: string,
312
764
  pathStr: string,
765
+ specEnumNames?: Set<string>,
313
766
  ): void {
314
- const params = buildPathParams(op);
767
+ const params = buildPathParams(op, specEnumNames);
315
768
  const hasQuery = op.queryParams.length > 0 && !plan.isPaginated;
769
+ const optionsType = hasQuery ? toPascalCase(method) + 'Options' : null;
316
770
 
317
- const allParams = hasQuery
318
- ? params
319
- ? `${params}, options?: Record<string, any>`
320
- : 'options?: Record<string, any>'
321
- : params;
771
+ const allParams = hasQuery ? (params ? `${params}, options?: ${optionsType}` : `options?: ${optionsType}`) : params;
322
772
 
323
773
  lines.push(` async ${method}(${allParams}): Promise<${responseModel}> {`);
324
774
  if (hasQuery) {
775
+ const queryExpr = renderQueryExpr(op.queryParams);
325
776
  lines.push(
326
777
  ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {`,
327
778
  );
328
- lines.push(' query: options,');
779
+ lines.push(` query: ${queryExpr},`);
329
780
  lines.push(' });');
781
+ } else if (httpMethodNeedsBody(op.httpMethod)) {
782
+ // PUT/PATCH/POST require a body argument even when the spec has no request body
783
+ lines.push(
784
+ ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr}, {});`,
785
+ );
330
786
  } else {
331
787
  lines.push(
332
788
  ` const { data } = await this.workos.${op.httpMethod}<${wireInterfaceName(responseModel)}>(${pathStr});`,
@@ -336,45 +792,204 @@ function renderGetMethod(
336
792
  lines.push(' }');
337
793
  }
338
794
 
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;
795
+ function renderVoidMethod(
796
+ lines: string[],
797
+ op: Operation,
798
+ plan: OperationPlan,
799
+ method: string,
800
+ pathStr: string,
801
+ ctx: EmitterContext,
802
+ specEnumNames?: Set<string>,
803
+ ): void {
804
+ const params = buildPathParams(op, specEnumNames);
805
+ const hasQuery = op.queryParams.length > 0 && !plan.hasBody;
806
+ const optionsType = hasQuery ? toPascalCase(method) + 'Options' : null;
807
+
808
+ let bodyParam = '';
809
+ let bodyExpr = 'payload';
810
+ if (plan.hasBody) {
811
+ const bodyInfo = extractRequestBodyType(op, ctx);
812
+ if (bodyInfo?.kind === 'model') {
813
+ const requestType = resolveInterfaceName(bodyInfo.name, ctx);
814
+ bodyParam = `payload: ${requestType}`;
815
+ bodyExpr = `serialize${requestType}(payload)`;
816
+ } else if (bodyInfo?.kind === 'union') {
817
+ bodyParam = `payload: ${bodyInfo.typeStr}`;
818
+ if (bodyInfo.discriminator) {
819
+ bodyExpr = renderUnionBodySerializer(bodyInfo.discriminator, ctx);
820
+ } else {
821
+ bodyExpr = 'payload';
822
+ }
823
+ } else {
824
+ bodyParam = 'payload: Record<string, unknown>';
825
+ bodyExpr = 'payload';
826
+ }
827
+ }
828
+
829
+ const paramParts: string[] = [];
830
+ if (params) paramParts.push(params);
831
+ if (bodyParam) paramParts.push(bodyParam);
832
+ if (optionsType) paramParts.push(`options?: ${optionsType}`);
833
+ const allParams = paramParts.join(', ');
342
834
 
343
835
  lines.push(` async ${method}(${allParams}): Promise<void> {`);
344
836
  if (plan.hasBody) {
345
- lines.push(` await this.workos.${op.httpMethod}(${pathStr}, payload);`);
837
+ lines.push(` await this.workos.${op.httpMethod}(${pathStr}, ${bodyExpr});`);
838
+ } else if (hasQuery) {
839
+ const queryExpr = renderQueryExpr(op.queryParams);
840
+ lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {`);
841
+ lines.push(` query: ${queryExpr},`);
842
+ lines.push(' });');
843
+ } else if (httpMethodNeedsBody(op.httpMethod)) {
844
+ lines.push(` await this.workos.${op.httpMethod}(${pathStr}, {});`);
346
845
  } else {
347
846
  lines.push(` await this.workos.${op.httpMethod}(${pathStr});`);
348
847
  }
349
848
  lines.push(' }');
350
849
  }
351
850
 
851
+ /**
852
+ * Generate an inline query serialization expression that maps camelCase option
853
+ * keys to their snake_case wire equivalents. When all keys already match
854
+ * (camel === snake), returns 'options' as-is for brevity.
855
+ */
856
+ function renderQueryExpr(queryParams: { name: string; required: boolean }[]): string {
857
+ // Check if any key actually needs conversion
858
+ const needsConversion = queryParams.some((p) => fieldName(p.name) !== wireFieldName(p.name));
859
+ if (!needsConversion) return 'options';
860
+
861
+ const parts: string[] = [];
862
+ for (const param of queryParams) {
863
+ const camel = fieldName(param.name);
864
+ const snake = wireFieldName(param.name);
865
+ if (param.required) {
866
+ parts.push(`${snake}: options.${camel}`);
867
+ } else {
868
+ parts.push(`...(options.${camel} !== undefined && { ${snake}: options.${camel} })`);
869
+ }
870
+ }
871
+ return `options ? { ${parts.join(', ')} } : undefined`;
872
+ }
873
+
352
874
  function buildPathStr(op: Operation): string {
353
875
  const interpolated = op.path.replace(/\{(\w+)\}/g, (_, p) => `\${${fieldName(p)}}`);
354
876
  return interpolated.includes('${') ? `\`${interpolated}\`` : `'${op.path}'`;
355
877
  }
356
878
 
357
- function buildPathParams(op: Operation): string {
358
- return op.pathParams.map((p) => `${fieldName(p.name)}: ${mapTypeRef(p.type)}`).join(', ');
879
+ function buildPathParams(op: Operation, specEnumNames?: Set<string>): string {
880
+ // Start with declared path params
881
+ const declaredNames = new Set(op.pathParams.map((p) => fieldName(p.name)));
882
+ const params = op.pathParams.map((p) => {
883
+ const type = specEnumNames ? mapParamType(p.type, specEnumNames) : mapTypeRef(p.type);
884
+ return `${fieldName(p.name)}: ${type}`;
885
+ });
886
+
887
+ // Detect path template variables not in declared pathParams and add them as string params.
888
+ // This handles cases where the spec path has {param} but pathParams is incomplete.
889
+ const templateVars = [...op.path.matchAll(/\{(\w+)\}/g)].map(([, name]) => fieldName(name));
890
+ for (const varName of templateVars) {
891
+ if (!declaredNames.has(varName)) {
892
+ params.push(`${varName}: string`);
893
+ }
894
+ }
895
+
896
+ return params.join(', ');
359
897
  }
360
898
 
361
- function extractRequestBodyModelName(op: Operation): string | null {
362
- if (!op.requestBody) return null;
363
- if (op.requestBody.kind === 'model') return op.requestBody.name;
364
- return null;
899
+ /**
900
+ * Walk a parameter's type tree and collect enum/model names for imports.
901
+ * Handles arrays and nullable wrappers that may contain nested enums/models.
902
+ */
903
+ function collectParamTypeRefs(type: TypeRef, enums: Set<string>, models: Set<string>): void {
904
+ switch (type.kind) {
905
+ case 'enum':
906
+ enums.add(type.name);
907
+ break;
908
+ case 'model':
909
+ models.add(type.name);
910
+ break;
911
+ case 'array':
912
+ collectParamTypeRefs(type.items, enums, models);
913
+ break;
914
+ case 'nullable':
915
+ collectParamTypeRefs(type.inner, enums, models);
916
+ break;
917
+ }
365
918
  }
366
919
 
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
- };
920
+ /**
921
+ * Extract request body type info, supporting both single models and union types.
922
+ * Returns structured info so callers can handle imports and serialization appropriately.
923
+ */
924
+ /**
925
+ * Generate an IIFE expression that dispatches to the correct serializer for a
926
+ * discriminated union request body. Switches on the camelCase discriminator
927
+ * property of the domain object and calls the appropriate serialize function
928
+ * for each mapped model variant.
929
+ */
930
+ function renderUnionBodySerializer(
931
+ disc: { property: string; mapping: Record<string, string> },
932
+ ctx: EmitterContext,
933
+ ): string {
934
+ const prop = fieldName(disc.property);
935
+ const cases: string[] = [];
936
+ for (const [value, modelName] of Object.entries(disc.mapping)) {
937
+ const resolved = resolveInterfaceName(modelName, ctx);
938
+ cases.push(`case '${value}': return serialize${resolved}(payload as any)`);
939
+ }
940
+ return `(() => { switch ((payload as any).${prop}) { ${cases.join('; ')}; default: return payload } })()`;
941
+ }
942
+
943
+ /** Return type for extractRequestBodyType when the body is a union. */
944
+ interface UnionBodyInfo {
945
+ kind: 'union';
946
+ typeStr: string;
947
+ modelNames: string[];
948
+ discriminator?: { property: string; mapping: Record<string, string> };
949
+ }
375
950
 
376
- function statusToExceptionName(statusCode: number): string | null {
377
- if (STATUS_TO_EXCEPTION[statusCode]) return STATUS_TO_EXCEPTION[statusCode];
378
- if (statusCode >= 500) return 'GenericServerException';
951
+ function extractRequestBodyType(
952
+ op: Operation,
953
+ ctx: EmitterContext,
954
+ ): { kind: 'model'; name: string } | UnionBodyInfo | null {
955
+ if (!op.requestBody) return null;
956
+ if (op.requestBody.kind === 'model') return { kind: 'model', name: op.requestBody.name };
957
+ if (op.requestBody.kind === 'union') {
958
+ const modelNames: string[] = [];
959
+ for (const variant of op.requestBody.variants) {
960
+ if (variant.kind === 'model') modelNames.push(variant.name);
961
+ }
962
+ if (modelNames.length > 0) {
963
+ const typeStr = modelNames.map((n) => resolveInterfaceName(n, ctx)).join(' | ');
964
+ return { kind: 'union', typeStr, modelNames, discriminator: op.requestBody.discriminator };
965
+ }
966
+ }
379
967
  return null;
380
968
  }
969
+
970
+ /**
971
+ * Map a parameter type to a TypeScript type string, handling inline enums
972
+ * that don't have corresponding global enum definitions. These would
973
+ * otherwise emit bare names like `Type` or `Action` that are never imported.
974
+ *
975
+ * Recursively handles container types (arrays, nullable) so that inline
976
+ * enums nested inside e.g. `array<enum>` are also inlined as string literal unions.
977
+ */
978
+ function mapParamType(type: TypeRef, specEnumNames: Set<string>): string {
979
+ if (type.kind === 'enum' && !specEnumNames.has(type.name)) {
980
+ // Inline enum with no generated file — render values as string literal union
981
+ if (type.values && type.values.length > 0) {
982
+ return type.values.map((v: string | number) => (typeof v === 'string' ? `'${v}'` : String(v))).join(' | ');
983
+ }
984
+ return 'string';
985
+ }
986
+ if (type.kind === 'array') {
987
+ const inner = mapParamType(type.items, specEnumNames);
988
+ // Parenthesize union types when used as array element type
989
+ return inner.includes(' | ') ? `(${inner})[]` : `${inner}[]`;
990
+ }
991
+ if (type.kind === 'nullable') {
992
+ return `${mapParamType(type.inner, specEnumNames)} | null`;
993
+ }
994
+ return mapTypeRef(type);
995
+ }