@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.
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +19 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-DOE0FqrZ.mjs → plugin-Dh9JSScr.mjs} +586 -86
- package/dist/plugin-Dh9JSScr.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +4 -4
- package/src/kotlin/client.ts +12 -6
- package/src/kotlin/enums.ts +12 -1
- package/src/kotlin/index.ts +8 -6
- package/src/kotlin/models.ts +99 -4
- package/src/kotlin/naming.ts +58 -4
- package/src/kotlin/resources.ts +436 -53
- package/src/kotlin/suspend.ts +96 -0
- package/src/kotlin/tests.ts +33 -5
- package/src/kotlin/wrappers.ts +104 -21
- package/test/kotlin/resources.test.ts +94 -1
- package/dist/plugin-DOE0FqrZ.mjs.map +0 -1
|
@@ -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
|
+
}
|
package/src/kotlin/tests.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
|
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
|
}
|
package/src/kotlin/wrappers.ts
CHANGED
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
|
|
2
|
-
import {
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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('/
|
|
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
|
});
|