@workos/oagen-emitters 0.3.0 → 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 (65) hide show
  1. package/.release-please-manifest.json +1 -1
  2. package/CHANGELOG.md +7 -0
  3. package/dist/index.d.mts +4 -1
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +3288 -791
  6. package/dist/index.mjs.map +1 -1
  7. package/docs/sdk-architecture/dotnet.md +336 -0
  8. package/oagen.config.ts +42 -12
  9. package/package.json +2 -2
  10. package/smoke/sdk-dotnet.ts +45 -12
  11. package/src/dotnet/client.ts +89 -0
  12. package/src/dotnet/enums.ts +323 -0
  13. package/src/dotnet/fixtures.ts +236 -0
  14. package/src/dotnet/index.ts +246 -0
  15. package/src/dotnet/manifest.ts +36 -0
  16. package/src/dotnet/models.ts +344 -0
  17. package/src/dotnet/naming.ts +330 -0
  18. package/src/dotnet/resources.ts +622 -0
  19. package/src/dotnet/tests.ts +693 -0
  20. package/src/dotnet/type-map.ts +201 -0
  21. package/src/dotnet/wrappers.ts +186 -0
  22. package/src/go/index.ts +5 -2
  23. package/src/go/naming.ts +5 -17
  24. package/src/index.ts +1 -0
  25. package/src/kotlin/client.ts +53 -0
  26. package/src/kotlin/enums.ts +162 -0
  27. package/src/kotlin/index.ts +92 -0
  28. package/src/kotlin/manifest.ts +55 -0
  29. package/src/kotlin/models.ts +395 -0
  30. package/src/kotlin/naming.ts +223 -0
  31. package/src/kotlin/overrides.ts +25 -0
  32. package/src/kotlin/resources.ts +667 -0
  33. package/src/kotlin/tests.ts +1019 -0
  34. package/src/kotlin/type-map.ts +123 -0
  35. package/src/kotlin/wrappers.ts +168 -0
  36. package/src/node/client.ts +50 -0
  37. package/src/node/index.ts +1 -0
  38. package/src/node/resources.ts +164 -44
  39. package/src/node/tests.ts +37 -7
  40. package/src/php/client.ts +11 -3
  41. package/src/php/naming.ts +2 -21
  42. package/src/php/resources.ts +81 -6
  43. package/src/php/tests.ts +93 -17
  44. package/src/php/wrappers.ts +1 -0
  45. package/src/python/client.ts +37 -29
  46. package/src/python/enums.ts +7 -7
  47. package/src/python/models.ts +1 -1
  48. package/src/python/naming.ts +2 -22
  49. package/src/shared/model-utils.ts +232 -15
  50. package/src/shared/naming-utils.ts +47 -0
  51. package/src/shared/wrapper-utils.ts +12 -1
  52. package/test/dotnet/client.test.ts +121 -0
  53. package/test/dotnet/enums.test.ts +193 -0
  54. package/test/dotnet/errors.test.ts +9 -0
  55. package/test/dotnet/manifest.test.ts +82 -0
  56. package/test/dotnet/models.test.ts +260 -0
  57. package/test/dotnet/resources.test.ts +255 -0
  58. package/test/dotnet/tests.test.ts +202 -0
  59. package/test/kotlin/models.test.ts +135 -0
  60. package/test/kotlin/tests.test.ts +176 -0
  61. package/test/node/client.test.ts +74 -0
  62. package/test/node/resources.test.ts +216 -15
  63. package/test/php/client.test.ts +2 -1
  64. package/test/php/resources.test.ts +38 -0
  65. package/test/php/tests.test.ts +67 -0
