@workos/oagen-emitters 0.8.1 → 0.8.2

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/dist/plugin.mjs CHANGED
@@ -1,2 +1,2 @@
1
- import { t as workosEmittersPlugin } from "./plugin-DOE0FqrZ.mjs";
1
+ import { t as workosEmittersPlugin } from "./plugin-CeNME04k.mjs";
2
2
  export { workosEmittersPlugin };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/oagen-emitters",
3
- "version": "0.8.1",
3
+ "version": "0.8.2",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -8,6 +8,36 @@ const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
8
8
  const MODELS_PACKAGE = 'com.workos.models';
9
9
  const MODELS_DIR = 'com/workos/models';
10
10
 
11
+ /**
12
+ * Some specs leave string fields without `format: date-time` even though the
13
+ * description (or the example) makes clear they carry an ISO-8601 timestamp.
14
+ * Detect that here so we can promote the type to `OffsetDateTime` in the
15
+ * Kotlin output.
16
+ */
17
+ const ISO_8601_DESCRIPTION_RE = /\bISO[-_ ]?8601\b/i;
18
+
19
+ function looksLikeIso8601String(description: string | undefined): boolean {
20
+ if (!description) return false;
21
+ return ISO_8601_DESCRIPTION_RE.test(description);
22
+ }
23
+
24
+ function promoteIso8601TypeRef(type: TypeRef, description: string | undefined): TypeRef {
25
+ if (!looksLikeIso8601String(description)) return type;
26
+ const promote = (t: TypeRef): TypeRef => {
27
+ if (t.kind === 'primitive' && t.type === 'string' && !t.format) {
28
+ return { kind: 'primitive', type: 'string', format: 'date-time' };
29
+ }
30
+ if (t.kind === 'nullable') return { kind: 'nullable', inner: promote(t.inner) };
31
+ return t;
32
+ };
33
+ return promote(type);
34
+ }
35
+
36
+ function promoteFieldType(f: Field): Field {
37
+ const promoted = promoteIso8601TypeRef(f.type, f.description);
38
+ return promoted === f.type ? f : { ...f, type: promoted };
39
+ }
40
+
11
41
  /**
12
42
  * Generate Kotlin `data class` models. Each model becomes a separate `.kt`
13
43
  * file under `com.workos.models`. Discriminated unions emit a sealed class
@@ -295,7 +325,8 @@ function renderFields(fields: Field[], overrideFields: Set<string> = new Set()):
295
325
  const seen = new Set<string>();
296
326
  const lines: string[] = [];
297
327
 
298
- for (const field of fields) {
328
+ for (const rawField of fields) {
329
+ const field = promoteFieldType(rawField);
299
330
  const kotlinName = propertyName(field.name);
300
331
  if (seen.has(kotlinName)) continue;
301
332
  seen.add(kotlinName);
@@ -329,7 +360,7 @@ function renderFields(fields: Field[], overrideFields: Set<string> = new Set()):
329
360
  // isEmailVerified(), etc.) for Java callers — matching the accessor
330
361
  // convention used by Stripe, AWS SDK v2, and Twilio.
331
362
  annotations.push(`@JsonProperty(${ktStringLiteral(field.name)})`);
332
- if (field.deprecated) annotations.push('@Deprecated("Deprecated field")');
363
+ if (field.deprecated) annotations.push(buildDeprecatedAnnotation(field.description));
333
364
 
334
365
  const paramParts: string[] = [];
335
366
  if (field.description?.trim()) {
@@ -372,6 +403,38 @@ function collapseFieldEntries(rawLines: string[]): string[] {
372
403
  return entries;
373
404
  }
374
405
 
406
+ /**
407
+ * Pull the most useful free-form deprecation hint out of a field description
408
+ * and lift it into the `@Deprecated(...)` message argument. Most WorkOS
409
+ * deprecations are written as a description that begins with "Deprecated"
410
+ * (e.g. "Deprecated. Use `domain_data` instead."). When the description
411
+ * doesn't carry a hint we fall back to a short, self-explanatory message
412
+ * rather than the generic "Deprecated field" placeholder.
413
+ */
414
+ function deprecationMessageFromDescription(description: string | undefined): string {
415
+ if (!description) return 'Deprecated.';
416
+ const firstLine = description
417
+ .split('\n')
418
+ .map((l) => l.trim())
419
+ .find((l) => l.length > 0);
420
+ if (!firstLine) return 'Deprecated.';
421
+ // Trim trailing whitespace and collapse internal whitespace runs so the
422
+ // annotation argument stays on one line.
423
+ const collapsed = firstLine.replace(/\s+/g, ' ').trim();
424
+ if (collapsed.length === 0) return 'Deprecated.';
425
+ // Only lift the description when it actually carries a deprecation hint
426
+ // (e.g. "Deprecated. Use `domain_data` instead.") — many fields keep their
427
+ // forward-looking description verbatim, which would be misleading inside
428
+ // an `@Deprecated(...)` argument.
429
+ if (/\bdeprecat/i.test(collapsed)) return collapsed;
430
+ return 'Deprecated.';
431
+ }
432
+
433
+ function buildDeprecatedAnnotation(description: string | undefined): string {
434
+ const message = deprecationMessageFromDescription(description);
435
+ return `@Deprecated(${ktStringLiteral(message)})`;
436
+ }
437
+
375
438
  /**
376
439
  * If the TypeRef is a literal (const) with a string, number, or boolean value,
377
440
  * return the Kotlin expression for that default. Otherwise return null.
@@ -388,7 +451,8 @@ function collectImports(fields: Field[]): Set<string> {
388
451
  const imports = new Set<string>();
389
452
  if (fields.length === 0) return imports;
390
453
  imports.add('com.fasterxml.jackson.annotation.JsonProperty');
391
- for (const field of fields) {
454
+ for (const rawField of fields) {
455
+ const field = promoteFieldType(rawField);
392
456
  const mapped = mapTypeRef(field.type);
393
457
  if (/\bOffsetDateTime\b/.test(mapped)) imports.add('java.time.OffsetDateTime');
394
458
  for (const enumName of collectEnumNames(field.type)) {
@@ -41,6 +41,46 @@ import { buildKotlinPathExpression, KOTLIN_PATH_ENCODE_IMPORT } from './path-exp
41
41
 
42
42
  const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
43
43
 
44
+ /**
45
+ * Some specs leave query params / fields typed as plain `string` even though
46
+ * the description (or the field name) makes clear they carry an ISO-8601
47
+ * timestamp. Detecting that here lets us emit `OffsetDateTime` so callers
48
+ * don't have to format the wire string themselves.
49
+ */
50
+ const ISO_8601_DESCRIPTION_RE = /\bISO[-_ ]?8601\b/i;
51
+
52
+ function looksLikeIso8601String(description: string | undefined): boolean {
53
+ if (!description) return false;
54
+ return ISO_8601_DESCRIPTION_RE.test(description);
55
+ }
56
+
57
+ /**
58
+ * Promote a string `TypeRef` to a `format: date-time` primitive when the
59
+ * accompanying description identifies it as an ISO-8601 timestamp. Leaves
60
+ * non-string types untouched.
61
+ */
62
+ function promoteIso8601TypeRef(type: TypeRef, description: string | undefined): TypeRef {
63
+ if (!looksLikeIso8601String(description)) return type;
64
+ const promote = (t: TypeRef): TypeRef => {
65
+ if (t.kind === 'primitive' && t.type === 'string' && !t.format) {
66
+ return { kind: 'primitive', type: 'string', format: 'date-time' };
67
+ }
68
+ if (t.kind === 'nullable') return { kind: 'nullable', inner: promote(t.inner) };
69
+ return t;
70
+ };
71
+ return promote(type);
72
+ }
73
+
74
+ function promoteParameterType(p: Parameter): Parameter {
75
+ const promoted = promoteIso8601TypeRef(p.type, p.description);
76
+ return promoted === p.type ? p : { ...p, type: promoted };
77
+ }
78
+
79
+ function promoteFieldType(f: Field): Field {
80
+ const promoted = promoteIso8601TypeRef(f.type, f.description);
81
+ return promoted === f.type ? f : { ...f, type: promoted };
82
+ }
83
+
44
84
  /**
45
85
  * Generate one API class per mount group. Methods map 1:1 to IR operations.
46
86
  * Path params, query params, and body fields are flattened into the method
@@ -249,10 +289,12 @@ function renderMethod(
249
289
  const pathParams = sortPathParamsByTemplateOrder(op);
250
290
  const groupedParamNames = collectGroupedParamNames(op);
251
291
  const hasGroups = (op.parameterGroups?.length ?? 0) > 0;
252
- const queryParams = op.queryParams.filter((p) => !hidden.has(p.name) && !groupedParamNames.has(p.name));
292
+ const queryParams = op.queryParams
293
+ .filter((p) => !hidden.has(p.name) && !groupedParamNames.has(p.name))
294
+ .map(promoteParameterType);
253
295
  const bodyModel = resolveBodyModel(op, ctx);
254
296
  const bodyFields = bodyModel
255
- ? bodyModel.fields.filter((f) => !hidden.has(f.name) && !groupedParamNames.has(f.name))
297
+ ? bodyModel.fields.filter((f) => !hidden.has(f.name) && !groupedParamNames.has(f.name)).map(promoteFieldType)
256
298
  : [];
257
299
 
258
300
  // Track imports we need
@@ -426,12 +468,13 @@ function renderMethod(
426
468
  lines.push(` before = ${pickNamedQueryParam(sortedQuery, 'before')},`);
427
469
  lines.push(` after = ${pickNamedQueryParam(sortedQuery, 'after')}`);
428
470
  lines.push(` ) {`);
429
- lines.push(` val params = this`);
430
471
  for (const qp of sortedQuery.filter((p) => p.name !== 'after' && p.name !== 'before')) {
431
- for (const ln of emitQueryParam(qp, ' ')) lines.push(ln);
472
+ for (const ln of emitQueryParam(qp, ' ', true)) lines.push(ln);
432
473
  }
433
474
  for (const group of op.parameterGroups ?? []) {
434
- for (const ln of emitGroupQueryDispatch(group, groupParamNames.get(group.name)!, ' ')) lines.push(ln);
475
+ for (const ln of emitGroupQueryDispatch(group, groupParamNames.get(group.name)!, ' ', true)) {
476
+ lines.push(ln);
477
+ }
435
478
  }
436
479
  lines.push(` }`);
437
480
  } else {
@@ -609,31 +652,32 @@ function buildMethodKdoc(
609
652
  }
610
653
 
611
654
  // @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.
655
+ // renamed, e.g. slug → bodySlug). Every parameter gets an `@param` line —
656
+ // Dokka does not flag missing `@param` blocks (only fully undocumented
657
+ // declarations), so we have to enforce coverage at emit time. Spec-provided
658
+ // descriptions are preferred; missing descriptions get a templated fallback
659
+ // derived from the parameter name. The fallback is intentionally a little
660
+ // ugly — it nudges callers to add real descriptions to the spec.
615
661
  const paramDocs: string[] = [];
616
662
  const seenParamDocs = new Set<string>();
617
- const pushParamDoc = (name: string, description: string | undefined, deprecated?: boolean) => {
663
+ const pushParamDoc = (name: string, sourceName: string, description: string | undefined, deprecated?: boolean) => {
618
664
  if (seenParamDocs.has(name)) return;
619
665
  seenParamDocs.add(name);
620
- paramDocs.push(formatParamDoc(name, description, deprecated));
666
+ paramDocs.push(formatParamDoc(name, description, deprecated, sourceName));
621
667
  };
622
668
  for (const pp of pathParams) {
623
- if (pp.description?.trim() || pp.deprecated) {
624
- pushParamDoc(propertyName(pp.name), pp.description, pp.deprecated);
625
- }
669
+ pushParamDoc(propertyName(pp.name), pp.name, pp.description, pp.deprecated);
626
670
  }
627
671
  for (const qp of queryParams) {
628
- if (qp.description?.trim() || qp.deprecated) {
629
- pushParamDoc(propertyName(qp.name), qp.description, qp.deprecated);
630
- }
672
+ pushParamDoc(propertyName(qp.name), qp.name, qp.description, qp.deprecated);
631
673
  }
632
674
  for (const bf of bodyFields) {
633
- if (bf.description?.trim() || bf.deprecated) {
634
- pushParamDoc(bodyParamNames.get(bf.name)!, bf.description, bf.deprecated);
635
- }
675
+ pushParamDoc(bodyParamNames.get(bf.name)!, bf.name, bf.description, bf.deprecated);
636
676
  }
677
+ // Always document the trailing `requestOptions` parameter with a stable,
678
+ // canned phrasing so generated SDKs are consistent and Dokka's coverage
679
+ // reporting has nothing to flag.
680
+ pushParamDoc('requestOptions', 'request_options', REQUEST_OPTIONS_PARAM_DESCRIPTION);
637
681
 
638
682
  const returnDoc = plan.isPaginated
639
683
  ? '@return a [com.workos.common.http.Page] of results'
@@ -658,12 +702,29 @@ function buildMethodKdoc(
658
702
  return out;
659
703
  }
660
704
 
661
- function formatParamDoc(kotlinName: string, description: string | undefined, deprecated?: boolean): string {
705
+ /**
706
+ * Stable, canned description for the trailing `requestOptions` parameter that
707
+ * every generated method exposes. Kept as a constant so the same phrasing
708
+ * appears across resource methods, wrapper methods, and union-split helpers.
709
+ */
710
+ const REQUEST_OPTIONS_PARAM_DESCRIPTION = 'per-request overrides (idempotency key, API key, headers, timeout)';
711
+
712
+ function formatParamDoc(
713
+ kotlinName: string,
714
+ description: string | undefined,
715
+ deprecated?: boolean,
716
+ sourceName?: string,
717
+ ): string {
662
718
  const firstLine = description?.split('\n').find((l) => l.trim()) ?? '';
663
- const text = firstLine.trim();
719
+ const specText = firstLine.trim();
664
720
  const deprecationNote = deprecated ? '**Deprecated.**' : '';
721
+ // Fall back to a templated description derived from the parameter name when
722
+ // the spec didn't provide one. Dokka has no `-Xdoclint:missing` analogue,
723
+ // so emitting a placeholder is the only way to guarantee `@param` coverage.
724
+ const fallback = `the ${humanize(sourceName ?? kotlinName)} of the request.`;
725
+ const text = specText || fallback;
665
726
  const parts = [deprecationNote, text].filter(Boolean).join(' ');
666
- return `@param ${kotlinName}${parts ? ` ${escapeKdoc(parts)}` : ''}`;
727
+ return `@param ${kotlinName} ${escapeKdoc(parts)}`;
667
728
  }
