@workos/oagen-emitters 0.4.0 → 0.6.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 (126) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +15 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +9 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -15234
  13. package/dist/plugin-Dws9b6T7.mjs +21441 -0
  14. package/dist/plugin-Dws9b6T7.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +5 -5
  19. package/oagen.config.ts +5 -373
  20. package/package.json +17 -41
  21. package/smoke/sdk-dotnet.ts +11 -5
  22. package/smoke/sdk-elixir.ts +11 -5
  23. package/smoke/sdk-go.ts +10 -4
  24. package/smoke/sdk-kotlin.ts +11 -5
  25. package/smoke/sdk-node.ts +11 -5
  26. package/smoke/sdk-php.ts +9 -4
  27. package/smoke/sdk-python.ts +10 -4
  28. package/smoke/sdk-ruby.ts +10 -4
  29. package/smoke/sdk-rust.ts +11 -5
  30. package/src/dotnet/index.ts +9 -7
  31. package/src/dotnet/manifest.ts +5 -11
  32. package/src/dotnet/models.ts +58 -82
  33. package/src/dotnet/naming.ts +44 -6
  34. package/src/dotnet/resources.ts +350 -29
  35. package/src/dotnet/tests.ts +44 -24
  36. package/src/dotnet/type-map.ts +44 -17
  37. package/src/dotnet/wrappers.ts +21 -10
  38. package/src/go/client.ts +35 -3
  39. package/src/go/enums.ts +4 -0
  40. package/src/go/index.ts +13 -8
  41. package/src/go/manifest.ts +5 -11
  42. package/src/go/models.ts +6 -1
  43. package/src/go/resources.ts +534 -73
  44. package/src/go/tests.ts +39 -3
  45. package/src/go/type-map.ts +8 -3
  46. package/src/go/wrappers.ts +79 -21
  47. package/src/index.ts +14 -0
  48. package/src/kotlin/client.ts +7 -2
  49. package/src/kotlin/enums.ts +30 -3
  50. package/src/kotlin/index.ts +3 -3
  51. package/src/kotlin/manifest.ts +9 -15
  52. package/src/kotlin/models.ts +97 -6
  53. package/src/kotlin/naming.ts +7 -1
  54. package/src/kotlin/resources.ts +370 -39
  55. package/src/kotlin/tests.ts +120 -6
  56. package/src/node/client.ts +38 -11
  57. package/src/node/field-plan.ts +12 -14
  58. package/src/node/fixtures.ts +39 -3
  59. package/src/node/index.ts +3 -3
  60. package/src/node/manifest.ts +4 -11
  61. package/src/node/models.ts +281 -37
  62. package/src/node/resources.ts +156 -52
  63. package/src/node/tests.ts +76 -27
  64. package/src/node/type-map.ts +1 -31
  65. package/src/node/utils.ts +96 -6
  66. package/src/node/wrappers.ts +31 -1
  67. package/src/php/index.ts +3 -3
  68. package/src/php/manifest.ts +5 -11
  69. package/src/php/models.ts +0 -33
  70. package/src/php/resources.ts +199 -18
  71. package/src/php/tests.ts +26 -2
  72. package/src/php/type-map.ts +16 -2
  73. package/src/php/wrappers.ts +6 -2
  74. package/src/plugin.ts +50 -0
  75. package/src/python/client.ts +13 -3
  76. package/src/python/enums.ts +28 -3
  77. package/src/python/index.ts +38 -30
  78. package/src/python/manifest.ts +5 -12
  79. package/src/python/models.ts +138 -1
  80. package/src/python/resources.ts +234 -17
  81. package/src/python/tests.ts +260 -16
  82. package/src/python/type-map.ts +16 -2
  83. package/src/ruby/client.ts +238 -0
  84. package/src/ruby/enums.ts +149 -0
  85. package/src/ruby/index.ts +93 -0
  86. package/src/ruby/manifest.ts +28 -0
  87. package/src/ruby/models.ts +360 -0
  88. package/src/ruby/naming.ts +187 -0
  89. package/src/ruby/rbi.ts +313 -0
  90. package/src/ruby/resources.ts +799 -0
  91. package/src/ruby/tests.ts +459 -0
  92. package/src/ruby/type-map.ts +97 -0
  93. package/src/ruby/wrappers.ts +161 -0
  94. package/src/shared/model-utils.ts +131 -7
  95. package/src/shared/naming-utils.ts +36 -0
  96. package/src/shared/non-spec-services.ts +13 -0
  97. package/src/shared/resolved-ops.ts +75 -1
  98. package/test/dotnet/client.test.ts +2 -2
  99. package/test/dotnet/manifest.test.ts +13 -12
  100. package/test/dotnet/models.test.ts +7 -9
  101. package/test/dotnet/resources.test.ts +135 -3
  102. package/test/dotnet/tests.test.ts +5 -5
  103. package/test/entrypoint.test.ts +89 -0
  104. package/test/go/client.test.ts +6 -6
  105. package/test/go/resources.test.ts +156 -7
  106. package/test/kotlin/models.test.ts +1 -1
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/node/models.test.ts +134 -1
  109. package/test/node/resources.test.ts +134 -26
  110. package/test/node/utils.test.ts +140 -0
  111. package/test/php/models.test.ts +5 -4
  112. package/test/php/resources.test.ts +66 -1
  113. package/test/plugin.test.ts +50 -0
  114. package/test/python/client.test.ts +56 -0
  115. package/test/python/manifest.test.ts +7 -7
  116. package/test/python/models.test.ts +99 -0
  117. package/test/python/resources.test.ts +294 -0
  118. package/test/python/tests.test.ts +91 -0
  119. package/test/ruby/client.test.ts +81 -0
  120. package/test/ruby/resources.test.ts +386 -0
  121. package/test/shared/resolved-ops.test.ts +122 -0
  122. package/tsconfig.json +1 -0
  123. package/tsdown.config.ts +1 -1
  124. package/dist/index.mjs.map +0 -1
  125. package/scripts/generate-php.js +0 -13
  126. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -22,6 +22,7 @@ import {
22
22
  ktLiteral,
23
23
  clientFieldExpression,
24
24
  escapeReserved,
25
+ humanize,
25
26
  } from './naming.js';
