@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.
@@ -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,9 +39,50 @@ 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
 
46
+ /**
47
+ * Some specs leave query params / fields typed as plain `string` even though
48
+ * the description (or the field name) makes clear they carry an ISO-8601
49
+ * timestamp. Detecting that here lets us emit `OffsetDateTime` so callers
50
+ * don't have to format the wire string themselves.
51
+ */
52
+ const ISO_8601_DESCRIPTION_RE = /\bISO[-_ ]?8601\b/i;
53
+
54
+ function looksLikeIso8601String(description: string | undefined): boolean {
55
+ if (!description) return false;
56
+ return ISO_8601_DESCRIPTION_RE.test(description);
57
+ }
58
+
59
+ /**
60
+ * Promote a string `TypeRef` to a `format: date-time` primitive when the
61
+ * accompanying description identifies it as an ISO-8601 timestamp. Leaves
62
+ * non-string types untouched.
63
+ */
64
+ function promoteIso8601TypeRef(type: TypeRef, description: string | undefined): TypeRef {
65
+ if (!looksLikeIso8601String(description)) return type;
66
+ const promote = (t: TypeRef): TypeRef => {
67
+ if (t.kind === 'primitive' && t.type === 'string' && !t.format) {
68
+ return { kind: 'primitive', type: 'string', format: 'date-time' };
69
+ }
70
+ if (t.kind === 'nullable') return { kind: 'nullable', inner: promote(t.inner) };
71
+ return t;
72
+ };
73
+ return promote(type);
74
+ }
75
+
76
+ function promoteParameterType(p: Parameter): Parameter {
77
+ const promoted = promoteIso8601TypeRef(p.type, p.description);
78
+ return promoted === p.type ? p : { ...p, type: promoted };
79
+ }
80
+
81
+ function promoteFieldType(f: Field): Field {
82
+ const promoted = promoteIso8601TypeRef(f.type, f.description);
83
+ return promoted === f.type ? f : { ...f, type: promoted };
84
+ }
85
+
44
86
  /**
45
87
  * Generate one API class per mount group. Methods map 1:1 to IR operations.
46
88
  * Path params, query params, and body fields are flattened into the method
@@ -84,6 +126,9 @@ function generateApiClass(
84
126
  imports.add('com.workos.common.http.Page');
85
127
  imports.add('com.workos.common.http.RequestConfig');
86
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);
87
132
 
88
133
  const body: string[] = [];
89
134
  const seenMethods = new Set<string>();
@@ -183,17 +228,29 @@ function generateApiClass(
183
228
  for (const line of sealedLines) lines.push(line);
184
229
 
185
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`).';
186
241
  if (serviceDescription) {
187
242
  const docLines = serviceDescription.trim().split('\n');
188
- if (docLines.length === 1) {
189
- lines.push(`/** ${escapeKdoc(docLines[0].trim())} */`);
190
- } else {
191
- lines.push('/**');
192
- for (const l of docLines) lines.push(l ? ` * ${escapeKdoc(l)}` : ' *');
193
- lines.push(' */');
194
- }
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(' */');
195
248
  } else {
196
- 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(' */');
197
254
  }
198
255
  // ktlint requires constructor-property parameters on their own line.
199
256
  // The property is `internal` so hand-maintained extension files in the
@@ -249,10 +306,12 @@ function renderMethod(
249
306
  const pathParams = sortPathParamsByTemplateOrder(op);
250
307
  const groupedParamNames = collectGroupedParamNames(op);
251
308
  const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
252
- const queryParams = op.queryParams.filter((p) => !hidden.has(p.name) && !groupedParamNames.has(p.name));
309
+ const queryParams = op.queryParams
310
+ .filter((p) => !hidden.has(p.name) && !groupedParamNames.has(p.name))
311
+ .map(promoteParameterType);
253
312
  const bodyModel = resolveBodyModel(op, ctx);
254
313
  const bodyFields = bodyModel
255
- ? bodyModel.fields.filter((f) => !hidden.has(f.name) && !groupedParamNames.has(f.name))
314
+ ? bodyModel.fields.filter((f) => !hidden.has(f.name) && !groupedParamNames.has(f.name)).map(promoteFieldType)
256
315
  : [];
257
316
 
258
317
  // Track imports we need
@@ -304,11 +363,21 @@ function renderMethod(
304
363
  const groupParamNames = assignGroupParameterNames(op, paramNames);
305
364
 
306
365
  const params: string[] = [];
307
- 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));
308
374
 
309
375
  const sortedQuery = [...uniqueQuery].sort((a, b) => (a.required === b.required ? 0 : a.required ? -1 : 1));
310
376
  for (const qp of sortedQuery) {
311
- 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
+ );
312
381
  }
313
382
 
314
383
  // Parameter group params (sealed class types)
@@ -316,9 +385,9 @@ function renderMethod(
316
385
  const sealedName = sealedGroupName(group.name);
317
386
  const prop = groupParamNames.get(group.name)!;
318
387
  if (group.optional) {
319
- params.push(` ${prop}: ${sealedName}? = null`);
388
+ pushParam(` ${prop}: ${sealedName}? = null`, prop);
320
389
  } else {
321
- params.push(` ${prop}: ${sealedName}`);
390
+ pushParam(` ${prop}: ${sealedName}`, prop);
322
391
  }
323
392
  }
324
393
 
@@ -332,14 +401,17 @@ function renderMethod(
332
401
  if (isPatch && !bf.required) {
333
402
  const baseType = mapTypeRef(bf.type);
334
403
  imports.add('com.workos.common.http.PatchField');
335
- 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
+ );
336
408
  } else {
337
- 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)!);
338
410
  }
339
411
  }
340
412
 
341
413
  // Per-request options trailer (always optional)
342
- params.push(' requestOptions: RequestOptions? = null');
414
+ pushParam(' requestOptions: RequestOptions? = null', 'requestOptions');
343
415
 
344
416
  const returnType = resolveReturnType(plan, imports, ctx);
345
417
  const isPaginated = plan.isPaginated && paginatedItemName !== null;
@@ -403,6 +475,7 @@ function renderMethod(
403
475
  }
404
476
  lines.push(` )`);
405
477
  lines.push(' }');
478
+ appendSuspendVariantLines(lines, method, suspendParams, returnType, op.deprecated);
406
479
  return lines.join('\n');
407
480
  }
408
481
 
@@ -426,12 +499,13 @@ function renderMethod(
426
499
  lines.push(` before = ${pickNamedQueryParam(sortedQuery, 'before')},`);
427
500
  lines.push(` after = ${pickNamedQueryParam(sortedQuery, 'after')}`);
428
501
  lines.push(` ) {`);
429
- lines.push(` val params = this`);
430
502
  for (const qp of sortedQuery.filter((p) => p.name !== 'after' && p.name !== 'before')) {
431
- for (const ln of emitQueryParam(qp, ' ')) lines.push(ln);
503
+ for (const ln of emitQueryParam(qp, ' ', true)) lines.push(ln);
432
504
  }
433
505
  for (const group of op.parameterGroups ?? []) {
434
- for (const ln of emitGroupQueryDispatch(group, groupParamNames.get(group.name)!, ' ')) lines.push(ln);
506
+ for (const ln of emitGroupQueryDispatch(group, groupParamNames.get(group.name)!, ' ', true)) {
507
+ lines.push(ln);
508
+ }
435
509
  }
436
510
  lines.push(` }`);
437
511
  } else {
@@ -537,9 +611,227 @@ function renderMethod(
537
611
  }
538
612
 
539
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
+
540
628
  return lines.join('\n');
541
629
  }
542
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
+
543
835
  function resolveReturnType(plan: ReturnType<typeof planOperation>, imports: Set<string>, ctx?: EmitterContext): string {
544
836
  const itemName = plan.isPaginated
545
837
  ? (resolvePaginatedItemName(plan.paginatedItemModelName, ctx) ?? plan.paginatedItemModelName)
@@ -609,31 +901,38 @@ function buildMethodKdoc(
609
901
  }
610
902
 
611
903
  // @param lines. Use the Kotlin-visible parameter name (body collisions get
612
- // renamed, e.g. slug → bodySlug). Deprecated parameters always get a
613
- // @param entry even without a description so the deprecation note is
614
- // surfaced in the docs.
904
+ // renamed, e.g. slug → bodySlug). Every parameter gets an `@param` line —
905
+ // Dokka does not flag missing `@param` blocks (only fully undocumented
906
+ // declarations), so we have to enforce coverage at emit time. Spec-provided
907
+ // descriptions are preferred; missing descriptions get a templated fallback
908
+ // derived from the parameter name. The fallback is intentionally a little
909
+ // ugly — it nudges callers to add real descriptions to the spec.
615
910
  const paramDocs: string[] = [];
616
911
  const seenParamDocs = new Set<string>();
617
- const pushParamDoc = (name: 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
+ ) => {
618
919
  if (seenParamDocs.has(name)) return;
619
920
  seenParamDocs.add(name);
620
- paramDocs.push(formatParamDoc(name, description, deprecated));
921
+ paramDocs.push(formatParamDoc(name, description, deprecated, sourceName, type));
621
922
  };
622
923
  for (const pp of pathParams) {
623
- if (pp.description?.trim() || pp.deprecated) {
624
- pushParamDoc(propertyName(pp.name), pp.description, pp.deprecated);
625
- }
924
+ pushParamDoc(propertyName(pp.name), pp.name, pp.description, pp.deprecated, pp.type);
626
925
  }
627
926
  for (const qp of queryParams) {
628
- if (qp.description?.trim() || qp.deprecated) {
629
- pushParamDoc(propertyName(qp.name), qp.description, qp.deprecated);
630
- }
927
+ pushParamDoc(propertyName(qp.name), qp.name, qp.description, qp.deprecated, qp.type);
631
928
  }
632
929
  for (const bf of bodyFields) {
633
- if (bf.description?.trim() || bf.deprecated) {
634
- pushParamDoc(bodyParamNames.get(bf.name)!, bf.description, bf.deprecated);
635
- }
930
+ pushParamDoc(bodyParamNames.get(bf.name)!, bf.name, bf.description, bf.deprecated, bf.type);
636
931
  }
932
+ // Always document the trailing `requestOptions` parameter with a stable,
933
+ // canned phrasing so generated SDKs are consistent and Dokka's coverage
934
+ // reporting has nothing to flag.
935
+ pushParamDoc('requestOptions', 'request_options', REQUEST_OPTIONS_PARAM_DESCRIPTION);
637
936
 
638
937
  const returnDoc = plan.isPaginated
639
938
  ? '@return a [com.workos.common.http.Page] of results'
@@ -658,12 +957,35 @@ function buildMethodKdoc(
658
957
  return out;
659
958
  }
660
959
 
661
- function formatParamDoc(kotlinName: string, description: string | undefined, deprecated?: boolean): string {
960
+ /**
961
+ * Stable, canned description for the trailing `requestOptions` parameter that
962
+ * every generated method exposes. Kept as a constant so the same phrasing
963
+ * appears across resource methods, wrapper methods, and union-split helpers.
964
+ */
965
+ const REQUEST_OPTIONS_PARAM_DESCRIPTION = 'per-request overrides (idempotency key, API key, headers, timeout)';
966
+
967
+ function formatParamDoc(
968
+ kotlinName: string,
969
+ description: string | undefined,
970
+ deprecated?: boolean,
971
+ sourceName?: string,
972
+ type?: TypeRef,
973
+ ): string {
662
974
  const firstLine = description?.split('\n').find((l) => l.trim()) ?? '';
663
- const text = firstLine.trim();
975
+ const specText = firstLine.trim();
664
976
  const deprecationNote = deprecated ? '**Deprecated.**' : '';
977
+ // Fall back to a templated description derived from the parameter name when
978
+ // the spec didn't provide one. Dokka has no `-Xdoclint:missing` analogue,
979
+ // so emitting a placeholder is the only way to guarantee `@param` coverage.
980
+ const fallback = `the ${humanize(sourceName ?? kotlinName)} of the request.`;
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;
665
987
  const parts = [deprecationNote, text].filter(Boolean).join(' ');
666
- return `@param ${kotlinName}${parts ? ` ${escapeKdoc(parts)}` : ''}`;
988
+ return `@param ${kotlinName} ${escapeKdoc(parts)}`;
667
989
  }
668
990
 
669
991
  /**
@@ -684,43 +1006,66 @@ function unwrapArray(t: TypeRef): TypeRef | null {
684
1006
  function valueExprForQuery(type: TypeRef): string {
685
1007
  const inner = type.kind === 'nullable' ? type.inner : type;
686
1008
  if (inner.kind === 'enum') return 'it.value';
687
- if (inner.kind === 'primitive' && inner.type === 'string') return 'it';
1009
+ if (inner.kind === 'primitive' && inner.type === 'string') {
1010
+ return inner.format === 'date-time' ? 'it.toString()' : 'it';
1011
+ }
688
1012
  return 'it.toString()';
689
1013
  }
690
1014
 
691
- function emitQueryParam(p: Parameter, indent: string): string[] {
1015
+ function emitQueryParam(p: Parameter, indent: string, receiverMode = false): string[] {
692
1016
  const prop = propertyName(p.name);
693
1017
  const rendered = queryParamToString(p.type, prop);
694
1018
  const inner = p.type.kind === 'nullable' ? p.type.inner : p.type;
695
1019
  const arrayItem = unwrapArray(p.type);
1020
+ // In receiver-lambda mode (`requestPage { ... }`) the surrounding closure is
1021
+ // an extension on `MutableList<Pair<String, String>>`, so we elide the
1022
+ // explicit `params.` qualifier (extension functions resolve via implicit
1023
+ // receiver) and route `+=` through `add(pair)` to keep ktlint happy.
1024
+ const callPrefix = receiverMode ? '' : 'params.';
1025
+ const addPair = (pair: string) => (receiverMode ? `add(${pair})` : `params += ${pair}`);
696
1026
  if (arrayItem) {
697
1027
  // Honor `style: form, explode: false` → comma-joined. Default (explode:true
698
1028
  // or unspecified for form) → repeated keys. `p.explode ?? true` matches
699
1029
  // the OpenAPI default for query parameters when `style` is form.
700
1030
  const explode = p.explode ?? true;
701
1031
  const itemExpr = valueExprForQuery(arrayItem);
1032
+ // `it` is the loop variable in the trivial mapping case — `xs.map { it }`
1033
+ // is the identity function, so emit the collection directly when the per-
1034
+ // item expression doesn't transform the value.
1035
+ const isIdentity = itemExpr === 'it';
702
1036
  if (!explode) {
703
1037
  if (p.required) {
704
- return [`${indent}params.addJoinedIfNotNull(${ktLiteral(p.name)}, ${prop}.map { ${itemExpr} })`];
1038
+ const arg = isIdentity ? prop : `${prop}.map { ${itemExpr} }`;
1039
+ return [`${indent}${callPrefix}addJoinedIfNotNull(${ktLiteral(p.name)}, ${arg})`];
705
1040
  }
706
- return [`${indent}params.addJoinedIfNotNull(${ktLiteral(p.name)}, ${prop}?.map { ${itemExpr} })`];
1041
+ const arg = isIdentity ? prop : `${prop}?.map { ${itemExpr} }`;
1042
+ return [`${indent}${callPrefix}addJoinedIfNotNull(${ktLiteral(p.name)}, ${arg})`];
707
1043
  }
708
1044
  if (p.required) {
709
- return [`${indent}params.addEach(${ktLiteral(p.name)}, ${prop}.map { ${itemExpr} })`];
1045
+ const arg = isIdentity ? prop : `${prop}.map { ${itemExpr} }`;
1046
+ return [`${indent}${callPrefix}addEach(${ktLiteral(p.name)}, ${arg})`];
710
1047
  }
711
- return [`${indent}${prop}?.let { params.addEach(${ktLiteral(p.name)}, it.map { ${itemExpr} }) }`];
1048
+ if (isIdentity) {
1049
+ return [`${indent}${prop}?.let { ${callPrefix}addEach(${ktLiteral(p.name)}, it) }`];
1050
+ }
1051
+ return [`${indent}${prop}?.let { ${callPrefix}addEach(${ktLiteral(p.name)}, it.map { ${itemExpr} }) }`];
712
1052
  }
713
- if (p.required) return [`${indent}params += ${ktLiteral(p.name)} to ${rendered}`];
714
- if (inner.kind === 'primitive' && inner.type === 'string') {
715
- return [`${indent}params.addIfNotNull(${ktLiteral(p.name)}, ${prop})`];
1053
+ if (p.required) return [`${indent}${addPair(`${ktLiteral(p.name)} to ${rendered}`)}`];
1054
+ if (inner.kind === 'primitive' && inner.type === 'string' && inner.format !== 'date-time') {
1055
+ return [`${indent}${callPrefix}addIfNotNull(${ktLiteral(p.name)}, ${prop})`];
716
1056
  }
717
- return [`${indent}${prop}?.let { params += ${ktLiteral(p.name)} to ${queryParamToString(inner, 'it')} }`];
1057
+ return [`${indent}${prop}?.let { ${addPair(`${ktLiteral(p.name)} to ${queryParamToString(inner, 'it')}`)} }`];
718
1058
  }
719
1059
 
720
1060
  function queryParamToString(type: TypeRef, varName: string): string {
721
1061
  if (type.kind === 'enum') return `${varName}.value`;
722
1062
  if (type.kind === 'nullable') return queryParamToString(type.inner, varName);
723
- if (type.kind === 'primitive' && type.type === 'string') return varName;
1063
+ // Plain `string` is already the wire type. ISO-8601 strings get promoted to
1064
+ // `OffsetDateTime`, so we need an explicit `.toString()` to serialize them
1065
+ // as the spec-required ISO-8601 representation.
1066
+ if (type.kind === 'primitive' && type.type === 'string') {
1067
+ return type.format === 'date-time' ? `${varName}.toString()` : varName;
1068
+ }
724
1069
  return `${varName}.toString()`;
725
1070
  }
726
1071
 
@@ -840,7 +1185,38 @@ function generateSealedClass(
840
1185
  ): string[] {
841
1186
  const lines: string[] = [];
842
1187
  const sealedName = sealedGroupName(group.name);
843
- 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
+ }
844
1220
  lines.push(`sealed class ${sealedName} {`);
845
1221
  for (let vi = 0; vi < group.variants.length; vi++) {
846
1222
  const variant = group.variants[vi];
@@ -870,16 +1246,21 @@ function generateSealedClass(
870
1246
  }
871
1247
 
872
1248
  /** Emit `when` dispatch that serializes a parameter group into query params. */
873
- function emitGroupQueryDispatch(group: import('@workos/oagen').ParameterGroup, prop: string, indent: string): string[] {
1249
+ function emitGroupQueryDispatch(
1250
+ group: import('@workos/oagen').ParameterGroup,
1251
+ prop: string,
1252
+ indent: string,
1253
+ receiverMode = false,
1254
+ ): string[] {
874
1255
  const sealedName = sealedGroupName(group.name);
875
1256
  const lines: string[] = [];
876
1257
 
877
1258
  if (group.optional) {
878
1259
  lines.push(`${indent}if (${prop} != null) {`);
879
- emitWhenBlock(lines, group, sealedName, prop, `${indent} `);
1260
+ emitWhenBlock(lines, group, sealedName, prop, `${indent} `, receiverMode);
880
1261
  lines.push(`${indent}}`);
881
1262
  } else {
882
- emitWhenBlock(lines, group, sealedName, prop, indent);
1263
+ emitWhenBlock(lines, group, sealedName, prop, indent, receiverMode);
883
1264
  }
884
1265
  return lines;
885
1266
  }
@@ -920,13 +1301,15 @@ function emitWhenBlock(
920
1301
  sealedName: string,
921
1302
  prop: string,
922
1303
  indent: string,
1304
+ receiverMode = false,
923
1305
  ): void {
924
1306
  lines.push(`${indent}when (${prop}) {`);
925
1307
  for (const variant of group.variants) {
926
1308
  const variantName = className(variant.name);
927
1309
  const entries = variant.parameters.map((p) => {
928
1310
  const fieldProp = deriveShortPropertyName(p.name, group.name);
929
- return `params += ${ktLiteral(p.name)} to ${prop}.${fieldProp}`;
1311
+ const pair = `${ktLiteral(p.name)} to ${prop}.${fieldProp}`;
1312
+ return receiverMode ? `add(${pair})` : `params += ${pair}`;
930
1313
  });
931
1314
  if (entries.length === 1) {
932
1315
  lines.push(`${indent} is ${sealedName}.${variantName} -> ${entries[0]}`);