@workos/oagen-emitters 0.8.1 → 0.9.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.
@@ -0,0 +1,96 @@
1
+ /**
2
+ * Helpers for emitting `suspend` overloads alongside every generated
3
+ * blocking SDK method. The suspend variant simply delegates to the blocking
4
+ * implementation under `withContext(Dispatchers.IO)`, so callers can invoke
5
+ * any operation from a coroutine context without blocking the calling
6
+ * dispatcher.
7
+ *
8
+ * Naming: the suspend variant uses a `Suspend`-suffixed Kotlin source name
9
+ * (e.g. `deleteEndpointSuspend`). This matters because Kotlin does not let
10
+ * a `suspend` function and a non-`suspend` function with the same name and
11
+ * identical value parameters coexist in the same scope — they are not
12
+ * distinguishable at call sites, even with `@JvmName`. (`@JvmName` only
13
+ * disambiguates JVM signatures, not Kotlin source names.) Naming the suspend
14
+ * variant explicitly sidesteps the conflict and makes the choice between
15
+ * blocking and suspending callable obvious at the call site. The matching
16
+ * `@JvmName("...Suspend")` is now technically redundant but kept for
17
+ * explicit clarity in Java interop / tooling.
18
+ */
19
+
20
+ import { escapeReserved } from './naming.js';
21
+
22
+ export interface SuspendParam {
23
+ /** Already-rendered "name: Type" or "name: Type = default" Kotlin declaration. */
24
+ decl: string;
25
+ /** Bare parameter name to forward to the blocking version. */
26
+ name: string;
27
+ }
28
+
29
+ /**
30
+ * Emit a suspend overload that delegates to a blocking method.
31
+ *
32
+ * The emitted lines preserve the parameter declarations (including default
33
+ * values) so callers can invoke the suspend variant with named arguments and
34
+ * skip optional parameters, just as they do with the blocking version.
35
+ *
36
+ * The emitted suspend method is named `${methodName}Suspend` (a distinct
37
+ * Kotlin source name from the blocking method) — see the file-level KDoc for
38
+ * why this is required.
39
+ */
40
+ export function emitSuspendVariant(opts: {
41
+ methodName: string;
42
+ params: SuspendParam[];
43
+ returnType: string;
44
+ deprecated?: boolean;
45
+ }): string[] {
46
+ const { methodName, params, returnType, deprecated } = opts;
47
+ const suspendName = `${methodName}Suspend`;
48
+ const lines: string[] = [];
49
+
50
+ lines.push(' /**');
51
+ lines.push(` * Coroutine-aware variant of [${escapeReserved(methodName)}]. Use this from`);
52
+ lines.push(' * a `suspend` function or coroutine scope.');
53
+ lines.push(' *');
54
+ lines.push(` * Delegates to the blocking [${escapeReserved(methodName)}] under`);
55
+ lines.push(' * `withContext(Dispatchers.IO)`, so this is safe to call from any');
56
+ lines.push(' * coroutine dispatcher (including `Dispatchers.Main`).');
57
+ lines.push(' */');
58
+ if (deprecated) lines.push(' @Deprecated("Deprecated operation")');
59
+ lines.push(` @JvmName(${jvmNameLiteral(suspendName)})`);
60
+
61
+ const returnClause = returnType === 'Unit' ? '' : `: ${returnType}`;
62
+ const callArgs = params.map((p) => p.name).join(', ');
63
+
64
+ if (params.length === 0) {
65
+ lines.push(` suspend fun ${escapeReserved(suspendName)}()${returnClause} = withContext(Dispatchers.IO) {`);
66
+ lines.push(` ${escapeReserved(methodName)}()`);
67
+ lines.push(' }');
68
+ return lines;
69
+ }
70
+
71
+ if (params.length === 1) {
72
+ const single = params[0].decl.replace(/^\s+/, '');
73
+ lines.push(
74
+ ` suspend fun ${escapeReserved(suspendName)}(${single})${returnClause} = withContext(Dispatchers.IO) {`,
75
+ );
76
+ } else {
77
+ lines.push(` suspend fun ${escapeReserved(suspendName)}(`);
78
+ for (let i = 0; i < params.length; i++) {
79
+ const suffix = i === params.length - 1 ? '' : ',';
80
+ lines.push(`${params[i].decl}${suffix}`);
81
+ }
82
+ lines.push(` )${returnClause} = withContext(Dispatchers.IO) {`);
83
+ }
84
+ lines.push(` ${escapeReserved(methodName)}(${callArgs})`);
85
+ lines.push(' }');
86
+ return lines;
87
+ }
88
+
89
+ /**
90
+ * Imports a service file needs to declare any suspend overloads.
91
+ */
92
+ export const SUSPEND_IMPORTS: readonly string[] = ['kotlinx.coroutines.Dispatchers', 'kotlinx.coroutines.withContext'];
93
+
94
+ function jvmNameLiteral(name: string): string {
95
+ return JSON.stringify(name);
96
+ }
@@ -25,6 +25,31 @@ import { isHandwrittenOverride } from './overrides.js';
25
25
 