@@ -0,0 +1,201 @@
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
+ /** Known C# value types that need `?` for nullable. */
6
+ const VALUE_TYPES = new Set(['int', 'long', 'double', 'bool', 'float', 'decimal', 'byte', 'short', 'DateTimeOffset']);
7
+
8
+ /**
9
+ * Module-level alias map for structurally-identical enums. Populated by
10
+ * `setEnumAliases` from the current spec's enum list; consulted by
11
+ * `mapTypeRef` so that every reference to a duplicate enum resolves to the
12
+ * canonical name. C# has no first-class type alias for enums, so dedup must
13
+ * happen at reference-rewrite time rather than via a runtime alias.
14
+ */
15
+ const enumAliases = new Map<string, string>();
16
+
17
+ /**
18
+ * Names of enums that resolve to a single wire value and should therefore be
19
+ * mapped to C# `string` at reference sites (the owning property emits a
20
+ * const initializer). Populated by `setSingleValueEnumNames`.
21
+ */
22
+ const singleValueEnumNames = new Set<string>();
23
+
24
+ /** Replace the current enum-alias map. Safe to call more than once. */
25
+ export function setEnumAliases(aliases: Map<string, string>): void {
26
+ enumAliases.clear();
27
+ for (const [k, v] of aliases) enumAliases.set(k, v);
28
+ }
29
+
30
+ /** Replace the set of enum names that are single-value discriminator stand-ins. */
31
+ export function setSingleValueEnumNames(names: Iterable<string>): void {
32
+ singleValueEnumNames.clear();
33
+ for (const n of names) singleValueEnumNames.add(n);
34
+ }
35
+
36
+ /** Resolve an enum reference name to its canonical form. */
37
+ export function resolveEnumTypeName(name: string): string {
38
+ return enumAliases.get(name) ?? name;
39
+ }
40
+
41
+ /**
42
+ * Map an IR TypeRef to a C# type string.
43
+ */
44
+ export function mapTypeRef(ref: TypeRef): string {
45
+ return irMapTypeRef<string>(ref, {
46
+ primitive: mapPrimitive,
47
+ array: (_ref, items) => `List<${items}>`,
48
+ model: (r) => className(r.name),
49
+ enum: (r) => {
50
+ // Single-value enums (discriminator consts in disguise) map to `string`
51
+ // so the caller can't misuse a public one-member enum type. The
52
+ // owning property emits a const initializer separately.
53
+ if ((r.values?.length ?? 0) === 1) return 'string';
54
+ if (singleValueEnumNames.has(r.name)) return 'string';
55
+ return className(resolveEnumTypeName(r.name));
56
+ },
57
+ union: (_r, variants) => joinUnionVariants(_r, variants),
58
+ nullable: (_ref, inner) => {
59
+ // With <Nullable>enable</Nullable>, all nullable types need `?`
60
+ if (inner.endsWith('?')) return inner; // already nullable (e.g., nested nullable)
61
+ return `${inner}?`;
62
+ },
63
+ literal: (r) => {
64
+ if (r.value === null) return 'object';
65
+ if (typeof r.value === 'string') return 'string';
66
+ if (typeof r.value === 'number') return Number.isInteger(r.value) ? 'int' : 'double';
67
+ if (typeof r.value === 'boolean') return 'bool';
68
+ return 'object';
69
+ },
70
+ map: (_ref, value) => `Dictionary<string, ${value}>`,
71
+ });
72
+ }
73
+
74
+ /**
75
+ * Map an IR TypeRef to a C# type string, making optional fields nullable.
76
+ * For value types, appends `?`. For reference types, returns as-is.
77
+ */
78
+ export function mapTypeRefOptional(ref: TypeRef): string {
79
+ const baseType = mapTypeRef(ref);
80
+ if (isValueType(baseType)) return `${baseType}?`;
81
+ return baseType;
82
+ }
83
+
84
+ /**
85
+ * Check if a C# type is a value type (needs ? for nullable).
86
+ */
87
+ export function isValueType(csType: string): boolean {
88
+ // Strip trailing ? if present
89
+ const bare = csType.endsWith('?') ? csType.slice(0, -1) : csType;
90
+ if (VALUE_TYPES.has(bare)) return true;
91
+ // Enums are value types, but we can't detect them purely from the type string.
92
+ // The caller should handle enum nullability explicitly when needed.
93
+ return false;
94
+ }
95
+
96
+ /**
97
+ * Check if an IR TypeRef maps to a C# value type.
98
+ */
99
+ export function isValueTypeRef(ref: TypeRef): boolean {
100
+ if (ref.kind === 'enum') return true;
101
+ if (ref.kind === 'primitive') {
102
+ // DateTimeOffset is a value type (struct)
103
+ if (ref.format === 'date-time') return true;
104
+ switch (ref.type) {
105
+ case 'integer':
106
+ case 'number':
107
+ case 'boolean':
108
+ return true;
109
+ default:
110
+ return false;
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+
116
+ /**
117
+ * Whether a TypeRef directly names an enum (no nullable wrapper).
118
+ * Used to detect required enum request fields that must not silently serialize
119
+ * their default Unknown sentinel.
120
+ */
121
+ export function isEnumRef(ref: TypeRef): boolean {
122
+ return ref.kind === 'enum';
123
+ }
124
+
125
+ /**
126
+ * Emit JSON attributes for a request-side property. When `isRequiredEnum` is
127
+ * true, configure both serializers to skip the field when its value equals the
128
+ * enum default (0 = Unknown sentinel). This converts "unset required enum"
129
+ * from a silent `"unknown"` wire value into a clean omission so the API
130
+ * returns a clear `missing required field` error instead of a confusing 422.
131
+ */
132
+ export function emitJsonPropertyAttributes(wireName: string, options: { isRequiredEnum?: boolean } = {}): string[] {
133
+ if (options.isRequiredEnum) {
134
+ return [
135
+ ` [JsonProperty("${wireName}", DefaultValueHandling = DefaultValueHandling.Ignore)]`,
136
+ ` [STJS.JsonPropertyName("${wireName}")]`,
137
+ ` [STJS.JsonIgnore(Condition = STJS.JsonIgnoreCondition.WhenWritingDefault)]`,
138
+ ];
139
+ }
140
+ return [` [JsonProperty("${wireName}")]`, ` [STJS.JsonPropertyName("${wireName}")]`];
141
+ }
142
+
143
+ function mapPrimitive(ref: PrimitiveType): string {
144
+ if (ref.format === 'binary') return 'byte[]';
145
+ if (ref.format === 'int32') return 'int';
146
+ if (ref.format === 'int64') return 'long';
147
+ if (ref.format === 'date-time') return 'DateTimeOffset';
148
+ switch (ref.type) {
149
+ case 'string':
150
+ return 'string';
151
+ case 'integer':
152
+ return 'long';
153
+ case 'number':
154
+ return 'double';
155
+ case 'boolean':
156
+ return 'bool';
157
+ case 'unknown':
158
+ return 'object';
159
+ }
160
+ }
161
+
162
+ /**
163
+ * Track discriminated unions for downstream model generation.
164
+ * Key = generated base type name, Value = discriminator info.
165
+ */
166
+ export const discriminatedUnions = new Map<
167
+ string,
168
+ { property: string; mapping: Record<string, string>; variantTypes: string[] }
169
+ >();
170
+
171
+ function joinUnionVariants(_ref: UnionType, variants: string[]): string {
172
+ if (_ref.compositionKind === 'allOf') {
173
+ return variants[0] ?? 'object';
174
+ }
175
+ const unique = [...new Set(variants)];
176
+ if (unique.length === 1) return unique[0];
177
+
178
+ // Discriminated union: register for converter generation and return first variant as base
179
+ if (_ref.discriminator && _ref.discriminator.mapping) {
180
+ const baseName = unique[0];
181
+ discriminatedUnions.set(baseName, {
182
+ property: _ref.discriminator.property,
183
+ mapping: _ref.discriminator.mapping,
184
+ variantTypes: unique,
185
+ });
186
+ // Use object with JsonConverter for discriminated unions since
187
+ // AnyOf<> doesn't support discriminator-based deserialization
188
+ return 'object';
189
+ }
190
+
191
+ if (unique.length >= 2 && unique.length <= 3) return `AnyOf<${unique.join(', ')}>`;
192
+ // AnyOf only supports arity 2 and 3. Higher-arity unions collapse to
193
+ // `object`, losing type information. Warn so the author knows the spec
194
+ // outgrew the runtime support instead of silently degrading.
195
+ if (unique.length >= 4) {
196
+ console.warn(
197
+ `[oagen:dotnet] Union with ${unique.length} variants exceeds AnyOf<T1,T2,T3> arity; falling back to object. Variants: ${unique.join(', ')}`,
198
+ );
199
+ }
200
+ return 'object';
201
+ }
@@ -0,0 +1,186 @@
1
+ import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
2
+ import {
3
+ className as csClassName,
4
+ fieldName as csFieldName,
5
+ methodName as csMethodName,
6
+ localName,
7
+ csLiteral,
8
+ clientFieldExpression,
9
+ httpMethodHelperName,
10
+ escapeXml,
11
+ emitXmlDoc,
12
+ humanize,
13
+ } from './naming.js';
14
+ import { sortPathParamsByTemplateOrder } from './resources.js';
15
+ import { resolveWrapperParams, formatWrapperDescription, type ResolvedWrapperParam } from '../shared/wrapper-utils.js';
16
+ import { mapTypeRef, isValueTypeRef, isEnumRef, emitJsonPropertyAttributes } from './type-map.js';
17
+
18
+ /**
19
+ * Generate C# wrapper method lines for union split operations.
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
+ emitWrapperMethod(lines, resolvedOp, wrapper, wrapperParams, ctx);
34
+ }
35
+
36
+ return lines;
37
+ }
38
+
39
+ function emitWrapperMethod(
40
+ lines: string[],
41
+ resolvedOp: ResolvedOperation,
42
+ wrapper: ResolvedWrapper,
43
+ _wrapperParams: ResolvedWrapperParam[],
44
+ _ctx: EmitterContext,
45
+ ): void {
46
+ const op = resolvedOp.operation;
47
+ const method = csMethodName(wrapper.name);
48
+ const optionsClass = `${method}Options`;
49
+ const responseType = wrapper.responseModelName ? csClassName(wrapper.responseModelName) : null;
50
+
51
+ // XML doc
52
+ lines.push(` /// <summary>${formatWrapperDescription(wrapper.name)}.</summary>`);
53
+ for (const p of sortPathParamsByTemplateOrder(op)) {
54
+ const paramDesc = p.description ? escapeXml(p.description) : `The ${humanize(p.name)}.`;
55
+ lines.push(` /// <param name="${localName(p.name)}">${paramDesc}</param>`);
56
+ }
57
+ lines.push(` /// <param name="options">Request options.</param>`);
58
+ lines.push(` /// <param name="requestOptions">Per-request configuration overrides.</param>`);
59
+ lines.push(` /// <param name="cancellationToken">Cancellation token.</param>`);
60
+ if (responseType) {
61
+ lines.push(` /// <returns>The <see cref="${responseType}"/> result.</returns>`);
62
+ }
63
+
64
+ // Signature
65
+ const sigParams: string[] = [];
66
+ for (const p of sortPathParamsByTemplateOrder(op)) {
67
+ sigParams.push(`string ${localName(p.name)}`);
68
+ }
69
+ sigParams.push(`${optionsClass} options`);
70
+ sigParams.push('RequestOptions? requestOptions = null');
71
+ sigParams.push('CancellationToken cancellationToken = default');
72
+
73
+ const returnType = responseType ? `Task<${responseType}>` : 'Task';
74
+ lines.push(` public async ${returnType} ${method}(${sigParams.join(', ')})`);
75
+ lines.push(' {');
76
+
77
+ // Set defaults on options
78
+ for (const [key, value] of Object.entries(wrapper.defaults)) {
79
+ lines.push(` options.${csFieldName(key)} = ${csLiteral(value)};`);
80
+ }
81
+
82
+ // Set inferred fields from client. ClientId is required: fail loudly via RequireClientId()
83
+ // so that callers who forgot to configure it get a clear error instead of a 422 from the API.
84
+ for (const field of wrapper.inferFromClient) {
85
+ if (field === 'client_id') {
86
+ lines.push(` options.${csFieldName(field)} = this.Client.RequireClientId();`);
87
+ } else {
88
+ lines.push(
89
+ ` options.${csFieldName(field)} = this.Client.${clientFieldExpression(field)} ?? string.Empty;`,
90
+ );
91
+ }
92
+ }
93
+
94
+ // Build path
95
+ let pathExpr: string;
96
+ if (op.pathParams.length > 0) {
97
+ let interpolated = op.path;
98
+ for (const p of sortPathParamsByTemplateOrder(op)) {
99
+ interpolated = interpolated.replace(`{${p.name}}`, `{${localName(p.name)}}`);
100
+ }
101
+ pathExpr = `$"${interpolated}"`;
102
+ } else {
103
+ pathExpr = `"${op.path}"`;
104
+ }
105
+
106
+ // Use the Service base-class helper so wrappers read as one-liners.
107
+ const helper = httpMethodHelperName(op.httpMethod);
108
+ if (responseType) {
109
+ lines.push(
110
+ ` return await this.${helper}<${responseType}>(${pathExpr}, options, requestOptions, cancellationToken);`,
111
+ );
112
+ } else if (helper === 'DeleteAsync') {
113
+ lines.push(` await this.${helper}(${pathExpr}, options, requestOptions, cancellationToken);`);
114
+ } else {
115
+ lines.push(` await this.${helper}<object>(${pathExpr}, options, requestOptions, cancellationToken);`);
116
+ }
117
+
118
+ lines.push(' }');
119
+ }
120
+
121
+ // NOTE: T26 (wrapper DRY) — the AuthenticateWith* wrappers share a small
122
+ // SendAuthenticateAsync helper at runtime to avoid 8x copies of the same
123
+ // MakeAPIRequest call. The helper itself lives in UserManagementService.cs as
124
+ // a hand-maintained method (it can't easily be expressed as a generic because
125
+ // the eight options classes don't share an interface). Keeping each generated
126
+ // wrapper's body short is the practical part of the DRY win.
127
+
128
+ /**
129
+ * Generate wrapper options classes. Called from resources.ts options generation.
130
+ */
131
+ export function generateWrapperOptionsClasses(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
132
+ if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
133
+
134
+ const lines: string[] = [];
135
+
136
+ for (const wrapper of resolvedOp.wrappers) {
137
+ const wrapperParams = resolveWrapperParams(wrapper, ctx);
138
+ const optionsClass = `${csMethodName(wrapper.name)}Options`;
139
+
140
+ lines.push('');
141
+ lines.push(` public class ${optionsClass} : BaseOptions`);
142
+ lines.push(' {');
143
+
144
+ // Exposed params
145
+ for (const { paramName, field, isOptional } of wrapperParams) {
146
+ const csField = csFieldName(paramName);
147
+ const csType = field ? resolveSimpleCsType(field.type, isOptional) : isOptional ? 'string?' : 'string';
148
+ const needsDefault = !isOptional && !csType.endsWith('?') && !(field && isValueTypeRef(field.type));
149
+ const initializer = needsDefault ? ' = default!;' : '';
150
+
151
+ const isRequiredEnum = !isOptional && !!field && isEnumRef(field.type);
152
+ lines.push(...emitXmlDoc(field?.description, ' '));
153
+ lines.push(...emitJsonPropertyAttributes(paramName, { isRequiredEnum }));
154
+ lines.push(` public ${csType} ${csField} { get; set; }${initializer}`);
155
+ lines.push('');
156
+ }
157
+
158
+ // Hidden fields (defaults + inferred)
159
+ for (const key of Object.keys(wrapper.defaults)) {
160
+ const csField = csFieldName(key);
161
+ lines.push(` [JsonProperty("${key}")]`);
162
+ lines.push(` [STJS.JsonPropertyName("${key}")]`);
163
+ lines.push(` internal string ${csField} { get; set; } = default!;`);
164
+ lines.push('');
165
+ }
166
+ for (const key of wrapper.inferFromClient) {
167
+ const csField = csFieldName(key);
168
+ // Skip if already added as a default
169
+ if (Object.keys(wrapper.defaults).includes(key)) continue;
170
+ lines.push(` [JsonProperty("${key}")]`);
171
+ lines.push(` [STJS.JsonPropertyName("${key}")]`);
172
+ lines.push(` internal string ${csField} { get; set; } = default!;`);
173
+ lines.push('');
174
+ }
175
+
176
+ lines.push(' }');
177
+ }
178
+
179
+ return lines;
180
+ }
181
+
182
+ function resolveSimpleCsType(ref: any, isOptional: boolean): string {
183
+ const base = mapTypeRef(ref);
184
+ if (isOptional && !base.endsWith('?')) return `${base}?`;
185
+ return base;
186
+ }
package/src/go/index.ts CHANGED
@@ -10,7 +10,7 @@ import type {
10
10
  } from '@workos/oagen';
11
11
 
12
12
  import { generateModels } from './models.js';
13
- import { enrichModelsFromSpec } from '../shared/model-utils.js';
13
+ import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
14
14
  import { generateEnums } from './enums.js';
15
15
  import { generateResources } from './resources.js';
16
16
  import { generateClient } from './client.js';
@@ -37,7 +37,10 @@ export const goEmitter: Emitter = {
37
37
  },
38
38
 
39
39
  generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
40
- return ensureTrailingNewlines(generateEnums(enums, ctx));
40
+ // Merge synthetic enums produced during model enrichment (inline oneOf
41
+ // definitions) so they get proper type definitions in enums.go.
42
+ const syntheticEnums = getSyntheticEnums();
43
+ return ensureTrailingNewlines(generateEnums([...enums, ...syntheticEnums], ctx));
41
44
  },
42
45
 
43
46
  generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
package/src/go/naming.ts CHANGED
@@ -1,24 +1,15 @@
1
1
  import type { Operation, Service, EmitterContext } from '@workos/oagen';
2
2
  import { toPascalCase, toSnakeCase } from '@workos/oagen';
3
3
  import { buildResolvedLookup, lookupMethodName, getMountTarget } from '../shared/resolved-ops.js';
4
- import { stripUrnPrefix } from '../shared/naming-utils.js';
4
+ import { stripUrnPrefix, applyAcronymFixes } from '../shared/naming-utils.js';
5
5
 
6
6
  /**
7
- * Acronym map: after PascalCase conversion, fix known acronyms to all-caps Go convention.
7
+ * Go-specific acronym extensions beyond the shared base set.
8
+ * Go convention requires ALL_CAPS for well-known initialisms.
8
9
  */
9
- const ACRONYM_FIXES: [RegExp, string][] = [
10
- [/Workos/g, 'WorkOS'],
11
- [/Sso/g, 'SSO'],
12
- [/Mfa/g, 'MFA'],
10
+ const GO_EXTRA_ACRONYM_FIXES: [RegExp, string][] = [
13
11
  [/Jwks(?=[A-Z]|$)/g, 'JWKS'],
14
- [/Jwt/g, 'JWT'],
15
12
  [/Totp(?=[A-Z]|$)/g, 'TOTP'],
16
- [/Cors/g, 'CORS'],
17
- [/Saml/g, 'SAML'],
18
- [/Scim/g, 'SCIM'],
19
- [/Rbac/g, 'RBAC'],
20
- [/Oauth/g, 'OAuth'],
21
- [/Oidc/g, 'OIDC'],
22
13
  [/Api(?=[A-Z]|$)/g, 'API'],
23
14
  [/Urls(?=[A-Z]|$)/g, 'URLs'],
24
15
  [/Url(?=[A-Z]|$)/g, 'URL'],
@@ -45,10 +36,7 @@ function fixTrailingId(s: string): string {
45
36
 
46
37
  /** Apply all Go acronym conventions to a PascalCase string. */
47
38
  function applyAcronyms(s: string): string {
48
- let result = s;
49
- for (const [pattern, replacement] of ACRONYM_FIXES) {
50
- result = result.replace(pattern, replacement);
51
- }
39
+ let result = applyAcronymFixes(s, GO_EXTRA_ACRONYM_FIXES);
52
40
  result = fixTrailingId(result);
53
41
  return result;
54
42
  }
package/src/index.ts CHANGED
@@ -2,3 +2,4 @@ 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';
@@ -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
+ }