@workos/oagen-emitters 0.8.2 → 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-CeNME04k.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.2",
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
  };
@@ -204,6 +204,12 @@ function emitDataClass(model: Model): GeneratedFile {
204
204
  for (let i = 0; i < rendered.length; i++) {
205
205
  const suffix = i === rendered.length - 1 ? '' : ',';
206
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
+ }
207
213
  }
208
214
 
209
215
  lines.push(`)${implClause}`);
@@ -227,7 +233,32 @@ function emitSealedUnion(
227
233
  lines.push('import com.fasterxml.jackson.annotation.JsonSubTypes');
228
234
  lines.push('import com.fasterxml.jackson.annotation.JsonTypeInfo');
229
235
  lines.push('');
230
- 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(' */');
231
262
  lines.push('@JsonTypeInfo(');
232
263
  lines.push(' use = JsonTypeInfo.Id.NAME,');
233
264
  lines.push(' include = JsonTypeInfo.As.EXISTING_PROPERTY,');
@@ -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
+ }