26
26
  const TEST_PREFIX = 'src/test/kotlin/';
27
27
 
28
+ /**
29
+ * Mirror the ISO-8601 hint promotion the resource/model emitters use so tests
30
+ * synthesize values whose Kotlin type matches the generated method signature.
31
+ * Kept in sync with the helpers in `resources.ts` / `models.ts`; if the
32
+ * detection rule changes, update all three.
33
+ */
34
+ const ISO_8601_DESCRIPTION_RE = /\bISO[-_ ]?8601\b/i;
35
+
36
+ function looksLikeIso8601String(description: string | undefined): boolean {
37
+ if (!description) return false;
38
+ return ISO_8601_DESCRIPTION_RE.test(description);
39
+ }
40
+
41
+ function promoteIso8601TypeRef(type: TypeRef, description: string | undefined): TypeRef {
42
+ if (!looksLikeIso8601String(description)) return type;
43
+ const promote = (t: TypeRef): TypeRef => {
44
+ if (t.kind === 'primitive' && t.type === 'string' && !t.format) {
45
+ return { kind: 'primitive', type: 'string', format: 'date-time' };
46
+ }
47
+ if (t.kind === 'nullable') return { kind: 'nullable', inner: promote(t.inner) };
48
+ return t;
49
+ };
50
+ return promote(type);
51
+ }
52
+
28
53
  /**
29
54
  * Generate one JUnit 5 + WireMock test class per API mount group, plus a
30
55
  * cross-cutting model round-trip test.
@@ -254,12 +279,13 @@ function buildOperationTest(
254
279
  }
255
280
  for (const qp of sortedQuery) {
256
281
  if (!qp.required) break;
257
- const val = synthValue(qp.type, ctx, imports);
282
+ const promotedType = promoteIso8601TypeRef(qp.type, qp.description);
283
+ const val = synthValue(promotedType, ctx, imports);
258
284
  if (val === null) return null;
259
285
  argParts.push(val);
260
286
  // Best-effort wire assertion: for primitives/strings we know the synthesized
261
287
  // value so we can assert equality; otherwise just assert presence.
262
- const regex = queryValueRegexFor(qp.type);
288
+ const regex = queryValueRegexFor(promotedType);
263
289
  if (regex !== null) requiredQueryAssertions.push({ name: qp.name, valueRegex: regex });
264
290
  }
265
291
 
@@ -283,14 +309,15 @@ function buildOperationTest(
283
309
  for (const bf of sortedBody) {
284
310
  if (sharedQueryBodyParams.has(bf.name)) continue;
285
311
  if (!bf.required) break;
286
- const val = synthValue(bf.type, ctx, imports);
312
+ const promotedType = promoteIso8601TypeRef(bf.type, bf.description);
313
+ const val = synthValue(promotedType, ctx, imports);
287
314
  if (val === null) return null;
288
315
  argParts.push(val);
289
316
  // matchingJsonPath on an array/map body field fails on empty
290
317
  // synthesized collections because JsonPath returns an empty result
291
318
  // set. Scalar fields always materialize with a concrete value, so
292
319
  // we only assert those paths.
293
- if (isScalarBodyField(bf.type)) requiredBodyPaths.push(bf.name);
320
+ if (isScalarBodyField(promotedType)) requiredBodyPaths.push(bf.name);
294
321
  }
295
322
  }
296
323
 
@@ -464,7 +491,8 @@ function buildWrapperTest(op: Operation, wrapper: ResolvedWrapper, ctx: EmitterC
464
491
  argParts.push(ktStringLiteral('sample-arg'));
465
492
  continue;
466
493
  }
467
- const val = synthValue(rp.field.type, ctx, imports);
494
+ const promotedType = promoteIso8601TypeRef(rp.field.type, rp.field.description);
495
+ const val = synthValue(promotedType, ctx, imports);
468
496
  if (val === null) return null;
469
497
  argParts.push(val);
470
498
  }
@@ -1,9 +1,18 @@
1
1
  import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
2
- import { className, propertyName, ktLiteral, clientFieldExpression, escapeReserved } from './naming.js';
2
+ import {
3
+ className,
4
+ propertyName,
5
+ ktLiteral,
6
+ clientFieldExpression,
7
+ escapeReserved,
8
+ humanize,
9
+ maybeShortenEnumParamDescription,
10
+ } from './naming.js';
3
11
  import { mapTypeRef, mapTypeRefOptional } from './type-map.js';
4
12
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
5
13
  import { sortPathParamsByTemplateOrder } from './resources.js';
6
14
  import { buildKotlinPathExpression } from './path-expression.js';
15
+ import { emitSuspendVariant, type SuspendParam } from './suspend.js';
7
16
 
8
17
  /**
9
18
  * Emit Kotlin wrapper methods for a union-split operation. Each wrapper
@@ -35,7 +44,11 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
35
44
 
36
45
  const lines: string[] = [];
37
46
 
38
- // Build KDoc from operation description + @param docs for each wrapper param.
47
+ // Build KDoc: operation description + a `@param` line for *every* parameter
48
+ // (Dokka does not flag missing @param blocks, so coverage has to be enforced
49
+ // at emit time) + `@return` when there's a response model. Spec-provided
50
+ // descriptions are preferred; the fallback is templated from the parameter
51
+ // name so the SDK still compiles cleanly under failOnWarning.
39
52
  const kdocLines: string[] = [];
40
53
  const opDesc = (op.description ?? '').trim();
41
54
  const wrapperHumanName = method.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
@@ -45,35 +58,57 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
45
58
  kdocLines.push(`${wrapperHumanName.charAt(0).toUpperCase()}${wrapperHumanName.slice(1)}.`);
46
59
  }
47
60
  const paramDocs: string[] = [];
61
+ const pushParamDoc = (
62
+ kotlinName: string,
63
+ sourceName: string,
64
+ description: string | undefined,
65
+ type?: import('@workos/oagen').TypeRef,
66
+ ) => {
67
+ const firstLine =
68
+ description
69
+ ?.split('\n')
70
+ .find((l) => l.trim())
71
+ ?.trim() ?? '';
72
+ const fallback = `the ${humanize(sourceName)} of the request.`;
73
+ let text = firstLine || fallback;
74
+ const shortened = maybeShortenEnumParamDescription(type, text);
75
+ if (shortened) text = shortened.description;
76
+ paramDocs.push(`@param ${kotlinName} ${escapeKdoc(text)}`);
77
+ };
48
78
  for (const pp of pathParams) {
49
- if (pp.description?.trim()) {
50
- paramDocs.push(`@param ${propertyName(pp.name)} ${escapeKdoc(pp.description.split('\n')[0].trim())}`);
51
- }
79
+ pushParamDoc(propertyName(pp.name), pp.name, pp.description, pp.type);
52
80
  }
53
81
  for (const rp of resolvedParams) {
54
- const desc = rp.field?.description?.trim();
55
- if (desc) {
56
- paramDocs.push(`@param ${propertyName(rp.paramName)} ${escapeKdoc(desc.split('\n')[0])}`);
57
- }
82
+ pushParamDoc(propertyName(rp.paramName), rp.paramName, rp.field?.description, rp.field?.type);
58
83
  }
84
+ // Trailing `requestOptions` parameter — stable canned phrasing.
85
+ pushParamDoc(
86
+ 'requestOptions',
87
+ 'request_options',
88
+ 'per-request overrides (idempotency key, API key, headers, timeout)',
89
+ );
59
90
  if (responseClass) {
60
91
  paramDocs.push(`@return the ${responseClass}`);
61
92
  }
62
- if (paramDocs.length > 0 || kdocLines.length > 0) {
63
- lines.push(' /**');
64
- for (const l of kdocLines) lines.push(` * ${escapeKdoc(l)}`);
65
- if (paramDocs.length > 0) {
66
- lines.push(' *');
67
- for (const p of paramDocs) lines.push(` * ${p}`);
68
- }
69
- lines.push(' */');
70
- }
93
+ lines.push(' /**');
94
+ for (const l of kdocLines) lines.push(` * ${escapeKdoc(l)}`);
95
+ lines.push(' *');
96
+ for (const p of paramDocs) lines.push(` * ${p}`);
97
+ lines.push(' */');
71
98
 
