@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.
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-Dh9JSScr.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.9.0",
4
4
  "description": "WorkOS' oagen emitters",
5
5
  "license": "MIT",
6
6
  "author": "WorkOS",
@@ -42,8 +42,8 @@
42
42
  "@commitlint/config-conventional": "^20.5.3",
43
43
  "@types/node": "^25.6.0",
44
44
  "husky": "^9.1.7",
45
- "oxfmt": "^0.47.0",
46
- "oxlint": "^1.62.0",
45
+ "oxfmt": "^0.48.0",
46
+ "oxlint": "^1.63.0",
47
47
  "prettier": "^3.8.3",
48
48
  "tsdown": "^0.21.10",
49
49
  "tsx": "^4.21.0",
@@ -54,6 +54,6 @@
54
54
  "node": ">=24.10.0"
55
55
  },
56
56
  "dependencies": {
57
- "@workos/oagen": "^0.17.0"
57
+ "@workos/oagen": "^0.17.2"
58
58
  }
59
59
  }
@@ -12,32 +12,38 @@ const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
12
12
  * contains a `WorkOS` class stub with only these properties — the oagen
13
13
  * merger deep-merges them into the existing hand-written `WorkOS.kt`.
14
14
  *
15
- * Accessors use fully-qualified type names so the merger doesn't need to
16
- * inject imports into the hand-written file.
15
+ * Each referenced service class is hoisted into a top-level `import` so the
16
+ * accessor bodies use the short class name. The live `WorkOS.kt` is marked
17
+ * `@oagen-ignore-file` and won't be regenerated, but the emitter still emits
18
+ * the cleaner shape so future regenerations don't reintroduce the FQN noise.
17
19
  */
18
20
  export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
19
21
  const targets = deduplicateByMount(spec.services, ctx);
20
22
  if (targets.length === 0) return [];
21
23
 
24
+ const imports = new Set<string>();
22
25
  const accessorLines: string[] = [];
23
26
  for (const mount of targets) {
24
27
  const apiCls = apiClassName(mount);
25
28
  const fqn = `com.workos.${packageSegment(mount)}.${apiCls}`;
29
+ imports.add(fqn);
26
30
  const prop = servicePropertyName(mount);
27
31
  accessorLines.push('');
28
- accessorLines.push(` /** Lazily-constructed [${fqn}] accessor for this [WorkOS] client. */`);
29
- accessorLines.push(` val ${prop}: ${fqn}`);
32
+ accessorLines.push(` /** Lazily-constructed [${apiCls}] accessor for this [WorkOS] client. */`);
33
+ accessorLines.push(` val ${prop}: ${apiCls}`);
30
34
  accessorLines.push(' get() =');
31
35
  accessorLines.push(' service(');
32
- accessorLines.push(` ${fqn}::class`);
36
+ accessorLines.push(` ${apiCls}::class`);
33
37
  accessorLines.push(' ) {');
34
- accessorLines.push(` ${fqn}(this)`);
38
+ accessorLines.push(` ${apiCls}(this)`);
35
39
  accessorLines.push(' }');
36
40
  }
37
41
 
38
42
  const lines: string[] = [];
39
43
  lines.push('package com.workos');
40
44
  lines.push('');
45
+ for (const imp of [...imports].sort()) lines.push(`import ${imp}`);
46
+ if (imports.size > 0) lines.push('');
41
47
  lines.push('open class WorkOS {');
42
48
  for (const line of accessorLines) lines.push(line);
43
49
  lines.push('}');
@@ -143,15 +143,26 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
143
143
  members.push(` ${memberName}(${ktStringLiteral(wire)})`);
144
144
  }
145
145
 