26
27
  import {
27
28
  buildResolvedLookup,
@@ -30,6 +31,8 @@ import {
30
31
  buildHiddenParams,
31
32
  getOpDefaults,
32
33
  getOpInferFromClient,
34
+ collectGroupedParamNames,
35
+ collectBodyFieldTypes,
33
36
  } from '../shared/resolved-ops.js';
34
37
  import { generateWrapperMethods } from './wrappers.js';
35
38
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
@@ -83,6 +86,15 @@ function generateApiClass(
83
86
 
84
87
  const body: string[] = [];
85
88
  const seenMethods = new Set<string>();
89
+ const hasAuthenticateHelper = operations.some(
90
+ (op) => op.path === '/user_management/authenticate' && op.httpMethod.toUpperCase() === 'POST',
91
+ );
92
+
93
+ if (hasAuthenticateHelper) {
94
+ imports.add('com.workos.common.http.bodyOf');
95
+ imports.add('com.workos.models.AuthenticateResponse');
96
+ body.push(...generateAuthenticateHelper());
97
+ }
86
98
 
87
99
  for (const op of operations) {
88
100
  if (isHandwrittenOverride(op)) continue;
@@ -118,13 +130,43 @@ function generateApiClass(
118
130
 
119
131
  if (body.length === 0) return null;
120
132
 
121
- // Drop unused imports by peeking at the body text.
122
- const bodyText = body.join('\n');
133
+ // Emit sealed classes for parameter groups before the API class.
134
+ // Parameter-group IR can lose body field type fidelity; prefer the request
135
+ // body model's field type when available.
136
+ const bodyFieldTypes = new Map<string, TypeRef>();
137
+ for (const op of operations) {
138
+ for (const [name, type] of collectBodyFieldTypes(op, ctx.spec.models)) {
139
+ bodyFieldTypes.set(name, type);
140
+ }
141
+ }
142
+ const sealedLines: string[] = [];
143
+ const emittedSealedClasses = new Set<string>();
144
+ for (const op of operations) {
145
+ if ((op.parameterGroups?.length ?? 0) > 0) {
146
+ for (const group of op.parameterGroups ?? []) {
147
+ // Register imports for types used in parameter group sealed classes.
148
+ // The body field type override may introduce enum/model types that
149
+ // the original IR parameter didn't reference.
150
+ for (const variant of group.variants) {
151
+ for (const p of variant.parameters) {
152
+ const effectiveType = bodyFieldTypes.get(p.name) ?? p.type;
153
+ registerTypeImports(effectiveType, imports, ctx);
154
+ }
155
+ }
156
+ if (emittedSealedClasses.has(group.name)) continue;
157
+ emittedSealedClasses.add(group.name);
158
+ for (const line of generateSealedClass(group, bodyFieldTypes)) sealedLines.push(line);
159
+ }
160
+ }
161
+ }
162
+
163
+ // Drop unused imports by peeking at the body text and sealed class text.
164
+ const allText = body.join('\n') + '\n' + sealedLines.join('\n');
123
165
  const filteredImports = [...imports].filter((imp) => {
124
166
  const simple = imp.slice(imp.lastIndexOf('.') + 1);
125
167
  // Skip the import if the class body never references the simple name.
126
- if (simple === 'WorkOS' || simple === 'RequestConfig' || simple === 'RequestOptions') return true;
127
- return new RegExp(`\\b${simple}\\b`).test(bodyText);
168
+ if (simple === 'WorkOS' || simple === 'RequestOptions') return true;
169
+ return new RegExp(`\\b${simple}\\b`).test(allText);
128
170
  });
129
171
 
130
172
  const lines: string[] = [];
@@ -132,6 +174,8 @@ function generateApiClass(
132
174
  lines.push('');
133
175
  for (const imp of filteredImports.sort()) lines.push(`import ${imp}`);
134
176
  lines.push('');
177
+ for (const line of sealedLines) lines.push(line);
178
+
135
179
  const serviceDescription = resolveServiceDescription(ctx, mountName, operations);
136
180
  if (serviceDescription) {
137
181
  const docLines = serviceDescription.trim().split('\n');
@@ -197,9 +241,13 @@ function renderMethod(
197
241
 
198
242
  const httpMethod = op.httpMethod.toUpperCase();
199
243
  const pathParams = sortPathParamsByTemplateOrder(op);
200
- const queryParams = op.queryParams.filter((p) => !hidden.has(p.name));
244
+ const groupedParamNames = collectGroupedParamNames(op);
245
+ const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
246
+ const queryParams = op.queryParams.filter((p) => !hidden.has(p.name) && !groupedParamNames.has(p.name));
201
247
  const bodyModel = resolveBodyModel(op, ctx);
202
- const bodyFields = bodyModel ? bodyModel.fields.filter((f) => !hidden.has(f.name)) : [];
248
+ const bodyFields = bodyModel
249
+ ? bodyModel.fields.filter((f) => !hidden.has(f.name) && !groupedParamNames.has(f.name))
250
+ : [];
203
251
 
204
252
  // Track imports we need
205
253
  for (const p of [...pathParams, ...queryParams]) registerTypeImports(p.type, imports, ctx);
@@ -222,11 +270,21 @@ function renderMethod(
222
270
  const uniqueQuery = queryParams.filter((qp) => !paramNames.has(propertyName(qp.name)));
223
271
  for (const qp of uniqueQuery) paramNames.add(propertyName(qp.name));
224
272
 
273
+ const sharedQueryBodyParams = new Set(
274
+ uniqueQuery
275
+ .filter((qp) => bodyFields.some((bf) => bf.name === qp.name && mapTypeRef(qp.type) === mapTypeRef(bf.type)))
276
+ .map((qp) => qp.name),
277
+ );
278
+
225
279
  // Map body field wire name → Kotlin parameter name. When the natural name
226
280
  // collides with a path/query, prefix with `body` (e.g. slug → bodySlug).
227
281
  const bodyParamNames = new Map<string, string>();
228
282
  for (const bf of bodyFields) {
229
283
  const natural = propertyName(bf.name);
284
+ if (sharedQueryBodyParams.has(bf.name)) {
285
+ bodyParamNames.set(bf.name, natural);
286
+ continue;
287
+ }
230
288
  if (paramNames.has(natural)) {
231
289
  const renamed = `body${natural.charAt(0).toUpperCase()}${natural.slice(1)}`;
232
290
  bodyParamNames.set(bf.name, renamed);
@@ -237,12 +295,25 @@ function renderMethod(
237
295
  }
238
296
  }
239
297
 
298
+ const groupParamNames = assignGroupParameterNames(op, paramNames);
299
+
240
300
  const params: string[] = [];
241
301
  for (const pp of pathParams) params.push(` ${propertyName(pp.name)}: String`);
242
302
 
243
303
  const sortedQuery = [...uniqueQuery].sort((a, b) => (a.required === b.required ? 0 : a.required ? -1 : 1));
244
304
  for (const qp of sortedQuery) {
245
- params.push(renderParam(qp.name, qp.type, qp.required));
305
+ params.push(renderParam(qp.name, qp.type, qp.required, method.startsWith('list') && qp.name === 'limit'));
306
+ }
307
+
308
+ // Parameter group params (sealed class types)
309
+ for (const group of op.parameterGroups ?? []) {
310
+ const sealedName = sealedGroupName(group.name);
311
+ const prop = groupParamNames.get(group.name)!;
312
+ if (group.optional) {
313
+ params.push(` ${prop}: ${sealedName}? = null`);
314
+ } else {
315
+ params.push(` ${prop}: ${sealedName}`);
316
+ }
246
317
  }
247
318
 
248
319
  // PATCH operations use PatchField<T> for optional body fields so callers
@@ -251,6 +322,7 @@ function renderMethod(
251
322
 
252
323
  const sortedBodyFields = [...bodyFields].sort((a, b) => (a.required === b.required ? 0 : a.required ? -1 : 1));
253
324
  for (const bf of sortedBodyFields) {
325
+ if (sharedQueryBodyParams.has(bf.name)) continue;
254
326
  if (isPatch && !bf.required) {
255
327
  const baseType = mapTypeRef(bf.type);
256
328
  imports.add('com.workos.common.http.PatchField');
@@ -304,36 +376,75 @@ function renderMethod(
304
376
  const appendDefaultsAsQuery = !hasBody && (Object.keys(defaults).length > 0 || inferFromClient.length > 0);
305
377
  const pathExpr = buildPathExpression(op.path, pathParams);
306
378
 
379
+ if (
380
+ op.path === '/user_management/authenticate' &&
381
+ httpMethod === 'POST' &&
382
+ plan.responseModelName === 'AuthenticateResponse'
383
+ ) {
384
+ imports.add('com.workos.models.AuthenticateResponse');
385
+ const grantType = defaults.grant_type ?? 'authorization_code';
386
+ const entryLines = sortedBodyFields
387
+ .filter((bf) => bf.name !== 'grant_type' && bf.name !== 'client_id' && bf.name !== 'client_secret')
388
+ .map((bf) => ` ${ktLiteral(bf.name)} to ${bodyParamNames.get(bf.name)!}`);
389
+ lines.push(` return authenticate(`);
390
+ lines.push(` grantType = ${ktLiteral(grantType)},`);
391
+ lines.push(` requestOptions = requestOptions,`);
392
+ for (let i = 0; i < entryLines.length; i++) {
393
+ const suffix = i === entryLines.length - 1 ? '' : ',';
394
+ lines.push(`${entryLines[i]}${suffix}`);
395
+ }
396
+ lines.push(` )`);
397
+ lines.push(' }');
398
+ return lines.join('\n');
399
+ }
400
+
307
401
  if (isPaginated) {
308
402
  // Nested helper function + requestPage call; 'after' is owned by the
309
403
  // cursor logic so we skip it in the generic query loop.
310
- const queryForConfig = sortedQuery.filter((p) => p.name !== 'after');
311
- lines.push(` fun configFor(afterCursor: String? = null): RequestConfig {`);
312
- lines.push(` val params = mutableListOf<Pair<String, String>>()`);
313
- for (const qp of queryForConfig) for (const ln of emitQueryParam(qp, ' ')) lines.push(ln);
314
- lines.push(` val effectiveAfter = afterCursor ?: ${pickNamedQueryParam(sortedQuery, 'after')}`);
315
- lines.push(` if (effectiveAfter != null) params += "after" to effectiveAfter`);
316
- lines.push(` return RequestConfig(`);
317
- lines.push(` method = ${ktLiteral(httpMethod)},`);
318
- lines.push(` path = ${pathExpr},`);
319
- lines.push(` queryParams = params,`);
320
- lines.push(` requestOptions = requestOptions`);
321
- lines.push(` )`);
322
- lines.push(` }`);
404
+ // 'after' and 'before' are owned by the cursor logic. 'before' is only
405
+ // included on the first page — re-sending it on follow-up pages (where
406
+ // afterCursor is set by the pagination engine) is nonsensical and can
407
+ // cause empty or looping results from the server.
408
+ imports.add('com.workos.common.http.addIfNotNull');
409
+ imports.add('com.workos.common.http.addJoinedIfNotNull');
410
+ imports.add('com.workos.common.http.addEach');
323
411
  const itemClass = className(paginatedItemName!);
324
412
  lines.push(` val itemType = object : TypeReference<${itemClass}>() {}`);
325
- lines.push(
326
- ` return workos.baseClient.requestPage(configFor(), itemType) { afterCursor -> configFor(afterCursor) }`,
327
- );
413
+ lines.push(` return workos.baseClient.requestPage(`);
414
+ lines.push(` method = ${ktLiteral(httpMethod)},`);
415
+ lines.push(` path = ${pathExpr},`);
416
+ lines.push(` itemType = itemType,`);
417
+ lines.push(` requestOptions = requestOptions,`);
418
+ lines.push(` before = ${pickNamedQueryParam(sortedQuery, 'before')},`);
419
+ lines.push(` after = ${pickNamedQueryParam(sortedQuery, 'after')}`);
420
+ lines.push(` ) {`);
421
+ lines.push(` val params = this`);
422
+ for (const qp of sortedQuery.filter((p) => p.name !== 'after' && p.name !== 'before')) {
423
+ for (const ln of emitQueryParam(qp, ' ')) lines.push(ln);
424
+ }
425
+ for (const group of op.parameterGroups ?? []) {
426
+ for (const ln of emitGroupQueryDispatch(group, groupParamNames.get(group.name)!, ' ')) lines.push(ln);
427
+ }
428
+ lines.push(` }`);
328
429
  } else {
329
430
  // Only emit the `params` local when the method actually contributes
330
431
  // query parameters (spec-declared query, or defaults/inferFromClient
331
432
  // for GET/DELETE without a body). `RequestConfig.queryParams` defaults
332
433
  // to `emptyList()` when omitted, so we avoid dead local declarations.
333
- const emitsQueryParams = sortedQuery.length > 0 || appendDefaultsAsQuery;
434
+ // Groups go to the body for POST/PUT/PATCH (hasBody), query otherwise.
435
+ const groupsGoToQuery = hasGroups && !hasBody;
436
+ const emitsQueryParams = sortedQuery.length > 0 || appendDefaultsAsQuery || groupsGoToQuery;
334
437
  if (emitsQueryParams) {
438
+ imports.add('com.workos.common.http.addIfNotNull');
439
+ imports.add('com.workos.common.http.addJoinedIfNotNull');
440
+ imports.add('com.workos.common.http.addEach');
335
441
  lines.push(` val params = mutableListOf<Pair<String, String>>()`);
336
442
  for (const qp of sortedQuery) for (const ln of emitQueryParam(qp, ' ')) lines.push(ln);
443
+ if (groupsGoToQuery) {
444
+ for (const group of op.parameterGroups ?? []) {
445
+ for (const ln of emitGroupQueryDispatch(group, groupParamNames.get(group.name)!, ' ')) lines.push(ln);
446
+ }
447
+ }
337
448
  if (appendDefaultsAsQuery) {
338
449
  for (const [k, v] of Object.entries(defaults)) lines.push(` params += ${ktLiteral(k)} to ${ktLiteral(v)}`);
339
450
  // Client-inferred fields may be nullable (e.g. clientId). Skip when
@@ -374,6 +485,15 @@ function renderMethod(
374
485
  // Empty body (POST/PUT/PATCH still require one for OkHttp).
375
486
  lines.push(` val body = linkedMapOf<String, Any?>()`);
376
487
  }
488
+ // Parameter group values go into the body for POST/PUT/PATCH so
489
+ // sensitive fields (passwords, role slugs) never leak into the URL.
490
+ if (hasGroups) {
491
+ for (const group of op.parameterGroups ?? []) {
492
+ for (const ln of emitGroupBodyDispatch(group, groupParamNames.get(group.name)!, ' ')) {
493
+ lines.push(ln);
494
+ }
495
+ }
496
+ }
377
497
  lines.push(` val config =`);
378
498
  lines.push(` RequestConfig(`);
379
499
  lines.push(` method = ${ktLiteral(httpMethod)},`);
@@ -449,12 +569,12 @@ function resolvePaginatedItemName(name: string | null, ctx?: EmitterContext): st
449
569
  return name;
450
570
  }
451
571
 
452
- function renderParam(name: string, type: TypeRef, required: boolean): string {
453
- return renderParamNamed(propertyName(name), type, required);
572
+ function renderParam(name: string, type: TypeRef, required: boolean, forceInt = false): string {
573
+ return renderParamNamed(propertyName(name), type, required, forceInt);
454
574
  }
455
575
 
456
- function renderParamNamed(kotlinName: string, type: TypeRef, required: boolean): string {
457
- const mapped = required ? mapTypeRef(type) : mapTypeRefOptional(type);
576
+ function renderParamNamed(kotlinName: string, type: TypeRef, required: boolean, forceInt = false): string {
577
+ const mapped = forceInt ? (required ? 'Int' : 'Int?') : required ? mapTypeRef(type) : mapTypeRefOptional(type);
458
578
  return required ? ` ${kotlinName}: ${mapped}` : ` ${kotlinName}: ${mapped} = null`;
459
579
  }
460
580
 
@@ -485,19 +605,25 @@ function buildMethodKdoc(
485
605
  // @param entry even without a description so the deprecation note is
486
606
  // surfaced in the docs.
487
607
  const paramDocs: string[] = [];
608
+ const seenParamDocs = new Set<string>();
609
+ const pushParamDoc = (name: string, description: string | undefined, deprecated?: boolean) => {
610
+ if (seenParamDocs.has(name)) return;
611
+ seenParamDocs.add(name);
612
+ paramDocs.push(formatParamDoc(name, description, deprecated));
613
+ };
488
614
  for (const pp of pathParams) {
489
615
  if (pp.description?.trim() || pp.deprecated) {
490
- paramDocs.push(formatParamDoc(propertyName(pp.name), pp.description, pp.deprecated));
616
+ pushParamDoc(propertyName(pp.name), pp.description, pp.deprecated);
491
617
  }
492
618
  }
493
619
  for (const qp of queryParams) {
494
620
  if (qp.description?.trim() || qp.deprecated) {
495
- paramDocs.push(formatParamDoc(propertyName(qp.name), qp.description, qp.deprecated));
621
+ pushParamDoc(propertyName(qp.name), qp.description, qp.deprecated);
496
622
  }
497
623
  }
498
624
  for (const bf of bodyFields) {
499
625
  if (bf.description?.trim() || bf.deprecated) {
500
- paramDocs.push(formatParamDoc(bodyParamNames.get(bf.name)!, bf.description, bf.deprecated));
626
+ pushParamDoc(bodyParamNames.get(bf.name)!, bf.description, bf.deprecated);
501
627
  }
502
628
  }
503
629
 
@@ -544,17 +670,20 @@ function unwrapArray(t: TypeRef): TypeRef | null {
544
670
 
545
671
  /**
546
672
  * Serialize a single value expression for a query parameter. For enums we
547
- * use `.value` so the wire name is used; for everything else `.toString()`.
673
+ * use `.value` so the wire name is used; for strings the value is already
674
+ * the right type; for everything else `.toString()`.
548
675
  */
549
676
  function valueExprForQuery(type: TypeRef): string {
550
677
  const inner = type.kind === 'nullable' ? type.inner : type;
551
678
  if (inner.kind === 'enum') return 'it.value';
679
+ if (inner.kind === 'primitive' && inner.type === 'string') return 'it';
552
680
  return 'it.toString()';
553
681
  }
554
682
 
555
683
  function emitQueryParam(p: Parameter, indent: string): string[] {
556
684
  const prop = propertyName(p.name);
557
685
  const rendered = queryParamToString(p.type, prop);
686
+ const inner = p.type.kind === 'nullable' ? p.type.inner : p.type;
558
687
  const arrayItem = unwrapArray(p.type);
559
688
  if (arrayItem) {
560
689
  // Honor `style: form, explode: false` → comma-joined. Default (explode:true
@@ -564,24 +693,26 @@ function emitQueryParam(p: Parameter, indent: string): string[] {
564
693
  const itemExpr = valueExprForQuery(arrayItem);
565
694
  if (!explode) {
566
695
  if (p.required) {
567
- return [`${indent}params += ${ktLiteral(p.name)} to ${prop}.joinToString(",") { ${itemExpr} }`];
696
+ return [`${indent}params.addJoinedIfNotNull(${ktLiteral(p.name)}, ${prop}.map { ${itemExpr} })`];
568
697
  }
569
- return [
570
- `${indent}if (${prop} != null) params += ${ktLiteral(p.name)} to ${prop}.joinToString(",") { ${itemExpr} }`,
571
- ];
698
+ return [`${indent}params.addJoinedIfNotNull(${ktLiteral(p.name)}, ${prop}?.map { ${itemExpr} })`];
572
699
  }
573
700
  if (p.required) {
574
- return [`${indent}${prop}.forEach { params += ${ktLiteral(p.name)} to ${itemExpr} }`];
701
+ return [`${indent}params.addEach(${ktLiteral(p.name)}, ${prop}.map { ${itemExpr} })`];
575
702
  }
576
- return [`${indent}if (${prop} != null) ${prop}.forEach { params += ${ktLiteral(p.name)} to ${itemExpr} }`];
703
+ return [`${indent}${prop}?.let { params.addEach(${ktLiteral(p.name)}, it.map { ${itemExpr} }) }`];
577
704
  }
578
705
  if (p.required) return [`${indent}params += ${ktLiteral(p.name)} to ${rendered}`];
579
- return [`${indent}if (${prop} != null) params += ${ktLiteral(p.name)} to ${rendered}`];
706
+ if (inner.kind === 'primitive' && inner.type === 'string') {
707
+ return [`${indent}params.addIfNotNull(${ktLiteral(p.name)}, ${prop})`];
708
+ }
709
+ return [`${indent}${prop}?.let { params += ${ktLiteral(p.name)} to ${queryParamToString(inner, 'it')} }`];
580
710
  }
581
711
 
582
712
  function queryParamToString(type: TypeRef, varName: string): string {
583
713
  if (type.kind === 'enum') return `${varName}.value`;
584
714
  if (type.kind === 'nullable') return queryParamToString(type.inner, varName);
715
+ if (type.kind === 'primitive' && type.type === 'string') return varName;
585
716
  return `${varName}.toString()`;
586
717
  }
587
718
 
@@ -620,6 +751,32 @@ function pickNamedQueryParam(sorted: Parameter[], name: string): string {
620
751
  return match ? propertyName(match.name) : 'null';
621
752
  }
622
753
 
754
+ function generateAuthenticateHelper(): string[] {
755
+ return [
756
+ ' private fun authenticate(',
757
+ ' grantType: String,',
758
+ ' requestOptions: RequestOptions?,',
759
+ ' vararg entries: Pair<String, Any?>',
760
+ ' ): AuthenticateResponse {',
761
+ ' val body =',
762
+ ' bodyOf(',
763
+ ' *entries,',
764
+ ' "grant_type" to grantType,',
765
+ ' "client_id" to workos.clientId,',
766
+ ' "client_secret" to workos.apiKey',
767
+ ' )',
768
+ ' val config =',
769
+ ' RequestConfig(',
770
+ ' method = "POST",',
771
+ ' path = "/user_management/authenticate",',
772
+ ' body = body,',
773
+ ' requestOptions = requestOptions',
774
+ ' )',
775
+ ' return workos.baseClient.request(config, AuthenticateResponse::class.java)',
776
+ ' }',
777
+ ];
778
+ }
779
+
623
780
  function resolveBodyModel(op: Operation, ctx: EmitterContext): Model | null {
624
781
  const body = op.requestBody;
625
782
  if (!body) return null;
@@ -665,3 +822,177 @@ export function sortPathParamsByTemplateOrder(op: Operation): Parameter[] {
665
822
  function escapeKdoc(s: string): string {
666
823
  return s.replace(/\*\//g, '*\u200b/');
667
824
  }
825
+
826
+ // ---------------------------------------------------------------------------
827
+ // Mutually-exclusive parameter group support
828
+ // ---------------------------------------------------------------------------
829
+
830
+ /**
831
+ * Derive a short Kotlin property name for a parameter within a variant,
832
+ * stripping the group name prefix to avoid stuttering.
833
+ */
834
+ function deriveShortPropertyName(paramName: string, groupName: string): string {
835
+ const prefix = groupName + '_';
836
+ const stripped = paramName.startsWith(prefix) ? paramName.slice(prefix.length) : paramName;
837
+ return propertyName(stripped);
838
+ }
839
+
840
+ /**
841
+ * Generate sealed class definitions for all parameter groups in an operation.
842
+ *
843
+ * [bodyFieldTypes] is a fallback map from wire field name → TypeRef built from
844
+ * the body model. When the oagen core resolves parameter group variants it
845
+ * sometimes loses array/object types, falling back to a primitive string.
846
+ * Cross-referencing the body model corrects that.
847
+ */
848
+ function generateSealedClass(
849
+ group: import('@workos/oagen').ParameterGroup,
850
+ bodyFieldTypes?: Map<string, TypeRef>,
851
+ ): string[] {
852
+ const lines: string[] = [];
853
+ const sealedName = sealedGroupName(group.name);
854
+ lines.push(`/** Mutually exclusive ${humanize(group.name)} parameter variants. */`);
855
+ lines.push(`sealed class ${sealedName} {`);
856
+ for (let vi = 0; vi < group.variants.length; vi++) {
857
+ const variant = group.variants[vi];
858
+ const variantName = className(variant.name);
859
+ const fields = variant.parameters.map((p) => {
860
+ const prop = deriveShortPropertyName(p.name, group.name);
861
+ // Prefer the body model's field type when available — the IR parameter
862
+ // group may have lost array/object type info for body fields.
863
+ const effectiveType = bodyFieldTypes?.get(p.name) ?? p.type;
864
+ return { decl: `val ${prop}: ${mapTypeRef(effectiveType)}`, name: p.name };
865
+ });
866
+ // ktlint requires blank line before each declaration inside a sealed class
867
+ if (vi > 0) lines.push('');
868
+ // ktlint class-signature rule requires multi-line constructors
869
+ lines.push(` /** Variant: ${humanize(variant.name)}. */`);
870
+ lines.push(` data class ${variantName}(`);
871
+ for (let i = 0; i < fields.length; i++) {
872
+ const comma = i < fields.length - 1 ? ',' : '';
873
+ lines.push(` /** The ${humanize(fields[i].name)}. */`);
874
+ lines.push(` ${fields[i].decl}${comma}`);
875
+ }
876
+ lines.push(` ) : ${sealedName}()`);
877
+ }
878
+ lines.push('}');
879
+ lines.push('');
880
+ return lines;
881
+ }
882
+
883
+ /** Emit `when` dispatch that serializes a parameter group into query params. */
884
+ function emitGroupQueryDispatch(group: import('@workos/oagen').ParameterGroup, prop: string, indent: string): string[] {
885
+ const sealedName = sealedGroupName(group.name);
886
+ const lines: string[] = [];
887
+
888
+ if (group.optional) {
889
+ lines.push(`${indent}if (${prop} != null) {`);
890
+ emitWhenBlock(lines, group, sealedName, prop, `${indent} `);
891
+ lines.push(`${indent}}`);
892
+ } else {
893
+ emitWhenBlock(lines, group, sealedName, prop, indent);
894
+ }
895
+ return lines;
896
+ }
897
+
898
+ function assignGroupParameterNames(op: Operation, occupiedNames: Set<string>): Map<string, string> {
899
+ const names = new Map<string, string>();
900
+ for (const group of op.parameterGroups ?? []) {
901
+ const natural = propertyName(sealedGroupName(group.name));
902
+ const assigned = reserveUniqueGroupParameterName(natural, occupiedNames);
903
+ names.set(group.name, assigned);
904
+ }
905
+ return names;
906
+ }
907
+
908
+ function reserveUniqueGroupParameterName(base: string, occupiedNames: Set<string>): string {
909
+ if (!occupiedNames.has(base)) {
910
+ occupiedNames.add(base);
911
+ return base;
912
+ }
913
+
914
+ const capitalized = `${base.charAt(0).toUpperCase()}${base.slice(1)}`;
915
+ const prefixed = `group${capitalized}`;
916
+ if (!occupiedNames.has(prefixed)) {
917
+ occupiedNames.add(prefixed);
918
+ return prefixed;
919
+ }
920
+
921
+ let index = 2;
922
+ while (occupiedNames.has(`${prefixed}${index}`)) index += 1;
923
+ const fallback = `${prefixed}${index}`;
924
+ occupiedNames.add(fallback);
925
+ return fallback;
926
+ }
927
+
928
+ function emitWhenBlock(
929
+ lines: string[],
930
+ group: import('@workos/oagen').ParameterGroup,
931
+ sealedName: string,
932
+ prop: string,
933
+ indent: string,
934
+ ): void {
935
+ lines.push(`${indent}when (${prop}) {`);
936
+ for (const variant of group.variants) {
937
+ const variantName = className(variant.name);
938
+ const entries = variant.parameters.map((p) => {
939
+ const fieldProp = deriveShortPropertyName(p.name, group.name);
940
+ return `params += ${ktLiteral(p.name)} to ${prop}.${fieldProp}`;
941
+ });
942
+ if (entries.length === 1) {
943
+ lines.push(`${indent} is ${sealedName}.${variantName} -> ${entries[0]}`);
944
+ } else {
945
+ lines.push(`${indent} is ${sealedName}.${variantName} -> {`);
946
+ for (const e of entries) lines.push(`${indent} ${e}`);
947
+ lines.push(`${indent} }`);
948
+ }
949
+ }
950
+ lines.push(`${indent}}`);
951
+ }
952
+
953
+ /** Emit `when` dispatch that serializes a parameter group into the request body map. */
954
+ function emitGroupBodyDispatch(group: import('@workos/oagen').ParameterGroup, prop: string, indent: string): string[] {
955
+ const sealedName = sealedGroupName(group.name);
956
+ const lines: string[] = [];
957
+
958
+ if (group.optional) {
959
+ lines.push(`${indent}if (${prop} != null) {`);
960
+ emitBodyWhenBlock(lines, group, sealedName, prop, `${indent} `);
961
+ lines.push(`${indent}}`);
962
+ } else {
963
+ emitBodyWhenBlock(lines, group, sealedName, prop, indent);
964
+ }
965
+ return lines;
966
+ }
967
+
968
+ function sealedGroupName(name: string): string {
969
+ const resolved = className(name);
970
+ if (resolved === 'Password') return 'CreateUserPassword';
971
+ if (resolved === 'Role') return 'CreateUserRole';
972
+ return resolved;
973
+ }
974
+
975
+ function emitBodyWhenBlock(
976
+ lines: string[],
977
+ group: import('@workos/oagen').ParameterGroup,
978
+ sealedName: string,
979
+ prop: string,
980
+ indent: string,
981
+ ): void {
982
+ lines.push(`${indent}when (${prop}) {`);
983
+ for (const variant of group.variants) {
984
+ const variantName = className(variant.name);
985
+ const entries = variant.parameters.map((p) => {
986
+ const fieldProp = deriveShortPropertyName(p.name, group.name);
987
+ return `body[${ktLiteral(p.name)}] = ${prop}.${fieldProp}`;
988
+ });
989
+ if (entries.length === 1) {
990
+ lines.push(`${indent} is ${sealedName}.${variantName} -> ${entries[0]}`);
991
+ } else {
992
+ lines.push(`${indent} is ${sealedName}.${variantName} -> {`);
993
+ for (const e of entries) lines.push(`${indent} ${e}`);
994
+ lines.push(`${indent} }`);
995
+ }
996
+ }
997
+ lines.push(`${indent}}`);
998
+ }