@workos/oagen-emitters 0.4.0 → 0.6.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.
Files changed (126) hide show
  1. package/.github/workflows/ci.yml +1 -1
  2. package/.github/workflows/lint.yml +1 -1
  3. package/.github/workflows/release-please.yml +2 -2
  4. package/.github/workflows/release.yml +1 -1
  5. package/.husky/pre-push +11 -0
  6. package/.node-version +1 -1
  7. package/.release-please-manifest.json +1 -1
  8. package/CHANGELOG.md +15 -0
  9. package/README.md +35 -224
  10. package/dist/index.d.mts +9 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -15234
  13. package/dist/plugin-Dws9b6T7.mjs +21441 -0
  14. package/dist/plugin-Dws9b6T7.mjs.map +1 -0
  15. package/dist/plugin.d.mts +7 -0
  16. package/dist/plugin.d.mts.map +1 -0
  17. package/dist/plugin.mjs +2 -0
  18. package/docs/sdk-architecture/dotnet.md +5 -5
  19. package/oagen.config.ts +5 -373
  20. package/package.json +17 -41
  21. package/smoke/sdk-dotnet.ts +11 -5
  22. package/smoke/sdk-elixir.ts +11 -5
  23. package/smoke/sdk-go.ts +10 -4
  24. package/smoke/sdk-kotlin.ts +11 -5
  25. package/smoke/sdk-node.ts +11 -5
  26. package/smoke/sdk-php.ts +9 -4
  27. package/smoke/sdk-python.ts +10 -4
  28. package/smoke/sdk-ruby.ts +10 -4
  29. package/smoke/sdk-rust.ts +11 -5
  30. package/src/dotnet/index.ts +9 -7
  31. package/src/dotnet/manifest.ts +5 -11
  32. package/src/dotnet/models.ts +58 -82
  33. package/src/dotnet/naming.ts +44 -6
  34. package/src/dotnet/resources.ts +350 -29
  35. package/src/dotnet/tests.ts +44 -24
  36. package/src/dotnet/type-map.ts +44 -17
  37. package/src/dotnet/wrappers.ts +21 -10
  38. package/src/go/client.ts +35 -3
  39. package/src/go/enums.ts +4 -0
  40. package/src/go/index.ts +13 -8
  41. package/src/go/manifest.ts +5 -11
  42. package/src/go/models.ts +6 -1
  43. package/src/go/resources.ts +534 -73
  44. package/src/go/tests.ts +39 -3
  45. package/src/go/type-map.ts +8 -3
  46. package/src/go/wrappers.ts +79 -21
  47. package/src/index.ts +14 -0
  48. package/src/kotlin/client.ts +7 -2
  49. package/src/kotlin/enums.ts +30 -3
  50. package/src/kotlin/index.ts +3 -3
  51. package/src/kotlin/manifest.ts +9 -15
  52. package/src/kotlin/models.ts +97 -6
  53. package/src/kotlin/naming.ts +7 -1
  54. package/src/kotlin/resources.ts +370 -39
  55. package/src/kotlin/tests.ts +120 -6
  56. package/src/node/client.ts +38 -11
  57. package/src/node/field-plan.ts +12 -14
  58. package/src/node/fixtures.ts +39 -3
  59. package/src/node/index.ts +3 -3
  60. package/src/node/manifest.ts +4 -11
  61. package/src/node/models.ts +281 -37
  62. package/src/node/resources.ts +156 -52
  63. package/src/node/tests.ts +76 -27
  64. package/src/node/type-map.ts +1 -31
  65. package/src/node/utils.ts +96 -6
  66. package/src/node/wrappers.ts +31 -1
  67. package/src/php/index.ts +3 -3
  68. package/src/php/manifest.ts +5 -11
  69. package/src/php/models.ts +0 -33
  70. package/src/php/resources.ts +199 -18
  71. package/src/php/tests.ts +26 -2
  72. package/src/php/type-map.ts +16 -2
  73. package/src/php/wrappers.ts +6 -2
  74. package/src/plugin.ts +50 -0
  75. package/src/python/client.ts +13 -3
  76. package/src/python/enums.ts +28 -3
  77. package/src/python/index.ts +38 -30
  78. package/src/python/manifest.ts +5 -12
  79. package/src/python/models.ts +138 -1
  80. package/src/python/resources.ts +234 -17
  81. package/src/python/tests.ts +260 -16
  82. package/src/python/type-map.ts +16 -2
  83. package/src/ruby/client.ts +238 -0
  84. package/src/ruby/enums.ts +149 -0
  85. package/src/ruby/index.ts +93 -0
  86. package/src/ruby/manifest.ts +28 -0
  87. package/src/ruby/models.ts +360 -0
  88. package/src/ruby/naming.ts +187 -0
  89. package/src/ruby/rbi.ts +313 -0
  90. package/src/ruby/resources.ts +799 -0
  91. package/src/ruby/tests.ts +459 -0
  92. package/src/ruby/type-map.ts +97 -0
  93. package/src/ruby/wrappers.ts +161 -0
  94. package/src/shared/model-utils.ts +131 -7
  95. package/src/shared/naming-utils.ts +36 -0
  96. package/src/shared/non-spec-services.ts +13 -0
  97. package/src/shared/resolved-ops.ts +75 -1
  98. package/test/dotnet/client.test.ts +2 -2
  99. package/test/dotnet/manifest.test.ts +13 -12
  100. package/test/dotnet/models.test.ts +7 -9
  101. package/test/dotnet/resources.test.ts +135 -3
  102. package/test/dotnet/tests.test.ts +5 -5
  103. package/test/entrypoint.test.ts +89 -0
  104. package/test/go/client.test.ts +6 -6
  105. package/test/go/resources.test.ts +156 -7
  106. package/test/kotlin/models.test.ts +1 -1
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/node/models.test.ts +134 -1
  109. package/test/node/resources.test.ts +134 -26
  110. package/test/node/utils.test.ts +140 -0
  111. package/test/php/models.test.ts +5 -4
  112. package/test/php/resources.test.ts +66 -1
  113. package/test/plugin.test.ts +50 -0
  114. package/test/python/client.test.ts +56 -0
  115. package/test/python/manifest.test.ts +7 -7
  116. package/test/python/models.test.ts +99 -0
  117. package/test/python/resources.test.ts +294 -0
  118. package/test/python/tests.test.ts +91 -0
  119. package/test/ruby/client.test.ts +81 -0
  120. package/test/ruby/resources.test.ts +386 -0
  121. package/test/shared/resolved-ops.test.ts +122 -0
  122. package/tsconfig.json +1 -0
  123. package/tsdown.config.ts +1 -1
  124. package/dist/index.mjs.map +0 -1
  125. package/scripts/generate-php.js +0 -13
  126. package/scripts/git-push-with-published-oagen.sh +0 -21