668
729
 
669
730
  /**
@@ -684,43 +745,66 @@ function unwrapArray(t: TypeRef): TypeRef | null {
684
745
  function valueExprForQuery(type: TypeRef): string {
685
746
  const inner = type.kind === 'nullable' ? type.inner : type;
686
747
  if (inner.kind === 'enum') return 'it.value';
687
- if (inner.kind === 'primitive' && inner.type === 'string') return 'it';
748
+ if (inner.kind === 'primitive' && inner.type === 'string') {
749
+ return inner.format === 'date-time' ? 'it.toString()' : 'it';
750
+ }
688
751
  return 'it.toString()';
689
752
  }
690
753
 
691
- function emitQueryParam(p: Parameter, indent: string): string[] {
754
+ function emitQueryParam(p: Parameter, indent: string, receiverMode = false): string[] {
692
755
  const prop = propertyName(p.name);
693
756
  const rendered = queryParamToString(p.type, prop);
694
757
  const inner = p.type.kind === 'nullable' ? p.type.inner : p.type;
695
758
  const arrayItem = unwrapArray(p.type);
759
+ // In receiver-lambda mode (`requestPage { ... }`) the surrounding closure is
760
+ // an extension on `MutableList<Pair<String, String>>`, so we elide the
761
+ // explicit `params.` qualifier (extension functions resolve via implicit
762
+ // receiver) and route `+=` through `add(pair)` to keep ktlint happy.
763
+ const callPrefix = receiverMode ? '' : 'params.';
764
+ const addPair = (pair: string) => (receiverMode ? `add(${pair})` : `params += ${pair}`);
696
765
  if (arrayItem) {
697
766
  // Honor `style: form, explode: false` → comma-joined. Default (explode:true
698
767
  // or unspecified for form) → repeated keys. `p.explode ?? true` matches
699
768
  // the OpenAPI default for query parameters when `style` is form.
700
769
  const explode = p.explode ?? true;
701
770
  const itemExpr = valueExprForQuery(arrayItem);
771
+ // `it` is the loop variable in the trivial mapping case — `xs.map { it }`
772
+ // is the identity function, so emit the collection directly when the per-
773
+ // item expression doesn't transform the value.
774
+ const isIdentity = itemExpr === 'it';
702
775
  if (!explode) {
703
776
  if (p.required) {
704
- return [`${indent}params.addJoinedIfNotNull(${ktLiteral(p.name)}, ${prop}.map { ${itemExpr} })`];
777
+ const arg = isIdentity ? prop : `${prop}.map { ${itemExpr} }`;
778
+ return [`${indent}${callPrefix}addJoinedIfNotNull(${ktLiteral(p.name)}, ${arg})`];
705
779
  }
706
- return [`${indent}params.addJoinedIfNotNull(${ktLiteral(p.name)}, ${prop}?.map { ${itemExpr} })`];
780
+ const arg = isIdentity ? prop : `${prop}?.map { ${itemExpr} }`;
781
+ return [`${indent}${callPrefix}addJoinedIfNotNull(${ktLiteral(p.name)}, ${arg})`];
707
782
  }
708
783
  if (p.required) {
709
- return [`${indent}params.addEach(${ktLiteral(p.name)}, ${prop}.map { ${itemExpr} })`];
784
+ const arg = isIdentity ? prop : `${prop}.map { ${itemExpr} }`;
785
+ return [`${indent}${callPrefix}addEach(${ktLiteral(p.name)}, ${arg})`];
786
+ }
787
+ if (isIdentity) {
788
+ return [`${indent}${prop}?.let { ${callPrefix}addEach(${ktLiteral(p.name)}, it) }`];
710
789
  }
711
- return [`${indent}${prop}?.let { params.addEach(${ktLiteral(p.name)}, it.map { ${itemExpr} }) }`];
790
+ return [`${indent}${prop}?.let { ${callPrefix}addEach(${ktLiteral(p.name)}, it.map { ${itemExpr} }) }`];
712
791
  }
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})`];
792
+ if (p.required) return [`${indent}${addPair(`${ktLiteral(p.name)} to ${rendered}`)}`];
793
+ if (inner.kind === 'primitive' && inner.type === 'string' && inner.format !== 'date-time') {
794
+ return [`${indent}${callPrefix}addIfNotNull(${ktLiteral(p.name)}, ${prop})`];
716
795
  }
717
- return [`${indent}${prop}?.let { params += ${ktLiteral(p.name)} to ${queryParamToString(inner, 'it')} }`];
796
+ return [`${indent}${prop}?.let { ${addPair(`${ktLiteral(p.name)} to ${queryParamToString(inner, 'it')}`)} }`];
718
797
  }
719
798
 
720
799
  function queryParamToString(type: TypeRef, varName: string): string {
721
800
  if (type.kind === 'enum') return `${varName}.value`;
722
801
  if (type.kind === 'nullable') return queryParamToString(type.inner, varName);
723
- if (type.kind === 'primitive' && type.type === 'string') return varName;
802
+ // Plain `string` is already the wire type. ISO-8601 strings get promoted to
803
+ // `OffsetDateTime`, so we need an explicit `.toString()` to serialize them
804
+ // as the spec-required ISO-8601 representation.
805
+ if (type.kind === 'primitive' && type.type === 'string') {
806
+ return type.format === 'date-time' ? `${varName}.toString()` : varName;
807
+ }
724
808
  return `${varName}.toString()`;
725
809
  }
726
810
 
@@ -870,16 +954,21 @@ function generateSealedClass(
870
954
  }
871
955
 
872
956
  /** Emit `when` dispatch that serializes a parameter group into query params. */
