@workos/oagen-emitters 0.8.2 → 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.
@@ -23,6 +23,7 @@ import {
23
23
  clientFieldExpression,
24
24
  escapeReserved,
25
25
  humanize,
26
+ maybeShortenEnumParamDescription,
26
27
  } from './naming.js';
27
28
  import {
28
29
  buildResolvedLookup,
@@ -38,6 +39,7 @@ import { generateWrapperMethods } from './wrappers.js';
38
39
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
39
40
  import { isHandwrittenOverride } from './overrides.js';
40
41
  import { buildKotlinPathExpression, KOTLIN_PATH_ENCODE_IMPORT } from './path-expression.js';
42
+ import { emitSuspendVariant, SUSPEND_IMPORTS, type SuspendParam } from './suspend.js';
41
43
 
42
44
  const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
43
45
 
@@ -124,6 +126,9 @@ function generateApiClass(
124
126
  imports.add('com.workos.common.http.Page');
125
127
  imports.add('com.workos.common.http.RequestConfig');
126
128
  imports.add('com.workos.common.http.RequestOptions');
129
+ // Every emitted method gains a `suspend` overload that delegates to the
130
+ // blocking version under `withContext(Dispatchers.IO)`.
131
+ for (const imp of SUSPEND_IMPORTS) imports.add(imp);
127
132
 
128
133
  const body: string[] = [];
129
134
  const seenMethods = new Set<string>();
@@ -223,17 +228,29 @@ function generateApiClass(
223
228
  for (const line of sealedLines) lines.push(line);
224
229
 
225
230
  const serviceDescription = resolveServiceDescription(ctx, mountName, operations);
231
+ // Every blocking method on this class also has a `suspend` overload — the
232
+ // suspend variant delegates to the blocking one via
233
+ // `withContext(Dispatchers.IO)` and is safe to call from any coroutine
234
+ // dispatcher. The service-level KDoc surfaces this so callers don't have to
235
+ // discover it per-method.
236
+ const suspendNote =
237
+ 'Every operation on this class is available in two flavors: a blocking variant ' +
238
+ '(`<methodName>`) and a coroutine-aware variant (`<methodName>Suspend`). ' +
239
+ 'The `Suspend` variants delegate to the blocking ones under `withContext(Dispatchers.IO)`, ' +
240
+ 'so they are safe to call from any coroutine dispatcher (including `Dispatchers.Main`).';
226
241
  if (serviceDescription) {
227
242
  const docLines = serviceDescription.trim().split('\n');
228
- if (docLines.length === 1) {
229
- lines.push(`/** ${escapeKdoc(docLines[0].trim())} */`);
230
- } else {
231
- lines.push('/**');
232
- for (const l of docLines) lines.push(l ? ` * ${escapeKdoc(l)}` : ' *');
233
- lines.push(' */');
234
- }
243
+ lines.push('/**');
244
+ for (const l of docLines) lines.push(l ? ` * ${escapeKdoc(l)}` : ' *');
245
+ lines.push(' *');
246
+ lines.push(` * ${escapeKdoc(suspendNote)}`);
247
+ lines.push(' */');
235
248
  } else {
236
- lines.push(`/** API accessor for ${mountName}. */`);
249
+ lines.push('/**');
250
+ lines.push(` * API accessor for ${mountName}.`);
251
+ lines.push(' *');
252
+ lines.push(` * ${escapeKdoc(suspendNote)}`);
253
+ lines.push(' */');
237
254
  }
238
255
  // ktlint requires constructor-property parameters on their own line.
239
256
  // The property is `internal` so hand-maintained extension files in the
@@ -346,11 +363,21 @@ function renderMethod(
346
363
  const groupParamNames = assignGroupParameterNames(op, paramNames);
347
364
 
348
365
  const params: string[] = [];
349
- for (const pp of pathParams) params.push(` ${propertyName(pp.name)}: String`);
366
+ // Mirrors `params` but tracks the bare Kotlin parameter name so the suspend
367
+ // overload (emitted alongside the blocking version) can forward arguments.
368
+ const suspendParams: SuspendParam[] = [];
369
+ const pushParam = (decl: string, name: string) => {
370
+ params.push(decl);
371
+ suspendParams.push({ decl, name });
372
+ };
373
+ for (const pp of pathParams) pushParam(` ${propertyName(pp.name)}: String`, propertyName(pp.name));
350
374
 
351
375
  const sortedQuery = [...uniqueQuery].sort((a, b) => (a.required === b.required ? 0 : a.required ? -1 : 1));
352
376
  for (const qp of sortedQuery) {
353
- params.push(renderParam(qp.name, qp.type, qp.required, method.startsWith('list') && qp.name === 'limit'));
377
+ pushParam(
378
+ renderParam(qp.name, qp.type, qp.required, method.startsWith('list') && qp.name === 'limit'),
379
+ propertyName(qp.name),
380
+ );
354
381
  }
355
382
 
356
383
  // Parameter group params (sealed class types)
@@ -358,9 +385,9 @@ function renderMethod(
358
385
  const sealedName = sealedGroupName(group.name);
359
386
  const prop = groupParamNames.get(group.name)!;
360
387
  if (group.optional) {
361
- params.push(` ${prop}: ${sealedName}? = null`);
388
+ pushParam(` ${prop}: ${sealedName}? = null`, prop);
362
389
  } else {
363
- params.push(` ${prop}: ${sealedName}`);
390
+ pushParam(` ${prop}: ${sealedName}`, prop);
364
391
  }
365
392
  }
366
393
 
@@ -374,14 +401,17 @@ function renderMethod(
374
401
  if (isPatch && !bf.required) {
375
402
  const baseType = mapTypeRef(bf.type);
376
403
  imports.add('com.workos.common.http.PatchField');
377
- params.push(` ${bodyParamNames.get(bf.name)!}: PatchField<${baseType}> = PatchField.Absent`);
404
+ pushParam(
405
+ ` ${bodyParamNames.get(bf.name)!}: PatchField<${baseType}> = PatchField.Absent`,
406
+ bodyParamNames.get(bf.name)!,
407
+ );
378
408
  } else {
379
- params.push(renderParamNamed(bodyParamNames.get(bf.name)!, bf.type, bf.required));
409
+ pushParam(renderParamNamed(bodyParamNames.get(bf.name)!, bf.type, bf.required), bodyParamNames.get(bf.name)!);
380
410
  }
381
411
  }
382
412
 
383
413
  // Per-request options trailer (always optional)
384
- params.push(' requestOptions: RequestOptions? = null');
414
+ pushParam(' requestOptions: RequestOptions? = null', 'requestOptions');
385
415
 
386
416
  const returnType = resolveReturnType(plan, imports, ctx);
387
417
  const isPaginated = plan.isPaginated && paginatedItemName !== null;
@@ -445,6 +475,7 @@ function renderMethod(
445
475
  }
446
476
  lines.push(` )`);
447
477
  lines.push(' }');
478
+ appendSuspendVariantLines(lines, method, suspendParams, returnType, op.deprecated);
448
479
  return lines.join('\n');
449
480
  }
450
481
 
@@ -580,9 +611,227 @@ function renderMethod(
580
611
  }
581
612
 
582
613
  lines.push(' }');
614
+ appendSuspendVariantLines(lines, method, suspendParams, returnType, op.deprecated);
615
+
616
+ // Java-friendly overloads: for each variant of every sealed-class parameter
617
+ // group, emit an additional overload that accepts the variant's flat fields
618
+ // directly (no `new ResourceTarget.ById(...)` boilerplate from Java).
619
+ appendJavaFriendlyVariantOverloads(lines, {
620
+ method,
621
+ op,
622
+ canonicalParams: suspendParams,
623
+ groupParamNames,
624
+ returnType,
625
+ deprecated: op.deprecated,
626
+ });
627
+
583
628
  return lines.join('\n');
584
629
  }
585
630
 
631
+ interface JavaOverloadCtx {
632
+ method: string;
633
+ op: Operation;
634
+ /**
635
+ * The exact parameter declarations of the canonical method, in order. The
636
+ * Java-friendly overload reuses these verbatim for non-variant params so
637
+ * type/nullability/default exactly match the canonical method (avoiding
638
+ * subtle drift like `Long?` vs `Int?` or `String?` vs `String`).
639
+ */
640
+ canonicalParams: SuspendParam[];
641
+ groupParamNames: Map<string, string>;
642
+ returnType: string;
643
+ deprecated: boolean | undefined;
644
+ }
645
+
646
+ /**
647
+ * For each variant of every sealed-class parameter group on `op`, append a
648
+ * Java-discoverable overload that takes the variant's flat fields directly
649
+ * and forwards to the canonical (sealed-class) method.
650
+ *
651
+ * Implementation scope: when an operation has multiple parameter groups, we
652
+ * emit overloads for each variant of each group while keeping the *other*
653
+ * groups' sealed-class params unchanged. This avoids combinatorial explosion
654
+ * for ops with several groups while still flattening the common case.
655
+ *
656
+ * Method-name derivation handles the most common variant shapes (`ById`,
657
+ * `ByExternalId`, etc.). Other shapes (e.g. `Plaintext`, `Imported`) are
658
+ * skipped with a comment so unusual unions don't compile-fail the SDK; if
659
+ * one becomes important we can add an explicit case here.
660
+ */
661
+ function appendJavaFriendlyVariantOverloads(lines: string[], ctx: JavaOverloadCtx): void {
662
+ const groups = ctx.op.parameterGroups ?? [];
663
+ if (groups.length === 0) return;
664
+
665
+ for (const group of groups) {
666
+ for (const variant of group.variants) {
667
+ const overloadMethodName = deriveOverloadMethodName(ctx.method, variant.name);
668
+ if (overloadMethodName === null) {
669
+ // Variant shape doesn't fit a clean Java-friendly method name; skip.
670
+ // TODO: extend deriveOverloadMethodName for less-common shapes
671
+ // (e.g. password variants) once we have a need for them.
672
+ continue;
673
+ }
674
+ emitOneJavaOverload(lines, ctx, group, variant, overloadMethodName);
675
+ }
676
+ }
677
+ }
678
+
679
+ function deriveOverloadMethodName(baseMethod: string, variantName: string): string | null {
680
+ const v = className(variantName);
681
+ if (v === 'ById') return baseMethod;
682
+ if (/^By[A-Z]/.test(v)) {
683
+ // Don't double-stack a By-suffix: if the operation method already ends
684
+ // with the variant suffix (case-insensitive), reuse it as-is. This keeps
685
+ // names like `updateResourceByExternalId` from becoming
686
+ // `updateResourceByExternalIdByExternalId`.
687
+ if (baseMethod.toLowerCase().endsWith(v.toLowerCase())) {
688
+ return baseMethod;
689
+ }
690
+ return `${baseMethod}${v}`;
691
+ }
692
+ return null;
693
+ }
694
+
695
+ function emitOneJavaOverload(
696
+ lines: string[],
697
+ ctx: JavaOverloadCtx,
698
+ group: import('@workos/oagen').ParameterGroup,
699
+ variant: import('@workos/oagen').ParameterGroup['variants'][number],
700
+ overloadMethodName: string,
701
+ ): void {
702
+ const sealedName = sealedGroupName(group.name);
703
+ const variantClass = className(variant.name);
704
+ const targetGroupProp = ctx.groupParamNames.get(group.name)!;
705
+
706
+ // Reuse the canonical method's exact parameter decls. We swap out only the
707
+ // target group's slot (replacing the sealed param with variant fields) and
708
+ // leave path/query/body params untouched so types/nullability/defaults
709
+ // line up exactly with what the canonical method accepts.
710
+ type ParamSpec = { decl: string; name: string };
711
+ const decls: ParamSpec[] = [];
712
+
713
+ // Track names already in use so variant field names that collide with
714
+ // existing op params can be deterministically disambiguated.
715
+ const existingNames = new Set<string>();
716
+ for (const cp of ctx.canonicalParams) {
717
+ if (cp.name !== targetGroupProp) existingNames.add(cp.name);
718
+ }
719
+
720
+ // Compute variant field decls (with collision-aware names). For a colliding
721
+ // field, prefix it with the group's property name (e.g. `externalId` →
722
+ // `parentResourceExternalId`) so the overload signature is unambiguous and
723
+ // doesn't shadow the operation's own path/query/body params.
724
+ const variantFieldDecls: ParamSpec[] = [];
725
+ const variantArgPairs: { sealedField: string; localName: string }[] = [];
726
+ for (const p of variant.parameters) {
727
+ const sealedField = deriveShortPropertyName(p.name, group.name);
728
+ let localName = sealedField;
729
+ if (existingNames.has(localName)) {
730
+ const groupPrefix = propertyName(group.name);
731
+ const camel = `${groupPrefix}${localName.charAt(0).toUpperCase()}${localName.slice(1)}`;
732
+ localName = camel;
733
+ }
734
+ existingNames.add(localName);
735
+ // Variant fields are always required when their variant is selected — the
736
+ // sealed-class data class declares them non-nullable (see
737
+ // [generateSealedClass]). Mirror that here so the overload's flat
738
+ // parameter types match the variant-constructor argument types exactly.
739
+ // Also: prefer the body model's field type via [bodyFieldTypes] when the
740
+ // parameter-group IR has lost type fidelity. Inside this scope we don't
741
+ // have bodyFieldTypes; the canonical method's type rendering for variant
742
+ // fields hasn't been needed elsewhere, so fall back to p.type — which is
743
+ // correct for non-body groups (path/query) and for body groups whose
744
+ // types weren't degraded.
745
+ const decl = renderParamNamed(localName, p.type, true);
746
+ variantFieldDecls.push({ decl, name: localName });
747
+ variantArgPairs.push({ sealedField, localName });
748
+ }
749
+
750
+ // Walk canonical params; substitute the target group slot with the variant's
751
+ // flat fields, copy everything else unchanged.
752
+ for (const cp of ctx.canonicalParams) {
753
+ if (cp.name === targetGroupProp) {
754
+ for (const v of variantFieldDecls) decls.push(v);
755
+ } else {
756
+ decls.push({ decl: cp.decl, name: cp.name });
757
+ }
758
+ }
759
+
760
+ const returnClause = ctx.returnType === 'Unit' ? '' : `: ${ctx.returnType}`;
761
+ const variantArgs = variantArgPairs.map((p) => `${p.sealedField} = ${p.localName}`).join(', ');
762
+ const sealedConstruct = `${sealedName}.${variantClass}(${variantArgs})`;
763
+
764
+ // Build the call to the canonical method by walking canonical params again:
765
+ // the target group slot is set to the inline-constructed variant; everything
766
+ // else passes through by its canonical name.
767
+ const forwardArgs: string[] = [];
768
+ for (const cp of ctx.canonicalParams) {
769
+ if (cp.name === targetGroupProp) {
770
+ forwardArgs.push(`${cp.name} = ${sealedConstruct}`);
771
+ } else {
772
+ forwardArgs.push(`${cp.name} = ${cp.name}`);
773
+ }
774
+ }
775
+
776
+ // KDoc.
777
+ lines.push('');
778
+ lines.push(' /**');
779
+ lines.push(` * Java-friendly overload — equivalent to`);
780
+ lines.push(` * \`${ctx.method}(${sealedName}.${variantClass}(...))\` from Kotlin.`);
781
+ lines.push(` *`);
782
+ lines.push(` * Accepts the discriminating fields directly so Java callers don't`);
783
+ lines.push(` * need to construct \`${sealedName}.${variantClass}\` explicitly.`);
784
+ lines.push(' */');
785
+ if (ctx.deprecated) lines.push(' @Deprecated("Deprecated operation")');
786
+ // Note: no `@JvmOverloads` here. The synthetic Java overloads `@JvmOverloads`
787
+ // generates (one per trailing optional, with the optional dropped) collide
788
+ // with the canonical method's `@JvmOverloads` permutations whenever the
789
+ // overload reuses the canonical method name (e.g. `ById` variants).
790
+ // Java callers pay a small ergonomic cost — they must pass `null` for
791
+ // optional trailing params (typically just `requestOptions`) — but the
792
+ // overload's main purpose (no sealed-class construction) still applies.
793
+
794
+ // Emit the function signature.
795
+ if (decls.length === 1) {
796
+ const single = decls[0].decl.replace(/^\s+/, '');
797
+ lines.push(` fun ${escapeReserved(overloadMethodName)}(${single})${returnClause} = ${ctx.method}(`);
798
+ } else {
799
+ lines.push(` fun ${escapeReserved(overloadMethodName)}(`);
800
+ for (let i = 0; i < decls.length; i++) {
801
+ const sep = i === decls.length - 1 ? '' : ',';
802
+ lines.push(`${decls[i].decl}${sep}`);
803
+ }
804
+ lines.push(` )${returnClause} = ${ctx.method}(`);
805
+ }
806
+ for (let i = 0; i < forwardArgs.length; i++) {
807
+ const sep = i === forwardArgs.length - 1 ? '' : ',';
808
+ lines.push(` ${forwardArgs[i]}${sep}`);
809
+ }
810
+ lines.push(' )');
811
+
812
+ // Emit a coroutine-friendly suspend variant of the overload too.
813
+ appendSuspendVariantLines(
814
+ lines,
815
+ overloadMethodName,
816
+ decls.map((d) => ({ decl: d.decl, name: d.name })),
817
+ ctx.returnType,
818
+ ctx.deprecated,
819
+ );
820
+ }
821
+
822
+ function appendSuspendVariantLines(
823
+ lines: string[],
824
+ method: string,
825
+ suspendParams: SuspendParam[],
826
+ returnType: string,
827
+ deprecated: boolean | undefined,
828
+ ): void {
829
+ lines.push('');
830
+ for (const ln of emitSuspendVariant({ methodName: method, params: suspendParams, returnType, deprecated })) {
831
+ lines.push(ln);
832
+ }
833
+ }
834
+
586
835
  function resolveReturnType(plan: ReturnType<typeof planOperation>, imports: Set<string>, ctx?: EmitterContext): string {
587
836
  const itemName = plan.isPaginated
588
837
  ? (resolvePaginatedItemName(plan.paginatedItemModelName, ctx) ?? plan.paginatedItemModelName)
@@ -660,19 +909,25 @@ function buildMethodKdoc(
660
909
  // ugly — it nudges callers to add real descriptions to the spec.
661
910
  const paramDocs: string[] = [];
662
911
  const seenParamDocs = new Set<string>();
663
- const pushParamDoc = (name: string, sourceName: string, description: string | undefined, deprecated?: boolean) => {
912
+ const pushParamDoc = (
913
+ name: string,
914
+ sourceName: string,
915
+ description: string | undefined,
916
+ deprecated?: boolean,
917
+ type?: TypeRef,
918
+ ) => {
664
919
  if (seenParamDocs.has(name)) return;
665
920
  seenParamDocs.add(name);
666
- paramDocs.push(formatParamDoc(name, description, deprecated, sourceName));
921
+ paramDocs.push(formatParamDoc(name, description, deprecated, sourceName, type));
667
922
  };
668
923
  for (const pp of pathParams) {
669
- pushParamDoc(propertyName(pp.name), pp.name, pp.description, pp.deprecated);
924
+ pushParamDoc(propertyName(pp.name), pp.name, pp.description, pp.deprecated, pp.type);
670
925
  }
671
926
  for (const qp of queryParams) {
672
- pushParamDoc(propertyName(qp.name), qp.name, qp.description, qp.deprecated);
927
+ pushParamDoc(propertyName(qp.name), qp.name, qp.description, qp.deprecated, qp.type);
673
928
  }
674
929
  for (const bf of bodyFields) {
675
- pushParamDoc(bodyParamNames.get(bf.name)!, bf.name, bf.description, bf.deprecated);
930
+ pushParamDoc(bodyParamNames.get(bf.name)!, bf.name, bf.description, bf.deprecated, bf.type);
676
931
  }
677
932
  // Always document the trailing `requestOptions` parameter with a stable,
678
933
  // canned phrasing so generated SDKs are consistent and Dokka's coverage
@@ -714,6 +969,7 @@ function formatParamDoc(
714
969
  description: string | undefined,
715
970
  deprecated?: boolean,
716
971
  sourceName?: string,
972
+ type?: TypeRef,
717
973
  ): string {
718
974
  const firstLine = description?.split('\n').find((l) => l.trim()) ?? '';
719
975
  const specText = firstLine.trim();
@@ -722,7 +978,12 @@ function formatParamDoc(
722
978
  // the spec didn't provide one. Dokka has no `-Xdoclint:missing` analogue,
723
979
  // so emitting a placeholder is the only way to guarantee `@param` coverage.
724
980
  const fallback = `the ${humanize(sourceName ?? kotlinName)} of the request.`;
725
- const text = specText || fallback;
981
+ let text = specText || fallback;
982
+ // For long enum-typed parameter descriptions (notably PaginationOrder),
983
+ // replace the verbose per-method copy with a short summary that defers to
984
+ // the enum's own KDoc — see [maybeShortenEnumParamDescription].
985
+ const shortened = maybeShortenEnumParamDescription(type, text);
986
+ if (shortened) text = shortened.description;
726
987
  const parts = [deprecationNote, text].filter(Boolean).join(' ');
727
988
  return `@param ${kotlinName} ${escapeKdoc(parts)}`;
728
989
  }
@@ -924,7 +1185,38 @@ function generateSealedClass(
924
1185
  ): string[] {
925
1186
  const lines: string[] = [];
926
1187
  const sealedName = sealedGroupName(group.name);
927
- lines.push(`/** Mutually exclusive ${humanize(group.name)} parameter variants. */`);
1188
+
1189
+ // KDoc with Kotlin + Java construction examples. Pick the first variant as
1190
+ // a worked example so callers see the variant constructor in both languages
1191
+ // without us having to enumerate every shape.
1192
+ const example = group.variants[0];
1193
+ if (example) {
1194
+ const exampleVariant = className(example.name);
1195
+ const exampleArgs = example.parameters
1196
+ .map((p) => `${deriveShortPropertyName(p.name, group.name)} = "..."`)
1197
+ .join(', ');
1198
+ lines.push('/**');
1199
+ lines.push(` * Mutually exclusive ${humanize(group.name)} parameter variants.`);
1200
+ lines.push(' *');
1201
+ lines.push(' * Usage from Kotlin:');
1202
+ lines.push(' * ```kotlin');
1203
+ lines.push(` * val target: ${sealedName} = ${sealedName}.${exampleVariant}(${exampleArgs})`);
1204
+ lines.push(' * ```');
1205
+ lines.push(' *');
1206
+ lines.push(' * Usage from Java:');
1207
+ lines.push(' * ```java');
1208
+ lines.push(
1209
+ ` * ${sealedName} target = new ${sealedName}.${exampleVariant}(${example.parameters.map(() => '"..."').join(', ')});`,
1210
+ );
1211
+ lines.push(' * ```');
1212
+ lines.push(' *');
1213
+ lines.push(
1214
+ ` * Java callers may also use the per-variant overloads on the surrounding API class to skip variant construction entirely.`,
1215
+ );
1216
+ lines.push(' */');
1217
+ } else {
1218
+ lines.push(`/** Mutually exclusive ${humanize(group.name)} parameter variants. */`);
1219
+ }
928
1220
  lines.push(`sealed class ${sealedName} {`);
929
1221
  for (let vi = 0; vi < group.variants.length; vi++) {
930
1222
  const variant = group.variants[vi];
@@ -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
+ }
@@ -1,9 +1,18 @@
1
1
  import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
2
- import { className, propertyName, ktLiteral, clientFieldExpression, escapeReserved, humanize } 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
@@ -49,21 +58,28 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
49
58
  kdocLines.push(`${wrapperHumanName.charAt(0).toUpperCase()}${wrapperHumanName.slice(1)}.`);
50
59
  }
51
60
  const paramDocs: string[] = [];
52
- const pushParamDoc = (kotlinName: string, sourceName: string, description: string | undefined) => {
61
+ const pushParamDoc = (
62
+ kotlinName: string,
63
+ sourceName: string,
64
+ description: string | undefined,
65
+ type?: import('@workos/oagen').TypeRef,
66
+ ) => {
53
67
  const firstLine =
54
68
  description
55
69
  ?.split('\n')
56
70
  .find((l) => l.trim())
57
71
  ?.trim() ?? '';
58
72
  const fallback = `the ${humanize(sourceName)} of the request.`;
59
- const text = firstLine || fallback;
73
+ let text = firstLine || fallback;
74
+ const shortened = maybeShortenEnumParamDescription(type, text);
75
+ if (shortened) text = shortened.description;
60
76
  paramDocs.push(`@param ${kotlinName} ${escapeKdoc(text)}`);
61
77
  };
62
78
  for (const pp of pathParams) {
63
- pushParamDoc(propertyName(pp.name), pp.name, pp.description);
79
+ pushParamDoc(propertyName(pp.name), pp.name, pp.description, pp.type);
64
80
  }
65
81
  for (const rp of resolvedParams) {
66
- pushParamDoc(propertyName(rp.paramName), rp.paramName, rp.field?.description);
82
+ pushParamDoc(propertyName(rp.paramName), rp.paramName, rp.field?.description, rp.field?.type);
67
83
  }
68
84
  // Trailing `requestOptions` parameter — stable canned phrasing.
69
85
  pushParamDoc(
@@ -82,9 +98,17 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
82
98
 
83
99
  lines.push(' @JvmOverloads');
84
100
 
85
- // 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.
86
105
  const params: string[] = [];
87
- 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
+ }
88
112
  for (const rp of resolvedParams) {
89
113
  const paramName = propertyName(rp.paramName);
90
114
  const kotlinType = rp.field
@@ -95,9 +119,12 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
95
119
  ? 'String?'
96
120
  : 'String';
97
121
  const trailer = rp.isOptional ? ' = null' : '';
98
- params.push(` ${paramName}: ${kotlinType}${trailer}`);
122
+ const decl = ` ${paramName}: ${kotlinType}${trailer}`;
123
+ params.push(decl);
124
+ suspendParams.push({ decl, name: paramName });
99
125
  }
100
126
  params.push(' requestOptions: RequestOptions? = null');
127
+ suspendParams.push({ decl: ' requestOptions: RequestOptions? = null', name: 'requestOptions' });
101
128
 
102
129
  const returnClause = responseClass ? `: ${responseClass}` : '';
103
130
  if (params.length === 1) {
@@ -140,6 +167,7 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
140
167
  }
141
168
  lines.push(` )`);
142
169
  lines.push(' }');
170
+ appendSuspendVariant(lines, method, suspendParams, responseClass ?? 'Unit');
143
171
  return lines;
144
172
  }
145
173
 
@@ -191,9 +219,22 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
191
219
  }
192
220
 
193
221
  lines.push(' }');
222
+ appendSuspendVariant(lines, method, suspendParams, responseClass ?? 'Unit');
194
223
  return lines;
195
224
  }
196
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
+
197
238
  function escapeKdoc(s: string): string {
198
239
  return s.replace(/\*\//g, '*\u200b/');
199
240
  }