@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/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +7 -0
- package/dist/index.mjs +1 -1
- package/dist/{plugin-DOE0FqrZ.mjs → plugin-CeNME04k.mjs} +221 -52
- package/dist/plugin-CeNME04k.mjs.map +1 -0
- package/dist/plugin.mjs +1 -1
- package/package.json +1 -1
- package/src/kotlin/models.ts +67 -3
- package/src/kotlin/resources.ts +128 -37
- package/src/kotlin/tests.ts +33 -5
- package/src/kotlin/wrappers.ts +60 -18
- package/dist/plugin-DOE0FqrZ.mjs.map +0 -1
package/dist/plugin.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { t as workosEmittersPlugin } from "./plugin-
|
|
1
|
+
import { t as workosEmittersPlugin } from "./plugin-CeNME04k.mjs";
|
|
2
2
|
export { workosEmittersPlugin };
|
package/package.json
CHANGED
package/src/kotlin/models.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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)) {
|
package/src/kotlin/resources.ts
CHANGED
|
@@ -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
|
|
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)!, ' '))
|
|
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).
|
|
613
|
-
//
|
|
614
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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}
|
|
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')
|
|
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
|
-
|
|
777
|
+
const arg = isIdentity ? prop : `${prop}.map { ${itemExpr} }`;
|
|
778
|
+
return [`${indent}${callPrefix}addJoinedIfNotNull(${ktLiteral(p.name)}, ${arg})`];
|
|
705
779
|
}
|
|
706
|
-
|
|
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
|
-
|
|
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 {
|
|
790
|
+
return [`${indent}${prop}?.let { ${callPrefix}addEach(${ktLiteral(p.name)}, it.map { ${itemExpr} }) }`];
|
|
712
791
|
}
|
|
713
|
-
if (p.required) return [`${indent}
|
|
714
|
-
if (inner.kind === 'primitive' && inner.type === 'string') {
|
|
715
|
-
return [`${indent}
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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]}`);
|
package/src/kotlin/tests.ts
CHANGED
|
@@ -25,6 +25,31 @@ import { isHandwrittenOverride } from './overrides.js';
|
|
|
25
25
|
|
|
26
26
|
const TEST_PREFIX = 'src/test/kotlin/';
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Mirror the ISO-8601 hint promotion the resource/model emitters use so tests
|
|
30
|
+
* synthesize values whose Kotlin type matches the generated method signature.
|
|
31
|
+
* Kept in sync with the helpers in `resources.ts` / `models.ts`; if the
|
|
32
|
+
* detection rule changes, update all three.
|
|
33
|
+
*/
|
|
34
|
+
const ISO_8601_DESCRIPTION_RE = /\bISO[-_ ]?8601\b/i;
|
|
35
|
+
|
|
36
|
+
function looksLikeIso8601String(description: string | undefined): boolean {
|
|
37
|
+
if (!description) return false;
|
|
38
|
+
return ISO_8601_DESCRIPTION_RE.test(description);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function promoteIso8601TypeRef(type: TypeRef, description: string | undefined): TypeRef {
|
|
42
|
+
if (!looksLikeIso8601String(description)) return type;
|
|
43
|
+
const promote = (t: TypeRef): TypeRef => {
|
|
44
|
+
if (t.kind === 'primitive' && t.type === 'string' && !t.format) {
|
|
45
|
+
return { kind: 'primitive', type: 'string', format: 'date-time' };
|
|
46
|
+
}
|
|
47
|
+
if (t.kind === 'nullable') return { kind: 'nullable', inner: promote(t.inner) };
|
|
48
|
+
return t;
|
|
49
|
+
};
|
|
50
|
+
return promote(type);
|
|
51
|
+
}
|
|
52
|
+
|
|
28
53
|
/**
|
|
29
54
|
* Generate one JUnit 5 + WireMock test class per API mount group, plus a
|
|
30
55
|
* cross-cutting model round-trip test.
|
|
@@ -254,12 +279,13 @@ function buildOperationTest(
|
|
|
254
279
|
}
|
|
255
280
|
for (const qp of sortedQuery) {
|
|
256
281
|
if (!qp.required) break;
|
|
257
|
-
const
|
|
282
|
+
const promotedType = promoteIso8601TypeRef(qp.type, qp.description);
|
|
283
|
+
const val = synthValue(promotedType, ctx, imports);
|
|
258
284
|
if (val === null) return null;
|
|
259
285
|
argParts.push(val);
|
|
260
286
|
// Best-effort wire assertion: for primitives/strings we know the synthesized
|
|
261
287
|
// value so we can assert equality; otherwise just assert presence.
|
|
262
|
-
const regex = queryValueRegexFor(
|
|
288
|
+
const regex = queryValueRegexFor(promotedType);
|
|
263
289
|
if (regex !== null) requiredQueryAssertions.push({ name: qp.name, valueRegex: regex });
|
|
264
290
|
}
|
|
265
291
|
|
|
@@ -283,14 +309,15 @@ function buildOperationTest(
|
|
|
283
309
|
for (const bf of sortedBody) {
|
|
284
310
|
if (sharedQueryBodyParams.has(bf.name)) continue;
|
|
285
311
|
if (!bf.required) break;
|
|
286
|
-
const
|
|
312
|
+
const promotedType = promoteIso8601TypeRef(bf.type, bf.description);
|
|
313
|
+
const val = synthValue(promotedType, ctx, imports);
|
|
287
314
|
if (val === null) return null;
|
|
288
315
|
argParts.push(val);
|
|
289
316
|
// matchingJsonPath on an array/map body field fails on empty
|
|
290
317
|
// synthesized collections because JsonPath returns an empty result
|
|
291
318
|
// set. Scalar fields always materialize with a concrete value, so
|
|
292
319
|
// we only assert those paths.
|
|
293
|
-
if (isScalarBodyField(
|
|
320
|
+
if (isScalarBodyField(promotedType)) requiredBodyPaths.push(bf.name);
|
|
294
321
|
}
|
|
295
322
|
}
|
|
296
323
|
|
|
@@ -464,7 +491,8 @@ function buildWrapperTest(op: Operation, wrapper: ResolvedWrapper, ctx: EmitterC
|
|
|
464
491
|
argParts.push(ktStringLiteral('sample-arg'));
|
|
465
492
|
continue;
|
|
466
493
|
}
|
|
467
|
-
const
|
|
494
|
+
const promotedType = promoteIso8601TypeRef(rp.field.type, rp.field.description);
|
|
495
|
+
const val = synthValue(promotedType, ctx, imports);
|
|
468
496
|
if (val === null) return null;
|
|
469
497
|
argParts.push(val);
|
|
470
498
|
}
|
package/src/kotlin/wrappers.ts
CHANGED
|
@@ -1,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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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[] = [];
|