@workos/oagen-emitters 0.3.0 → 0.4.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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.d.mts +4 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +3288 -791
- package/dist/index.mjs.map +1 -1
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/oagen.config.ts +42 -12
- package/package.json +2 -2
- 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 +246 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +344 -0
- package/src/dotnet/naming.ts +330 -0
- package/src/dotnet/resources.ts +622 -0
- package/src/dotnet/tests.ts +693 -0
- package/src/dotnet/type-map.ts +201 -0
- package/src/dotnet/wrappers.ts +186 -0
- package/src/go/index.ts +5 -2
- package/src/go/naming.ts +5 -17
- package/src/index.ts +1 -0
- package/src/kotlin/client.ts +53 -0
- package/src/kotlin/enums.ts +162 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +395 -0
- package/src/kotlin/naming.ts +223 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +667 -0
- package/src/kotlin/tests.ts +1019 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +50 -0
- package/src/node/index.ts +1 -0
- package/src/node/resources.ts +164 -44
- package/src/node/tests.ts +37 -7
- package/src/php/client.ts +11 -3
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +81 -6
- package/src/php/tests.ts +93 -17
- package/src/php/wrappers.ts +1 -0
- package/src/python/client.ts +37 -29
- package/src/python/enums.ts +7 -7
- package/src/python/models.ts +1 -1
- package/src/python/naming.ts +2 -22
- package/src/shared/model-utils.ts +232 -15
- package/src/shared/naming-utils.ts +47 -0
- 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 +260 -0
- package/test/dotnet/resources.test.ts +255 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/resources.test.ts +216 -15
- package/test/php/client.test.ts +2 -1
- package/test/php/resources.test.ts +38 -0
- package/test/php/tests.test.ts +67 -0
|
@@ -0,0 +1,667 @@
|
|
|
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
|
+
} from './naming.js';
|
|
26
|
+
import {
|
|
27
|
+
buildResolvedLookup,
|
|
28
|
+
lookupResolved,
|
|
29
|
+
groupByMount,
|
|
30
|
+
buildHiddenParams,
|
|
31
|
+
getOpDefaults,
|
|
32
|
+
getOpInferFromClient,
|
|
33
|
+
} from '../shared/resolved-ops.js';
|
|
34
|
+
import { generateWrapperMethods } from './wrappers.js';
|
|
35
|
+
import { resolveWrapperParams } from '../shared/wrapper-utils.js';
|
|
36
|
+
import { isHandwrittenOverride } from './overrides.js';
|
|
37
|
+
|
|
38
|
+
const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate one API class per mount group. Methods map 1:1 to IR operations.
|
|
42
|
+
* Path params, query params, and body fields are flattened into the method
|
|
43
|
+
* signature so callers never need to construct an intermediate options object.
|
|
44
|
+
*/
|
|
45
|
+
export function generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
|
|
46
|
+
if (services.length === 0) return [];
|
|
47
|
+
|
|
48
|
+
const mountGroups = groupByMount(ctx);
|
|
49
|
+
if (mountGroups.size === 0) return [];
|
|
50
|
+
|
|
51
|
+
const files: GeneratedFile[] = [];
|
|
52
|
+
const resolvedLookup = buildResolvedLookup(ctx);
|
|
53
|
+
|
|
54
|
+
for (const [mountName, group] of mountGroups) {
|
|
55
|
+
const classCode = generateApiClass(mountName, group.operations, ctx, resolvedLookup);
|
|
56
|
+
if (!classCode) continue;
|
|
57
|
+
const pkg = packageSegment(mountName);
|
|
58
|
+
files.push({
|
|
59
|
+
path: `${KOTLIN_SRC_PREFIX}com/workos/${pkg}/${apiClassName(mountName)}.kt`,
|
|
60
|
+
content: classCode,
|
|
61
|
+
overwriteExisting: true,
|
|
62
|
+
});
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return files;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function generateApiClass(
|
|
69
|
+
mountName: string,
|
|
70
|
+
operations: Operation[],
|
|
71
|
+
ctx: EmitterContext,
|
|
72
|
+
resolvedLookup: Map<string, ResolvedOperation>,
|
|
73
|
+
): string | null {
|
|
74
|
+
if (operations.length === 0) return null;
|
|
75
|
+
const apiClass = apiClassName(mountName);
|
|
76
|
+
const pkg = `com.workos.${packageSegment(mountName)}`;
|
|
77
|
+
|
|
78
|
+
const imports = new Set<string>();
|
|
79
|
+
imports.add('com.workos.WorkOS');
|
|
80
|
+
imports.add('com.workos.common.http.Page');
|
|
81
|
+
imports.add('com.workos.common.http.RequestConfig');
|
|
82
|
+
imports.add('com.workos.common.http.RequestOptions');
|
|
83
|
+
|
|
84
|
+
const body: string[] = [];
|
|
85
|
+
const seenMethods = new Set<string>();
|
|
86
|
+
|
|
87
|
+
for (const op of operations) {
|
|
88
|
+
if (isHandwrittenOverride(op)) continue;
|
|
89
|
+
const resolvedOp = lookupResolved(op, resolvedLookup);
|
|
90
|
+
if ((resolvedOp?.wrappers?.length ?? 0) > 0) {
|
|
91
|
+
// Emit one method per wrapper instead of the raw union-split operation.
|
|
92
|
+
for (const wrapper of resolvedOp!.wrappers!) {
|
|
93
|
+
if (wrapper.responseModelName) {
|
|
94
|
+
imports.add(`com.workos.models.${className(wrapper.responseModelName)}`);
|
|
95
|
+
}
|
|
96
|
+
// Register imports for wrapper param field types
|
|
97
|
+
const resolvedParams = resolveWrapperParams(wrapper, ctx);
|
|
98
|
+
for (const rp of resolvedParams) {
|
|
99
|
+
if (rp.field) registerTypeImports(rp.field.type, imports, ctx);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
// Wrapper methods use bodyOf() for request body construction.
|
|
103
|
+
imports.add('com.workos.common.http.bodyOf');
|
|
104
|
+
const wrapperLines = generateWrapperMethods(resolvedOp!, ctx);
|
|
105
|
+
if (body.length > 0) body.push('');
|
|
106
|
+
for (const line of wrapperLines) body.push(line);
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const method = resolveMethodName(op, findService(ctx, op) ?? ({} as Service), ctx);
|
|
111
|
+
if (seenMethods.has(method)) continue;
|
|
112
|
+
seenMethods.add(method);
|
|
113
|
+
|
|
114
|
+
const rendered = renderMethod(mountName, method, op, ctx, resolvedOp, imports);
|
|
115
|
+
if (body.length > 0) body.push('');
|
|
116
|
+
body.push(rendered);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (body.length === 0) return null;
|
|
120
|
+
|
|
121
|
+
// Drop unused imports by peeking at the body text.
|
|
122
|
+
const bodyText = body.join('\n');
|
|
123
|
+
const filteredImports = [...imports].filter((imp) => {
|
|
124
|
+
const simple = imp.slice(imp.lastIndexOf('.') + 1);
|
|
125
|
+
// Skip the import if the class body never references the simple name.
|
|
126
|
+
if (simple === 'WorkOS' || simple === 'RequestConfig' || simple === 'RequestOptions') return true;
|
|
127
|
+
return new RegExp(`\\b${simple}\\b`).test(bodyText);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const lines: string[] = [];
|
|
131
|
+
lines.push(`package ${pkg}`);
|
|
132
|
+
lines.push('');
|
|
133
|
+
for (const imp of filteredImports.sort()) lines.push(`import ${imp}`);
|
|
134
|
+
lines.push('');
|
|
135
|
+
const serviceDescription = resolveServiceDescription(ctx, mountName, operations);
|
|
136
|
+
if (serviceDescription) {
|
|
137
|
+
const docLines = serviceDescription.trim().split('\n');
|
|
138
|
+
if (docLines.length === 1) {
|
|
139
|
+
lines.push(`/** ${escapeKdoc(docLines[0].trim())} */`);
|
|
140
|
+
} else {
|
|
141
|
+
lines.push('/**');
|
|
142
|
+
for (const l of docLines) lines.push(l ? ` * ${escapeKdoc(l)}` : ' *');
|
|
143
|
+
lines.push(' */');
|
|
144
|
+
}
|
|
145
|
+
} else {
|
|
146
|
+
lines.push(`/** API accessor for ${mountName}. */`);
|
|
147
|
+
}
|
|
148
|
+
// ktlint requires constructor-property parameters on their own line.
|
|
149
|
+
// The property is `internal` so hand-maintained extension files in the
|
|
150
|
+
// same module can reach the underlying [WorkOS] client (e.g. to build
|
|
151
|
+
// URLs that are not HTTP calls).
|
|
152
|
+
lines.push(`class ${apiClass}(`);
|
|
153
|
+
lines.push(' internal val workos: WorkOS');
|
|
154
|
+
lines.push(`) {`);
|
|
155
|
+
for (const line of body) lines.push(line);
|
|
156
|
+
lines.push('}');
|
|
157
|
+
lines.push('');
|
|
158
|
+
return lines.join('\n');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function findService(ctx: EmitterContext, op: Operation): Service | undefined {
|
|
162
|
+
for (const service of ctx.spec.services) {
|
|
163
|
+
if (service.operations.includes(op)) return service;
|
|
164
|
+
}
|
|
165
|
+
return undefined;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Resolve a human-friendly description for a generated API class. Walks the
|
|
170
|
+
* operations in the mount group, picks the first service whose description
|
|
171
|
+
* is populated, and falls back to `null` when nothing meaningful is
|
|
172
|
+
* available (the caller uses a generic fallback).
|
|
173
|
+
*/
|
|
174
|
+
function resolveServiceDescription(ctx: EmitterContext, _mountName: string, operations: Operation[]): string | null {
|
|
175
|
+
for (const op of operations) {
|
|
176
|
+
const svc = findService(ctx, op);
|
|
177
|
+
if (svc?.description?.trim()) return svc.description;
|
|
178
|
+
}
|
|
179
|
+
return null;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Render a single SDK method for an operation.
|
|
184
|
+
*/
|
|
185
|
+
function renderMethod(
|
|
186
|
+
_mountName: string,
|
|
187
|
+
method: string,
|
|
188
|
+
op: Operation,
|
|
189
|
+
ctx: EmitterContext,
|
|
190
|
+
resolvedOp: ResolvedOperation | undefined,
|
|
191
|
+
imports: Set<string>,
|
|
192
|
+
): string {
|
|
193
|
+
const plan = planOperation(op);
|
|
194
|
+
const hidden = buildHiddenParams(resolvedOp);
|
|
195
|
+
const defaults = getOpDefaults(resolvedOp);
|
|
196
|
+
const inferFromClient = getOpInferFromClient(resolvedOp);
|
|
197
|
+
|
|
198
|
+
const httpMethod = op.httpMethod.toUpperCase();
|
|
199
|
+
const pathParams = sortPathParamsByTemplateOrder(op);
|
|
200
|
+
const queryParams = op.queryParams.filter((p) => !hidden.has(p.name));
|
|
201
|
+
const bodyModel = resolveBodyModel(op, ctx);
|
|
202
|
+
const bodyFields = bodyModel ? bodyModel.fields.filter((f) => !hidden.has(f.name)) : [];
|
|
203
|
+
|
|
204
|
+
// Track imports we need
|
|
205
|
+
for (const p of [...pathParams, ...queryParams]) registerTypeImports(p.type, imports, ctx);
|
|
206
|
+
for (const f of bodyFields) registerTypeImports(f.type, imports, ctx);
|
|
207
|
+
const paginatedItemName = resolvePaginatedItemName(plan.paginatedItemModelName, ctx);
|
|
208
|
+
if (plan.responseModelName && !plan.isPaginated) {
|
|
209
|
+
imports.add(`com.workos.models.${className(plan.responseModelName)}`);
|
|
210
|
+
}
|
|
211
|
+
if (paginatedItemName) {
|
|
212
|
+
imports.add(`com.workos.models.${className(paginatedItemName)}`);
|
|
213
|
+
imports.add('com.fasterxml.jackson.core.type.TypeReference');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Deduplicate: path params take precedence; query params second; body last.
|
|
217
|
+
// If a body field collides with a path/query param, rename the body field's
|
|
218
|
+
// Kotlin parameter (e.g. `slug` → `bodySlug`) so callers can pass both
|
|
219
|
+
// values. The wire name on the body map still uses the original field name.
|
|
220
|
+
const paramNames = new Set<string>();
|
|
221
|
+
for (const pp of pathParams) paramNames.add(propertyName(pp.name));
|
|
222
|
+
const uniqueQuery = queryParams.filter((qp) => !paramNames.has(propertyName(qp.name)));
|
|
223
|
+
for (const qp of uniqueQuery) paramNames.add(propertyName(qp.name));
|
|
224
|
+
|
|
225
|
+
// Map body field wire name → Kotlin parameter name. When the natural name
|
|
226
|
+
// collides with a path/query, prefix with `body` (e.g. slug → bodySlug).
|
|
227
|
+
const bodyParamNames = new Map<string, string>();
|
|
228
|
+
for (const bf of bodyFields) {
|
|
229
|
+
const natural = propertyName(bf.name);
|
|
230
|
+
if (paramNames.has(natural)) {
|
|
231
|
+
const renamed = `body${natural.charAt(0).toUpperCase()}${natural.slice(1)}`;
|
|
232
|
+
bodyParamNames.set(bf.name, renamed);
|
|
233
|
+
paramNames.add(renamed);
|
|
234
|
+
} else {
|
|
235
|
+
bodyParamNames.set(bf.name, natural);
|
|
236
|
+
paramNames.add(natural);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const params: string[] = [];
|
|
241
|
+
for (const pp of pathParams) params.push(` ${propertyName(pp.name)}: String`);
|
|
242
|
+
|
|
243
|
+
const sortedQuery = [...uniqueQuery].sort((a, b) => (a.required === b.required ? 0 : a.required ? -1 : 1));
|
|
244
|
+
for (const qp of sortedQuery) {
|
|
245
|
+
params.push(renderParam(qp.name, qp.type, qp.required));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// PATCH operations use PatchField<T> for optional body fields so callers
|
|
249
|
+
// can distinguish "omit" (Absent) from "clear" (Present(null)).
|
|
250
|
+
const isPatch = httpMethod === 'PATCH';
|
|
251
|
+
|
|
252
|
+
const sortedBodyFields = [...bodyFields].sort((a, b) => (a.required === b.required ? 0 : a.required ? -1 : 1));
|
|
253
|
+
for (const bf of sortedBodyFields) {
|
|
254
|
+
if (isPatch && !bf.required) {
|
|
255
|
+
const baseType = mapTypeRef(bf.type);
|
|
256
|
+
imports.add('com.workos.common.http.PatchField');
|
|
257
|
+
params.push(` ${bodyParamNames.get(bf.name)!}: PatchField<${baseType}> = PatchField.Absent`);
|
|
258
|
+
} else {
|
|
259
|
+
params.push(renderParamNamed(bodyParamNames.get(bf.name)!, bf.type, bf.required));
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Per-request options trailer (always optional)
|
|
264
|
+
params.push(' requestOptions: RequestOptions? = null');
|
|
265
|
+
|
|
266
|
+
const returnType = resolveReturnType(plan, imports, ctx);
|
|
267
|
+
const isPaginated = plan.isPaginated && paginatedItemName !== null;
|
|
268
|
+
|
|
269
|
+
const lines: string[] = [];
|
|
270
|
+
const kdocLines = buildMethodKdoc(op, pathParams, sortedQuery, sortedBodyFields, bodyParamNames, plan);
|
|
271
|
+
for (const ln of kdocLines) lines.push(ln);
|
|
272
|
+
if (op.deprecated) lines.push(' @Deprecated("Deprecated operation")');
|
|
273
|
+
lines.push(' @JvmOverloads');
|
|
274
|
+
// Omit explicit `: Unit` to keep ktlint happy.
|
|
275
|
+
const returnClause = returnType === 'Unit' ? '' : `: ${returnType}`;
|
|
276
|
+
if (params.length === 1) {
|
|
277
|
+
// Single param fits on one line; ktlint enforces inline form.
|
|
278
|
+
const singleParam = params[0].replace(/^\s+/, '');
|
|
279
|
+
lines.push(` fun ${escapeReserved(method)}(${singleParam})${returnClause} {`);
|
|
280
|
+
} else {
|
|
281
|
+
lines.push(` fun ${escapeReserved(method)}(`);
|
|
282
|
+
for (let i = 0; i < params.length; i++) {
|
|
283
|
+
const suffix = i === params.length - 1 ? '' : ',';
|
|
284
|
+
lines.push(`${params[i]}${suffix}`);
|
|
285
|
+
}
|
|
286
|
+
lines.push(` )${returnClause} {`);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Build body / query config
|
|
290
|
+
//
|
|
291
|
+
// POST/PUT/PATCH always need a request body — OkHttp rejects them otherwise.
|
|
292
|
+
// DELETE and GET only emit a body when the spec explicitly declares one
|
|
293
|
+
// (OpenAPI allows DELETE-with-body; GET-with-body is uncommon but legal).
|
|
294
|
+
// GET never carries defaults/inferFromClient in the body — those fall back
|
|
295
|
+
// to the query string for GET.
|
|
296
|
+
const methodAlwaysHasBody = ['POST', 'PUT', 'PATCH'].includes(httpMethod);
|
|
297
|
+
const specDeclaresBody = op.requestBody !== undefined;
|
|
298
|
+
const hasBody =
|
|
299
|
+
methodAlwaysHasBody ||
|
|
300
|
+
(specDeclaresBody && httpMethod !== 'GET') ||
|
|
301
|
+
((httpMethod === 'PUT' || httpMethod === 'PATCH' || httpMethod === 'POST' || httpMethod === 'DELETE') &&
|
|
302
|
+
(Object.keys(defaults).length > 0 || inferFromClient.length > 0) &&
|
|
303
|
+
specDeclaresBody);
|
|
304
|
+
const appendDefaultsAsQuery = !hasBody && (Object.keys(defaults).length > 0 || inferFromClient.length > 0);
|
|
305
|
+
const pathExpr = buildPathExpression(op.path, pathParams);
|
|
306
|
+
|
|
307
|
+
if (isPaginated) {
|
|
308
|
+
// Nested helper function + requestPage call; 'after' is owned by the
|
|
309
|
+
// cursor logic so we skip it in the generic query loop.
|
|
310
|
+
const queryForConfig = sortedQuery.filter((p) => p.name !== 'after');
|
|
311
|
+
lines.push(` fun configFor(afterCursor: String? = null): RequestConfig {`);
|
|
312
|
+
lines.push(` val params = mutableListOf<Pair<String, String>>()`);
|
|
313
|
+
for (const qp of queryForConfig) for (const ln of emitQueryParam(qp, ' ')) lines.push(ln);
|
|
314
|
+
lines.push(` val effectiveAfter = afterCursor ?: ${pickNamedQueryParam(sortedQuery, 'after')}`);
|
|
315
|
+
lines.push(` if (effectiveAfter != null) params += "after" to effectiveAfter`);
|
|
316
|
+
lines.push(` return RequestConfig(`);
|
|
317
|
+
lines.push(` method = ${ktLiteral(httpMethod)},`);
|
|
318
|
+
lines.push(` path = ${pathExpr},`);
|
|
319
|
+
lines.push(` queryParams = params,`);
|
|
320
|
+
lines.push(` requestOptions = requestOptions`);
|
|
321
|
+
lines.push(` )`);
|
|
322
|
+
lines.push(` }`);
|
|
323
|
+
const itemClass = className(paginatedItemName!);
|
|
324
|
+
lines.push(` val itemType = object : TypeReference<${itemClass}>() {}`);
|
|
325
|
+
lines.push(
|
|
326
|
+
` return workos.baseClient.requestPage(configFor(), itemType) { afterCursor -> configFor(afterCursor) }`,
|
|
327
|
+
);
|
|
328
|
+
} else {
|
|
329
|
+
// Only emit the `params` local when the method actually contributes
|
|
330
|
+
// query parameters (spec-declared query, or defaults/inferFromClient
|
|
331
|
+
// for GET/DELETE without a body). `RequestConfig.queryParams` defaults
|
|
332
|
+
// to `emptyList()` when omitted, so we avoid dead local declarations.
|
|
333
|
+
const emitsQueryParams = sortedQuery.length > 0 || appendDefaultsAsQuery;
|
|
334
|
+
if (emitsQueryParams) {
|
|
335
|
+
lines.push(` val params = mutableListOf<Pair<String, String>>()`);
|
|
336
|
+
for (const qp of sortedQuery) for (const ln of emitQueryParam(qp, ' ')) lines.push(ln);
|
|
337
|
+
if (appendDefaultsAsQuery) {
|
|
338
|
+
for (const [k, v] of Object.entries(defaults)) lines.push(` params += ${ktLiteral(k)} to ${ktLiteral(v)}`);
|
|
339
|
+
// Client-inferred fields may be nullable (e.g. clientId). Skip when
|
|
340
|
+
// null rather than serializing "null" into the URL.
|
|
341
|
+
for (const k of inferFromClient) {
|
|
342
|
+
lines.push(` workos.${clientFieldExpression(k)}?.let { params += ${ktLiteral(k)} to it }`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (hasBody) {
|
|
348
|
+
// Use bodyOf() / patchBodyOf() helpers to build the request body in a
|
|
349
|
+
// single expression. This drops null optional values automatically
|
|
350
|
+
// instead of repeating `if (x != null) body["x"] = x` per field.
|
|
351
|
+
const helperFn = isPatch ? 'patchBodyOf' : 'bodyOf';
|
|
352
|
+
imports.add(`com.workos.common.http.${helperFn}`);
|
|
353
|
+
const bodyEntries: string[] = [];
|
|
354
|
+
for (const bf of sortedBodyFields) {
|
|
355
|
+
const prop = bodyParamNames.get(bf.name)!;
|
|
356
|
+
bodyEntries.push(` ${ktLiteral(bf.name)} to ${prop}`);
|
|
357
|
+
}
|
|
358
|
+
for (const [k, v] of Object.entries(defaults)) {
|
|
359
|
+
bodyEntries.push(` ${ktLiteral(k)} to ${ktLiteral(v)}`);
|
|
360
|
+
}
|
|
361
|
+
for (const k of inferFromClient) {
|
|
362
|
+
bodyEntries.push(` ${ktLiteral(k)} to workos.${clientFieldExpression(k)}`);
|
|
363
|
+
}
|
|
364
|
+
if (bodyEntries.length > 0) {
|
|
365
|
+
// ktlint: "A multiline expression should start on a new line"
|
|
366
|
+
lines.push(` val body =`);
|
|
367
|
+
lines.push(` ${helperFn}(`);
|
|
368
|
+
for (let i = 0; i < bodyEntries.length; i++) {
|
|
369
|
+
const sep = i === bodyEntries.length - 1 ? '' : ',';
|
|
370
|
+
lines.push(` ${bodyEntries[i]}${sep}`);
|
|
371
|
+
}
|
|
372
|
+
lines.push(` )`);
|
|
373
|
+
} else {
|
|
374
|
+
// Empty body (POST/PUT/PATCH still require one for OkHttp).
|
|
375
|
+
lines.push(` val body = linkedMapOf<String, Any?>()`);
|
|
376
|
+
}
|
|
377
|
+
lines.push(` val config =`);
|
|
378
|
+
lines.push(` RequestConfig(`);
|
|
379
|
+
lines.push(` method = ${ktLiteral(httpMethod)},`);
|
|
380
|
+
lines.push(` path = ${pathExpr},`);
|
|
381
|
+
if (emitsQueryParams) lines.push(` queryParams = params,`);
|
|
382
|
+
lines.push(` body = body,`);
|
|
383
|
+
lines.push(` requestOptions = requestOptions`);
|
|
384
|
+
lines.push(` )`);
|
|
385
|
+
} else {
|
|
386
|
+
lines.push(` val config =`);
|
|
387
|
+
lines.push(` RequestConfig(`);
|
|
388
|
+
lines.push(` method = ${ktLiteral(httpMethod)},`);
|
|
389
|
+
lines.push(` path = ${pathExpr},`);
|
|
390
|
+
if (emitsQueryParams) lines.push(` queryParams = params,`);
|
|
391
|
+
lines.push(` requestOptions = requestOptions`);
|
|
392
|
+
lines.push(` )`);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (plan.responseModelName && plan.isArrayResponse) {
|
|
396
|
+
// `type: array` response — deserialize as List<T> via TypeReference.
|
|
397
|
+
const itemClass = className(plan.responseModelName);
|
|
398
|
+
imports.add('com.fasterxml.jackson.core.type.TypeReference');
|
|
399
|
+
lines.push(` val responseType = object : TypeReference<List<${itemClass}>>() {}`);
|
|
400
|
+
lines.push(` return workos.baseClient.request(config, responseType)`);
|
|
401
|
+
} else if (plan.responseModelName) {
|
|
402
|
+
const responseClass = className(plan.responseModelName);
|
|
403
|
+
lines.push(` return workos.baseClient.request(config, ${responseClass}::class.java)`);
|
|
404
|
+
} else if (plan.isDelete || !plan.isModelResponse) {
|
|
405
|
+
lines.push(` workos.baseClient.requestVoid(config)`);
|
|
406
|
+
} else {
|
|
407
|
+
lines.push(` workos.baseClient.requestVoid(config)`);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
lines.push(' }');
|
|
412
|
+
return lines.join('\n');
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function resolveReturnType(plan: ReturnType<typeof planOperation>, imports: Set<string>, ctx?: EmitterContext): string {
|
|
416
|
+
const itemName = plan.isPaginated
|
|
417
|
+
? (resolvePaginatedItemName(plan.paginatedItemModelName, ctx) ?? plan.paginatedItemModelName)
|
|
418
|
+
: null;
|
|
419
|
+
if (plan.isPaginated && itemName) {
|
|
420
|
+
const item = className(itemName);
|
|
421
|
+
imports.add(`com.workos.models.${item}`);
|
|
422
|
+
return `Page<${item}>`;
|
|
423
|
+
}
|
|
424
|
+
if (plan.responseModelName && plan.isArrayResponse) {
|
|
425
|
+
const cls = className(plan.responseModelName);
|
|
426
|
+
imports.add(`com.workos.models.${cls}`);
|
|
427
|
+
return `List<${cls}>`;
|
|
428
|
+
}
|
|
429
|
+
if (plan.responseModelName) {
|
|
430
|
+
const cls = className(plan.responseModelName);
|
|
431
|
+
imports.add(`com.workos.models.${cls}`);
|
|
432
|
+
return cls;
|
|
433
|
+
}
|
|
434
|
+
return 'Unit';
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* If [paginatedItemModelName] points to a list wrapper (`{ data, list_metadata }`),
|
|
439
|
+
* unwrap it and return the actual item model name. Otherwise return as-is.
|
|
440
|
+
*/
|
|
441
|
+
function resolvePaginatedItemName(name: string | null, ctx?: EmitterContext): string | null {
|
|
442
|
+
if (!name || !ctx) return name;
|
|
443
|
+
const model = ctx.spec.models.find((m) => m.name === name);
|
|
444
|
+
if (!model) return name;
|
|
445
|
+
const dataField = model.fields.find((f) => f.name === 'data');
|
|
446
|
+
if (!dataField || dataField.type.kind !== 'array') return name;
|
|
447
|
+
const items = dataField.type.items;
|
|
448
|
+
if (items.kind === 'model') return items.name;
|
|
449
|
+
return name;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function renderParam(name: string, type: TypeRef, required: boolean): string {
|
|
453
|
+
return renderParamNamed(propertyName(name), type, required);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function renderParamNamed(kotlinName: string, type: TypeRef, required: boolean): string {
|
|
457
|
+
const mapped = required ? mapTypeRef(type) : mapTypeRefOptional(type);
|
|
458
|
+
return required ? ` ${kotlinName}: ${mapped}` : ` ${kotlinName}: ${mapped} = null`;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Build the KDoc block preceding an SDK method. Combines the operation's
|
|
463
|
+
* summary/description with `@param` docs for every parameter that has a
|
|
464
|
+
* description in the spec, `@return` when a response model is known, and
|
|
465
|
+
* `@throws` for the standard error types.
|
|
466
|
+
*/
|
|
467
|
+
function buildMethodKdoc(
|
|
468
|
+
op: Operation,
|
|
469
|
+
pathParams: Parameter[],
|
|
470
|
+
queryParams: Parameter[],
|
|
471
|
+
bodyFields: Field[],
|
|
472
|
+
bodyParamNames: Map<string, string>,
|
|
473
|
+
plan: ReturnType<typeof planOperation>,
|
|
474
|
+
): string[] {
|
|
475
|
+
// Use the operation's description as the KDoc body, split by newline.
|
|
476
|
+
// Escape `*/` sequences to keep KDoc valid.
|
|
477
|
+
const descriptionRaw = (op.description ?? '').trim();
|
|
478
|
+
const textLines: string[] = [];
|
|
479
|
+
if (descriptionRaw) {
|
|
480
|
+
for (const l of descriptionRaw.split('\n')) textLines.push(escapeKdoc(l));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// @param lines. Use the Kotlin-visible parameter name (body collisions get
|
|
484
|
+
// renamed, e.g. slug → bodySlug). Deprecated parameters always get a
|
|
485
|
+
// @param entry even without a description so the deprecation note is
|
|
486
|
+
// surfaced in the docs.
|
|
487
|
+
const paramDocs: string[] = [];
|
|
488
|
+
for (const pp of pathParams) {
|
|
489
|
+
if (pp.description?.trim() || pp.deprecated) {
|
|
490
|
+
paramDocs.push(formatParamDoc(propertyName(pp.name), pp.description, pp.deprecated));
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
for (const qp of queryParams) {
|
|
494
|
+
if (qp.description?.trim() || qp.deprecated) {
|
|
495
|
+
paramDocs.push(formatParamDoc(propertyName(qp.name), qp.description, qp.deprecated));
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
for (const bf of bodyFields) {
|
|
499
|
+
if (bf.description?.trim() || bf.deprecated) {
|
|
500
|
+
paramDocs.push(formatParamDoc(bodyParamNames.get(bf.name)!, bf.description, bf.deprecated));
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
const returnDoc = plan.isPaginated
|
|
505
|
+
? '@return a [com.workos.common.http.Page] of results'
|
|
506
|
+
: plan.responseModelName
|
|
507
|
+
? `@return the ${plan.isArrayResponse ? `list of ${className(plan.responseModelName)}` : className(plan.responseModelName)}`
|
|
508
|
+
: null;
|
|
509
|
+
|
|
510
|
+
const hasAnyContent = textLines.length > 0 || paramDocs.length > 0 || returnDoc !== null;
|
|
511
|
+
if (!hasAnyContent) return [];
|
|
512
|
+
|
|
513
|
+
const out: string[] = [' /**'];
|
|
514
|
+
for (const l of textLines) out.push(l ? ` * ${l}` : ' *');
|
|
515
|
+
const hasBodyText = textLines.length > 0;
|
|
516
|
+
const needsSpacer = hasBodyText && (paramDocs.length > 0 || returnDoc !== null);
|
|
517
|
+
if (needsSpacer) out.push(' *');
|
|
518
|
+
for (const p of paramDocs) out.push(` * ${p}`);
|
|
519
|
+
if (returnDoc) {
|
|
520
|
+
if (paramDocs.length > 0) out.push(' *');
|
|
521
|
+
out.push(` * ${returnDoc}`);
|
|
522
|
+
}
|
|
523
|
+
out.push(' */');
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function formatParamDoc(kotlinName: string, description: string | undefined, deprecated?: boolean): string {
|
|
528
|
+
const firstLine = description?.split('\n').find((l) => l.trim()) ?? '';
|
|
529
|
+
const text = firstLine.trim();
|
|
530
|
+
const deprecationNote = deprecated ? '**Deprecated.**' : '';
|
|
531
|
+
const parts = [deprecationNote, text].filter(Boolean).join(' ');
|
|
532
|
+
return `@param ${kotlinName}${parts ? ` ${escapeKdoc(parts)}` : ''}`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Unwrap a possibly-nullable type to check if the inner type is an array,
|
|
537
|
+
* and return the array's item type for downstream serialization decisions.
|
|
538
|
+
*/
|
|
539
|
+
function unwrapArray(t: TypeRef): TypeRef | null {
|
|
540
|
+
if (t.kind === 'array') return t.items;
|
|
541
|
+
if (t.kind === 'nullable' && t.inner.kind === 'array') return t.inner.items;
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/**
|
|
546
|
+
* Serialize a single value expression for a query parameter. For enums we
|
|
547
|
+
* use `.value` so the wire name is used; for everything else `.toString()`.
|
|
548
|
+
*/
|
|
549
|
+
function valueExprForQuery(type: TypeRef): string {
|
|
550
|
+
const inner = type.kind === 'nullable' ? type.inner : type;
|
|
551
|
+
if (inner.kind === 'enum') return 'it.value';
|
|
552
|
+
return 'it.toString()';
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
function emitQueryParam(p: Parameter, indent: string): string[] {
|
|
556
|
+
const prop = propertyName(p.name);
|
|
557
|
+
const rendered = queryParamToString(p.type, prop);
|
|
558
|
+
const arrayItem = unwrapArray(p.type);
|
|
559
|
+
if (arrayItem) {
|
|
560
|
+
// Honor `style: form, explode: false` → comma-joined. Default (explode:true
|
|
561
|
+
// or unspecified for form) → repeated keys. `p.explode ?? true` matches
|
|
562
|
+
// the OpenAPI default for query parameters when `style` is form.
|
|
563
|
+
const explode = p.explode ?? true;
|
|
564
|
+
const itemExpr = valueExprForQuery(arrayItem);
|
|
565
|
+
if (!explode) {
|
|
566
|
+
if (p.required) {
|
|
567
|
+
return [`${indent}params += ${ktLiteral(p.name)} to ${prop}.joinToString(",") { ${itemExpr} }`];
|
|
568
|
+
}
|
|
569
|
+
return [
|
|
570
|
+
`${indent}if (${prop} != null) params += ${ktLiteral(p.name)} to ${prop}.joinToString(",") { ${itemExpr} }`,
|
|
571
|
+
];
|
|
572
|
+
}
|
|
573
|
+
if (p.required) {
|
|
574
|
+
return [`${indent}${prop}.forEach { params += ${ktLiteral(p.name)} to ${itemExpr} }`];
|
|
575
|
+
}
|
|
576
|
+
return [`${indent}if (${prop} != null) ${prop}.forEach { params += ${ktLiteral(p.name)} to ${itemExpr} }`];
|
|
577
|
+
}
|
|
578
|
+
if (p.required) return [`${indent}params += ${ktLiteral(p.name)} to ${rendered}`];
|
|
579
|
+
return [`${indent}if (${prop} != null) params += ${ktLiteral(p.name)} to ${rendered}`];
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
function queryParamToString(type: TypeRef, varName: string): string {
|
|
583
|
+
if (type.kind === 'enum') return `${varName}.value`;
|
|
584
|
+
if (type.kind === 'nullable') return queryParamToString(type.inner, varName);
|
|
585
|
+
return `${varName}.toString()`;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function _emitBodyField(field: Field, kotlinParamName: string, isPatch: boolean): string[] {
|
|
589
|
+
const prop = kotlinParamName;
|
|
590
|
+
if (field.required) return [` body[${ktLiteral(field.name)}] = ${prop}`];
|
|
591
|
+
// PATCH: PatchField<T> — serialize Present(value) including explicit null;
|
|
592
|
+
// skip Absent entirely so the server preserves the field's current value.
|
|
593
|
+
if (isPatch) {
|
|
594
|
+
return [` if (${prop} is PatchField.Present) body[${ktLiteral(field.name)}] = ${prop}.value`];
|
|
595
|
+
}
|
|
596
|
+
return [` if (${prop} != null) body[${ktLiteral(field.name)}] = ${prop}`];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function buildPathExpression(path: string, pathParams: Parameter[]): string {
|
|
600
|
+
if (pathParams.length === 0) return ktLiteral(path);
|
|
601
|
+
let result = path;
|
|
602
|
+
for (const pp of pathParams) {
|
|
603
|
+
const placeholder = `{${pp.name}}`;
|
|
604
|
+
const propName = propertyName(pp.name);
|
|
605
|
+
// Use $propName for simple identifiers and ${propName} only when followed by
|
|
606
|
+
// an ident-continuing char (to avoid false continuations). ktlint prefers the
|
|
607
|
+
// unbraced form for bare identifiers.
|
|
608
|
+
const replacement = isBareIdentifier(propName) ? `\$${propName}` : `\${${propName}}`;
|
|
609
|
+
result = result.replaceAll(placeholder, replacement);
|
|
610
|
+
}
|
|
611
|
+
return `"${result.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function isBareIdentifier(name: string): boolean {
|
|
615
|
+
return /^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name);
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
function pickNamedQueryParam(sorted: Parameter[], name: string): string {
|
|
619
|
+
const match = sorted.find((p) => p.name === name);
|
|
620
|
+
return match ? propertyName(match.name) : 'null';
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function resolveBodyModel(op: Operation, ctx: EmitterContext): Model | null {
|
|
624
|
+
const body = op.requestBody;
|
|
625
|
+
if (!body) return null;
|
|
626
|
+
if (body.kind !== 'model') return null;
|
|
627
|
+
return ctx.spec.models.find((m) => m.name === body.name) ?? null;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function registerTypeImports(ref: TypeRef, imports: Set<string>, ctx: EmitterContext): void {
|
|
631
|
+
const mapped = mapTypeRef(ref);
|
|
632
|
+
for (const imp of implicitImportsFor(mapped)) imports.add(imp);
|
|
633
|
+
|
|
634
|
+
walk(ref, (r) => {
|
|
635
|
+
if (r.kind === 'enum') {
|
|
636
|
+
// When an enum is aliased, import the canonical class instead of the alias.
|
|
637
|
+
const canonicalName = enumCanonicalMap.get(r.name) ?? r.name;
|
|
638
|
+
imports.add(`com.workos.types.${className(canonicalName)}`);
|
|
639
|
+
}
|
|
640
|
+
if (r.kind === 'model') {
|
|
641
|
+
const referenced = ctx.spec.models.find((m) => m.name === r.name);
|
|
642
|
+
if (referenced && (isListWrapperModel(referenced) || isListMetadataModel(referenced))) return;
|
|
643
|
+
imports.add(`com.workos.models.${className(r.name)}`);
|
|
644
|
+
}
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function walk(ref: TypeRef, fn: (r: TypeRef) => void): void {
|
|
649
|
+
fn(ref);
|
|
650
|
+
if (ref.kind === 'array') walk(ref.items, fn);
|
|
651
|
+
else if (ref.kind === 'map') walk(ref.valueType, fn);
|
|
652
|
+
else if (ref.kind === 'nullable') walk(ref.inner, fn);
|
|
653
|
+
else if (ref.kind === 'union') for (const v of ref.variants) walk(v, fn);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** Sort operation path parameters by their first appearance in the URL template. */
|
|
657
|
+
export function sortPathParamsByTemplateOrder(op: Operation): Parameter[] {
|
|
658
|
+
return [...op.pathParams].sort((a, b) => {
|
|
659
|
+
const posA = op.path.indexOf(`{${a.name}}`);
|
|
660
|
+
const posB = op.path.indexOf(`{${b.name}}`);
|
|
661
|
+
return posA - posB;
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function escapeKdoc(s: string): string {
|
|
666
|
+
return s.replace(/\*\//g, '*\u200b/');
|
|
667
|
+
}
|