873
- function emitGroupQueryDispatch(group: import('@workos/oagen').ParameterGroup, prop: string, indent: string): string[] {
957
+ function emitGroupQueryDispatch(
958
+ group: import('@workos/oagen').ParameterGroup,
959
+ prop: string,
960
+ indent: string,
961
+ receiverMode = false,
962
+ ): string[] {
874
963
  const sealedName = sealedGroupName(group.name);
875
964
  const lines: string[] = [];
876
965
 
877
966
  if (group.optional) {
878
967
  lines.push(`${indent}if (${prop} != null) {`);
879
- emitWhenBlock(lines, group, sealedName, prop, `${indent} `);
968
+ emitWhenBlock(lines, group, sealedName, prop, `${indent} `, receiverMode);
880
969
  lines.push(`${indent}}`);
881
970
  } else {
882
- emitWhenBlock(lines, group, sealedName, prop, indent);
971
+ emitWhenBlock(lines, group, sealedName, prop, indent, receiverMode);
883
972
  }
884
973
  return lines;
885
974
  }
@@ -920,13 +1009,15 @@ function emitWhenBlock(
920
1009
  sealedName: string,
921
1010
  prop: string,
922
1011
  indent: string,
1012
+ receiverMode = false,
923
1013
  ): void {
924
1014
  lines.push(`${indent}when (${prop}) {`);
925
1015
  for (const variant of group.variants) {
926
1016
  const variantName = className(variant.name);
927
1017
  const entries = variant.parameters.map((p) => {
928
1018
  const fieldProp = deriveShortPropertyName(p.name, group.name);
929
- return `params += ${ktLiteral(p.name)} to ${prop}.${fieldProp}`;
1019
+ const pair = `${ktLiteral(p.name)} to ${prop}.${fieldProp}`;
1020
+ return receiverMode ? `add(${pair})` : `params += ${pair}`;
930
1021
  });
931
1022
  if (entries.length === 1) {
932
1023
  lines.push(`${indent} is ${sealedName}.${variantName} -> ${entries[0]}`);
@@ -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,5 +1,5 @@
1
1
  import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
2
- import { className, propertyName, ktLiteral, clientFieldExpression, escapeReserved } from './naming.js';
2
+ import { className, propertyName, ktLiteral, clientFieldExpression, escapeReserved, humanize } from './naming.js';
3
3
  import { mapTypeRef, mapTypeRefOptional } from './type-map.js';
4
4
  import { resolveWrapperParams } from '../shared/wrapper-utils.js';
5
5
  import { sortPathParamsByTemplateOrder } from './resources.js';
@@ -35,7 +35,11 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
35
35
 
36
36
  const lines: string[] = [];
37
37
 
38
- // Build KDoc from operation description + @param docs for each wrapper param.
38
+ // Build KDoc: operation description + a `@param` line for *every* parameter
39
+ // (Dokka does not flag missing @param blocks, so coverage has to be enforced
40
+ // at emit time) + `@return` when there's a response model. Spec-provided
41
+ // descriptions are preferred; the fallback is templated from the parameter
42
+ // name so the SDK still compiles cleanly under failOnWarning.
39
43
  const kdocLines: string[] = [];
40
44
  const opDesc = (op.description ?? '').trim();
41
45
  const wrapperHumanName = method.replace(/([a-z])([A-Z])/g, '$1 $2').toLowerCase();
@@ -45,29 +49,36 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
45
49
  kdocLines.push(`${wrapperHumanName.charAt(0).toUpperCase()}${wrapperHumanName.slice(1)}.`);
46
50
  }
47
51
  const paramDocs: string[] = [];
52
+ const pushParamDoc = (kotlinName: string, sourceName: string, description: string | undefined) => {
53
+ const firstLine =
54
+ description
55
+ ?.split('\n')
56
+ .find((l) => l.trim())
57
+ ?.trim() ?? '';
58
+ const fallback = `the ${humanize(sourceName)} of the request.`;
59
+ const text = firstLine || fallback;
60
+ paramDocs.push(`@param ${kotlinName} ${escapeKdoc(text)}`);
61
+ };
48
62
  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
- }
63
+ pushParamDoc(propertyName(pp.name), pp.name, pp.description);
52
64
  }
53
65
  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
- }
66
+ pushParamDoc(propertyName(rp.paramName), rp.paramName, rp.field?.description);
58
67
  }