72
99
  lines.push(' @JvmOverloads');
73
100
 
74
- // Build the method parameter list: path params, wrapper params, requestOptions
101
+ // Build the method parameter list: path params, wrapper params, requestOptions.
102
+ // `suspendParams` mirrors `params` but tracks the bare parameter name so the
103
+ // suspend overload (emitted at the end of this function) can forward each
104
+ // argument to the blocking implementation.
75
105
  const params: string[] = [];
76
- for (const pp of pathParams) params.push(` ${propertyName(pp.name)}: String`);
106
+ const suspendParams: SuspendParam[] = [];
107
+ for (const pp of pathParams) {
108
+ const decl = ` ${propertyName(pp.name)}: String`;
109
+ params.push(decl);
110
+ suspendParams.push({ decl, name: propertyName(pp.name) });
111
+ }
77
112
  for (const rp of resolvedParams) {
78
113
  const paramName = propertyName(rp.paramName);
79
114
  const kotlinType = rp.field
@@ -84,9 +119,12 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
84
119
  ? 'String?'
85
120
  : 'String';
86
121
  const trailer = rp.isOptional ? ' = null' : '';
87
- params.push(` ${paramName}: ${kotlinType}${trailer}`);
122
+ const decl = ` ${paramName}: ${kotlinType}${trailer}`;
123
+ params.push(decl);
124
+ suspendParams.push({ decl, name: paramName });
88
125
  }
