@workos/oagen-emitters 0.8.0 → 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-bCMdV7KX.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.0",
3
+ "version": "0.8.2",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -133,8 +133,11 @@ export const dotnetEmitter: Emitter = {
133
133
  lines.push(' {');
134
134
  lines.push(' public override bool CanConvert(Type objectType) => objectType == typeof(object);');
135
135
  lines.push('');
136
+ // Override returns `object?` to match Newtonsoft.Json 13+'s nullable
137
+ // signature; `JToken.ToObject<T>` is itself `T?`, so a non-nullable
138
+ // override would trigger CS8603 under <Nullable>enable</Nullable>.
136
139
  lines.push(
137
- ' public override object ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer)',
140
+ ' public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer)',
138
141
  );
139
142
  lines.push(' {');
140
143
  lines.push(' var jObject = JObject.Load(reader);');
@@ -194,8 +197,9 @@ export const dotnetEmitter: Emitter = {
194
197
  ` public override bool CanConvert(Type objectType) => typeof(${baseClass}).IsAssignableFrom(objectType);`,
195
198
  );
196
199
  lines.push('');
200
+ // See first converter — `object?` matches Newtonsoft 13+ to avoid CS8603.
197
201
  lines.push(
198
- ' public override object ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer)',
202
+ ' public override object? ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object? existingValue, Newtonsoft.Json.JsonSerializer serializer)',
199
203
  );
200
204
  lines.push(' {');
201
205
  lines.push(' var jObject = JObject.Load(reader);');
@@ -7,6 +7,7 @@ import {
7
7
  emitJsonPropertyAttributes,
8
8
  setModelAliases,
9
9
  isModelAlias,
10
+ resolveModelName,
10
11
  } from './type-map.js';
11
12
  import {
12
13
  articleFor,
@@ -54,6 +55,17 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
54
55
  }
55
56
  }
56
57
 
58
+ const files: GeneratedFile[] = [];
59
+
60
+ // Compute and publish model aliases so mapTypeRef rewrites references.
61
+ // Must run BEFORE collectRequestBodyOnlyModelNames so the body/non-body
62
+ // tally collapses aliased pairs onto their canonical name — otherwise a
63
+ // model that's only a request body in name (e.g. `AddRolePermissionDto`)
64
+ // but is the canonical for a field-referenced alias (e.g. `SlimRole`)
65
+ // would be wrongly classified as body-only and skipped from emission,
66
+ // leaving every alias-rewritten field reference dangling.
67
+ primeModelAliases(models);
68
+
57
69
  // Models that are referenced ONLY as an operation request body (not by any
58
70
  // response, field, or other operation type) are dead surface in .NET because
59
71
  // the wrapper generator emits a per-operation `*Options` class containing
@@ -63,11 +75,6 @@ export function generateModels(models: Model[], ctx: EmitterContext, discCtx?: D
63
75
  // `UserManagementCreateApiKeyOptions`). Skip emission for those.
64
76
  const requestBodyOnlyNames = collectRequestBodyOnlyModelNames(ctx.spec.services, models);
65
77
 
66
- const files: GeneratedFile[] = [];
67
-
68
- // Compute and publish model aliases so mapTypeRef rewrites references.
69
- primeModelAliases(models);
70
-
71
78
  // Build a lookup of base model field C# names → C# types for inheritance.
72
79
  // Variant models skip inherited fields and use `new` for type-divergent ones.
73
80
  const baseFieldLookup = new Map<string, Map<string, string>>();
@@ -453,17 +460,22 @@ function collectRequestBodyOnlyModelNames(services: Service[], models: Model[]):
453
460
  const requestBodyNames = new Set<string>();
454
461
  const otherReferences = new Set<string>();
455
462
 
463
+ // Resolve every reference through the alias map so structurally-identical
464
+ // models share a body/non-body classification. Without this, an alias being
465
+ // used as a field would only mark the alias name as non-body — leaving its
466
+ // canonical (which carries the same shape and gets emitted) wrongly tagged
467
+ // as body-only and skipped.
456
468
  const collect = (ref: TypeRef | undefined, into: Set<string>): void => {
457
469
  if (!ref) return;
458
470
  walkTypeRef(ref, {
459
- model: (r) => into.add(r.name),
471
+ model: (r) => into.add(resolveModelName(r.name)),
460
472
  });
461
473
  };
462
474
 
463
475
  for (const service of services) {
464
476
  for (const op of service.operations) {
465
477
  if (op.requestBody?.kind === 'model') {
466
- requestBodyNames.add(op.requestBody.name);
478
+ requestBodyNames.add(resolveModelName(op.requestBody.name));
467
479
  }
468
480
  collect(op.response, otherReferences);
469
481
  if (op.pagination) collect(op.pagination.itemType, otherReferences);
@@ -46,7 +46,12 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
46
46
  });
47
47
  }
48
48
 
49
- // Generate list fixtures for paginated responses
49
+ // Generate list fixtures for paginated responses. Multiple operations may
50
+ // share the same item model (e.g. several role-assignment list endpoints all
51
+ // returning UserRoleAssignmentList) — emit each fixture path once so the
52
+ // content-dedup pass below doesn't see N copies of the same path and drop
53
+ // the file entirely.
54
+ const seenListPaths = new Set<string>();
50
55
  for (const service of spec.services) {
51
56
  for (const op of service.operations) {
52
57
  if (op.pagination) {
@@ -55,6 +60,9 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
55
60
  const unwrapped = unwrapListModel(itemModel, modelMap);
56
61
  if (unwrapped) itemModel = unwrapped;
57
62
  if (itemModel.fields.length === 0) continue;
63
+ const path = `testdata/list_${fileName(itemModel.name)}.json`;
64
+ if (seenListPaths.has(path)) continue;
65
+ seenListPaths.add(path);
58
66
  const fixture = generateModelFixture(itemModel, modelMap, enumMap);
59
67
  const listFixture = {
60
68
  data: [fixture],
@@ -64,7 +72,7 @@ export function generateFixtures(spec: { models: Model[]; enums: Enum[]; service
64
72
  },
65
73
  };
66
74
  files.push({
67
- path: `testdata/list_${fileName(itemModel.name)}.json`,
75
+ path,
68
76
  content: JSON.stringify(listFixture, null, 2),
69
77
  });
70
78
  }
@@ -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
  }