@workos/oagen-emitters 0.3.0 → 0.5.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 (128) 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 +12 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -12737
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.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 +336 -0
  19. package/oagen.config.ts +5 -343
  20. package/package.json +10 -34
  21. package/smoke/sdk-dotnet.ts +45 -12
  22. package/src/dotnet/client.ts +89 -0
  23. package/src/dotnet/enums.ts +323 -0
  24. package/src/dotnet/fixtures.ts +236 -0
  25. package/src/dotnet/index.ts +248 -0
  26. package/src/dotnet/manifest.ts +36 -0
  27. package/src/dotnet/models.ts +320 -0
  28. package/src/dotnet/naming.ts +368 -0
  29. package/src/dotnet/resources.ts +943 -0
  30. package/src/dotnet/tests.ts +713 -0
  31. package/src/dotnet/type-map.ts +228 -0
  32. package/src/dotnet/wrappers.ts +197 -0
  33. package/src/go/client.ts +35 -3
  34. package/src/go/enums.ts +4 -0
  35. package/src/go/index.ts +15 -7
  36. package/src/go/models.ts +6 -1
  37. package/src/go/naming.ts +5 -17
  38. package/src/go/resources.ts +534 -73
  39. package/src/go/tests.ts +39 -3
  40. package/src/go/type-map.ts +8 -3
  41. package/src/go/wrappers.ts +79 -21
  42. package/src/index.ts +15 -0
  43. package/src/kotlin/client.ts +58 -0
  44. package/src/kotlin/enums.ts +189 -0
  45. package/src/kotlin/index.ts +92 -0
  46. package/src/kotlin/manifest.ts +55 -0
  47. package/src/kotlin/models.ts +486 -0
  48. package/src/kotlin/naming.ts +229 -0
  49. package/src/kotlin/overrides.ts +25 -0
  50. package/src/kotlin/resources.ts +998 -0
  51. package/src/kotlin/tests.ts +1133 -0
  52. package/src/kotlin/type-map.ts +123 -0
  53. package/src/kotlin/wrappers.ts +168 -0
  54. package/src/node/client.ts +84 -7
  55. package/src/node/field-plan.ts +12 -14
  56. package/src/node/fixtures.ts +39 -3
  57. package/src/node/index.ts +1 -0
  58. package/src/node/models.ts +281 -37
  59. package/src/node/resources.ts +319 -95
  60. package/src/node/tests.ts +108 -29
  61. package/src/node/type-map.ts +1 -31
  62. package/src/node/utils.ts +96 -6
  63. package/src/node/wrappers.ts +31 -1
  64. package/src/php/client.ts +11 -3
  65. package/src/php/models.ts +0 -33
  66. package/src/php/naming.ts +2 -21
  67. package/src/php/resources.ts +275 -19
  68. package/src/php/tests.ts +118 -18
  69. package/src/php/type-map.ts +16 -2
  70. package/src/php/wrappers.ts +7 -2
  71. package/src/plugin.ts +50 -0
  72. package/src/python/client.ts +50 -32
  73. package/src/python/enums.ts +35 -10
  74. package/src/python/index.ts +35 -27
  75. package/src/python/models.ts +139 -2
  76. package/src/python/naming.ts +2 -22
  77. package/src/python/resources.ts +234 -17
  78. package/src/python/tests.ts +260 -16
  79. package/src/python/type-map.ts +16 -2
  80. package/src/ruby/client.ts +238 -0
  81. package/src/ruby/enums.ts +149 -0
  82. package/src/ruby/index.ts +93 -0
  83. package/src/ruby/manifest.ts +35 -0
  84. package/src/ruby/models.ts +360 -0
  85. package/src/ruby/naming.ts +187 -0
  86. package/src/ruby/rbi.ts +313 -0
  87. package/src/ruby/resources.ts +799 -0
  88. package/src/ruby/tests.ts +459 -0
  89. package/src/ruby/type-map.ts +97 -0
  90. package/src/ruby/wrappers.ts +161 -0
  91. package/src/shared/model-utils.ts +357 -16
  92. package/src/shared/naming-utils.ts +83 -0
  93. package/src/shared/non-spec-services.ts +13 -0
  94. package/src/shared/resolved-ops.ts +75 -1
  95. package/src/shared/wrapper-utils.ts +12 -1
  96. package/test/dotnet/client.test.ts +121 -0
  97. package/test/dotnet/enums.test.ts +193 -0
  98. package/test/dotnet/errors.test.ts +9 -0
  99. package/test/dotnet/manifest.test.ts +82 -0
  100. package/test/dotnet/models.test.ts +258 -0
  101. package/test/dotnet/resources.test.ts +387 -0
  102. package/test/dotnet/tests.test.ts +202 -0
  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 +135 -0
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/kotlin/tests.test.ts +176 -0
  109. package/test/node/client.test.ts +74 -0
  110. package/test/node/models.test.ts +134 -1
  111. package/test/node/resources.test.ts +343 -34
  112. package/test/node/utils.test.ts +140 -0
  113. package/test/php/client.test.ts +2 -1
  114. package/test/php/models.test.ts +5 -4
  115. package/test/php/resources.test.ts +103 -0
  116. package/test/php/tests.test.ts +67 -0
  117. package/test/plugin.test.ts +50 -0
  118. package/test/python/client.test.ts +56 -0
  119. package/test/python/models.test.ts +99 -0
  120. package/test/python/resources.test.ts +294 -0
  121. package/test/python/tests.test.ts +91 -0
  122. package/test/ruby/client.test.ts +81 -0
  123. package/test/ruby/resources.test.ts +386 -0
  124. package/test/shared/resolved-ops.test.ts +122 -0
  125. package/tsdown.config.ts +1 -1
  126. package/dist/index.mjs.map +0 -1
  127. package/scripts/generate-php.js +0 -13
  128. package/scripts/git-push-with-published-oagen.sh +0 -21