68
+ // Trailing `requestOptions` parameter — stable canned phrasing.
69
+ pushParamDoc(
70
+ 'requestOptions',
71
+ 'request_options',
72
+ 'per-request overrides (idempotency key, API key, headers, timeout)',
73
+ );
59
74
  if (responseClass) {
60
75
  paramDocs.push(`@return the ${responseClass}`);
61
76
  }
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
- }
77
+ lines.push(' /**');
78
+ for (const l of kdocLines) lines.push(` * ${escapeKdoc(l)}`);
79
+ lines.push(' *');
80
+ for (const p of paramDocs) lines.push(` * ${p}`);
81
+ lines.push(' */');
71
82
 
72
83
  lines.push(' @JvmOverloads');
73
84
 
@@ -101,6 +112,37 @@ function emitWrapperMethod(resolvedOp: ResolvedOperation, wrapper: ResolvedWrapp
101
112
  lines.push(` )${returnClause} {`);
102
113
  }
103
114
 
115
+ // The /user_management/authenticate endpoint is union-split into one
116
+ // wrapper per grant_type. Every variant posts the same shape (caller
117
+ // params + grant_type + client_id + client_secret) to the same path with
118
+ // the same response model, so we route through a single `authenticate(...)`
119
+ // private helper instead of duplicating the request boilerplate per grant.
120
+ const inferred = wrapper.inferFromClient ?? [];
121
+ const usesStandardClientCreds = inferred.includes('client_id') && inferred.includes('client_secret');
122
+ if (
123
+ op.path === '/user_management/authenticate' &&
124
+ op.httpMethod.toUpperCase() === 'POST' &&
125
+ responseClass === 'AuthenticateResponse' &&
126
+ typeof wrapper.defaults?.grant_type === 'string' &&
127
+ usesStandardClientCreds
128
+ ) {
129
+ const grantType = wrapper.defaults.grant_type;
130
+ lines.push(` return authenticate(`);
131
+ lines.push(` grantType = ${ktLiteral(grantType)},`);
132
+ lines.push(` requestOptions = requestOptions,`);
133
+ const entryLines = resolvedParams.map((rp) => {
134
+ const paramName = propertyName(rp.paramName);
135
+ return ` ${ktLiteral(rp.paramName)} to ${paramName}`;
136
+ });
137
+ for (let i = 0; i < entryLines.length; i++) {
138
+ const sep = i === entryLines.length - 1 ? '' : ',';
139
+ lines.push(`${entryLines[i]}${sep}`);
140
+ }
141
+ lines.push(` )`);
142
+ lines.push(' }');
143
+ return lines;
144
+ }
145
+
104
146
  // Build body using bodyOf() — consistent with non-wrapper methods.
105
147
  // bodyOf() automatically drops null optional values.
106
148
  const bodyEntries: string[] = [];