@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.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -12737
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/oagen.config.ts +5 -343
- package/package.json +10 -34
- package/smoke/sdk-dotnet.ts +45 -12
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +248 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +320 -0
- package/src/dotnet/naming.ts +368 -0
- package/src/dotnet/resources.ts +943 -0
- package/src/dotnet/tests.ts +713 -0
- package/src/dotnet/type-map.ts +228 -0
- package/src/dotnet/wrappers.ts +197 -0
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +15 -7
- package/src/go/models.ts +6 -1
- package/src/go/naming.ts +5 -17
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +15 -0
- package/src/kotlin/client.ts +58 -0
- package/src/kotlin/enums.ts +189 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +486 -0
- package/src/kotlin/naming.ts +229 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +998 -0
- package/src/kotlin/tests.ts +1133 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +84 -7
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +1 -0
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +319 -95
- package/src/node/tests.ts +108 -29
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/client.ts +11 -3
- package/src/php/models.ts +0 -33
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +275 -19
- package/src/php/tests.ts +118 -18
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +7 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +50 -32
- package/src/python/enums.ts +35 -10
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +139 -2
- package/src/python/naming.ts +2 -22
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +35 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +357 -16
- package/src/shared/naming-utils.ts +83 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/src/shared/wrapper-utils.ts +12 -1
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +258 -0
- package/test/dotnet/resources.test.ts +387 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/resources.test.ts +210 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +343 -34
- package/test/node/utils.test.ts +140 -0
- package/test/php/client.test.ts +2 -1
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +103 -0
- package/test/php/tests.test.ts +67 -0
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- 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
|
+
}
|