146
+ // Track whether we are currently in a doc block leading up to the next
147
+ // enum value declaration. When the upcoming case has KDoc, insert a blank
148
+ // line before the doc comment for visual separation (skip for the very
149
+ // first case so we don't open the block with a blank).
150
+ let firstValueEmitted = false;
146
151
  for (let i = 0; i < members.length; i++) {
147
152
  const isLast = i === members.length - 1;
148
153
  const line = members[i];
149
154
  const trimmedStart = line.trimStart();
150
- if (trimmedStart.startsWith('/**') || trimmedStart.startsWith('@')) {
155
+ const isDocStart = trimmedStart.startsWith('/**');
156
+ const isAnnotation = trimmedStart.startsWith('@');
157
+ if (isDocStart && firstValueEmitted) {
158
+ lines.push('');
159
+ }
160
+ if (isDocStart || isAnnotation) {
151
161
  lines.push(line);
152
162
  continue;
153
163
  }
154
164
  lines.push(isLast ? line : `${line},`);
165
+ firstValueEmitted = true;
155
166
  }
156
167
 
157
168
  lines.push('}');
@@ -93,15 +93,17 @@ export const kotlinEmitter: Emitter = {
93
93
  },
94
94
 
95
95
  formatCommand(targetDir: string): FormatCommand | null {
96
- // ktlint enforces a 140-char max line length that cannot be auto-corrected
97
- // by the Gradle plugin alone, but `./gradlew ktlintFormat` fixes most
98
- // violations (trailing whitespace, import ordering, etc.) across all
99
- // generated files. The file list appended by oagen is ignored — Gradle
100
- // reformats the whole source set.
96
+ // `./gradlew ktlintFormat` fixes whitespace, import ordering, and most
97
+ // wrapping/line-length violations across the whole source set. The file
98
+ // list appended by oagen is ignored Gradle reformats every Kotlin
99
+ // file. oagen's writer already runs the spawned process with
100
+ // `cwd: targetDir`, so we don't `cd` again (an additional `cd` re-resolves
101
+ // a relative `targetDir` against itself and silently lands in a missing
102
+ // directory, which is what was causing this command to no-op in practice).
101
103
  if (!fs.existsSync(path.join(targetDir, 'gradlew'))) return null;
102
104
  return {
103
105
  cmd: 'bash',
104
- args: ['-c', `cd ${JSON.stringify(targetDir)} && ./gradlew ktlintFormat --quiet 2>/dev/null; true`, '--'],
106
+ args: ['-c', './gradlew ktlintFormat --quiet >/dev/null 2>&1; true'],
105
107
  };
106
108
  },
107
109
  };