89
126
  params.push(' requestOptions: RequestOptions? = null');
127
+ suspendParams.push({ decl: ' requestOptions: RequestOptions? = null', name: 'requestOptions' });
90
128
 
91
129
  const returnClause = responseClass ? `: ${responseClass}` : '';
92
130
  if (params.length === 1) {
@@ -101,6 +139,38 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
101
139
  lines.push(` )${returnClause} {`);
102
140
  }
103
141
 
142
+ // The /user_management/authenticate endpoint is union-split into one
143
+ // wrapper per grant_type. Every variant posts the same shape (caller
144
+ // params + grant_type + client_id + client_secret) to the same path with
145
+ // the same response model, so we route through a single `authenticate(...)`
146
+ // private helper instead of duplicating the request boilerplate per grant.
147
+ const inferred = wrapper.inferFromClient ?? [];
148
+ const usesStandardClientCreds = inferred.includes('client_id') && inferred.includes('client_secret');
149
+ if (
150
+ op.path === '/user_management/authenticate' &&
151
+ op.httpMethod.toUpperCase() === 'POST' &&
152
+ responseClass === 'AuthenticateResponse' &&
153
+ typeof wrapper.defaults?.grant_type === 'string' &&
154
+ usesStandardClientCreds
155
+ ) {
156
+ const grantType = wrapper.defaults.grant_type;
157
+ lines.push(` return authenticate(`);
158
+ lines.push(` grantType = ${ktLiteral(grantType)},`);
159
+ lines.push(` requestOptions = requestOptions,`);
160
+ const entryLines = resolvedParams.map((rp) => {
161
+ const paramName = propertyName(rp.paramName);
162
+ return ` ${ktLiteral(rp.paramName)} to ${paramName}`;
163
+ });
164
+ for (let i = 0; i < entryLines.length; i++) {
165
+ const sep = i === entryLines.length - 1 ? '' : ',';
166
+ lines.push(`${entryLines[i]}${sep}`);
167
+ }
168
+ lines.push(` )`);
169
+ lines.push(' }');
170
+ appendSuspendVariant(lines, method, suspendParams, responseClass ?? 'Unit');
171
+ return lines;
172
+ }
173
+
104
174
  // Build body using bodyOf() — consistent with non-wrapper methods.
105
175
  // bodyOf() automatically drops null optional values.
106
176
  const bodyEntries: string[] = [];
@@ -149,9 +219,22 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
149
219
  }
150
220
 
151
221
  lines.push(' }');
222
+ appendSuspendVariant(lines, method, suspendParams, responseClass ?? 'Unit');
152
223
  return lines;
153
224
  }
154
225
 
