@workos/oagen-emitters 0.12.1 → 0.12.3

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 (45) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint-pr-title.yml +1 -1
  3. package/.github/workflows/lint.yml +1 -1
  4. package/.github/workflows/release-please.yml +2 -2
  5. package/.github/workflows/release.yml +1 -1
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +14 -0
  9. package/dist/index.d.mts.map +1 -1
  10. package/dist/index.mjs +1 -1
  11. package/dist/{plugin-CmfzawTp.mjs → plugin-D2N2ZT5W.mjs} +2566 -1493
  12. package/dist/plugin-D2N2ZT5W.mjs.map +1 -0
  13. package/dist/plugin.mjs +1 -1
  14. package/package.json +6 -6
  15. package/renovate.json +46 -6
  16. package/src/node/client.ts +19 -32
  17. package/src/node/enums.ts +67 -30
  18. package/src/node/errors.ts +2 -8
  19. package/src/node/field-plan.ts +188 -52
  20. package/src/node/fixtures.ts +11 -33
  21. package/src/node/index.ts +354 -20
  22. package/src/node/live-surface.ts +378 -0
  23. package/src/node/models.ts +547 -351
  24. package/src/node/naming.ts +122 -25
  25. package/src/node/node-overrides.ts +77 -0
  26. package/src/node/options.ts +41 -0
  27. package/src/node/path-expression.ts +11 -4
  28. package/src/node/resources.ts +473 -48
  29. package/src/node/sdk-errors.ts +0 -16
  30. package/src/node/tests.ts +152 -93
  31. package/src/node/type-map.ts +40 -18
  32. package/src/node/utils.ts +89 -102
  33. package/src/node/wrappers.ts +0 -20
  34. package/test/node/client.test.ts +106 -1201
  35. package/test/node/enums.test.ts +59 -130
  36. package/test/node/errors.test.ts +2 -3
  37. package/test/node/live-surface.test.ts +240 -0
  38. package/test/node/models.test.ts +396 -765
  39. package/test/node/naming.test.ts +69 -234
  40. package/test/node/resources.test.ts +435 -2025
  41. package/test/node/tests.test.ts +214 -0
  42. package/test/node/type-map.test.ts +49 -54
  43. package/test/node/utils.test.ts +29 -80
  44. package/dist/plugin-CmfzawTp.mjs.map +0 -1
  45. package/test/node/serializers.test.ts +0 -444
package/src/node/utils.ts CHANGED
@@ -20,7 +20,6 @@ import { assignModelsToServices } from '@workos/oagen';
20
20
 
21
21
  /**
22
22
  * Compute a relative import path between two files within the generated SDK.
23
- * Strips .ts extension from the result.
24
23
  */
