@workos/oagen-emitters 0.2.1 → 0.4.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 (136) hide show
  1. package/.husky/pre-commit +1 -0
  2. package/.release-please-manifest.json +1 -1
  3. package/CHANGELOG.md +15 -0
  4. package/README.md +129 -0
  5. package/dist/index.d.mts +13 -1
  6. package/dist/index.d.mts.map +1 -1
  7. package/dist/index.mjs +14549 -3385
  8. package/dist/index.mjs.map +1 -1
  9. package/docs/sdk-architecture/dotnet.md +336 -0
  10. package/docs/sdk-architecture/go.md +338 -0
  11. package/docs/sdk-architecture/php.md +315 -0
  12. package/docs/sdk-architecture/python.md +511 -0
  13. package/oagen.config.ts +328 -2
  14. package/package.json +9 -5
  15. package/scripts/generate-php.js +13 -0
  16. package/scripts/git-push-with-published-oagen.sh +21 -0
  17. package/smoke/sdk-dotnet.ts +45 -12
  18. package/smoke/sdk-go.ts +116 -42
  19. package/smoke/sdk-php.ts +28 -26
  20. package/smoke/sdk-python.ts +5 -2
  21. package/src/dotnet/client.ts +89 -0
  22. package/src/dotnet/enums.ts +323 -0
  23. package/src/dotnet/fixtures.ts +236 -0
  24. package/src/dotnet/index.ts +246 -0
  25. package/src/dotnet/manifest.ts +36 -0
  26. package/src/dotnet/models.ts +344 -0
  27. package/src/dotnet/naming.ts +330 -0
  28. package/src/dotnet/resources.ts +622 -0
  29. package/src/dotnet/tests.ts +693 -0
  30. package/src/dotnet/type-map.ts +201 -0
  31. package/src/dotnet/wrappers.ts +186 -0
  32. package/src/go/client.ts +141 -0
  33. package/src/go/enums.ts +196 -0
  34. package/src/go/fixtures.ts +212 -0
  35. package/src/go/index.ts +84 -0
  36. package/src/go/manifest.ts +36 -0
  37. package/src/go/models.ts +254 -0
  38. package/src/go/naming.ts +179 -0
  39. package/src/go/resources.ts +827 -0
  40. package/src/go/tests.ts +751 -0
  41. package/src/go/type-map.ts +82 -0
  42. package/src/go/wrappers.ts +261 -0
  43. package/src/index.ts +4 -0
  44. package/src/kotlin/client.ts +53 -0
  45. package/src/kotlin/enums.ts +162 -0
  46. package/src/kotlin/index.ts +92 -0
  47. package/src/kotlin/manifest.ts +55 -0
  48. package/src/kotlin/models.ts +395 -0
  49. package/src/kotlin/naming.ts +223 -0
  50. package/src/kotlin/overrides.ts +25 -0
  51. package/src/kotlin/resources.ts +667 -0
  52. package/src/kotlin/tests.ts +1019 -0
  53. package/src/kotlin/type-map.ts +123 -0
  54. package/src/kotlin/wrappers.ts +168 -0
  55. package/src/node/client.ts +128 -115
  56. package/src/node/enums.ts +9 -0
  57. package/src/node/errors.ts +37 -232
  58. package/src/node/field-plan.ts +726 -0
  59. package/src/node/fixtures.ts +9 -1
  60. package/src/node/index.ts +3 -9
  61. package/src/node/models.ts +178 -21
  62. package/src/node/naming.ts +49 -111
  63. package/src/node/resources.ts +527 -397
  64. package/src/node/sdk-errors.ts +41 -0
  65. package/src/node/tests.ts +69 -19
  66. package/src/node/type-map.ts +4 -2
  67. package/src/node/utils.ts +13 -71
  68. package/src/node/wrappers.ts +151 -0
  69. package/src/php/client.ts +179 -0
  70. package/src/php/enums.ts +67 -0
  71. package/src/php/errors.ts +9 -0
  72. package/src/php/fixtures.ts +181 -0
  73. package/src/php/index.ts +96 -0
  74. package/src/php/manifest.ts +36 -0
  75. package/src/php/models.ts +310 -0
  76. package/src/php/naming.ts +279 -0
  77. package/src/php/resources.ts +636 -0
  78. package/src/php/tests.ts +609 -0
  79. package/src/php/type-map.ts +90 -0
  80. package/src/php/utils.ts +18 -0
  81. package/src/php/wrappers.ts +152 -0
  82. package/src/python/client.ts +345 -0
  83. package/src/python/enums.ts +313 -0
  84. package/src/python/fixtures.ts +196 -0
  85. package/src/python/index.ts +95 -0
  86. package/src/python/manifest.ts +38 -0
  87. package/src/python/models.ts +688 -0
  88. package/src/python/naming.ts +189 -0
  89. package/src/python/resources.ts +1322 -0
  90. package/src/python/tests.ts +1335 -0
  91. package/src/python/type-map.ts +93 -0
  92. package/src/python/wrappers.ts +191 -0
  93. package/src/shared/model-utils.ts +472 -0
  94. package/src/shared/naming-utils.ts +154 -0
  95. package/src/shared/non-spec-services.ts +54 -0
  96. package/src/shared/resolved-ops.ts +109 -0
  97. package/src/shared/wrapper-utils.ts +70 -0
  98. package/test/dotnet/client.test.ts +121 -0
  99. package/test/dotnet/enums.test.ts +193 -0
  100. package/test/dotnet/errors.test.ts +9 -0
  101. package/test/dotnet/manifest.test.ts +82 -0
  102. package/test/dotnet/models.test.ts +260 -0
  103. package/test/dotnet/resources.test.ts +255 -0
  104. package/test/dotnet/tests.test.ts +202 -0
  105. package/test/go/client.test.ts +92 -0
  106. package/test/go/enums.test.ts +132 -0
  107. package/test/go/errors.test.ts +9 -0
  108. package/test/go/models.test.ts +265 -0
  109. package/test/go/resources.test.ts +408 -0
  110. package/test/go/tests.test.ts +143 -0
  111. package/test/kotlin/models.test.ts +135 -0
  112. package/test/kotlin/tests.test.ts +176 -0
  113. package/test/node/client.test.ts +92 -12
  114. package/test/node/enums.test.ts +2 -0
  115. package/test/node/errors.test.ts +2 -41
  116. package/test/node/models.test.ts +2 -0
  117. package/test/node/naming.test.ts +23 -0
  118. package/test/node/resources.test.ts +315 -84
  119. package/test/node/serializers.test.ts +3 -1
  120. package/test/node/type-map.test.ts +11 -0
  121. package/test/php/client.test.ts +95 -0
  122. package/test/php/enums.test.ts +173 -0
  123. package/test/php/errors.test.ts +9 -0
  124. package/test/php/models.test.ts +497 -0
  125. package/test/php/resources.test.ts +682 -0
  126. package/test/php/tests.test.ts +185 -0
  127. package/test/python/client.test.ts +200 -0
  128. package/test/python/enums.test.ts +228 -0
  129. package/test/python/errors.test.ts +16 -0
  130. package/test/python/manifest.test.ts +74 -0
  131. package/test/python/models.test.ts +716 -0
  132. package/test/python/resources.test.ts +617 -0
  133. package/test/python/tests.test.ts +202 -0
  134. package/src/node/common.ts +0 -273
  135. package/src/node/config.ts +0 -71
  136. package/src/node/serializers.ts +0 -746
