@workos/oagen-emitters 0.3.0 → 0.5.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 (128) 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 +12 -1
  11. package/dist/index.d.mts.map +1 -1
  12. package/dist/index.mjs +2 -12737
  13. package/dist/plugin-BSop9f9z.mjs +21471 -0
  14. package/dist/plugin-BSop9f9z.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 +336 -0
  19. package/oagen.config.ts +5 -343
  20. package/package.json +10 -34
  21. package/smoke/sdk-dotnet.ts +45 -12
  22. package/src/dotnet/client.ts +89 -0
  23. package/src/dotnet/enums.ts +323 -0
  24. package/src/dotnet/fixtures.ts +236 -0
  25. package/src/dotnet/index.ts +248 -0
  26. package/src/dotnet/manifest.ts +36 -0
  27. package/src/dotnet/models.ts +320 -0
  28. package/src/dotnet/naming.ts +368 -0
  29. package/src/dotnet/resources.ts +943 -0
  30. package/src/dotnet/tests.ts +713 -0
  31. package/src/dotnet/type-map.ts +228 -0
  32. package/src/dotnet/wrappers.ts +197 -0
  33. package/src/go/client.ts +35 -3
  34. package/src/go/enums.ts +4 -0
  35. package/src/go/index.ts +15 -7
  36. package/src/go/models.ts +6 -1
  37. package/src/go/naming.ts +5 -17
  38. package/src/go/resources.ts +534 -73
  39. package/src/go/tests.ts +39 -3
  40. package/src/go/type-map.ts +8 -3
  41. package/src/go/wrappers.ts +79 -21
  42. package/src/index.ts +15 -0
  43. package/src/kotlin/client.ts +58 -0
  44. package/src/kotlin/enums.ts +189 -0
  45. package/src/kotlin/index.ts +92 -0
  46. package/src/kotlin/manifest.ts +55 -0
  47. package/src/kotlin/models.ts +486 -0
  48. package/src/kotlin/naming.ts +229 -0
  49. package/src/kotlin/overrides.ts +25 -0
  50. package/src/kotlin/resources.ts +998 -0
  51. package/src/kotlin/tests.ts +1133 -0
  52. package/src/kotlin/type-map.ts +123 -0
  53. package/src/kotlin/wrappers.ts +168 -0
  54. package/src/node/client.ts +84 -7
  55. package/src/node/field-plan.ts +12 -14
  56. package/src/node/fixtures.ts +39 -3
  57. package/src/node/index.ts +1 -0
  58. package/src/node/models.ts +281 -37
  59. package/src/node/resources.ts +319 -95
  60. package/src/node/tests.ts +108 -29
  61. package/src/node/type-map.ts +1 -31
  62. package/src/node/utils.ts +96 -6
  63. package/src/node/wrappers.ts +31 -1
  64. package/src/php/client.ts +11 -3
  65. package/src/php/models.ts +0 -33
  66. package/src/php/naming.ts +2 -21
  67. package/src/php/resources.ts +275 -19
  68. package/src/php/tests.ts +118 -18
  69. package/src/php/type-map.ts +16 -2
  70. package/src/php/wrappers.ts +7 -2
  71. package/src/plugin.ts +50 -0
  72. package/src/python/client.ts +50 -32
  73. package/src/python/enums.ts +35 -10
  74. package/src/python/index.ts +35 -27
  75. package/src/python/models.ts +139 -2
  76. package/src/python/naming.ts +2 -22
  77. package/src/python/resources.ts +234 -17
  78. package/src/python/tests.ts +260 -16
  79. package/src/python/type-map.ts +16 -2
  80. package/src/ruby/client.ts +238 -0
  81. package/src/ruby/enums.ts +149 -0
  82. package/src/ruby/index.ts +93 -0
  83. package/src/ruby/manifest.ts +35 -0
  84. package/src/ruby/models.ts +360 -0
  85. package/src/ruby/naming.ts +187 -0
  86. package/src/ruby/rbi.ts +313 -0
  87. package/src/ruby/resources.ts +799 -0
  88. package/src/ruby/tests.ts +459 -0
  89. package/src/ruby/type-map.ts +97 -0
  90. package/src/ruby/wrappers.ts +161 -0
  91. package/src/shared/model-utils.ts +357 -16
  92. package/src/shared/naming-utils.ts +83 -0
  93. package/src/shared/non-spec-services.ts +13 -0
  94. package/src/shared/resolved-ops.ts +75 -1
  95. package/src/shared/wrapper-utils.ts +12 -1
  96. package/test/dotnet/client.test.ts +121 -0
  97. package/test/dotnet/enums.test.ts +193 -0
  98. package/test/dotnet/errors.test.ts +9 -0
  99. package/test/dotnet/manifest.test.ts +82 -0
  100. package/test/dotnet/models.test.ts +258 -0
  101. package/test/dotnet/resources.test.ts +387 -0
  102. package/test/dotnet/tests.test.ts +202 -0
  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 +135 -0
  107. package/test/kotlin/resources.test.ts +210 -0
  108. package/test/kotlin/tests.test.ts +176 -0
  109. package/test/node/client.test.ts +74 -0
  110. package/test/node/models.test.ts +134 -1
  111. package/test/node/resources.test.ts +343 -34
  112. package/test/node/utils.test.ts +140 -0
  113. package/test/php/client.test.ts +2 -1
  114. package/test/php/models.test.ts +5 -4
  115. package/test/php/resources.test.ts +103 -0
  116. package/test/php/tests.test.ts +67 -0
  117. package/test/plugin.test.ts +50 -0
  118. package/test/python/client.test.ts +56 -0
  119. package/test/python/models.test.ts +99 -0
  120. package/test/python/resources.test.ts +294 -0
  121. package/test/python/tests.test.ts +91 -0
  122. package/test/ruby/client.test.ts +81 -0
  123. package/test/ruby/resources.test.ts +386 -0
  124. package/test/shared/resolved-ops.test.ts +122 -0
  125. package/tsdown.config.ts +1 -1
  126. package/dist/index.mjs.map +0 -1
  127. package/scripts/generate-php.js +0 -13
  128. 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