package/src/go/tests.ts CHANGED
@@ -152,6 +152,7 @@ function generateServiceTest(
152
152
 
153
153
  // Deduplicate test functions by method name
154
154
  const emittedTestMethods = new Set<string>();
155
+ const resolvedLookupMain = buildResolvedLookup(ctx);
155
156
  for (const op of service.operations) {
156
157
  const plan = planOperation(op);
157
158
  const method = resolveGoMethodName(op, resolvedName, ctx);
@@ -162,7 +163,38 @@ function generateServiceTest(
162
163
  if (emittedTestMethods.has(method)) continue;
163
164
  emittedTestMethods.add(method);
164
165
 
166
+ // Skip operations with wrapper splits — the parent method isn't emitted
167
+ // (see src/go/resources.ts), only the typed wrappers are tested below.
168
+ const resolvedMain = lookupResolved(op, resolvedLookupMain);
169
+ if ((resolvedMain?.wrappers?.length ?? 0) > 0) continue;
170
+
165
171
  const testName = `Test${accessorName}_${method}`;
172
+ const isUrlBuilder = resolvedMain?.urlBuilder ?? false;
173
+
174
+ if (isUrlBuilder) {
175
+ // URL-builder methods return a string synchronously without making
176
+ // an HTTP request. Assert the URL contains the expected path.
177
+ const expectedPath = buildExpectedPath(op);
178
+ // Call args: no ctx, path params (if any), then params struct.
179
+ const callArgs: string[] = [];
180
+ for (const p of sortPathParamsByTemplateOrder(op)) {
181
+ callArgs.push(`"test_${p.name}"`);
182
+ }
183
+ const hidden = buildHiddenParams(resolvedMain);
184
+ const hasVisibleQueryParams = op.queryParams.filter((qp) => !hidden.has(qp.name)).length > 0;
185
+ if (hasVisibleQueryParams) {
186
+ const pName = paramsStructName(resolvedName, method);
187
+ callArgs.push(`&${ctx.namespace}.${pName}{}`);
188
+ }
189
+ lines.push(`func ${testName}(t *testing.T) {`);
190
+ lines.push(`\tclient := ${ctx.namespace}.NewClient("sk_test", ${ctx.namespace}.WithClientID("client_test"))`);
191
+ lines.push(`\turl := client.${accessorName}().${method}(${callArgs.join(', ')})`);
192
+ lines.push('\trequire.NotEmpty(t, url)');
193
+ lines.push(`\trequire.Contains(t, url, "${expectedPath}")`);
194
+ lines.push('}');
195
+ lines.push('');
196
+ continue;
197
+ }
166
198
 
167
199
  if (isPaginated && op.pagination) {
168
200
  // Pagination test
@@ -371,7 +403,7 @@ function generateServiceTest(
371
403
  if (emittedTestMethods.has(wrapperMethod)) continue;
372
404
  emittedTestMethods.add(wrapperMethod);
373
405
 
374
- const wrapperParamsStruct = `${wrapperMethod}Params`;
406
+ const wrapperParamsStruct = paramsStructName(resolvedName, wrapperMethod);
375
407
  const responseType = wrapper.responseModelName;
376
408
  const testName = `Test${accessorName}_${wrapperMethod}`;
377
409
  const fixturePath = responseType ? `testdata/${fileName(responseType)}.json` : null;
@@ -398,8 +430,12 @@ function generateServiceTest(
398
430
  }
399
431
  }
400
432
 
401
- // Error test (one per file: 401)
402
- const sampleOp = service.operations[0];
433
+ // Error test (one per file: 401). Skip wrapper-split ops (parent method
434
+ // isn't emitted) and URL-builder ops (no HTTP call to error-test against).
435
+ const sampleOp = service.operations.find((o) => {
436
+ const r = lookupResolved(o, resolvedLookupMain);
437
+ return (r?.wrappers?.length ?? 0) === 0 && !(r?.urlBuilder ?? false);
438
+ });
403
439
  if (sampleOp) {
404
440
  const plan = planOperation(sampleOp);
405
441
  const method = resolveGoMethodName(sampleOp, resolvedName, ctx);
@@ -13,8 +13,10 @@ export function mapTypeRef(ref: TypeRef, asPointer = false): string {
13
13
  enum: (r) => className(r.name),
14
14
  union: (_r, variants) => joinUnionVariants(_r, variants),
15
15
  nullable: (_ref, inner) => {
16
- // If inner is already a pointer type (model), don't double-pointer
17
- if (inner.startsWith('*')) return inner;
16
+ // Slices, maps, and pointer types (models) don't get pointer-wrapped:
17
+ // nil slice/map + omitempty already handles absence, and double-pointers
18
+ // are confusing at the call site.
19
+ if (inner.startsWith('*') || inner.startsWith('[]') || inner.startsWith('map[')) return inner;
18
20
  return `*${inner}`;
19
21
  },
20
22
  literal: (r) => {
@@ -43,7 +45,10 @@ export function mapTypeRefValue(ref: TypeRef): string {
43
45
  model: (r) => className(r.name),
44
46
  enum: (r) => className(r.name),
45
47
  union: (_r, variants) => joinUnionVariants(_r, variants),
46
- nullable: (_ref, inner) => `*${inner}`,
48
+ nullable: (_ref, inner) => {
49
+ if (inner.startsWith('*') || inner.startsWith('[]') || inner.startsWith('map[')) return inner;
50
+ return `*${inner}`;
51
+ },
47
52
  literal: (r) => {
48
53
  if (r.value === null) return 'interface{}';
49
54
  if (typeof r.value === 'string') return 'string';
@@ -5,7 +5,7 @@ import {
5
5
  methodName as goMethodName,
6
6
  unexportedName,
7
7
  } from './naming.js';
8
- import { sortPathParamsByTemplateOrder } from './resources.js';
8
+ import { sortPathParamsByTemplateOrder, paramsStructName } from './resources.js';
9
9
  import { resolveWrapperParams, formatWrapperDescription, type ResolvedWrapperParam } from '../shared/wrapper-utils.js';
10
10
  import { lowerFirstForDoc, fieldDocComment } from '../shared/naming-utils.js';
11
11
 
@@ -20,6 +20,7 @@ import { lowerFirstForDoc, fieldDocComment } from '../shared/naming-utils.js';
20
20
  */
21
21
  export function generateWrapperMethods(
22
22
  serviceType: string,
23
+ mountName: string,
23
24
  resolvedOp: ResolvedOperation,
24
25
  ctx: EmitterContext,
25
26
  ): string[] {
@@ -30,20 +31,72 @@ export function generateWrapperMethods(
30
31
  for (const wrapper of resolvedOp.wrappers) {
31
32
  const wrapperParams = resolveWrapperParams(wrapper, ctx);
32
33
  lines.push('');
33
- emitWrapperParamsStruct(lines, wrapper, wrapperParams);
34
+ emitWrapperParamsStruct(lines, mountName, wrapper, wrapperParams);
34
35
  lines.push('');
35
- emitWrapperMethod(lines, serviceType, resolvedOp, wrapper, wrapperParams);
36
+ emitWrapperBodyStruct(lines, wrapper, wrapperParams);
37
+ lines.push('');
38
+ emitWrapperMethod(lines, serviceType, mountName, resolvedOp, wrapper, wrapperParams);
36
39
  }
37
40
 
38
41
  return lines;
39
42
  }
40
43
 
44
+ /** Unexported struct name used as the typed JSON body for a wrapper method. */
45
+ function wrapperBodyStructName(wrapper: ResolvedWrapper): string {
46
+ return `${unexportedName(goMethodName(wrapper.name))}Body`;
47
+ }
48
+
49
+ /**
50
+ * Emit the private body struct used to serialize the JSON request for a
51
+ * wrapper method. Fields come from: constant defaults, required exposed
52
+ * params, client-inferred fields, and optional exposed params (in that
53
+ * order to match how bodies are constructed at call sites).
54
+ */
55
+ function emitWrapperBodyStruct(lines: string[], wrapper: ResolvedWrapper, wrapperParams: ResolvedWrapperParam[]): void {
56
+ const structName = wrapperBodyStructName(wrapper);
57
+ lines.push(`// ${structName} is the JSON request body for ${goMethodName(wrapper.name)}.`);
58
+ lines.push(`type ${structName} struct {`);
59
+
60
+ // Constant defaults (always sent — no omitempty so the wire format is deterministic)
61
+ for (const [key, value] of Object.entries(wrapper.defaults)) {
62
+ const goField = goFieldName(key);
63
+ const goType = typeof value === 'boolean' ? 'bool' : typeof value === 'number' ? 'int' : 'string';
64
+ lines.push(`\t${goField} ${goType} \`json:"${key}"\``);
65
+ }
66
+
67
+ // Required exposed params
68
+ for (const { paramName, field, isOptional } of wrapperParams) {
69
+ if (isOptional) continue;
70
+ const goField = goFieldName(paramName);
71
+ const goType = field ? resolveSimpleGoType(field.type) : 'string';
72
+ lines.push(`\t${goField} ${goType} \`json:"${paramName}"\``);
73
+ }
74
+
75
+ // Inferred fields (from client config) — omit when empty
76
+ for (const inferred of wrapper.inferFromClient) {
77
+ const goField = goFieldName(inferred);
78
+ lines.push(`\t${goField} string \`json:"${inferred},omitempty"\``);
79
+ }
80
+
81
+ // Optional exposed params
82
+ for (const { paramName, field, isOptional } of wrapperParams) {
83
+ if (!isOptional) continue;
84
+ const goField = goFieldName(paramName);
85
+ const baseType = field ? resolveSimpleGoType(field.type) : 'string';
86
+ const optType = baseType.startsWith('*') || baseType.startsWith('[]') ? baseType : `*${baseType}`;
87
+ lines.push(`\t${goField} ${optType} \`json:"${paramName},omitempty"\``);
88
+ }
89
+
90
+ lines.push('}');
91
+ }
92
+
41
93
  function emitWrapperParamsStruct(
42
94
  lines: string[],
95
+ mountName: string,
43
96
  wrapper: ResolvedWrapper,
44
97
  wrapperParams: ResolvedWrapperParam[],
45
98
  ): void {
46
- const structName = `${goMethodName(wrapper.name)}Params`;
99
+ const structName = paramsStructName(mountName, goMethodName(wrapper.name));
47
100
 
48
101
  lines.push(`// ${structName} contains the parameters for ${goMethodName(wrapper.name)}.`);
49
102
  lines.push(`type ${structName} struct {`);
@@ -73,13 +126,14 @@ function emitWrapperParamsStruct(
73
126
  function emitWrapperMethod(
74
127
  lines: string[],
75
128
  serviceType: string,
129
+ mountName: string,
76
130
  resolvedOp: ResolvedOperation,
77
131
  wrapper: ResolvedWrapper,
78
132
  wrapperParams: ResolvedWrapperParam[],
79
133
  ): void {
80
134
  const op = resolvedOp.operation;
81
135
  const method = goMethodName(wrapper.name);
82
- const paramsStruct = `${method}Params`;
136
+ const paramsStruct = paramsStructName(mountName, method);
83
137
 
84
138
  // Return type
85
139
  const responseType = wrapper.responseModelName ? goClassName(wrapper.responseModelName) : null;
@@ -104,38 +158,37 @@ function emitWrapperMethod(
104
158
  lines.push(`func (s *${serviceType}) ${method}(${sigParams.join(', ')}) error {`);
105
159
  }
106
160
 
107
- // Build body map with defaults + exposed params
108
- lines.push('\tbody := map[string]interface{}{');
161
+ // Build typed body struct defaults + required params set at literal,
162
+ // inferred + optional fields assigned after (conditional on presence).
163
+ const bodyType = wrapperBodyStructName(wrapper);
164
+ lines.push(`\tbody := ${bodyType}{`);
109
165
 
110
166
  // Constant defaults (e.g., grant_type)
111
167
  for (const [key, value] of Object.entries(wrapper.defaults)) {
112
- lines.push(`\t\t"${key}": ${goLiteral(value)},`);
168
+ lines.push(`\t\t${goFieldName(key)}: ${goLiteral(value)},`);
113
169
  }
114
170
 
115
171
  // Required exposed params
116
172
  for (const { paramName, isOptional } of wrapperParams) {
117
173
  if (isOptional) continue;
118
174
  const goField = goFieldName(paramName);
119
- lines.push(`\t\t"${paramName}": params.${goField},`);
175
+ lines.push(`\t\t${goField}: params.${goField},`);
120
176
  }
121
177
 
122
178
  lines.push('\t}');
123
179
 
124
- // Inferred fields from client config
125
- for (const field of wrapper.inferFromClient) {
126
- const expr = clientFieldExpression(field);
127
- lines.push(`\tif ${expr} != "" {`);
128
- lines.push(`\t\tbody["${field}"] = ${expr}`);
129
- lines.push('\t}');
180
+ // Inferred fields from client config — omitempty handles the unset case
181
+ for (const inferred of wrapper.inferFromClient) {
182
+ const goField = goFieldName(inferred);
183
+ lines.push(`\tbody.${goField} = ${clientFieldExpression(inferred)}`);
130
184
  }
131
185
 
132
- // Optional exposed params
186
+ // Optional exposed params copy through as pointers — encoding/json +
187
+ // omitempty will drop nil fields on the wire.
133
188
  for (const { paramName, isOptional } of wrapperParams) {
134
189
  if (!isOptional) continue;
135
190
  const goField = goFieldName(paramName);
136
- lines.push(`\tif params.${goField} != nil {`);
137
- lines.push(`\t\tbody["${paramName}"] = *params.${goField}`);
138
- lines.push('\t}');
191
+ lines.push(`\tbody.${goField} = params.${goField}`);
139
192
  }
140
193
 
141
194
  // Build path expression
@@ -145,7 +198,7 @@ function emitWrapperMethod(
145
198
  const fmtArgs: string[] = [];
146
199
  for (const p of sortPathParamsByTemplateOrder(op)) {
147
200
  fmtStr = fmtStr.replace(`{${p.name}}`, '%s');
148
- fmtArgs.push(lowerFirstSafe(goFieldName(p.name)));
201
+ fmtArgs.push(`url.PathEscape(string(${lowerFirstSafe(goFieldName(p.name))}))`);
149
202
  }
150
203
  pathExpr = `fmt.Sprintf("${fmtStr}", ${fmtArgs.join(', ')})`;
151
204
  } else {
@@ -207,7 +260,12 @@ function resolveSimpleGoType(ref: any): string {
207
260
  return 'interface{}';
208
261
  }
209
262
  }
210
- if (ref.kind === 'nullable') return `*${resolveSimpleGoType(ref.inner)}`;
263
+ if (ref.kind === 'nullable') {
264
+ const inner = resolveSimpleGoType(ref.inner);
265
+ // Slices, maps, and pointer types don't get pointer-wrapped (mirrors type-map.ts).
266
+ if (inner.startsWith('*') || inner.startsWith('[]') || inner.startsWith('map[')) return inner;
267
+ return `*${inner}`;
268
+ }
211
269
  if (ref.kind === 'array') return `[]${resolveSimpleGoType(ref.items)}`;
212
270
  if (ref.kind === 'model') return `*${goClassName(ref.name)}`;
213
271
  if (ref.kind === 'enum') return goClassName(ref.name);
package/src/index.ts CHANGED
@@ -3,3 +3,17 @@ export { pythonEmitter } from './python/index.js';
3
3
  export { phpEmitter } from './php/index.js';
4
4
  export { goEmitter } from './go/index.js';
5
5
  export { dotnetEmitter } from './dotnet/index.js';
6
+ export { kotlinEmitter } from './kotlin/index.js';
7
+ export { rubyEmitter } from './ruby/index.js';
8
+
9
+ export { nodeExtractor } from './compat/extractors/node.js';
10
+ export { rubyExtractor } from './compat/extractors/ruby.js';
11
+ export { pythonExtractor } from './compat/extractors/python.js';
12
+ export { phpExtractor } from './compat/extractors/php.js';
13
+ export { goExtractor } from './compat/extractors/go.js';
14
+ export { rustExtractor } from './compat/extractors/rust.js';
15
+ export { kotlinExtractor } from './compat/extractors/kotlin.js';
16
+ export { dotnetExtractor } from './compat/extractors/dotnet.js';
17
+ export { elixirExtractor } from './compat/extractors/elixir.js';
18
+
19
+ export { workosEmittersPlugin } from './plugin.js';
@@ -25,9 +25,14 @@ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFil
25
25
  const fqn = `com.workos.${packageSegment(mount)}.${apiCls}`;
26
26
  const prop = servicePropertyName(mount);
27
27
  accessorLines.push('');
28
- accessorLines.push(` /** Lazily-constructed [${apiCls}] accessor for this [WorkOS] client. */`);
28
+ accessorLines.push(` /** Lazily-constructed [${fqn}] accessor for this [WorkOS] client. */`);
29
29
  accessorLines.push(` val ${prop}: ${fqn}`);
30
- accessorLines.push(` get() = service(${fqn}::class) { ${fqn}(this) }`);
30
+ accessorLines.push(' get() =');
31
+ accessorLines.push(' service(');
32
+ accessorLines.push(` ${fqn}::class`);
33
+ accessorLines.push(' ) {');
34
+ accessorLines.push(` ${fqn}(this)`);
35
+ accessorLines.push(' }');
31
36
  }
32
37
 
33
38
  const lines: string[] = [];
@@ -43,6 +43,12 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
43
43
  const aliasOf = new Map<string, string>(); // enum name → canonical enum name
44
44
  for (const [, group] of hashGroups) {
45
45
  if (group.length <= 1) continue;
46
+ if (group.every(isSharedSortOrderEnum)) {
47
+ const [canonical, ...rest] = [...group].sort((a, b) => a.name.localeCompare(b.name));
48
+ enumCanonicalMap.set(canonical.name, canonical.name);
49
+ for (const enumDef of rest) enumCanonicalMap.set(enumDef.name, 'SortOrder');
50
+ continue;
51
+ }
46
52
  const sorted = [...group].sort(
47
53
  (a, b) =>
48
54
  className(a.name).length - className(b.name).length || className(a.name).localeCompare(className(b.name)),
@@ -59,17 +65,28 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
59
65
  for (const enumDef of enums) {
60
66
  if (enumDef.values.length === 0) continue;
61
67
 
62
- const typeName = className(enumDef.name);
68
+ const typeName = canonicalEnumTypeName(enumDef);
63
69
 
64
70
  // Non-canonical enum: emit a typealias instead of a full enum class.
65
- const canonicalName = aliasOf.get(enumDef.name);
71
+ const sharedSortEmitter = isSharedSortOrderEnum(enumDef) && enumCanonicalMap.get(enumDef.name) === enumDef.name;
72
+ const canonicalName = sharedSortEmitter
73
+ ? undefined
74
+ : (aliasOf.get(enumDef.name) ?? enumCanonicalMap.get(enumDef.name));
66
75
  if (canonicalName) {
67
76
  const canonicalType = className(canonicalName);
77
+ // Skip when different IR names collapse to the same output name
78
+ if (typeName === canonicalType) continue;
68
79
  const aliasLine = `typealias ${typeName} = ${canonicalType}`;
69
80
  // ktlint enforces a 140-char max line length. When the typealias
70
81
  // exceeds that, add a @file:Suppress to avoid an unfixable violation.
71
82
  const suppressLine = aliasLine.length > 140 ? `@file:Suppress("ktlint:standard:max-line-length")\n\n` : '';
72
- const aliasContent = [`${suppressLine}package ${ENUMS_PACKAGE}`, '', aliasLine, ''].join('\n');
83
+ const aliasContent = [
84
+ `${suppressLine}package ${ENUMS_PACKAGE}`,
85
+ '',
86
+ `/** Alias for [${canonicalType}]. */`,
87
+ aliasLine,
88
+ '',
89
+ ].join('\n');
73
90
  files.push({
74
91
  path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
75
92
  content: aliasContent,
@@ -89,6 +106,7 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
89
106
  // sentinel for values the server introduces after this SDK was built.
90
107
  lines.push(`/** Enumeration of valid ${typeName} values returned or accepted by the API. */`);
91
108
  lines.push(`enum class ${typeName}(`);
109
+ lines.push(' /** The wire value sent to and received from the API. */');
92
110
  lines.push(' @JsonValue val value: String');
93
111
  lines.push(') {');
94
112
  // `@JsonEnumDefaultValue` makes Jackson's
@@ -149,6 +167,15 @@ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFil
149
167
  return files;
150
168
  }
151
169
 
170
+ function canonicalEnumTypeName(enumDef: Enum): string {
171
+ return isSharedSortOrderEnum(enumDef) ? 'SortOrder' : className(enumDef.name);
172
+ }
173
+
174
+ function isSharedSortOrderEnum(enumDef: Enum): boolean {
175
+ const wireValues = [...new Set(enumDef.values.map((value) => String(value.value).toLowerCase()))].sort();
176
+ return wireValues.length === 2 && wireValues[0] === 'asc' && wireValues[1] === 'desc';
177
+ }
178
+
152
179
  /** Hash an enum by its sorted wire values so identical enums collide. */
153
180
  function enumWireHash(enumDef: Enum): string {
154
181
  return [...enumDef.values]
@@ -16,7 +16,7 @@ import { generateEnums } from './enums.js';
16
16
  import { generateResources } from './resources.js';
17
17
  import { generateClient } from './client.js';
18
18
  import { generateTests } from './tests.js';
19
- import { generateManifest } from './manifest.js';
19
+ import { buildOperationsMap } from './manifest.js';
20
20
  import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
21
21
 
22
22
  /** Ensure every generated file ends with a trailing newline. */
@@ -69,8 +69,8 @@ export const kotlinEmitter: Emitter = {
69
69
  return ensureTrailingNewlines(generateTests(enrichedSpec, { ...ctx, spec: enrichedSpec }));
70
70
  },
71
71
 
72
- generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
73
- return ensureTrailingNewlines(generateManifest(spec, ctx));
72
+ buildOperationsMap(spec: ApiSpec, ctx: EmitterContext) {
73
+ return buildOperationsMap(spec, ctx);
74
74
  },
75
75
 
76
76
  fileHeader(): string {
@@ -1,20 +1,20 @@
1
- import type { ApiSpec, EmitterContext, GeneratedFile } from '@workos/oagen';
1
+ import type { ApiSpec, EmitterContext, OperationsMap } from '@workos/oagen';
2
2
  import { resolveMethodName, servicePropertyName, resolveClassName } from './naming.js';
3
3
  import { buildResolvedLookup, lookupResolved, getMountTarget } from '../shared/resolved-ops.js';
4
4
  import { propertyName } from './naming.js';
5
5
  import { isHandwrittenOverride } from './overrides.js';
6
6
 
7
7
  /**
8
- * Generate the smoke-test manifest mapping `"HTTP_METHOD /path"` to
9
- * `{ sdkMethod, service }`. The `service` is the camelCase accessor property
10
- * on the main `WorkOS` client (e.g., `organizations`).
8
+ * Build the operation-to-SDK-method mapping for the manifest.
9
+ *
10
+ * The `service` is the camelCase accessor property on the main `WorkOS`
11
+ * client (e.g., `organizations`).
11
12
  *
12
13
  * For polymorphic/split operations (e.g., authenticate -> 8 methods), the
13
- * manifest emits one entry per wrapper method so each variant is addressable.
14
+ * manifest emits an array of methods so each variant is addressable.
14
15
  */
15
- export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
16
- const manifest: Record<string, { sdkMethod: string; service: string } | { sdkMethod: string; service: string }[]> =
17
- {};
16
+ export function buildOperationsMap(spec: ApiSpec, ctx: EmitterContext): OperationsMap {
17
+ const manifest: OperationsMap = {};
18
18
  const resolvedLookup = buildResolvedLookup(ctx);
19
19
 
20
20
  for (const service of spec.services) {
@@ -45,11 +45,5 @@ export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedF
45
45
  }
46
46
  }
47
47
 
48
- return [
49
- {
50
- path: 'smoke-manifest.json',
51
- content: JSON.stringify(manifest, null, 2),
52
- integrateTarget: false,
53
- },
54
- ];
48
+ return manifest;
55
49
  }
@@ -1,6 +1,6 @@
1
1
  import type { Model, EmitterContext, GeneratedFile, TypeRef, Field } from '@workos/oagen';
2
2
  import { mapTypeRef, discriminatedUnions } from './type-map.js';
3
- import { className, propertyName, ktStringLiteral } from './naming.js';
3
+ import { className, propertyName, ktStringLiteral, humanize } from './naming.js';
4
4
  import { enumCanonicalMap } from './enums.js';
5
5
  import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
6
6
 
@@ -86,10 +86,14 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
86
86
 
87
87
  const canonical = aliasOf.get(model.name);
88
88
  if (canonical) {
89
+ const canonicalType = className(canonical);
90
+ // Skip when different IR names collapse to the same output name
91
+ if (typeName === canonicalType) continue;
89
92
  const aliasContent = [
90
93
  `package ${MODELS_PACKAGE}`,
91
94
  '',
92
- `typealias ${typeName} = ${className(canonical)}`,
95
+ `/** Alias for [${canonicalType}]. */`,
96
+ `typealias ${typeName} = ${canonicalType}`,
93
97
  '',
94
98
  ].join('\n');
95
99
  files.push({
@@ -103,6 +107,23 @@ export function generateModels(models: Model[], _ctx: EmitterContext): Generated
103
107
  files.push(emitDataClass(model));
104
108
  }
105
109
 
110
+ // Generate the sealed WorkOSEvent interface. Collect all event envelope
111
+ // models that have a literal `event` field and build the @JsonSubTypes
112
+ // mapping so Jackson can deserialize directly to the correct concrete type.
113
+ const eventMapping: Array<{ wireValue: string; modelName: string }> = [];
114
+ for (const model of models) {
115
+ if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
116
+ if (aliasOf.has(model.name)) continue;
117
+ if (!isEventEnvelopeModel(model)) continue;
118
+ const eventField = model.fields.find((f) => f.name === 'event');
119
+ if (eventField && eventField.type.kind === 'literal' && typeof eventField.type.value === 'string') {
120
+ eventMapping.push({ wireValue: eventField.type.value, modelName: model.name });
121
+ }
122
+ }
123
+ if (eventMapping.length > 0) {
124
+ files.push(emitWorkOSEvent(eventMapping));
125
+ }
126
+
106
127
  return files;
107
128
  }
108
129
 
@@ -119,7 +140,7 @@ function emitDataClass(model: Model): GeneratedFile {
119
140
  const typeName = className(model.name);
120
141
  const imports = collectImports(model.fields);
121
142
  const implementsEvent = isEventEnvelopeModel(model);
122
- if (implementsEvent) imports.add('com.workos.common.http.WorkOSEvent');
143
+ // WorkOSEvent sealed interface is generated in the same package — no import needed.
123
144
  const lines: string[] = [];
124
145
  lines.push(`package ${MODELS_PACKAGE}`);
125
146
  lines.push('');
@@ -202,6 +223,74 @@ function emitSealedUnion(
202
223
  };
203
224
  }
204
225
 
226
+ /**
227
+ * Emit the sealed `WorkOSEvent` interface with Jackson discriminated
228
+ * deserialization. Each concrete event model (UserCreated, DsyncUserUpdated,
229
+ * etc.) already extends this interface. The `@JsonSubTypes` annotation lets
230
+ * Jackson pick the right subclass when deserializing JSON with an `event`
231
+ * discriminator field. `EventSchema` is the fallback for unknown event types.
232
+ */
233
+ function emitWorkOSEvent(eventMapping: Array<{ wireValue: string; modelName: string }>): GeneratedFile {
234
+ const lines: string[] = [];
235
+ lines.push(`package ${MODELS_PACKAGE}`);
236
+ lines.push('');
237
+ lines.push('import com.fasterxml.jackson.annotation.JsonSubTypes');
238
+ lines.push('import com.fasterxml.jackson.annotation.JsonTypeInfo');
239
+ lines.push('import java.time.OffsetDateTime');
240
+ lines.push('');
241
+
242
+ lines.push('/**');
243
+ lines.push(' * Sealed interface for all webhook/event envelope models.');
244
+ lines.push(' *');
245
+ lines.push(' * Jackson deserializes incoming event JSON to the correct concrete type');
246
+ lines.push(' * based on the `event` discriminator field. Unknown event types fall back');
247
+ lines.push(' * to [EventSchema] with untyped `data: Map<String, Any>`.');
248
+ lines.push(' *');
249
+ lines.push(' * ```kotlin');
250
+ lines.push(' * val event: WorkOSEvent = objectMapper.readValue(json, WorkOSEvent::class.java)');
251
+ lines.push(' * when (event) {');
252
+ lines.push(' * is UserCreated -> println("User created: ${event.data.id}")');
253
+ lines.push(' * is EventSchema -> println("Unknown event: ${event.event}")');
254
+ lines.push(' * }');
255
+ lines.push(' * ```');
256
+ lines.push(' */');
257
+
258
+ lines.push('@JsonTypeInfo(');
259
+ lines.push(' use = JsonTypeInfo.Id.NAME,');
260
+ lines.push(' include = JsonTypeInfo.As.EXISTING_PROPERTY,');
261
+ lines.push(' property = "event",');
262
+ lines.push(' visible = true,');
263
+ lines.push(' defaultImpl = EventSchema::class');
264
+ lines.push(')');
265
+ lines.push('@JsonSubTypes(');
266
+ // Sort entries for stable output
267
+ const sorted = [...eventMapping].sort((a, b) => a.wireValue.localeCompare(b.wireValue));
268
+ for (let i = 0; i < sorted.length; i++) {
269
+ const { wireValue, modelName } = sorted[i];
270
+ const typeName = className(modelName);
271
+ const suffix = i === sorted.length - 1 ? '' : ',';
272
+ lines.push(` JsonSubTypes.Type(value = ${typeName}::class, name = ${ktStringLiteral(wireValue)})${suffix}`);
273
+ }
274
+ lines.push(')');
275
+ lines.push('sealed interface WorkOSEvent {');
276
+ lines.push(' /** Unique identifier for this event. */');
277
+ lines.push(' val id: String');
278
+ lines.push('');
279
+ lines.push(' /** The event type identifier. */');
280
+ lines.push(' val event: String');
281
+ lines.push('');
282
+ lines.push(' /** Timestamp when the event was created. */');
283
+ lines.push(' val createdAt: OffsetDateTime');
284
+ lines.push('}');
285
+ lines.push('');
286
+
287
+ return {
288
+ path: `${KOTLIN_SRC_PREFIX}${MODELS_DIR}/WorkOSEvent.kt`,
289
+ content: lines.join('\n'),
290
+ overwriteExisting: true,
291
+ };
292
+ }
293
+
205
294
  function renderFields(fields: Field[], overrideFields: Set<string> = new Set()): string[] {
206
295
  const seen = new Set<string>();
207
296
  const lines: string[] = [];
@@ -234,9 +323,9 @@ function renderFields(fields: Field[], overrideFields: Set<string> = new Set()):
234
323
 
235
324
  const isOverride = overrideFields.has(kotlinName);
236
325
  const annotations: string[] = [];
237
- // @JvmField cannot be applied to override properties in Kotlin.
238
- // Java callers can still reach the field through the generated getter.
239
- if (!isOverride) annotations.push('@JvmField');
326
+ // Omit @JvmField so Kotlin generates proper getter methods (getId(),
327
+ // isEmailVerified(), etc.) for Java callers matching the accessor
328
+ // convention used by Stripe, AWS SDK v2, and Twilio.
240
329
  annotations.push(`@JsonProperty(${ktStringLiteral(field.name)})`);
241
330
  if (field.deprecated) annotations.push('@Deprecated("Deprecated field")');
242
331
 
@@ -246,6 +335,8 @@ function renderFields(fields: Field[], overrideFields: Set<string> = new Set()):
246
335
  lines.push(` /** ${escapeKdoc(line.trim())} */`);
247
336
  } else if (literalDefault !== null) {
248
337
  lines.push(` /** Always \`${literalDefault}\`. */`);
338
+ } else {
339
+ lines.push(` /** The ${humanize(field.name)}. */`);
249
340
  }
250
341
  for (const anno of annotations) lines.push(` ${anno}`);
251
342
 
@@ -25,7 +25,12 @@ export function methodName(name: string): string {
25
25
 
26
26
  /** camelCase Kotlin property / local variable name. */
27
27
  export function propertyName(name: string): string {
28
- return escapeReserved(toCamelCase(name));
28
+ const camel = toCamelCase(name);
29
+ // `object` is a Kotlin reserved word. Instead of backtick-escaping it
30
+ // (forcing callers to write `event.\`object\``), rename to `objectType`
31
+ // and rely on @JsonProperty("object") for wire mapping.
32
+ if (camel === 'object') return 'objectType';
33
+ return escapeReserved(camel);
29
34
  }
30
35
 
31
36
  /** camelCase alias (kept for parity with other emitters). */
@@ -45,6 +50,7 @@ export function packageSegment(name: string): string {
45
50
 
46
51
  /** Kotlin service class name for a mount group (e.g., `Organizations`). */
47
52
  export function apiClassName(name: string): string {
53
+ if (className(name) === 'SSO') return 'Sso';
48
54
  return className(name);
49
55
  }
50
56