@@ -0,0 +1,998 @@
1
+ import type {
2
+ Service,
3
+ Operation,
4
+ Parameter,
5
+ EmitterContext,
6
+ GeneratedFile,
7
+ ResolvedOperation,
8
+ Model,
9
+ TypeRef,
10
+ Field,
11
+ } from '@workos/oagen';
12
+ import { planOperation } from '@workos/oagen';
13
+ import { mapTypeRef, mapTypeRefOptional, implicitImportsFor } from './type-map.js';
14
+ import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
15
+ import { enumCanonicalMap } from './enums.js';
16
+ import {
17
+ className,
18
+ propertyName,
19
+ apiClassName,
20
+ packageSegment,
21
+ resolveMethodName,
22
+ ktLiteral,
23
+ clientFieldExpression,
24
+ escapeReserved,
25
+ humanize,
26
+ } from './naming.js';
27
+ import {
28
+ buildResolvedLookup,
29
+ lookupResolved,
30
+ groupByMount,
31
+ buildHiddenParams,
32
+ getOpDefaults,
33
+ getOpInferFromClient,
34
+ collectGroupedParamNames,
35
+ collectBodyFieldTypes,
36
+ } from '../shared/resolved-ops.js';
37
+ import { generateWrapperMethods } from './wrappers.js';
38
+ import { resolveWrapperParams } from '../shared/wrapper-utils.js';
39
+ import { isHandwrittenOverride } from './overrides.js';
40
+
41
+ const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
42
+
43
+ /**
44
+ * Generate one API class per mount group. Methods map 1:1 to IR operations.
45
+ * Path params, query params, and body fields are flattened into the method
46
+ * signature so callers never need to construct an intermediate options object.
47
+ */
48
+ export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
49
+ if (services.length === 0) return [];
50
+
51
+ const mountGroups = groupByMount(ctx);
52
+ if (mountGroups.size === 0) return [];
53
+
54
+ const files: GeneratedFile[] = [];
55
+ const resolvedLookup = buildResolvedLookup(ctx);
56
+
57
+ for (const [mountName, group] of mountGroups) {
58
+ const classCode = generateApiClass(mountName, group.operations, ctx, resolvedLookup);
59
+ if (!classCode) continue;
60
+ const pkg = packageSegment(mountName);
61
+ files.push({
62
+ path: `${KOTLIN_SRC_PREFIX}com/workos/${pkg}/${apiClassName(mountName)}.kt`,
63
+ content: classCode,
64
+ overwriteExisting: true,
65
+ });
66
+ }
67
+
68
+ return files;
69
+ }
70
+
71
+ function generateApiClass(
72
+ mountName: string,
73
+ operations: Operation[],
74
+ ctx: EmitterContext,
75
+ resolvedLookup: Map<string, ResolvedOperation>,
76
+ ): string | null {
77
+ if (operations.length === 0) return null;
78
+ const apiClass = apiClassName(mountName);
79
+ const pkg = `com.workos.${packageSegment(mountName)}`;
80
+
81
+ const imports = new Set<string>();
82
+ imports.add('com.workos.WorkOS');
83
+ imports.add('com.workos.common.http.Page');
84
+ imports.add('com.workos.common.http.RequestConfig');
85
+ imports.add('com.workos.common.http.RequestOptions');
86
+
87
+ const body: string[] = [];
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
+ }
98
+
99
+ for (const op of operations) {
100
+ if (isHandwrittenOverride(op)) continue;
101
+ const resolvedOp = lookupResolved(op, resolvedLookup);
102
+ if ((resolvedOp?.wrappers?.length ?? 0) > 0) {
103
+ // Emit one method per wrapper instead of the raw union-split operation.
104
+ for (const wrapper of resolvedOp!.wrappers!) {
105
+ if (wrapper.responseModelName) {
106
+ imports.add(`com.workos.models.${className(wrapper.responseModelName)}`);
107
+ }
108
+ // Register imports for wrapper param field types
109
+ const resolvedParams = resolveWrapperParams(wrapper, ctx);
110
+ for (const rp of resolvedParams) {
111
+ if (rp.field) registerTypeImports(rp.field.type, imports, ctx);
112
+ }
113
+ }
114
+ // Wrapper methods use bodyOf() for request body construction.
115
+ imports.add('com.workos.common.http.bodyOf');
116
+ const wrapperLines = generateWrapperMethods(resolvedOp!, ctx);
117
+ if (body.length > 0) body.push('');
118
+ for (const line of wrapperLines) body.push(line);
119
+ continue;
120
+ }
121
+
122
+ const method = resolveMethodName(op, findService(ctx, op) ?? ({} as Service), ctx);
123
+ if (seenMethods.has(method)) continue;
124
+ seenMethods.add(method);
125
+
126
+ const rendered = renderMethod(mountName, method, op, ctx, resolvedOp, imports);
127
+ if (body.length > 0) body.push('');
128
+ body.push(rendered);
129
+ }
130
+
131
+ if (body.length === 0) return null;
132
+
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');
165
+ const filteredImports = [...imports].filter((imp) => {
166
+ const simple = imp.slice(imp.lastIndexOf('.') + 1);
167
+ // Skip the import if the class body never references the simple name.
168
+ if (simple === 'WorkOS' || simple === 'RequestOptions') return true;
169
+ return new RegExp(`\\b${simple}\\b`).test(allText);
170
+ });
171
+
172
+ const lines: string[] = [];
173
+ lines.push(`package ${pkg}`);
174
+ lines.push('');
175
+ for (const imp of filteredImports.sort()) lines.push(`import ${imp}`);
176
+ lines.push('');
177
+ for (const line of sealedLines) lines.push(line);
178
+
179
+ const serviceDescription = resolveServiceDescription(ctx, mountName, operations);
180
+ if (serviceDescription) {
181
+ const docLines = serviceDescription.trim().split('\n');
182
+ if (docLines.length === 1) {
183
+ lines.push(`/** ${escapeKdoc(docLines[0].trim())} */`);
184
+ } else {
185
+ lines.push('/**');
186
+ for (const l of docLines) lines.push(l ? ` * ${escapeKdoc(l)}` : ' *');
187
+ lines.push(' */');
188
+ }
189
+ } else {
190
+ lines.push(`/** API accessor for ${mountName}. */`);
191
+ }
192
+ // ktlint requires constructor-property parameters on their own line.
193
+ // The property is `internal` so hand-maintained extension files in the
194
+ // same module can reach the underlying [WorkOS] client (e.g. to build
195
+ // URLs that are not HTTP calls).
196
+ lines.push(`class ${apiClass}(`);
197
+ lines.push(' internal val workos: WorkOS');
198
+ lines.push(`) {`);
199
+ for (const line of body) lines.push(line);
200
+ lines.push('}');
201
+ lines.push('');
202
+ return lines.join('\n');
203
+ }
204
+
205
+ function findService(ctx: EmitterContext, op: Operation): Service | undefined {
206
+ for (const service of ctx.spec.services) {
207
+ if (service.operations.includes(op)) return service;
208
+ }
209
+ return undefined;
210
+ }
211
+
212
+ /**
213
+ * Resolve a human-friendly description for a generated API class. Walks the
214
+ * operations in the mount group, picks the first service whose description
215
+ * is populated, and falls back to `null` when nothing meaningful is
216
+ * available (the caller uses a generic fallback).
217
+ */
218
+ function resolveServiceDescription(ctx: EmitterContext, _mountName: string, operations: Operation[]): string | null {
219
+ for (const op of operations) {
220
+ const svc = findService(ctx, op);
221
+ if (svc?.description?.trim()) return svc.description;
222
+ }
223
+ return null;
224
+ }
225
+
226
+ /**
227
+ * Render a single SDK method for an operation.
228
+ */
229
+ function renderMethod(
230
+ _mountName: string,
231
+ method: string,
232
+ op: Operation,
233
+ ctx: EmitterContext,
234
+ resolvedOp: ResolvedOperation | undefined,
235
+ imports: Set<string>,
236
+ ): string {
237
+ const plan = planOperation(op);
238
+ const hidden = buildHiddenParams(resolvedOp);
239
+ const defaults = getOpDefaults(resolvedOp);
240
+ const inferFromClient = getOpInferFromClient(resolvedOp);
241
+
242
+ const httpMethod = op.httpMethod.toUpperCase();
243
+ const pathParams = sortPathParamsByTemplateOrder(op);
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));
247
+ const bodyModel = resolveBodyModel(op, ctx);
248
+ const bodyFields = bodyModel
249
+ ? bodyModel.fields.filter((f) => !hidden.has(f.name) && !groupedParamNames.has(f.name))
250
+ : [];
251
+
252
+ // Track imports we need
253
+ for (const p of [...pathParams, ...queryParams]) registerTypeImports(p.type, imports, ctx);
254
+ for (const f of bodyFields) registerTypeImports(f.type, imports, ctx);
255
+ const paginatedItemName = resolvePaginatedItemName(plan.paginatedItemModelName, ctx);
256
+ if (plan.responseModelName && !plan.isPaginated) {
257
+ imports.add(`com.workos.models.${className(plan.responseModelName)}`);
258
+ }
259
+ if (paginatedItemName) {
260
+ imports.add(`com.workos.models.${className(paginatedItemName)}`);
261
+ imports.add('com.fasterxml.jackson.core.type.TypeReference');
262
+ }
263
+
264
+ // Deduplicate: path params take precedence; query params second; body last.
265
+ // If a body field collides with a path/query param, rename the body field's
266
+ // Kotlin parameter (e.g. `slug` → `bodySlug`) so callers can pass both
267
+ // values. The wire name on the body map still uses the original field name.
268
+ const paramNames = new Set<string>();
269
+ for (const pp of pathParams) paramNames.add(propertyName(pp.name));
270
+ const uniqueQuery = queryParams.filter((qp) => !paramNames.has(propertyName(qp.name)));
271
+ for (const qp of uniqueQuery) paramNames.add(propertyName(qp.name));
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
+
279
+ // Map body field wire name → Kotlin parameter name. When the natural name
280
+ // collides with a path/query, prefix with `body` (e.g. slug → bodySlug).
281
+ const bodyParamNames = new Map<string, string>();
282
+ for (const bf of bodyFields) {
283
+ const natural = propertyName(bf.name);
284
+ if (sharedQueryBodyParams.has(bf.name)) {
285
+ bodyParamNames.set(bf.name, natural);
286
+ continue;
287
+ }
288
+ if (paramNames.has(natural)) {
289
+ const renamed = `body${natural.charAt(0).toUpperCase()}${natural.slice(1)}`;
290
+ bodyParamNames.set(bf.name, renamed);
291
+ paramNames.add(renamed);
292
+ } else {
293
+ bodyParamNames.set(bf.name, natural);
294
+ paramNames.add(natural);
295
+ }
296
+ }
297
+
298
+ const groupParamNames = assignGroupParameterNames(op, paramNames);
299
+
300
+ const params: string[] = [];
301
+ for (const pp of pathParams) params.push(` ${propertyName(pp.name)}: String`);
302
+
303
+ const sortedQuery = [...uniqueQuery].sort((a, b) => (a.required === b.required ? 0 : a.required ? -1 : 1));
304
+ for (const qp of sortedQuery) {
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
+ }
317
+ }
318
+
319
+ // PATCH operations use PatchField<T> for optional body fields so callers
320
+ // can distinguish "omit" (Absent) from "clear" (Present(null)).
321
+ const isPatch = httpMethod === 'PATCH';
322
+
323
+ const sortedBodyFields = [...bodyFields].sort((a, b) => (a.required === b.required ? 0 : a.required ? -1 : 1));
324
+ for (const bf of sortedBodyFields) {
325
+ if (sharedQueryBodyParams.has(bf.name)) continue;
326
+ if (isPatch && !bf.required) {
327
+ const baseType = mapTypeRef(bf.type);
328
+ imports.add('com.workos.common.http.PatchField');
329
+ params.push(` ${bodyParamNames.get(bf.name)!}: PatchField<${baseType}> = PatchField.Absent`);
330
+ } else {
331
+ params.push(renderParamNamed(bodyParamNames.get(bf.name)!, bf.type, bf.required));
332
+ }
333
+ }
334
+
335
+ // Per-request options trailer (always optional)
336
+ params.push(' requestOptions: RequestOptions? = null');
337
+
338
+ const returnType = resolveReturnType(plan, imports, ctx);
339
+ const isPaginated = plan.isPaginated && paginatedItemName !== null;
340
+
341
+ const lines: string[] = [];
342
+ const kdocLines = buildMethodKdoc(op, pathParams, sortedQuery, sortedBodyFields, bodyParamNames, plan);
343
+ for (const ln of kdocLines) lines.push(ln);
344
+ if (op.deprecated) lines.push(' @Deprecated("Deprecated operation")');
345
+ lines.push(' @JvmOverloads');
346
+ // Omit explicit `: Unit` to keep ktlint happy.
347
+ const returnClause = returnType === 'Unit' ? '' : `: ${returnType}`;
348
+ if (params.length === 1) {
349
+ // Single param fits on one line; ktlint enforces inline form.
350
+ const singleParam = params[0].replace(/^\s+/, '');
351
+ lines.push(` fun ${escapeReserved(method)}(${singleParam})${returnClause} {`);
352
+ } else {
353
+ lines.push(` fun ${escapeReserved(method)}(`);
354
+ for (let i = 0; i < params.length; i++) {
355
+ const suffix = i === params.length - 1 ? '' : ',';
356
+ lines.push(`${params[i]}${suffix}`);
357
+ }
358
+ lines.push(` )${returnClause} {`);
359
+ }
360
+
361
+ // Build body / query config
362
+ //
363
+ // POST/PUT/PATCH always need a request body — OkHttp rejects them otherwise.
364
+ // DELETE and GET only emit a body when the spec explicitly declares one
365
+ // (OpenAPI allows DELETE-with-body; GET-with-body is uncommon but legal).
366
+ // GET never carries defaults/inferFromClient in the body — those fall back
367
+ // to the query string for GET.
368
+ const methodAlwaysHasBody = ['POST', 'PUT', 'PATCH'].includes(httpMethod);
369
+ const specDeclaresBody = op.requestBody !== undefined;
370
+ const hasBody =
371
+ methodAlwaysHasBody ||
372
+ (specDeclaresBody && httpMethod !== 'GET') ||
373
+ ((httpMethod === 'PUT' || httpMethod === 'PATCH' || httpMethod === 'POST' || httpMethod === 'DELETE') &&
374
+ (Object.keys(defaults).length > 0 || inferFromClient.length > 0) &&
375
+ specDeclaresBody);
376
+ const appendDefaultsAsQuery = !hasBody && (Object.keys(defaults).length > 0 || inferFromClient.length > 0);
377
+ const pathExpr = buildPathExpression(op.path, pathParams);
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
+
401
+ if (isPaginated) {
402
+ // Nested helper function + requestPage call; 'after' is owned by the
403
+ // cursor logic so we skip it in the generic query loop.
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');
411
+ const itemClass = className(paginatedItemName!);
412
+ lines.push(` val itemType = object : TypeReference<${itemClass}>() {}`);
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(` }`);
429
+ } else {
430
+ // Only emit the `params` local when the method actually contributes
431
+ // query parameters (spec-declared query, or defaults/inferFromClient
432
+ // for GET/DELETE without a body). `RequestConfig.queryParams` defaults
433
+ // to `emptyList()` when omitted, so we avoid dead local declarations.
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;
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');
441
+ lines.push(` val params = mutableListOf<Pair<String, String>>()`);
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
+ }
448
+ if (appendDefaultsAsQuery) {
449
+ for (const [k, v] of Object.entries(defaults)) lines.push(` params += ${ktLiteral(k)} to ${ktLiteral(v)}`);
450
+ // Client-inferred fields may be nullable (e.g. clientId). Skip when
451
+ // null rather than serializing "null" into the URL.
452
+ for (const k of inferFromClient) {
453
+ lines.push(` workos.${clientFieldExpression(k)}?.let { params += ${ktLiteral(k)} to it }`);
454
+ }
455
+ }
456
+ }
457
+
458
+ if (hasBody) {
459
+ // Use bodyOf() / patchBodyOf() helpers to build the request body in a
460
+ // single expression. This drops null optional values automatically
461
+ // instead of repeating `if (x != null) body["x"] = x` per field.
462
+ const helperFn = isPatch ? 'patchBodyOf' : 'bodyOf';
463
+ imports.add(`com.workos.common.http.${helperFn}`);
464
+ const bodyEntries: string[] = [];
465
+ for (const bf of sortedBodyFields) {
466
+ const prop = bodyParamNames.get(bf.name)!;
467
+ bodyEntries.push(` ${ktLiteral(bf.name)} to ${prop}`);
468
+ }
469
+ for (const [k, v] of Object.entries(defaults)) {
470
+ bodyEntries.push(` ${ktLiteral(k)} to ${ktLiteral(v)}`);
471
+ }
472
+ for (const k of inferFromClient) {
473
+ bodyEntries.push(` ${ktLiteral(k)} to workos.${clientFieldExpression(k)}`);
474
+ }
475
+ if (bodyEntries.length > 0) {
476
+ // ktlint: "A multiline expression should start on a new line"
477
+ lines.push(` val body =`);
478
+ lines.push(` ${helperFn}(`);
479
+ for (let i = 0; i < bodyEntries.length; i++) {
480
+ const sep = i === bodyEntries.length - 1 ? '' : ',';
481
+ lines.push(` ${bodyEntries[i]}${sep}`);
482
+ }
483
+ lines.push(` )`);
484
+ } else {
485
+ // Empty body (POST/PUT/PATCH still require one for OkHttp).
486
+ lines.push(` val body = linkedMapOf<String, Any?>()`);
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
+ }
497
+ lines.push(` val config =`);
498
+ lines.push(` RequestConfig(`);
499
+ lines.push(` method = ${ktLiteral(httpMethod)},`);
500
+ lines.push(` path = ${pathExpr},`);
501
+ if (emitsQueryParams) lines.push(` queryParams = params,`);
502
+ lines.push(` body = body,`);
503
+ lines.push(` requestOptions = requestOptions`);
504
+ lines.push(` )`);
505
+ } else {
506
+ lines.push(` val config =`);
507
+ lines.push(` RequestConfig(`);
508
+ lines.push(` method = ${ktLiteral(httpMethod)},`);
509
+ lines.push(` path = ${pathExpr},`);
510
+ if (emitsQueryParams) lines.push(` queryParams = params,`);
511
+ lines.push(` requestOptions = requestOptions`);
512
+ lines.push(` )`);
513
+ }
514
+
515
+ if (plan.responseModelName && plan.isArrayResponse) {
516
+ // `type: array` response — deserialize as List<T> via TypeReference.
517
+ const itemClass = className(plan.responseModelName);
518
+ imports.add('com.fasterxml.jackson.core.type.TypeReference');
519
+ lines.push(` val responseType = object : TypeReference<List<${itemClass}>>() {}`);
520
+ lines.push(` return workos.baseClient.request(config, responseType)`);
521
+ } else if (plan.responseModelName) {
522
+ const responseClass = className(plan.responseModelName);
523
+ lines.push(` return workos.baseClient.request(config, ${responseClass}::class.java)`);
524
+ } else if (plan.isDelete || !plan.isModelResponse) {
525
+ lines.push(` workos.baseClient.requestVoid(config)`);
526
+ } else {
527
+ lines.push(` workos.baseClient.requestVoid(config)`);
528
+ }
529
+ }
530
+
531
+ lines.push(' }');
532
+ return lines.join('\n');
533
+ }
534
+
535
+ function resolveReturnType(plan: ReturnType<typeof planOperation>, imports: Set<string>, ctx?: EmitterContext): string {
536
+ const itemName = plan.isPaginated
537
+ ? (resolvePaginatedItemName(plan.paginatedItemModelName, ctx) ?? plan.paginatedItemModelName)
538
+ : null;
539
+ if (plan.isPaginated && itemName) {
540
+ const item = className(itemName);
541
+ imports.add(`com.workos.models.${item}`);
542
+ return `Page<${item}>`;
543
+ }
544
+ if (plan.responseModelName && plan.isArrayResponse) {
545
+ const cls = className(plan.responseModelName);
546
+ imports.add(`com.workos.models.${cls}`);
547
+ return `List<${cls}>`;
548
+ }
549
+ if (plan.responseModelName) {
550
+ const cls = className(plan.responseModelName);
551
+ imports.add(`com.workos.models.${cls}`);
552
+ return cls;
553
+ }
554
+ return 'Unit';
555
+ }
556
+
557
+ /**
558
+ * If [paginatedItemModelName] points to a list wrapper (`{ data, list_metadata }`),
559
+ * unwrap it and return the actual item model name. Otherwise return as-is.
560
+ */
561
+ function resolvePaginatedItemName(name: string | null, ctx?: EmitterContext): string | null {
562
+ if (!name || !ctx) return name;
563
+ const model = ctx.spec.models.find((m) => m.name === name);
564
+ if (!model) return name;
565
+ const dataField = model.fields.find((f) => f.name === 'data');
566
+ if (!dataField || dataField.type.kind !== 'array') return name;
567
+ const items = dataField.type.items;
568
+ if (items.kind === 'model') return items.name;
569
+ return name;
570
+ }
571
+
572
+ function renderParam(name: string, type: TypeRef, required: boolean, forceInt = false): string {
573
+ return renderParamNamed(propertyName(name), type, required, forceInt);
574
+ }
575
+
576
+ function renderParamNamed(kotlinName: string, type: TypeRef, required: boolean, forceInt = false): string {
577
+ const mapped = forceInt ? (required ? 'Int' : 'Int?') : required ? mapTypeRef(type) : mapTypeRefOptional(type);
578
+ return required ? ` ${kotlinName}: ${mapped}` : ` ${kotlinName}: ${mapped} = null`;
579
+ }
580
+
581
+ /**
582
+ * Build the KDoc block preceding an SDK method. Combines the operation's
583
+ * summary/description with `@param` docs for every parameter that has a
584
+ * description in the spec, `@return` when a response model is known, and
585
+ * `@throws` for the standard error types.
586
+ */
587
+ function buildMethodKdoc(
588
+ op: Operation,
589
+ pathParams: Parameter[],
590
+ queryParams: Parameter[],
591
+ bodyFields: Field[],
592
+ bodyParamNames: Map<string, string>,
593
+ plan: ReturnType<typeof planOperation>,
594
+ ): string[] {
595
+ // Use the operation's description as the KDoc body, split by newline.
596
+ // Escape `*/` sequences to keep KDoc valid.
597
+ const descriptionRaw = (op.description ?? '').trim();
598
+ const textLines: string[] = [];
599
+ if (descriptionRaw) {
600
+ for (const l of descriptionRaw.split('\n')) textLines.push(escapeKdoc(l));
601
+ }
602
+
603
+ // @param lines. Use the Kotlin-visible parameter name (body collisions get
604
+ // renamed, e.g. slug → bodySlug). Deprecated parameters always get a
605
+ // @param entry even without a description so the deprecation note is
606
+ // surfaced in the docs.
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
+ };
614
+ for (const pp of pathParams) {
615
+ if (pp.description?.trim() || pp.deprecated) {
616
+ pushParamDoc(propertyName(pp.name), pp.description, pp.deprecated);
617
+ }
618
+ }
619
+ for (const qp of queryParams) {
620
+ if (qp.description?.trim() || qp.deprecated) {
621
+ pushParamDoc(propertyName(qp.name), qp.description, qp.deprecated);
622
+ }
623
+ }
624
+ for (const bf of bodyFields) {
625
+ if (bf.description?.trim() || bf.deprecated) {
626
+ pushParamDoc(bodyParamNames.get(bf.name)!, bf.description, bf.deprecated);
627
+ }
628
+ }
629
+
630
+ const returnDoc = plan.isPaginated
631
+ ? '@return a [com.workos.common.http.Page] of results'
632
+ : plan.responseModelName
633
+ ? `@return the ${plan.isArrayResponse ? `list of ${className(plan.responseModelName)}` : className(plan.responseModelName)}`
634
+ : null;
635
+
636
+ const hasAnyContent = textLines.length > 0 || paramDocs.length > 0 || returnDoc !== null;
637
+ if (!hasAnyContent) return [];
638
+
639
+ const out: string[] = [' /**'];
640
+ for (const l of textLines) out.push(l ? ` * ${l}` : ' *');
641
+ const hasBodyText = textLines.length > 0;
642
+ const needsSpacer = hasBodyText && (paramDocs.length > 0 || returnDoc !== null);
643
+ if (needsSpacer) out.push(' *');
644
+ for (const p of paramDocs) out.push(` * ${p}`);
645
+ if (returnDoc) {
646
+ if (paramDocs.length > 0) out.push(' *');
647
+ out.push(` * ${returnDoc}`);
648
+ }
649
+ out.push(' */');
650
+ return out;
651
+ }
652
+
653
+ function formatParamDoc(kotlinName: string, description: string | undefined, deprecated?: boolean): string {
654
+ const firstLine = description?.split('\n').find((l) => l.trim()) ?? '';
655
+ const text = firstLine.trim();
656
+ const deprecationNote = deprecated ? '**Deprecated.**' : '';
657
+ const parts = [deprecationNote, text].filter(Boolean).join(' ');
658
+ return `@param ${kotlinName}${parts ? ` ${escapeKdoc(parts)}` : ''}`;
659
+ }
660
+
661
+ /**
662
+ * Unwrap a possibly-nullable type to check if the inner type is an array,
663
+ * and return the array's item type for downstream serialization decisions.
664
+ */
665
+ function unwrapArray(t: TypeRef): TypeRef | null {
666
+ if (t.kind === 'array') return t.items;
667
+ if (t.kind === 'nullable' && t.inner.kind === 'array') return t.inner.items;
668
+ return null;
669
+ }
670
+
671
+ /**
672
+ * Serialize a single value expression for a query parameter. For enums we
673
+ * use `.value` so the wire name is used; for strings the value is already
674
+ * the right type; for everything else `.toString()`.
675
+ */
676
+ function valueExprForQuery(type: TypeRef): string {
677
+ const inner = type.kind === 'nullable' ? type.inner : type;
678
+ if (inner.kind === 'enum') return 'it.value';
679
+ if (inner.kind === 'primitive' && inner.type === 'string') return 'it';
680
+ return 'it.toString()';
681
+ }
682
+
683
+ function emitQueryParam(p: Parameter, indent: string): string[] {
684
+ const prop = propertyName(p.name);
685
+ const rendered = queryParamToString(p.type, prop);
686
+ const inner = p.type.kind === 'nullable' ? p.type.inner : p.type;
687
+ const arrayItem = unwrapArray(p.type);
688
+ if (arrayItem) {
689
+ // Honor `style: form, explode: false` → comma-joined. Default (explode:true
690
+ // or unspecified for form) → repeated keys. `p.explode ?? true` matches
691
+ // the OpenAPI default for query parameters when `style` is form.
692
+ const explode = p.explode ?? true;
693
+ const itemExpr = valueExprForQuery(arrayItem);
694
+ if (!explode) {
695
+ if (p.required) {
696
+ return [`${indent}params.addJoinedIfNotNull(${ktLiteral(p.name)}, ${prop}.map { ${itemExpr} })`];
697
+ }
698
+ return [`${indent}params.addJoinedIfNotNull(${ktLiteral(p.name)}, ${prop}?.map { ${itemExpr} })`];
699
+ }
700
+ if (p.required) {
701
+ return [`${indent}params.addEach(${ktLiteral(p.name)}, ${prop}.map { ${itemExpr} })`];
702
+ }
703
+ return [`${indent}${prop}?.let { params.addEach(${ktLiteral(p.name)}, it.map { ${itemExpr} }) }`];
704
+ }
705
+ if (p.required) return [`${indent}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')} }`];
710
+ }
711
+
712
+ function queryParamToString(type: TypeRef, varName: string): string {
713
+ if (type.kind === 'enum') return `${varName}.value`;
714
+ if (type.kind === 'nullable') return queryParamToString(type.inner, varName);
715
+ if (type.kind === 'primitive' && type.type === 'string') return varName;
716
+ return `${varName}.toString()`;
717
+ }
718
+
719
+ function _emitBodyField(field: Field, kotlinParamName: string, isPatch: boolean): string[] {
720
+ const prop = kotlinParamName;
721
+ if (field.required) return [` body[${ktLiteral(field.name)}] = ${prop}`];
722
+ // PATCH: PatchField<T> — serialize Present(value) including explicit null;
723
+ // skip Absent entirely so the server preserves the field's current value.
724
+ if (isPatch) {
725
+ return [` if (${prop} is PatchField.Present) body[${ktLiteral(field.name)}] = ${prop}.value`];
726
+ }
727
+ return [` if (${prop} != null) body[${ktLiteral(field.name)}] = ${prop}`];
728
+ }
729
+
730
+ function buildPathExpression(path: string, pathParams: Parameter[]): string {
731
+ if (pathParams.length === 0) return ktLiteral(path);
732
+ let result = path;
733
+ for (const pp of pathParams) {
734
+ const placeholder = `{${pp.name}}`;
735
+ const propName = propertyName(pp.name);
736
+ // Use $propName for simple identifiers and ${propName} only when followed by
737
+ // an ident-continuing char (to avoid false continuations). ktlint prefers the
738
+ // unbraced form for bare identifiers.
739
+ const replacement = isBareIdentifier(propName) ? `\$${propName}` : `\${${propName}}`;
740
+ result = result.replaceAll(placeholder, replacement);
741
+ }
742
+ return `"${result.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
743
+ }
744
+
745
+ function isBareIdentifier(name: string): boolean {
746
+ return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
747
+ }
748
+
749
+ function pickNamedQueryParam(sorted: Parameter[], name: string): string {
750
+ const match = sorted.find((p) => p.name === name);
751
+ return match ? propertyName(match.name) : 'null';
752
+ }
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
+
780
+ function resolveBodyModel(op: Operation, ctx: EmitterContext): Model | null {
781
+ const body = op.requestBody;
782
+ if (!body) return null;
783
+ if (body.kind !== 'model') return null;
784
+ return ctx.spec.models.find((m) => m.name === body.name) ?? null;
785
+ }
786
+
787
+ function registerTypeImports(ref: TypeRef, imports: Set<string>, ctx: EmitterContext): void {
788
+ const mapped = mapTypeRef(ref);
789
+ for (const imp of implicitImportsFor(mapped)) imports.add(imp);
790
+
791
+ walk(ref, (r) => {
792
+ if (r.kind === 'enum') {
793
+ // When an enum is aliased, import the canonical class instead of the alias.
794
+ const canonicalName = enumCanonicalMap.get(r.name) ?? r.name;
795
+ imports.add(`com.workos.types.${className(canonicalName)}`);
796
+ }
797
+ if (r.kind === 'model') {
798
+ const referenced = ctx.spec.models.find((m) => m.name === r.name);
799
+ if (referenced && (isListWrapperModel(referenced) || isListMetadataModel(referenced))) return;
800
+ imports.add(`com.workos.models.${className(r.name)}`);
801
+ }
802
+ });
803
+ }
804
+
805
+ function walk(ref: TypeRef, fn: (r: TypeRef) => void): void {
806
+ fn(ref);
807
+ if (ref.kind === 'array') walk(ref.items, fn);
808
+ else if (ref.kind === 'map') walk(ref.valueType, fn);
809
+ else if (ref.kind === 'nullable') walk(ref.inner, fn);
810
+ else if (ref.kind === 'union') for (const v of ref.variants) walk(v, fn);
811
+ }
812
+
813
+ /** Sort operation path parameters by their first appearance in the URL template. */
814
+ export function sortPathParamsByTemplateOrder(op: Operation): Parameter[] {
815
+ return [...op.pathParams].sort((a, b) => {
816
+ const posA = op.path.indexOf(`{${a.name}}`);
817
+ const posB = op.path.indexOf(`{${b.name}}`);
818
+ return posA - posB;
819
+ });
820
+ }
821
+
822
+ function escapeKdoc(s: string): string {
823
+ return s.replace(/\*\//g, '*\u200b/');
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
+ }