226
+ function appendSuspendVariant(
227
+ lines: string[],
228
+ method: string,
229
+ suspendParams: SuspendParam[],
230
+ returnType: string,
231
+ ): void {
232
+ lines.push('');
233
+ for (const ln of emitSuspendVariant({ methodName: method, params: suspendParams, returnType })) {
234
+ lines.push(ln);
235
+ }
236
+ }
237
+
155
238
  function escapeKdoc(s: string): string {
156
239
  return s.replace(/\*\//g, '*\u200b/');
157
240
  }
@@ -103,7 +103,7 @@ describe('kotlin/resources', () => {
103
103
  ],
104
104
  };
105
105
  const files = generateResources(services, { ...ctxFor(services), spec: spec as ApiSpec });
106
- const ssoFile = files.find((file) => file.path.endsWith('/Sso.kt'));
106
+ const ssoFile = files.find((file) => file.path.endsWith('/SSO.kt'));
107
107
  expect(ssoFile).toBeDefined();
108
108
  expect(ssoFile!.content).toContain('fun getProfileAndToken(');
109
109
  expect(ssoFile!.content).toContain('code: String');
@@ -236,4 +236,97 @@ describe('kotlin/resources', () => {
236
236
  expect(sortOrder!.content).toContain('enum class SortOrder');
237
237
  expect(aliases.length).toBeLessThanOrEqual(1);
238
238
  });
239
+
240
+ it('emits a coroutine-friendly suspend overload alongside every blocking method', () => {
241
+ const services: Service[] = [
242
+ {
243
+ name: 'Users',
244
+ operations: [
245
+ {
246
+ name: 'getUser',
247
+ httpMethod: 'get',
248
+ path: '/users/{id}',
249
+ pathParams: [{ name: 'id', type: { kind: 'primitive', type: 'string' }, required: true }],
250
+ queryParams: [],
251
+ headerParams: [],
252
+ response: { kind: 'primitive', type: 'unknown' },
253
+ errors: [],
254
+ injectIdempotencyKey: false,
255
+ },
256
+ ],
257
+ },
258
+ ];
259
+ const files = generateResources(services, ctxFor(services));
260
+ const file = files.find((f) => f.path.endsWith('/Users.kt'))!;
261
+ expect(file.content).toContain('import kotlinx.coroutines.Dispatchers');
262
+ expect(file.content).toContain('import kotlinx.coroutines.withContext');
263
+ expect(file.content).toContain('@JvmName("getSuspend")');
264
+ expect(file.content).toMatch(/suspend fun getSuspend\([\s\S]*?withContext\(Dispatchers\.IO\)/);
265
+ });
266
+
267
+ it('emits Java-friendly per-variant overloads for sealed-class parameter groups', () => {
268
+ const services: Service[] = [
269
+ {
270
+ name: 'Authorization',
271
+ operations: [
272
+ {
273
+ name: 'check',
274
+ httpMethod: 'post',
275
+ path: '/authorization/check',
276
+ pathParams: [],
277
+ queryParams: [],
278
+ headerParams: [],
279
+ requestBody: { kind: 'model', name: 'CheckRequest' },
280
+ response: { kind: 'primitive', type: 'unknown' },
281
+ errors: [],
282
+ injectIdempotencyKey: false,
283
+ parameterGroups: [
284
+ {
285
+ name: 'resource_target',
286
+ optional: false,
287
+ variants: [
288
+ {
289
+ name: 'ById',
290
+ parameters: [{ name: 'resource_id', type: { kind: 'primitive', type: 'string' }, required: true }],
291
+ },
292
+ {
293
+ name: 'ByExternalId',
294
+ parameters: [
295
+ { name: 'resource_external_id', type: { kind: 'primitive', type: 'string' }, required: true },
296
+ { name: 'resource_type_slug', type: { kind: 'primitive', type: 'string' }, required: true },
297
+ ],
298
+ },
299
+ ],
300
+ },
301
+ ],
302
+ },
303
+ ],
304
+ },
305
+ ];
306
+ const spec = {
307
+ ...baseSpec,
308
+ services,
309
+ models: [
310
+ ...baseSpec.models,
311
+ {
312
+ name: 'CheckRequest',
313
+ fields: [],
314
+ },
315
+ ],
316
+ };
317
+ const files = generateResources(services, { ...ctxFor(services), spec: spec as ApiSpec });
318
+ const file = files.find((f) => f.path.endsWith('/Authorization.kt'))!;
319
+ // The canonical method still takes the sealed class.
320
+ expect(file.content).toMatch(/fun check\([\s\S]*?resourceTarget: ResourceTarget[\s\S]*?\)/);
321
+ // Java-friendly ById overload — keeps the base method name and takes the
322
+ // flat resource_id field.
323
+ expect(file.content).toMatch(/fun check\([\s\S]*?resourceId: String[\s\S]*?\) = check\(/);
324
+ // Java-friendly ByExternalId overload — uses the variant suffix.
325
+ expect(file.content).toMatch(/fun checkByExternalId\([\s\S]*?resourceExternalId: String/);
326
+ expect(file.content).toContain('ResourceTarget.ById(resourceId = resourceId)');
327
+ expect(file.content).toContain('Java-friendly overload');
328
+ // The sealed class itself carries Kotlin + Java construction examples.
329
+ expect(file.content).toContain('Usage from Kotlin:');
330
+ expect(file.content).toContain('Usage from Java:');
331
+ });
239
332
  });