@@ -0,0 +1,82 @@
1
+ import type { TypeRef, PrimitiveType, UnionType } from '@workos/oagen';
2
+ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
+ import { className } from './naming.js';
4
+
5
+ /**
6
+ * Map an IR TypeRef to a Go type string.
7
+ */
8
+ export function mapTypeRef(ref: TypeRef, asPointer = false): string {
9
+ const base = irMapTypeRef<string>(ref, {
10
+ primitive: mapPrimitive,
11
+ array: (_ref, items) => `[]${items}`,
12
+ model: (r) => `*${className(r.name)}`,
13
+ enum: (r) => className(r.name),
14
+ union: (_r, variants) => joinUnionVariants(_r, variants),
15
+ nullable: (_ref, inner) => {
16
+ // If inner is already a pointer type (model), don't double-pointer
17
+ if (inner.startsWith('*')) return inner;
18
+ return `*${inner}`;
19
+ },
20
+ literal: (r) => {
21
+ if (r.value === null) return 'interface{}';
22
+ if (typeof r.value === 'string') return 'string';
23
+ if (typeof r.value === 'number') return Number.isInteger(r.value) ? 'int' : 'float64';
24
+ if (typeof r.value === 'boolean') return 'bool';
25
+ return 'interface{}';
26
+ },
27
+ map: (_ref, value) => `map[string]${value}`,
28
+ });
29
+ if (asPointer && !base.startsWith('*') && !base.startsWith('[]') && !base.startsWith('map[')) {
30
+ return `*${base}`;
31
+ }
32
+ return base;
33
+ }
34
+
35
+ /**
36
+ * Map an IR TypeRef to a Go type string without pointer wrapping for models.
37
+ * Used for response type references where we don't want a double pointer.
38
+ */
39
+ export function mapTypeRefValue(ref: TypeRef): string {
40
+ return irMapTypeRef<string>(ref, {
41
+ primitive: mapPrimitive,
42
+ array: (_ref, items) => `[]${items}`,
43
+ model: (r) => className(r.name),
44
+ enum: (r) => className(r.name),
45
+ union: (_r, variants) => joinUnionVariants(_r, variants),
46
+ nullable: (_ref, inner) => `*${inner}`,
47
+ literal: (r) => {
48
+ if (r.value === null) return 'interface{}';
49
+ if (typeof r.value === 'string') return 'string';
50
+ if (typeof r.value === 'number') return Number.isInteger(r.value) ? 'int' : 'float64';
51
+ if (typeof r.value === 'boolean') return 'bool';
52
+ return 'interface{}';
53
+ },
54
+ map: (_ref, value) => `map[string]${value}`,
55
+ });
56
+ }
57
+
58
+ function mapPrimitive(ref: PrimitiveType): string {
59
+ if (ref.format === 'binary') return '[]byte';
60
+ switch (ref.type) {
61
+ case 'string':
62
+ return 'string';
63
+ case 'integer':
64
+ return 'int';
65
+ case 'number':
66
+ return 'float64';
67
+ case 'boolean':
68
+ return 'bool';
69
+ case 'unknown':
70
+ return 'interface{}';
71
+ }
72
+ }
73
+
74
+ function joinUnionVariants(_ref: UnionType, variants: string[]): string {
75
+ if (_ref.compositionKind === 'allOf') {
76
+ return variants[0] ?? 'interface{}';
77
+ }
78
+ const unique = [...new Set(variants)];
79
+ if (unique.length === 1) return unique[0];
80
+ // Go doesn't have union types; use interface{}
81
+ return 'interface{}';
82
+ }
@@ -0,0 +1,261 @@
1
+ import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
2
+ import {
3
+ className as goClassName,
4
+ fieldName as goFieldName,
5
+ methodName as goMethodName,
6
+ unexportedName,
7
+ } from './naming.js';
8
+ import { sortPathParamsByTemplateOrder } from './resources.js';
9
+ import { resolveWrapperParams, formatWrapperDescription, type ResolvedWrapperParam } from '../shared/wrapper-utils.js';
10
+ import { lowerFirstForDoc, fieldDocComment } from '../shared/naming-utils.js';
11
+
12
+ /**
13
+ * Generate Go wrapper method lines for union split operations.
14
+ *
15
+ * Each wrapper is a typed convenience method that:
16
+ * - Accepts only the exposed params (not the full union body)
17
+ * - Injects constant defaults (e.g., grant_type)
18
+ * - Reads inferred fields from client config (e.g., client_id)
19
+ * - Delegates to the HTTP client with the constructed body
20
+ */
21
+ export function generateWrapperMethods(
22
+ serviceType: string,
23
+ resolvedOp: ResolvedOperation,
24
+ ctx: EmitterContext,
25
+ ): string[] {
26
+ if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
27
+
28
+ const lines: string[] = [];
29
+
30
+ for (const wrapper of resolvedOp.wrappers) {
31
+ const wrapperParams = resolveWrapperParams(wrapper, ctx);
32
+ lines.push('');
33
+ emitWrapperParamsStruct(lines, wrapper, wrapperParams);
34
+ lines.push('');
35
+ emitWrapperMethod(lines, serviceType, resolvedOp, wrapper, wrapperParams);
36
+ }
37
+
38
+ return lines;
39
+ }
40
+
41
+ function emitWrapperParamsStruct(
42
+ lines: string[],
43
+ wrapper: ResolvedWrapper,
44
+ wrapperParams: ResolvedWrapperParam[],
45
+ ): void {
46
+ const structName = `${goMethodName(wrapper.name)}Params`;
47
+
48
+ lines.push(`// ${structName} contains the parameters for ${goMethodName(wrapper.name)}.`);
49
+ lines.push(`type ${structName} struct {`);
50
+
51
+ for (const { paramName, field, isOptional } of wrapperParams) {
52
+ const goField = goFieldName(paramName);
53
+ const goType = field ? resolveSimpleGoType(field.type) : 'string';
54
+
55
+ if (field?.description) {
56
+ const fdLines = field.description.split('\n').filter((l: string) => l.trim());
57
+ lines.push(`\t// ${fieldDocComment(goField, fdLines[0])}`);
58
+ for (let i = 1; i < fdLines.length; i++) {
59
+ lines.push(`\t// ${fdLines[i].trim()}`);
60
+ }
61
+ }
62
+ if (isOptional) {
63
+ const optType = goType.startsWith('*') || goType.startsWith('[]') ? goType : `*${goType}`;
64
+ lines.push(`\t${goField} ${optType} \`json:"${paramName},omitempty"\``);
65
+ } else {
66
+ lines.push(`\t${goField} ${goType} \`json:"${paramName}"\``);
67
+ }
68
+ }
69
+
70
+ lines.push('}');
71
+ }
72
+
73
+ function emitWrapperMethod(
74
+ lines: string[],
75
+ serviceType: string,
76
+ resolvedOp: ResolvedOperation,
77
+ wrapper: ResolvedWrapper,
78
+ wrapperParams: ResolvedWrapperParam[],
79
+ ): void {
80
+ const op = resolvedOp.operation;
81
+ const method = goMethodName(wrapper.name);
82
+ const paramsStruct = `${method}Params`;
83
+
84
+ // Return type
85
+ const responseType = wrapper.responseModelName ? goClassName(wrapper.responseModelName) : null;
86
+
87
+ // GoDoc
88
+ lines.push(`// ${method} ${formatWrapperDescription(wrapper.name)}.`);
89
+
90
+ // Signature
91
+ const sigParams: string[] = ['ctx context.Context'];
92
+
93
+ // Path params as positional args (sorted by template order)
94
+ for (const p of sortPathParamsByTemplateOrder(op)) {
95
+ sigParams.push(`${lowerFirstSafe(goFieldName(p.name))} ${resolveSimpleGoType(p.type)}`);
96
+ }
97
+
98
+ sigParams.push(`params *${paramsStruct}`);
99
+ sigParams.push('opts ...RequestOption');
100
+
101
+ if (responseType) {
102
+ lines.push(`func (s *${serviceType}) ${method}(${sigParams.join(', ')}) (*${responseType}, error) {`);
103
+ } else {
104
+ lines.push(`func (s *${serviceType}) ${method}(${sigParams.join(', ')}) error {`);
105
+ }
106
+
107
+ // Build body map with defaults + exposed params
108
+ lines.push('\tbody := map[string]interface{}{');
109
+
110
+ // Constant defaults (e.g., grant_type)
111
+ for (const [key, value] of Object.entries(wrapper.defaults)) {
112
+ lines.push(`\t\t"${key}": ${goLiteral(value)},`);
113
+ }
114
+
115
+ // Required exposed params
116
+ for (const { paramName, isOptional } of wrapperParams) {
117
+ if (isOptional) continue;
118
+ const goField = goFieldName(paramName);
119
+ lines.push(`\t\t"${paramName}": params.${goField},`);
120
+ }
121
+
122
+ lines.push('\t}');
123
+
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}');
130
+ }
131
+
132
+ // Optional exposed params
133
+ for (const { paramName, isOptional } of wrapperParams) {
134
+ if (!isOptional) continue;
135
+ const goField = goFieldName(paramName);
136
+ lines.push(`\tif params.${goField} != nil {`);
137
+ lines.push(`\t\tbody["${paramName}"] = *params.${goField}`);
138
+ lines.push('\t}');
139
+ }
140
+
141
+ // Build path expression
142
+ let pathExpr: string;
143
+ if (op.pathParams.length > 0) {
144
+ let fmtStr = op.path;
145
+ const fmtArgs: string[] = [];
146
+ for (const p of sortPathParamsByTemplateOrder(op)) {
147
+ fmtStr = fmtStr.replace(`{${p.name}}`, '%s');
148
+ fmtArgs.push(lowerFirstSafe(goFieldName(p.name)));
149
+ }
150
+ pathExpr = `fmt.Sprintf("${fmtStr}", ${fmtArgs.join(', ')})`;
151
+ } else {
152
+ pathExpr = `"${op.path}"`;
153
+ }
154
+
155
+ // Make the request
156
+ if (responseType) {
157
+ lines.push(`\tvar result ${responseType}`);
158
+ lines.push(
159
+ `\t_, err := s.client.request(ctx, "${op.httpMethod.toUpperCase()}", ${pathExpr}, nil, body, &result, opts)`,
160
+ );
161
+ lines.push('\tif err != nil {');
162
+ lines.push('\t\treturn nil, err');
163
+ lines.push('\t}');
164
+ lines.push('\treturn &result, nil');
165
+ } else {
166
+ lines.push(
167
+ `\t_, err := s.client.request(ctx, "${op.httpMethod.toUpperCase()}", ${pathExpr}, nil, body, nil, opts)`,
168
+ );
169
+ lines.push('\treturn err');
170
+ }
171
+
172
+ lines.push('}');
173
+ }
174
+
175
+ /** Convert a value to a Go literal. */
176
+ function goLiteral(value: string | number | boolean): string {
177
+ if (typeof value === 'string') return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`;
178
+ if (typeof value === 'boolean') return value ? 'true' : 'false';
179
+ return String(value);
180
+ }
181
+
182
+ /** Get the Go expression for reading a client config field. */
183
+ function clientFieldExpression(field: string): string {
184
+ switch (field) {
185
+ case 'client_id':
186
+ return 's.client.clientID';
187
+ case 'client_secret':
188
+ return 's.client.apiKey';
189
+ default:
190
+ return `s.client.${lowerFirstSafe(goFieldName(field))}`;
191
+ }
192
+ }
193
+
194
+ /** Resolve a TypeRef to a simple Go type string. */
195
+ function resolveSimpleGoType(ref: any): string {
196
+ if (ref.kind === 'primitive') {
197
+ switch (ref.type) {
198
+ case 'string':
199
+ return 'string';
200
+ case 'integer':
201
+ return 'int';
202
+ case 'number':
203
+ return 'float64';
204
+ case 'boolean':
205
+ return 'bool';
206
+ default:
207
+ return 'interface{}';
208
+ }
209
+ }
210
+ if (ref.kind === 'nullable') return `*${resolveSimpleGoType(ref.inner)}`;
211
+ if (ref.kind === 'array') return `[]${resolveSimpleGoType(ref.items)}`;
212
+ if (ref.kind === 'model') return `*${goClassName(ref.name)}`;
213
+ if (ref.kind === 'enum') return goClassName(ref.name);
214
+ if (ref.kind === 'union') {
215
+ // For oneOf with a single non-null variant, use that variant's type
216
+ const nonNull = ref.variants.filter((v: any) => v.kind !== 'literal' || v.value !== null);
217
+ if (nonNull.length === 1) return resolveSimpleGoType(nonNull[0]);
218
+ return 'interface{}';
219
+ }
220
+ return 'interface{}';
221
+ }
222
+
223
+ /** Go reserved words set. */
224
+ const GO_RESERVED = new Set([
225
+ 'break',
226
+ 'case',
227
+ 'chan',
228
+ 'const',
229
+ 'continue',
230
+ 'default',
231
+ 'defer',
232
+ 'else',
233
+ 'fallthrough',
234
+ 'for',
235
+ 'func',
236
+ 'go',
237
+ 'goto',
238
+ 'if',
239
+ 'import',
240
+ 'interface',
241
+ 'map',
242
+ 'package',
243
+ 'range',
244
+ 'return',
245
+ 'select',
246
+ 'struct',
247
+ 'switch',
248
+ 'type',
249
+ 'var',
250
+ ]);
251
+
252
+ function lowerFirstSafe(s: string): string {
253
+ if (!s) return s;
254
+ const result = unexportedName(s);
255
+ if (GO_RESERVED.has(result)) return `${result}Param`;
256
+ return result;
257
+ }
258
+
259
+ function _lowerFirstField(s: string): string {
260
+ return lowerFirstForDoc(s);
261
+ }
package/src/index.ts CHANGED
@@ -1 +1,5 @@
1
1
  export { nodeEmitter } from './node/index.js';
2
+ export { pythonEmitter } from './python/index.js';
3
+ export { phpEmitter } from './php/index.js';
4
+ export { goEmitter } from './go/index.js';
5
+ export { dotnetEmitter } from './dotnet/index.js';
@@ -0,0 +1,53 @@
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 [${apiCls}] accessor for this [WorkOS] client. */`);
29
+ accessorLines.push(` val ${prop}: ${fqn}`);
30
+ accessorLines.push(` get() = service(${fqn}::class) { ${fqn}(this) }`);
31
+ }
32
+
33
+ const lines: string[] = [];
34
+ lines.push('package com.workos');
35
+ lines.push('');
36
+ lines.push('open class WorkOS {');
37
+ for (const line of accessorLines) lines.push(line);
38
+ lines.push('}');
39
+ lines.push('');
40
+
41
+ return [
42
+ {
43
+ path: `${KOTLIN_SRC_PREFIX}com/workos/WorkOS.kt`,
44
+ content: lines.join('\n'),
45
+ },
46
+ ];
47
+ }
48
+
49
+ function deduplicateByMount(services: Service[], ctx: EmitterContext): string[] {
50
+ const targets = new Set<string>();
51
+ for (const s of services) targets.add(getMountTarget(s, ctx));
52
+ return [...targets].sort();
53
+ }
@@ -0,0 +1,162 @@
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
+ const sorted = [...group].sort(
47
+ (a, b) =>
48
+ className(a.name).length - className(b.name).length || className(a.name).localeCompare(className(b.name)),
49
+ );
50
+ const canonical = sorted[0];
51
+ for (let i = 1; i < sorted.length; i++) {
52
+ aliasOf.set(sorted[i].name, canonical.name);
53
+ enumCanonicalMap.set(sorted[i].name, canonical.name);
54
+ }
55
+ }
56
+
57
+ const files: GeneratedFile[] = [];
58
+
59
+ for (const enumDef of enums) {
60
+ if (enumDef.values.length === 0) continue;
61
+
62
+ const typeName = className(enumDef.name);
63
+
64
+ // Non-canonical enum: emit a typealias instead of a full enum class.
65
+ const canonicalName = aliasOf.get(enumDef.name);
66
+ if (canonicalName) {
67
+ const canonicalType = className(canonicalName);
68
+ const aliasLine = `typealias ${typeName} = ${canonicalType}`;
69
+ // ktlint enforces a 140-char max line length. When the typealias
70
+ // exceeds that, add a @file:Suppress to avoid an unfixable violation.
71
+ const suppressLine = aliasLine.length > 140 ? `@file:Suppress("ktlint:standard:max-line-length")\n\n` : '';
72
+ const aliasContent = [`${suppressLine}package ${ENUMS_PACKAGE}`, '', aliasLine, ''].join('\n');
73
+ files.push({
74
+ path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
75
+ content: aliasContent,
76
+ overwriteExisting: true,
77
+ });
78
+ continue;
79
+ }
80
+
81
+ const lines: string[] = [];
82
+ lines.push(`package ${ENUMS_PACKAGE}`);
83
+ lines.push('');
84
+ lines.push('import com.fasterxml.jackson.annotation.JsonEnumDefaultValue');
85
+ lines.push('import com.fasterxml.jackson.annotation.JsonValue');
86
+ lines.push('');
87
+ // Replace the tautological "Foo enum." docstring with a slightly more
88
+ // informative summary. `Unknown` is emitted as the forward-compatibility
89
+ // sentinel for values the server introduces after this SDK was built.
90
+ lines.push(`/** Enumeration of valid ${typeName} values returned or accepted by the API. */`);
91
+ lines.push(`enum class ${typeName}(`);
92
+ lines.push(' @JsonValue val value: String');
93
+ lines.push(') {');
94
+ // `@JsonEnumDefaultValue` makes Jackson's
95
+ // READ_UNKNOWN_ENUM_VALUES_USING_DEFAULT_VALUE feature map unrecognized
96
+ // wire values onto `Unknown` instead of throwing — required for forward
97
+ // compatibility when the API introduces new variants.
98
+ lines.push(' @JsonEnumDefaultValue');
99
+ lines.push(` Unknown(${ktStringLiteral('unknown')}),`);
100
+
101
+ const seenNames = new Set<string>(['Unknown']);
102
+ const seenWire = new Set<string>(['unknown']);
103
+ const members: string[] = [];
104
+
105
+ for (const v of enumDef.values) {
106
+ const wire = String(v.value);
107
+ if (seenWire.has(wire)) continue;
108
+ seenWire.add(wire);
109
+
110
+ let memberName = className(wire);
111
+ if (!memberName || /^[0-9]/.test(memberName)) memberName = `Value${memberName || wire}`;
112
+ if (memberName === typeName || seenNames.has(memberName)) {
113
+ let suffix = 2;
114
+ while (seenNames.has(`${memberName}${suffix}`)) suffix += 1;
115
+ memberName = `${memberName}${suffix}`;
116
+ }
117
+ seenNames.add(memberName);
118
+
119
+ if (v.description?.trim()) {
120
+ members.push(` /** ${escapeKdoc(v.description.split('\n')[0].trim())} */`);
121
+ }
122
+ if (v.deprecated) {
123
+ members.push(' @Deprecated("Deprecated enum value")');
124
+ }
125
+ members.push(` ${memberName}(${ktStringLiteral(wire)})`);
126
+ }
127
+
128
+ for (let i = 0; i < members.length; i++) {
129
+ const isLast = i === members.length - 1;
130
+ const line = members[i];
131
+ const trimmedStart = line.trimStart();
132
+ if (trimmedStart.startsWith('/**') || trimmedStart.startsWith('@')) {
133
+ lines.push(line);
134
+ continue;
135
+ }
136
+ lines.push(isLast ? line : `${line},`);
137
+ }
138
+
139
+ lines.push('}');
140
+ lines.push('');
141
+
142
+ files.push({
143
+ path: `${KOTLIN_SRC_PREFIX}${ENUMS_DIR}/${typeName}.kt`,
144
+ content: lines.join('\n'),
145
+ overwriteExisting: true,
146
+ });
147
+ }
148
+
149
+ return files;
150
+ }
151
+
152
+ /** Hash an enum by its sorted wire values so identical enums collide. */
153
+ function enumWireHash(enumDef: Enum): string {
154
+ return [...enumDef.values]
155
+ .map((v) => String(v.value))
156
+ .sort()
157
+ .join('|');
158
+ }
159
+
160
+ function escapeKdoc(s: string): string {
161
+ return s.replace(/\*\//g, '*\u200b/');
162
+ }
@@ -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
+ };