@@ -2,3 +2,18 @@ export { nodeEmitter } from './node/index.js';
2
2
  export { pythonEmitter } from './python/index.js';
3
3
  export { phpEmitter } from './php/index.js';
4
4
  export { goEmitter } from './go/index.js';
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';
@@ -0,0 +1,58 @@
1
+ import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oagen';
2
+ import { apiClassName, packageSegment, servicePropertyName } from './naming.js';
3
+ import { getMountTarget } from '../shared/resolved-ops.js';
4
+
5
+ const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
6
+
7
+ /**
8
+ * Generate service accessor properties for the hand-maintained `WorkOS` class.
9
+ *
10
+ * Each accessor is a `val` property with a custom getter that delegates to
11
+ * `WorkOS.service(...)` for lazy, cached construction. The generated file
12
+ * contains a `WorkOS` class stub with only these properties — the oagen
13
+ * merger deep-merges them into the existing hand-written `WorkOS.kt`.
14
+ *
15
+ * Accessors use fully-qualified type names so the merger doesn't need to
16
+ * inject imports into the hand-written file.
17
+ */
18
+ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
19
+ const targets = deduplicateByMount(spec.services, ctx);
20
+ if (targets.length === 0) return [];
21
+
22
+ const accessorLines: string[] = [];
23
+ for (const mount of targets) {
24
+ const apiCls = apiClassName(mount);
25
+ const fqn = `com.workos.${packageSegment(mount)}.${apiCls}`;
26
+ const prop = servicePropertyName(mount);
27
+ accessorLines.push('');
28
+ accessorLines.push(` /** Lazily-constructed [${fqn}] accessor for this [WorkOS] client. */`);
29
+ accessorLines.push(` val ${prop}: ${fqn}`);
30
+ accessorLines.push(' get() =');
31
+ accessorLines.push(' service(');
32
+ accessorLines.push(` ${fqn}::class`);
33
+ accessorLines.push(' ) {');
34
+ accessorLines.push(` ${fqn}(this)`);
35
+ accessorLines.push(' }');
36
+ }
37
+
38
+ const lines: string[] = [];
39
+ lines.push('package com.workos');
40
+ lines.push('');
41
+ lines.push('open class WorkOS {');
42
+ for (const line of accessorLines) lines.push(line);
43
+ lines.push('}');
44
+ lines.push('');
45
+
46
+ return [
47
+ {
48
+ path: `${KOTLIN_SRC_PREFIX}com/workos/WorkOS.kt`,
49
+ content: lines.join('\n'),
50
+ },
51
+ ];
52
+ }
53
+
54
+ function deduplicateByMount(services: Service[], ctx: EmitterContext): string[] {
55
+ const targets = new Set<string>();
56
+ for (const s of services) targets.add(getMountTarget(s, ctx));
57
+ return [...targets].sort();
58
+ }
@@ -0,0 +1,189 @@
1
+ import type { Enum, EmitterContext, GeneratedFile } from '@workos/oagen';
2
+ import { className, ktStringLiteral } from './naming.js';
3
+
4
+ const KOTLIN_SRC_PREFIX = 'src/main/kotlin/';
5
+ const ENUMS_PACKAGE = 'com.workos.types';
6
+ const ENUMS_DIR = 'com/workos/types';
7
+
8
+ /**
9
+ * Mapping from an IR enum name to its canonical enum name. When two enums
10
+ * share identical sorted wire values the shorter-named one is canonical and
11
+ * the others become `typealias` files. Downstream consumers (type-map,
12
+ * resources) use this map to resolve references to the canonical class.
13
+ */
14
+ export const enumCanonicalMap = new Map<string, string>();
15
+
16
+ /**
17
+ * Generate Kotlin `enum class` types from the IR enums. Each enum is emitted
18
+ * to its own file under `com.workos.types`, annotated with Jackson
19
+ * `@JsonValue` on the wire value. An `Unknown` sentinel is always the first
20
+ * constant so that responses with new variants still deserialize instead of
21
+ * throwing.
22
+ *
23
+ * Enums with identical sets of wire values are deduplicated: the one with the
24
+ * shortest PascalCase name becomes canonical and the rest emit `typealias`
25
+ * files pointing at the canonical class.
26
+ */
27
+ export function generateEnums(enums: Enum[], _ctx: EmitterContext): GeneratedFile[] {
28
+ if (enums.length === 0) return [];
29
+
30
+ // Reset the canonical map on every generation run (guards against re-entry).
31
+ enumCanonicalMap.clear();
32
+
33
+ // --- Dedup: group enums by a hash of their sorted wire values. ---
34
+ const hashGroups = new Map<string, Enum[]>();
35
+ for (const enumDef of enums) {
36
+ if (enumDef.values.length === 0) continue;
37
+ const hash = enumWireHash(enumDef);
38
+ if (!hashGroups.has(hash)) hashGroups.set(hash, []);
39
+ hashGroups.get(hash)!.push(enumDef);
40
+ }
41
+
42
+ // Within each group, pick the shortest className as canonical.
43
+ const aliasOf = new Map<string, string>(); // enum name → canonical enum name
44
+ for (const [, group] of hashGroups) {
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
+ }
52
+ const sorted = [...group].sort(
53
+ (a, b) =>
54
+ className(a.name).length - className(b.name).length || className(a.name).localeCompare(className(b.name)),
55
+ );
56
+ const canonical = sorted[0];
57
+ for (let i = 1; i < sorted.length; i++) {
58
+ aliasOf.set(sorted[i].name, canonical.name);
59
+ enumCanonicalMap.set(sorted[i].name, canonical.name);
60
+ }
61
+ }
62
+
63
+ const files: GeneratedFile[] = [];
64
+
65
+ for (const enumDef of enums) {
66
+ if (enumDef.values.length === 0) continue;
67
+
68
+ const typeName = canonicalEnumTypeName(enumDef);
69
+
70
+ // Non-canonical enum: emit a typealias instead of a full enum class.
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));
75
+ if (canonicalName) {
76
+ const canonicalType = className(canonicalName);
77
+ // Skip when different IR names collapse to the same output name
78
+ if (typeName === canonicalType) continue;
79
+ const aliasLine = `typealias ${typeName} = ${canonicalType}`;
80
+ // ktlint enforces a 140-char max line length. When the typealias
81
+ // exceeds that, add a @file:Suppress to avoid an unfixable violation.
82
+ const suppressLine = aliasLine.length > 140 ? `@file:Suppress("ktlint:standard:max-line-length")\n\n` : '';
83
+ const aliasContent = [
84
+ `${suppressLine}package ${ENUMS_PACKAGE}`,
85
+ '',
86
+ `/** Alias for [${canonicalType}]. */`,
87
+ aliasLine,
88
+ '',
89
+ ].join('\n');
90
+ files.push({
91
+ path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
92
+ content: aliasContent,
93
+ overwriteExisting: true,
94
+ });
95
+ continue;
96
+ }
97
+
98
+ const lines: string[] = [];
99
+ lines.push(`package ${ENUMS_PACKAGE}`);
100
+ lines.push('');
101
+ lines.push('import com.fasterxml.jackson.annotation.JsonEnumDefaultValue');
102
+ lines.push('import com.fasterxml.jackson.annotation.JsonValue');
103
+ lines.push('');
104
+ // Replace the tautological "Foo enum." docstring with a slightly more
105
+ // informative summary. `Unknown` is emitted as the forward-compatibility
106
+ // sentinel for values the server introduces after this SDK was built.
107
+ lines.push(`/** Enumeration of valid ${typeName} values returned or accepted by the API. */`);
108
+ lines.push(`enum class ${typeName}(`);
109
+ lines.push(' /** The wire value sent to and received from the API. */');
110
+ lines.push(' @JsonValue val value: String');
111
+ lines.push(') {');
112
+ // `@JsonEnumDefaultValue` makes Jackson's
113
+ // READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE feature map unrecognized
114
+ // wire values onto `Unknown` instead of throwing — required for forward
115
+ // compatibility when the API introduces new variants.
116
+ lines.push(' @JsonEnumDefaultValue');
117
+ lines.push(` Unknown(${ktStringLiteral('unknown')}),`);
118
+
119
+ const seenNames = new Set<string>(['Unknown']);
120
+ const seenWire = new Set<string>(['unknown']);
121
+ const members: string[] = [];
122
+
123
+ for (const v of enumDef.values) {
124
+ const wire = String(v.value);
125
+ if (seenWire.has(wire)) continue;
126
+ seenWire.add(wire);
127
+
128
+ let memberName = className(wire);
129
+ if (!memberName || /^[0-9]/.test(memberName)) memberName = `Value${memberName || wire}`;
130
+ if (memberName === typeName || seenNames.has(memberName)) {
131
+ let suffix = 2;
132
+ while (seenNames.has(`${memberName}${suffix}`)) suffix += 1;
133
+ memberName = `${memberName}${suffix}`;
134
+ }
135
+ seenNames.add(memberName);
136
+
137
+ if (v.description?.trim()) {
138
+ members.push(` /** ${escapeKdoc(v.description.split('\n')[0].trim())} */`);
139
+ }
140
+ if (v.deprecated) {
141
+ members.push(' @Deprecated("Deprecated enum value")');
142
+ }
143
+ members.push(` ${memberName}(${ktStringLiteral(wire)})`);
144
+ }
145
+
146
+ for (let i = 0; i < members.length; i++) {
147
+ const isLast = i === members.length - 1;
148
+ const line = members[i];
149
+ const trimmedStart = line.trimStart();
150
+ if (trimmedStart.startsWith('/**') || trimmedStart.startsWith('@')) {
151
+ lines.push(line);
152
+ continue;
153
+ }
154
+ lines.push(isLast ? line : `${line},`);
155
+ }
156
+
157
+ lines.push('}');
158
+ lines.push('');
159
+
160
+ files.push({
161
+ path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
162
+ content: lines.join('\n'),
163
+ overwriteExisting: true,
164
+ });
165
+ }
166
+
167
+ return files;
168
+ }
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
+
179
+ /** Hash an enum by its sorted wire values so identical enums collide. */
180
+ function enumWireHash(enumDef: Enum): string {
181
+ return [...enumDef.values]
182
+ .map((v) => String(v.value))
183
+ .sort()
184
+ .join('|');
185
+ }
186
+
187
+ function escapeKdoc(s: string): string {
188
+ return s.replace(/\*\//g, '*\u200b/');
189
+ }
@@ -0,0 +1,92 @@
1
+ import type {
2
+ Emitter,
3
+ EmitterContext,
4
+ FormatCommand,
5
+ GeneratedFile,
6
+ ApiSpec,
7
+ Model,
8
+ Enum,
9
+ Service,
10
+ } from '@workos/oagen';
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+
14
+ import { generateModels } from './models.js';
15
+ import { generateEnums } from './enums.js';
16
+ import { generateResources } from './resources.js';
17
+ import { generateClient } from './client.js';
18
+ import { generateTests } from './tests.js';
19
+ import { generateManifest } from './manifest.js';
20
+ import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
21
+
22
+ /** Ensure every generated file ends with a trailing newline. */
23
+ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
24
+ for (const f of files) {
25
+ if (f.content && !f.content.endsWith('\n')) {
26
+ f.content += '\n';
27
+ }
28
+ }
29
+ return files;
30
+ }
31
+
32
+ export const kotlinEmitter: Emitter = {
33
+ language: 'kotlin',
34
+
35
+ generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
36
+ const enriched = enrichModelsFromSpec(models);
37
+ return ensureTrailingNewlines(generateModels(enriched, ctx));
38
+ },
39
+
40
+ generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
41
+ // Merge synthetic enums produced during model enrichment (inline oneOf
42
+ // definitions) so they get proper enum class files.
43
+ const syntheticEnums = getSyntheticEnums();
44
+ return ensureTrailingNewlines(generateEnums([...enums, ...syntheticEnums], ctx));
45
+ },
46
+
47
+ generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
48
+ return ensureTrailingNewlines(generateResources(services, ctx));
49
+ },
50
+
51
+ generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
52
+ return ensureTrailingNewlines(generateClient(spec, ctx));
53
+ },
54
+
55
+ generateErrors(_ctx: EmitterContext): GeneratedFile[] {
56
+ // Exception hierarchy is hand-maintained in workos-kotlin (see Phase 2).
57
+ return [];
58
+ },
59
+
60
+ generateTypeSignatures(): GeneratedFile[] {
61
+ return [];
62
+ },
63
+
64
+ generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
65
+ // Pass enriched models so round-trip tests see the full field set
66
+ // (including optional oneOf-enriched fields) and can filter accurately.
67
+ const enrichedModels = enrichModelsFromSpec(spec.models);
68
+ const enrichedSpec: ApiSpec = { ...spec, models: enrichedModels };
69
+ return ensureTrailingNewlines(generateTests(enrichedSpec, { ...ctx, spec: enrichedSpec }));
70
+ },
71
+
72
+ generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
73
+ return ensureTrailingNewlines(generateManifest(spec, ctx));
74
+ },
75
+
76
+ fileHeader(): string {
77
+ return '// This file is auto-generated by oagen. Do not edit.';
78
+ },
79
+
80
+ formatCommand(targetDir: string): FormatCommand | null {
81
+ // ktlint enforces a 140-char max line length that cannot be auto-corrected
82
+ // by the Gradle plugin alone, but `./gradlew ktlintFormat` fixes most
83
+ // violations (trailing whitespace, import ordering, etc.) across all
84
+ // generated files. The file list appended by oagen is ignored — Gradle
85
+ // reformats the whole source set.
86
+ if (!fs.existsSync(path.join(targetDir, 'gradlew'))) return null;
87
+ return {
88
+ cmd: 'bash',
89
+ args: ['-c', `cd ${JSON.stringify(targetDir)} && ./gradlew ktlintFormat --quiet 2>/dev/null; true`, '--'],
90
+ };
91
+ },
92
+ };