@@ -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
@@ -174,6 +204,12 @@ function emitDataClass(model: Model): GeneratedFile {
174
204
  for (let i = 0; i < rendered.length; i++) {
175
205
  const suffix = i === rendered.length - 1 ? '' : ',';
176
206
  lines.push(`${rendered[i]}${suffix}`);
207
+ // Visually separate properties: KDoc + annotations for the next field
208
+ // get their own paragraph. Kotlin requires the comma to come *before*
209
+ // the blank line; we already appended it above.
210
+ if (i < rendered.length - 1) {
211
+ lines.push('');
212
+ }
177
213
  }
178
214
 
179
215
  lines.push(`)${implClause}`);
@@ -197,7 +233,32 @@ function emitSealedUnion(
197
233
  lines.push('import com.fasterxml.jackson.annotation.JsonSubTypes');
198
234
  lines.push('import com.fasterxml.jackson.annotation.JsonTypeInfo');
199
235
  lines.push('');
200
- appendKdoc(lines, `Discriminated union over ${typeName} variants. Selected by \`${disc.property}\`.`, 0);
236
+ // KDoc with worked Kotlin + Java consumption examples. These unions are
237
+ // returned by Jackson; callers branch on the concrete subtype to access
238
+ // variant-specific data.
239
+ const exampleVariantWire = Object.keys(disc.mapping)[0];
240
+ const exampleVariantType = exampleVariantWire ? className(disc.mapping[exampleVariantWire]) : null;
241
+ lines.push('/**');
242
+ lines.push(` * Discriminated union over ${typeName} variants. Selected by \`${disc.property}\`.`);
243
+ if (exampleVariantType) {
244
+ lines.push(' *');
245
+ lines.push(' * Usage from Kotlin:');
246
+ lines.push(' * ```kotlin');
247
+ lines.push(` * when (val v: ${typeName} = receivedFromApi()) {`);
248
+ lines.push(` * is ${exampleVariantType} -> handle(v)`);
249
+ lines.push(' * else -> handleOther(v)');
250
+ lines.push(' * }');
251
+ lines.push(' * ```');
252
+ lines.push(' *');
253
+ lines.push(' * Usage from Java:');
254
+ lines.push(' * ```java');
255
+ lines.push(` * ${typeName} v = receivedFromApi();`);
256
+ lines.push(` * if (v instanceof ${exampleVariantType}) {`);
257
+ lines.push(` * handle((${exampleVariantType}) v);`);
258
+ lines.push(' * }');
259
+ lines.push(' * ```');
260
+ }
261
+ lines.push(' */');
201
262
  lines.push('@JsonTypeInfo(');
202
263
  lines.push(' use = JsonTypeInfo.Id.NAME,');
203
264
  lines.push(' include = JsonTypeInfo.As.EXISTING_PROPERTY,');
@@ -295,7 +356,8 @@ function renderFields(fields: Field[], overrideFields: Set<string> = new Set()):
295
356
  const seen = new Set<string>();
296
357
  const lines: string[] = [];
297
358
 
298
- for (const field of fields) {
359
+ for (const rawField of fields) {
360
+ const field = promoteFieldType(rawField);
299
361
  const kotlinName = propertyName(field.name);
300
362
  if (seen.has(kotlinName)) continue;
301
363
  seen.add(kotlinName);
@@ -329,7 +391,7 @@ function renderFields(fields: Field[], overrideFields: Set<string> = new Set()):
329
391
  // isEmailVerified(), etc.) for Java callers — matching the accessor
330
392
  // convention used by Stripe, AWS SDK v2, and Twilio.
331
393
  annotations.push(`@JsonProperty(${ktStringLiteral(field.name)})`);
332
- if (field.deprecated) annotations.push('@Deprecated("Deprecated field")');
394
+ if (field.deprecated) annotations.push(buildDeprecatedAnnotation(field.description));
333
395
 
334
396
  const paramParts: string[] = [];
335
397
  if (field.description?.trim()) {
@@ -372,6 +434,38 @@ function collapseFieldEntries(rawLines: string[]): string[] {
372
434
  return entries;
373
435
  }
374
436
 
437
+ /**
438
+ * Pull the most useful free-form deprecation hint out of a field description
439
+ * and lift it into the `@Deprecated(...)` message argument. Most WorkOS
440
+ * deprecations are written as a description that begins with "Deprecated"
441
+ * (e.g. "Deprecated. Use `domain_data` instead."). When the description
442
+ * doesn't carry a hint we fall back to a short, self-explanatory message
443
+ * rather than the generic "Deprecated field" placeholder.
444
+ */
445
+ function deprecationMessageFromDescription(description: string | undefined): string {
446
+ if (!description) return 'Deprecated.';
447
+ const firstLine = description
448
+ .split('\n')
449
+ .map((l) => l.trim())
450
+ .find((l) => l.length > 0);
451
+ if (!firstLine) return 'Deprecated.';
452
+ // Trim trailing whitespace and collapse internal whitespace runs so the
453
+ // annotation argument stays on one line.
454
+ const collapsed = firstLine.replace(/\s+/g, ' ').trim();
455
+ if (collapsed.length === 0) return 'Deprecated.';
456
+ // Only lift the description when it actually carries a deprecation hint
457
+ // (e.g. "Deprecated. Use `domain_data` instead.") — many fields keep their
458
+ // forward-looking description verbatim, which would be misleading inside
459
+ // an `@Deprecated(...)` argument.
460
+ if (/\bdeprecat/i.test(collapsed)) return collapsed;
461
+ return 'Deprecated.';
462
+ }
463
+
464
+ function buildDeprecatedAnnotation(description: string | undefined): string {
465
+ const message = deprecationMessageFromDescription(description);
466
+ return `@Deprecated(${ktStringLiteral(message)})`;
467
+ }
468
+
375
469
  /**
376
470
  * If the TypeRef is a literal (const) with a string, number, or boolean value,
377
471
  * return the Kotlin expression for that default. Otherwise return null.
@@ -388,7 +482,8 @@ function collectImports(fields: Field[]): Set<string> {
388
482
  const imports = new Set<string>();
389
483
  if (fields.length === 0) return imports;
390
484
  imports.add('com.fasterxml.jackson.annotation.JsonProperty');
391
- for (const field of fields) {
485
+ for (const rawField of fields) {
486
+ const field = promoteFieldType(rawField);
392
487
  const mapped = mapTypeRef(field.type);
393
488
  if (/\bOffsetDateTime\b/.test(mapped)) imports.add('java.time.OffsetDateTime');
394
489
  for (const enumName of collectEnumNames(field.type)) {
@@ -1,16 +1,36 @@
1
- import type { Operation, Service, EmitterContext } from '@workos/oagen';
1
+ import type { Operation, Service, EmitterContext, TypeRef } from '@workos/oagen';
2
2
  import { toPascalCase, toCamelCase, toSnakeCase } from '@workos/oagen';
3
3
  import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
4
4
  import { stripUrnPrefix } from '../shared/naming-utils.js';
5
5
 
6
+ /**
7
+ * Acronyms that should appear fully uppercase in PascalCase identifiers.
8
+ * `toPascalCase` would otherwise titlecase them (e.g. `Sso`, `Pkce`, `Mfa`).
9
+ * Both [className] and [apiClassName] consult this list so model and service
10
+ * class names stay consistent (e.g. `SsoConnection` -> `SSOConnection`).
11
+ */
12
+ const PASCAL_ACRONYMS = ['SSO', 'PKCE', 'MFA'];
13
+
14
+ function uppercaseAcronyms(pascal: string): string {
15
+ let result = pascal;
16
+ for (const acronym of PASCAL_ACRONYMS) {
17
+ // Match the titlecased form (e.g. `Sso`) at any position where it stands
18
+ // as a complete word (followed by an uppercase letter, digit, or end).
19
+ const titled = acronym.charAt(0) + acronym.slice(1).toLowerCase();
20
+ const re = new RegExp(`${titled}(?=[A-Z0-9]|$)`, 'g');
21
+ result = result.replace(re, acronym);
22
+ }
23
+ return result;
24
+ }
25
+
6
26
  /** PascalCase class/type name. */
7
27
  export function className(name: string): string {
8
- return toPascalCase(stripUrnPrefix(name));
28
+ return uppercaseAcronyms(toPascalCase(stripUrnPrefix(name)));
9
29
  }
10
30
 
11
31
  /** PascalCase file name (matches the primary class). */
12
32
  export function fileName(name: string): string {
13
- return toPascalCase(stripUrnPrefix(name));
33
+ return uppercaseAcronyms(toPascalCase(stripUrnPrefix(name)));
14
34
  }
15
35
 
16
36
  /** snake_case file name for fixtures/test data. */
@@ -50,7 +70,6 @@ export function packageSegment(name: string): string {
50
70
 
51
71
  /** Kotlin service class name for a mount group (e.g., `Organizations`). */
52
72
  export function apiClassName(name: string): string {
53
- if (className(name) === 'SSO') return 'Sso';
54
73
  return className(name);
55
74
  }
56
75
 
@@ -227,3 +246,38 @@ export function humanize(name: string): string {
227
246
  .replace(/([a-z])([A-Z])/g, '$1 $2')
228
247
  .toLowerCase();
229
248
  }
249
+
250
+ /**
251
+ * If the parameter is typed as an enum and its description is long enough
252
+ * that repeating it across every method's KDoc adds noise, return a short
253
+ * description that defers to the enum's own KDoc with a `See [EnumName].`
254
+ * reference. Otherwise return null and the caller keeps the spec text.
255
+ *
256
+ * Currently only `PaginationOrder` is shortened — the SSO/MFA/etc. enums
257
+ * have brief descriptions already. Generalize when other long-description
258
+ * enums appear in the spec.
259
+ */
260
+ const LONG_ENUM_DESC_THRESHOLD = 120;
261
+
262
+ export function maybeShortenEnumParamDescription(
263
+ type: TypeRef | undefined,
264
+ description: string | undefined,
265
+ ): { description: string; enumRef: string } | null {
266
+ if (!type || !description) return null;
267
+ const enumName = extractEnumName(type);
268
+ if (!enumName) return null;
269
+ if (description.length <= LONG_ENUM_DESC_THRESHOLD) return null;
270
+ // Only special-case PaginationOrder for now; other enums keep their
271
+ // descriptions verbatim so we don't over-trim until a pattern emerges.
272
+ if (className(enumName) !== 'PaginationOrder') return null;
273
+ return {
274
+ description: `the order to return records in. See [${className(enumName)}].`,
275
+ enumRef: className(enumName),
276
+ };
277
+ }
278
+
279
+ function extractEnumName(type: TypeRef): string | null {
280
+ if (type.kind === 'enum') return type.name;
281
+ if (type.kind === 'nullable') return extractEnumName(type.inner);
282
+ return null;
283
+ }