25
24
  export function relativeImport(fromFile: string, toFile: string): string {
26
25
  const fromDir = fromFile.split('/').slice(0, -1);
@@ -44,8 +43,6 @@ export function relativeImport(fromFile: string, toFile: string): string {
44
43
 
45
44
  /**
46
45
  * Render a JSDoc comment block from a description string.
47
- * Handles multiline descriptions by prefixing each line with ` * `.
48
- * Returns the lines with the given indent (default 0 spaces).
49
46
  */
50
47
  export function docComment(description: string, indent = 0): string[] {
51
48
  const pad = ' '.repeat(indent);
@@ -62,11 +59,7 @@ export function docComment(description: string, indent = 0): string[] {
62
59
  }
63
60
 
64
61
  /**
65
- * Build a map from model name default type args string for generic models.
66
- * E.g., Profile<CustomAttributesType = Record<string, unknown>>
67
- * → Map { 'Profile' → '<Record<string, unknown>>' }
68
- *
69
- * Non-generic models are not included in the map.
62
+ * Build a map from model name -> default type args string for generic models.
70
63
  */
71
64
  export function buildGenericModelDefaults(models: Model[]): Map<string, string> {
72
65
  const result = new Map<string, string>();
@@ -80,12 +73,8 @@ export function buildGenericModelDefaults(models: Model[]): Map<string, string>
80
73
 
81
74
  /**
82
75
  * Remove unused imports from generated source code.
83
- * Scans the non-import body for each imported identifier and drops
84
- * individual names that are never referenced. Removes entire import
85
- * statements when no names are used.
86
76
  */
87
77
  export function pruneUnusedImports(lines: string[]): string[] {
88
- // Split lines into imports and body
89
78
  const importLines: string[] = [];
90
79
  const bodyLines: string[] = [];
91
80
  let inBody = false;
@@ -106,10 +95,8 @@ export function pruneUnusedImports(lines: string[]): string[] {
106
95
  kept.push(line);
107
96
  continue;
108
97
  }
109
- // Extract imported names from the import statement
110
98
  const match = line.match(/\{([^}]+)\}/);
111
99
  if (!match) {
112
- // Non-destructured import (e.g., import X from '...') — keep
113
100
  kept.push(line);
114
101
  continue;
115
102
  }
@@ -117,20 +104,14 @@ export function pruneUnusedImports(lines: string[]): string[] {
117
104
  .split(',')
118
105
  .map((n) => n.trim())
119
106
  .filter(Boolean);
120
- // Filter to only names that appear in the body
121
107
  const usedNames = names.filter((name) => {
122
108
  const re = new RegExp(`\\b${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`);
123
109
  return re.test(body);
124
110
  });
125
- if (usedNames.length === 0) {
126
- // No names used — drop entire import
127
- continue;
128
- }
111
+ if (usedNames.length === 0) continue;
129
112
  if (usedNames.length === names.length) {
130
- // All names used — keep original line
131
113
  kept.push(line);
132
114
  } else {
133
- // Some names unused — reconstruct import with only used names
134
115
  const isTypeImport = line.startsWith('import type');
135
116
  const fromMatch = line.match(/from\s+['"]([^'"]+)['"]/);
136
117
  if (fromMatch) {
@@ -145,7 +126,7 @@ export function pruneUnusedImports(lines: string[]): string[] {
145
126
  return [...kept, ...bodyLines];
146
127
  }
147
128
 
148
- /** Built-in TypeScript types that are always available (no import needed). */
129
+ /** Built-in TypeScript types that are always available. */
149
130
  export const TS_BUILTINS = new Set([
150
131
  'Record',
151
132
  'Promise',
@@ -167,9 +148,7 @@ export const TS_BUILTINS = new Set([
167
148
  ]);
168
149
 
169
150
  /**
170
- * Detect whether the existing SDK uses string (ISO 8601) representation for
171
- * date-time fields. Checks if any baseline interface has a date-time IR field
172
- * typed as plain `string` (not `Date`).
151
+ * Detect whether the existing SDK uses string representation for date-time fields.
173
152
  */
174
153
  export function detectStringDateConvention(models: Model[], ctx: EmitterContext): boolean {
175
154
  if (!ctx.apiSurface?.interfaces) return false;
@@ -190,8 +169,6 @@ export function detectStringDateConvention(models: Model[], ctx: EmitterContext)
190
169
 
191
170
  /**
192
171
  * Build a comprehensive set of all known type names from the IR and baseline.
193
- * Used to identify type parameters by elimination — any PascalCase name not in
194
- * this set is likely a generic type parameter.
195
172
  */
196
173
  export function buildKnownTypeNames(models: Model[], ctx: EmitterContext): Set<string> {
197
174
  const knownNames = new Set<string>();
@@ -211,8 +188,11 @@ export function buildKnownTypeNames(models: Model[], ctx: EmitterContext): Set<s
211
188
 
212
189
  /**
213
190
  * Create a service directory resolver bundle.
214
- * Encapsulates the common pattern of mapping models to services and resolving
215
- * the output directory for a given IR service name.
191
+ *
192
+ * When `ctx.apiSurface` is populated, the baseline `sourceFile` of an
193
+ * existing interface wins over the IR-derived first-reference assignment.
194
+ * This keeps generated imports pointing at the existing live SDK location
195
+ * instead of duplicating a model into a different service directory.
216
196
  */
217
197
  export function createServiceDirResolver(
218
198
  models: Model[],
@@ -225,19 +205,68 @@ export function createServiceDirResolver(
225
205
  } {
226
206
  const modelToService = assignModelsToServices(models, services, ctx.modelHints);
227
207
  const serviceNameMap = buildServiceNameMap(services, ctx);
228
- const resolveDir = (irService: string | undefined) =>
229
- irService ? resolveServiceDir(serviceNameMap.get(irService) ?? irService) : 'common';
208
+
209
+ // Per-name directory override, harvested from the live SDK surface.
210
+ // Stored under a sentinel "" service key in modelToService so resolveDir
211
+ // can dispatch on it without a separate map. Implementation: model name ->
212
+ // baseline directory string (e.g., "user-management"). The override map is
213
+ // attached by tagging the model name with a directory prefix that bypasses
214
+ // the IR-service lookup. Concretely we keep a side map.
215
+ const baselineDirByModel = new Map<string, string>();
216
+ const recordSource = (name: string, info: { sourceFile?: string } | undefined) => {
217
+ const sourceFile = info?.sourceFile;
218
+ if (!sourceFile) return;
219
+ const m = sourceFile.match(/^src\/([^/]+)\//);
220
+ if (!m) return;
221
+ baselineDirByModel.set(name, m[1]);
222
+ };
223
+ // Both interfaces and type aliases can shadow IR model names — e.g.
224
+ // `type Role = EnvironmentRole | OrganizationRole;` is the live SDK's
225
+ // canonical Role definition even though the IR represents Role as a model.
226
+ for (const [name, info] of Object.entries(ctx.apiSurface?.interfaces ?? {})) {
227
+ recordSource(name, info as { sourceFile?: string });
228
+ }
229
+ for (const [name, info] of Object.entries(ctx.apiSurface?.typeAliases ?? {})) {
230
+ if (!baselineDirByModel.has(name)) {
231
+ recordSource(name, info as { sourceFile?: string });
232
+ }
233
+ }
234
+
235
+ // Override modelToService for any IR model that has a baseline sourceFile.
236
+ // We invent a synthetic IR-service key that maps directly to the baseline
237
+ // directory via serviceNameMap so resolveDir returns the correct dir.
238
+ for (const [modelName] of modelToService) {
239
+ const dir = baselineDirByModel.get(modelName);
240
+ if (!dir) continue;
241
+ const synthetic = `__baseline_dir__:${dir}`;
242
+ modelToService.set(modelName, synthetic);
243
+ if (!serviceNameMap.has(synthetic)) {
244
+ // resolveServiceDir is identity on already-kebab-case names, so storing
245
+ // the dir directly keeps round-tripping through the resolver clean.
246
+ serviceNameMap.set(synthetic, dir);
247
+ }
248
+ }
249
+
250
+ const resolveDir = (irService: string | undefined) => {
251
+ if (!irService) return 'common';
252
+ if (irService.startsWith('__baseline_dir__:')) return irService.slice('__baseline_dir__:'.length);
253
+ return resolveServiceDir(serviceNameMap.get(irService) ?? irService);
254
+ };
230
255
  return { modelToService, serviceNameMap, resolveDir };
231
256
  }
232
257
 
233
258
  /**
234
- * Check if a set of baseline interface fields appears to contain generic type
235
- * parameters — PascalCase names that aren't known models, enums, or builtins.
259
+ * Check if baseline interface fields appear to contain generic type parameters.
260
+ *
261
+ * Heuristic: strip string literals first (so `'GoogleSAML'` is not mistaken
262
+ * for a type name), then look for any PascalCase token that isn't a known
263
+ * type — those indicate an unbound generic parameter like `TCustomAttributes`.
236
264
  */
237
265
  export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: Set<string>): boolean {
238
266
  for (const [, bf] of Object.entries(fields)) {
239
- const fieldType = (bf as { type: string }).type;
240
- const typeNames = fieldType.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
267
+ const rawType = (bf as { type: string }).type;
268
+ const stripped = rawType.replace(/'[^']*'/g, '').replace(/"[^"]*"/g, '');
269
+ const typeNames = stripped.match(/\b[A-Z][a-zA-Z0-9]*\b/g);
241
270
  if (!typeNames) continue;
242
271
  for (const tn of typeNames) {
243
272
  if (TS_BUILTINS.has(tn)) continue;
@@ -248,13 +277,8 @@ export function isBaselineGeneric(fields: Record<string, unknown>, knownNames: S
248
277
  return false;
249
278
  }
250
279
 
251
- // Re-export shared model detection utilities
252
280
  export { isListMetadataModel, isListWrapperModel } from '../shared/model-utils.js';
253
281
 
254
- /**
255
- * Compute a structural fingerprint for a model based on its fields.
256
- * Two models with identical fingerprints are structurally equivalent.
257
- */
258
282
  function modelFingerprint(model: Model): string {
259
283
  const fields = model.fields.map((f) => `${f.name}:${JSON.stringify(f.type)}:${f.required}`).sort();
260
284
  return fields.join('|');
@@ -262,12 +286,6 @@ function modelFingerprint(model: Model): string {
262
286
 
263
287
  /**
264
288
  * Find structurally identical models and build a deduplication map.
265
- * Also deduplicates models that resolve to the same interface name across
266
- * services — when a `$ref` schema is used by multiple tags, the IR may
267
- * produce per-tag copies that diverge slightly. The version with the most
268
- * fields is chosen as canonical.
269
- *
270
- * Returns a Map from duplicate model name → canonical model name.
271
289
  */
272
290
  export function buildDeduplicationMap(
273
291
  models: Model[],
@@ -276,19 +294,15 @@ export function buildDeduplicationMap(
276
294
  ): Map<string, string> {
277
295
  const dedup = new Map<string, string>();
278
296
 
279
- // Pass 1: structural fingerprint dedup (exact match)
280
- // When a reachability set is provided, prefer reachable models as canonicals
281
- // so that aliases always point to models that will actually be generated.
297
+ // Pass 1: structural fingerprint dedup
282
298
  const fingerprints = new Map<string, string>();
283
299
  for (const model of models) {
284
300
  if (model.fields.length === 0) continue;
285
301
  const fp = modelFingerprint(model);
286
302
  const existing = fingerprints.get(fp);
287
303
  if (existing) {
288
- // If the existing canonical is unreachable but this model is reachable,
289
- // swap: make this model the canonical and demote the old one to alias.
290
304
  if (reachable && !reachable.has(existing) && reachable.has(model.name)) {
291
- dedup.delete(existing); // remove stale alias if present
305
+ dedup.delete(existing);
292
306
  dedup.set(existing, model.name);
293
307
  fingerprints.set(fp, model.name);
294
308
  } else {
@@ -299,15 +313,12 @@ export function buildDeduplicationMap(
299
313
  }
300
314
  }
301
315
 
302
- // Pass 2: name-based dedup for models that resolve to the same interface
303
- // name across services. Only applies when context with name resolution is
304
- // available. Picks the model with the most fields as canonical, preferring
305
- // reachable models when a reachability set is provided.
316
+ // Pass 2: name-based dedup
306
317
  if (ctx) {
307
318
  const byDomainName = new Map<string, Model[]>();
308
319
  for (const model of models) {
309
320
  if (model.fields.length === 0) continue;
310
- if (dedup.has(model.name)) continue; // already deduped in pass 1
321
+ if (dedup.has(model.name)) continue;
311
322
  const domainName = resolveInterfaceName(model.name, ctx);
312
323
  const group = byDomainName.get(domainName);
313
324
  if (group) {
@@ -318,7 +329,6 @@ export function buildDeduplicationMap(
318
329
  }
319
330
  for (const [, group] of byDomainName) {
320
331
  if (group.length < 2) continue;
321
- // Choose canonical: prefer reachable, then most fields, then alphabetically
322
332
  group.sort((a, b) => {
323
333
  if (reachable) {
324
334
  const aReach = reachable.has(a.name) ? 0 : 1;
@@ -340,23 +350,8 @@ export function buildDeduplicationMap(
340
350
  /**
341
351
  * Check whether a service's endpoints are already fully covered by existing
342
352
  * hand-written service classes.
343
- *
344
- * A service is considered "covered" when:
345
- * 1. **Every** operation in it appears in `overlayLookup.methodByOperation`
346
- * 2. The overlay maps those operations to a class that exists in the baseline
347
- * `apiSurface` (confirming the hand-written class is actually present)
348
- *
349
- * Services with zero operations are never considered covered (nothing to
350
- * deduplicate). When no `apiSurface` is available, the overlay alone is
351
- * used as the coverage signal (the overlay is only built from existing code).
352
- *
353
- * This prevents the emitter from generating resource classes like `Connections`
354
- * that would duplicate hand-written modules like `SSO` for the same API
355
- * endpoints (e.g., `GET /connections`).
356
353
  */
357
354
  export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext): boolean {
358
- // A service is "covered" when its mountOn differs from its own name,
359
- // meaning its operations are mounted on a different (existing) class.
360
355
  const mountTarget = getMountTarget(service, ctx);
361
356
  if (mountTarget !== toPascalCase(service.name)) return true;
362
357
 
@@ -364,15 +359,10 @@ export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext
364
359
  if (!overlay || overlay.size === 0) return false;
365
360
  if (service.operations.length === 0) return false;
366
361
 
367
- // Collect the set of existing class names from the baseline surface.
368
- // When no apiSurface is available, the overlay alone cannot confirm that
369
- // a hand-written class exists — it may only carry naming hints.
370
362
  const baselineClasses = ctx.apiSurface?.classes;
371
363
  if (!baselineClasses) return false;
372
364
  const existingClassNames = new Set(Object.keys(baselineClasses));
373
365
 
374
- // Check that every operation is in the overlay AND the overlay's target class
375
- // exists in the baseline.
376
366
  return service.operations.every((op: Operation) => {
377
367
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
378
368
  const match = overlay.get(httpKey);
@@ -383,20 +373,16 @@ export function isServiceCoveredByExisting(service: Service, ctx: EmitterContext
383
373
 
384
374
  /**
385
375
  * Check whether a fully-covered service has operations whose overlay-mapped
386
- * methods are missing from the baseline class. Returns true when at least
387
- * one operation maps to a method name that the baseline class does not have,
388
- * meaning the merger needs to add new methods (skipIfExists must be removed).
376
+ * methods are missing from the baseline class.
389
377
  */
390
378
  export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterContext): boolean {
391
379
  const baselineClasses = ctx.apiSurface?.classes;
392
380
  if (!baselineClasses) return false;
393
381
 
394
- // When a service mounts on a different class (via mount rules), check
395
- // each operation's resolved method name against the target class directly.
396
382
  const mountTarget = getMountTarget(service, ctx);
397
383
  if (mountTarget !== toPascalCase(service.name)) {
398
384
  const cls = baselineClasses[mountTarget];
399
- if (!cls) return true; // Target class missing from baseline — treat as absent
385
+ if (!cls) return true;
400
386
  for (const op of service.operations) {
401
387
  const method = resolveMethodName(op, service, ctx);
402
388
  if (!cls.methods?.[method]) return true;
@@ -404,7 +390,6 @@ export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterConte
404
390
  return false;
405
391
  }
406
392
 
407
- // Default overlay-based detection
408
393
  const overlay = ctx.overlayLookup?.methodByOperation;
409
394
  if (!overlay) return false;
410
395
 
@@ -421,28 +406,36 @@ export function hasMethodsAbsentFromBaseline(service: Service, ctx: EmitterConte
421
406
 
422
407
  /**
423
408
  * Check whether an IR model has fields not present in the baseline interface.
424
- * Returns true if the model has new fields that need generation.
425
- * Returns true if no baseline exists (new model entirely).
409
+ *
410
+ * When the live SDK exposes the same name as a type alias (e.g.
411
+ * `type Role = EnvironmentRole | OrganizationRole;`), treat it as already
412
+ * fully covered — generating an interface against an existing alias would
413
+ * collide. The alias's referenced types still get generated independently
414
+ * and serve as the canonical implementation.
426
415
  */
427
416
  export function modelHasNewFields(model: Model, ctx: EmitterContext): boolean {
428
- if (!ctx.apiSurface?.interfaces) return true; // No surface = generate everything
417
+ if (!ctx.apiSurface?.interfaces && !ctx.apiSurface?.typeAliases) return true;
429
418
 
430
419
  const domainName = resolveInterfaceName(model.name, ctx);
431
- const baseline = ctx.apiSurface.interfaces[domainName];
432
- if (!baseline?.fields) return true; // No baseline for this model = new model
420
+
421
+ if (ctx.apiSurface?.typeAliases?.[domainName]) {
422
+ return false;
423
+ }
424
+
425
+ const baseline = ctx.apiSurface?.interfaces?.[domainName];
426
+ if (!baseline?.fields) return true;
433
427
 
434
428
  for (const field of model.fields) {
435
429
  const camelName = fieldName(field.name);
436
- if (!baseline.fields[camelName]) return true; // New field found
430
+ if (!baseline.fields[camelName]) return true;
437
431
  }
438
432
 
439
- return false; // All fields exist in baseline
433
+ return false;
440
434
  }
441
435
 
442
436
  /**
443
437
  * Return operations in a service that are NOT covered by existing hand-written
444
- * service classes. For fully uncovered services, returns all operations.
445
- * For partially covered services, returns only the uncovered operations.
438
+ * service classes.
446
439
  */
447
440
  export function uncoveredOperations(service: Service, ctx: EmitterContext): Operation[] {
448
441
  const overlay = ctx.overlayLookup?.methodByOperation;
@@ -455,19 +448,13 @@ export function uncoveredOperations(service: Service, ctx: EmitterContext): Oper
455
448
  return service.operations.filter((op: Operation) => {
456
449
  const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
457
450
  const match = overlay.get(httpKey);
458
- if (!match) return true; // Not in overlay → uncovered
459
- return !existingClassNames.has(match.className); // Class doesn't exist → uncovered
451
+ if (!match) return true;
452
+ return !existingClassNames.has(match.className);
460
453
  });
461
454
  }
462
455
 
463
456
  /**
464
457
  * Compute the set of model names reachable from non-event service operations.
465
- * The Events service pulls in hundreds of webhook payload models that the
466
- * existing SDK handles via hand-written event types, so those models are
467
- * excluded from generation.
468
- *
469
- * Shared between model generation, barrel generation, dedup, and tests to
470
- * ensure consistency: every module agrees on which models will be generated.
471
458
  */
472
459
  export function computeNonEventReachable(services: Service[], models: Model[]): Set<string> {
473
460
  const seeds = new Set<string>();
@@ -7,12 +7,6 @@ import { buildNodePathExpression } from './path-expression.js';
7
7
 
8
8
  /**
9
9
  * Generate TypeScript wrapper method lines for union split operations.
10
- *
11
- * Each wrapper is a typed convenience method that:
12
- * - Accepts only the exposed params (not the full union body)
13
- * - Injects constant defaults (e.g., grant_type)
14
- * - Reads inferred fields from client config (e.g., clientId)
15
- * - Delegates to the HTTP client with the constructed body
16
10
  */
17
11
  export function generateWrapperMethods(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
18
12
  if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
@@ -29,7 +23,6 @@ export function generateWrapperMethods(resolvedOp: ResolvedOperation, ctx: Emitt
29
23
 
30
24
  /**
31
25
  * Collect response model names referenced by wrappers on a resolved operation.
32
- * Used by the resource generator to ensure the correct imports are emitted.
33
26
  */
34
27
  export function collectWrapperResponseModels(resolvedOp: ResolvedOperation): Set<string> {
35
28
  const models = new Set<string>();
@@ -51,7 +44,6 @@ function emitWrapperMethod(
51
44
  const method = toCamelCase(wrapper.name);
52
45
  const wrapperParams = resolveWrapperParams(wrapper, ctx);
53
46
 
54
- // Build parameter list: path params, then required exposed, then optional exposed
55
47
  const paramParts: string[] = [];
56
48
 
57
49
  for (const p of op.pathParams) {
@@ -72,7 +64,6 @@ function emitWrapperMethod(
72
64
  paramParts.push(`${tsName}?: ${tsType}`);
73
65
  }
74
66
 
75
- // Response type
76
67
  const responseTypeName = wrapper.responseModelName ? resolveInterfaceName(wrapper.responseModelName, ctx) : null;
77
68
  const wireType = responseTypeName ? wireInterfaceName(responseTypeName) : null;
78
69
  const returnType = responseTypeName ?? 'void';
@@ -110,24 +101,19 @@ function emitWrapperMethod(
110
101
  lines.push(' */');
111
102
  }
112
103
 
113
- // Method signature
114
104
  lines.push(` async ${method}(${paramParts.join(', ')}): Promise<${returnType}> {`);
115
105
 
116
- // Build body with wire-format (snake_case) keys
117
106
  lines.push(' const body: Record<string, unknown> = {');
118
107
 
119
- // Constant defaults
120
108
  for (const [key, value] of Object.entries(wrapper.defaults)) {
121
109
  lines.push(` ${key}: ${tsLiteral(value)},`);
122
110
  }
123
111
 
124
- // Inferred fields from client config
125
112
  for (const field of wrapper.inferFromClient) {
126
113
  const expr = clientFieldExpression(field);
127
114
  lines.push(` ${field}: ${expr},`);
128
115
  }
129
116
 
130
- // Required exposed params (wire-format key, camelCase value)
131
117
  for (const { paramName, isOptional } of wrapperParams) {
132
118
  if (isOptional) continue;
133
119
  lines.push(` ${paramName}: ${fieldName(paramName)},`);
@@ -135,17 +121,14 @@ function emitWrapperMethod(
135
121
 
136
122
  lines.push(' };');
137
123
 
138
- // Optional exposed params — add conditionally
139
124
  for (const { paramName, isOptional } of wrapperParams) {
140
125
  if (!isOptional) continue;
141
126
  const tsName = fieldName(paramName);
142
127
  lines.push(` if (${tsName} !== undefined) body.${paramName} = ${tsName};`);
143
128
  }
144
129
 
145
- // Build path expression
146
130
  const pathStr = buildPathStr(op);
147
131
 
148
- // Make the request
149
132
  if (responseTypeName) {
150
133
  lines.push(` const { data } = await this.workos.${op.httpMethod}<${wireType}>(${pathStr}, body);`);
151
134
  lines.push(` return deserialize${responseTypeName}(data);`);
@@ -156,19 +139,16 @@ function emitWrapperMethod(
156
139
  lines.push(' }');
157
140
  }
158
141
 
159
- /** Build a path template string from an Operation. */
160
142
  function buildPathStr(op: { path: string; pathParams: Array<{ name: string }> }): string {
161
143
  return buildNodePathExpression(op.path);
162
144
  }
163
145
 
164
- /** Convert a JS value to a TypeScript literal. */
165
146
  function tsLiteral(value: string | number | boolean): string {
166
147
  if (typeof value === 'string') return `'${value.replace(/'/g, "\\'")}'`;
167
148
  if (typeof value === 'boolean') return value ? 'true' : 'false';
168
149
  return String(value);
169
150
  }
170
151
 
171
- /** Get the TypeScript expression for reading a client config field. */
172
152
  function clientFieldExpression(field: string): string {
173
153
  switch (field) {
174
154
  case 'client_id':