@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
@@ -0,0 +1,228 @@
1
+ import type { TypeRef, PrimitiveType, UnionType } from '@workos/oagen';
2
+ import { mapTypeRef as irMapTypeRef } from '@workos/oagen';
3
+ import { className, modelClassName } 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
+ /**
25
+ * Module-level alias map for structurally-identical models. Populated by
26
+ * `setModelAliases` from model deduplication; consulted by `mapTypeRef` so
27
+ * that every reference to a duplicate model resolves to the canonical name.
28
+ */
29
+ const modelAliases = new Map<string, string>();
30
+
31
+ /** Replace the current enum-alias map. Safe to call more than once. */
32
+ export function setEnumAliases(aliases: Map<string, string>): void {
33
+ enumAliases.clear();
34
+ for (const [k, v] of aliases) enumAliases.set(k, v);
35
+ }
36
+
37
+ /** Replace the current model-alias map. Safe to call more than once. */
38
+ export function setModelAliases(aliases: Map<string, string>): void {
39
+ modelAliases.clear();
40
+ for (const [k, v] of aliases) modelAliases.set(k, v);
41
+ }
42
+
43
+ /** Check if a model name is an alias (i.e., structurally identical to another model). */
44
+ export function isModelAlias(name: string): boolean {
45
+ return modelAliases.has(name);
46
+ }
47
+
48
+ /** Resolve a model name to its canonical form (identity if not an alias). */
49
+ export function resolveModelName(name: string): string {
50
+ return modelAliases.get(name) ?? name;
51
+ }
52
+
53
+ /** Replace the set of enum names that are single-value discriminator stand-ins. */
54
+ export function setSingleValueEnumNames(names: Iterable<string>): void {
55
+ singleValueEnumNames.clear();
56
+ for (const n of names) singleValueEnumNames.add(n);
57
+ }
58
+
59
+ /** Resolve an enum reference name to its canonical form. */
60
+ export function resolveEnumTypeName(name: string): string {
61
+ return enumAliases.get(name) ?? name;
62
+ }
63
+
64
+ /**
65
+ * Map an IR TypeRef to a C# type string.
66
+ */
67
+ export function mapTypeRef(ref: TypeRef): string {
68
+ return irMapTypeRef<string>(ref, {
69
+ primitive: mapPrimitive,
70
+ array: (_ref, items) => `List<${items}>`,
71
+ model: (r) => modelClassName(modelAliases.get(r.name) ?? r.name),
72
+ enum: (r) => {
73
+ // Single-value enums (discriminator consts in disguise) map to `string`
74
+ // so the caller can't misuse a public one-member enum type. The
75
+ // owning property emits a const initializer separately.
76
+ if ((r.values?.length ?? 0) === 1) return 'string';
77
+ if (singleValueEnumNames.has(r.name)) return 'string';
78
+ return className(resolveEnumTypeName(r.name));
79
+ },
80
+ union: (_r, variants) => joinUnionVariants(_r, variants),
81
+ nullable: (_ref, inner) => {
82
+ // With <Nullable>enable</Nullable>, all nullable types need `?`
83
+ if (inner.endsWith('?')) return inner; // already nullable (e.g., nested nullable)
84
+ return `${inner}?`;
85
+ },
86
+ literal: (r) => {
87
+ if (r.value === null) return 'object';
88
+ if (typeof r.value === 'string') return 'string';
89
+ if (typeof r.value === 'number') return Number.isInteger(r.value) ? 'int' : 'double';
90
+ if (typeof r.value === 'boolean') return 'bool';
91
+ return 'object';
92
+ },
93
+ map: (_ref, value) => `Dictionary<string, ${value}>`,
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Map an IR TypeRef to a C# type string, making optional fields nullable.
99
+ * For value types, appends `?`. For reference types, returns as-is.
100
+ */
101
+ export function mapTypeRefOptional(ref: TypeRef): string {
102
+ const baseType = mapTypeRef(ref);
103
+ if (isValueType(baseType)) return `${baseType}?`;
104
+ return baseType;
105
+ }
106
+
107
+ /**
108
+ * Check if a C# type is a value type (needs ? for nullable).
109
+ */
110
+ export function isValueType(csType: string): boolean {
111
+ // Strip trailing ? if present
112
+ const bare = csType.endsWith('?') ? csType.slice(0, -1) : csType;
113
+ if (VALUE_TYPES.has(bare)) return true;
114
+ // Enums are value types, but we can't detect them purely from the type string.
115
+ // The caller should handle enum nullability explicitly when needed.
116
+ return false;
117
+ }
118
+
119
+ /**
120
+ * Check if an IR TypeRef maps to a C# value type.
121
+ */
122
+ export function isValueTypeRef(ref: TypeRef): boolean {
123
+ if (ref.kind === 'enum') return true;
124
+ if (ref.kind === 'primitive') {
125
+ // DateTimeOffset is a value type (struct)
126
+ if (ref.format === 'date-time') return true;
127
+ switch (ref.type) {
128
+ case 'integer':
129
+ case 'number':
130
+ case 'boolean':
131
+ return true;
132
+ default:
133
+ return false;
134
+ }
135
+ }
136
+ return false;
137
+ }
138
+
139
+ /**
140
+ * Whether a TypeRef directly names an enum (no nullable wrapper).
141
+ * Used to detect required enum request fields that must not silently serialize
142
+ * their default Unknown sentinel.
143
+ */
144
+ export function isEnumRef(ref: TypeRef): boolean {
145
+ return ref.kind === 'enum';
146
+ }
147
+
148
+ /**
149
+ * Emit JSON attributes for a request-side property. Property name mapping is
150
+ * handled by a global SnakeCaseLower / SnakeCaseNamingStrategy configuration
151
+ * on both serializers, so per-property name attributes are not emitted.
152
+ *
153
+ * When `isRequiredEnum` is true, configure both serializers to skip the field
154
+ * when its value equals the enum default (0 = Unknown sentinel). This converts
155
+ * "unset required enum" from a silent `"unknown"` wire value into a clean
156
+ * omission so the API returns a clear `missing required field` error instead
157
+ * of a confusing 422.
158
+ */
159
+ export function emitJsonPropertyAttributes(_wireName: string, options: { isRequiredEnum?: boolean } = {}): string[] {
160
+ if (options.isRequiredEnum) {
161
+ return [
162
+ ` [JsonProperty(DefaultValueHandling = DefaultValueHandling.Ignore)]`,
163
+ ` [STJS.JsonIgnore(Condition = STJS.JsonIgnoreCondition.WhenWritingDefault)]`,
164
+ ];
165
+ }
166
+ // Convention-based: SnakeCaseLower naming policy handles the name mapping.
167
+ return [];
168
+ }
169
+
170
+ function mapPrimitive(ref: PrimitiveType): string {
171
+ if (ref.format === 'binary') return 'byte[]';
172
+ if (ref.format === 'int32') return 'int';
173
+ if (ref.format === 'int64') return 'long';
174
+ if (ref.format === 'date-time') return 'DateTimeOffset';
175
+ switch (ref.type) {
176
+ case 'string':
177
+ return 'string';
178
+ case 'integer':
179
+ return 'long';
180
+ case 'number':
181
+ return 'double';
182
+ case 'boolean':
183
+ return 'bool';
184
+ case 'unknown':
185
+ return 'object';
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Track discriminated unions for downstream model generation.
191
+ * Key = generated base type name, Value = discriminator info.
192
+ */
193
+ export const discriminatedUnions = new Map<
194
+ string,
195
+ { property: string; mapping: Record<string, string>; variantTypes: string[] }
196
+ >();
197
+
198
+ function joinUnionVariants(_ref: UnionType, variants: string[]): string {
199
+ if (_ref.compositionKind === 'allOf') {
200
+ return variants[0] ?? 'object';
201
+ }
202
+ const unique = [...new Set(variants)];
203
+ if (unique.length === 1) return unique[0];
204
+
205
+ // Discriminated union: register for converter generation and return first variant as base
206
+ if (_ref.discriminator && _ref.discriminator.mapping) {
207
+ const baseName = unique[0];
208
+ discriminatedUnions.set(baseName, {
209
+ property: _ref.discriminator.property,
210
+ mapping: _ref.discriminator.mapping,
211
+ variantTypes: unique,
212
+ });
213
+ // Use object with JsonConverter for discriminated unions since
214
+ // AnyOf<> doesn't support discriminator-based deserialization
215
+ return 'object';
216
+ }
217
+
218
+ if (unique.length >= 2 && unique.length <= 9) return `OneOf.OneOf<${unique.join(', ')}>`;
219
+ // OneOf supports arity 2-9. Higher-arity unions collapse to `object`,
220
+ // losing type information. Warn so the author knows the spec outgrew the
221
+ // runtime support instead of silently degrading.
222
+ if (unique.length >= 10) {
223
+ console.warn(
224
+ `[oagen:dotnet] Union with ${unique.length} variants exceeds OneOf<T0..T8> arity; falling back to object. Variants: ${unique.join(', ')}`,
225
+ );
226
+ }
227
+ return 'object';
228
+ }
@@ -0,0 +1,197 @@
1
+ import type { EmitterContext, ResolvedOperation, ResolvedWrapper } from '@workos/oagen';
2
+ import {
3
+ fieldName as csFieldName,
4
+ methodName as csMethodName,
5
+ localName,
6
+ csLiteral,
7
+ clientFieldExpression,
8
+ httpMethodHelperName,
9
+ escapeXml,
10
+ emitXmlDoc,
11
+ humanize,
12
+ appendAsyncSuffix,
13
+ modelClassName,
14
+ } from './naming.js';
15
+ import { sortPathParamsByTemplateOrder } from './resources.js';
16
+ import { resolveWrapperParams, formatWrapperDescription, type ResolvedWrapperParam } from '../shared/wrapper-utils.js';
17
+ import { mapTypeRef, isValueTypeRef, isEnumRef, emitJsonPropertyAttributes } from './type-map.js';
18
+
19
+ /**
20
+ * Generate C# wrapper method lines for union split operations.
21
+ */
22
+ export function generateWrapperMethods(
23
+ _serviceType: string,
24
+ resolvedOp: ResolvedOperation,
25
+ ctx: EmitterContext,
26
+ ): string[] {
27
+ if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
28
+
29
+ const lines: string[] = [];
30
+
31
+ for (const wrapper of resolvedOp.wrappers) {
32
+ const wrapperParams = resolveWrapperParams(wrapper, ctx);
33
+ lines.push('');
34
+ emitWrapperMethod(lines, resolvedOp, wrapper, wrapperParams, ctx);
35
+ }
36
+
37
+ return lines;
38
+ }
39
+
40
+ function emitWrapperMethod(
41
+ lines: string[],
42
+ resolvedOp: ResolvedOperation,
43
+ wrapper: ResolvedWrapper,
44
+ _wrapperParams: ResolvedWrapperParam[],
45
+ _ctx: EmitterContext,
46
+ ): void {
47
+ const op = resolvedOp.operation;
48
+ const methodStem = csMethodName(wrapper.name);
49
+ const method = appendAsyncSuffix(methodStem);
50
+ const optionsClass = `${methodStem}Options`;
51
+ const responseType = wrapper.responseModelName ? modelClassName(wrapper.responseModelName) : null;
52
+
53
+ // XML doc
54
+ lines.push(` /// <summary>${formatWrapperDescription(wrapper.name)}.</summary>`);
55
+ for (const p of sortPathParamsByTemplateOrder(op)) {
56
+ const paramDesc = p.description ? escapeXml(p.description) : `The ${humanize(p.name)}.`;
57
+ lines.push(` /// <param name="${localName(p.name)}">${paramDesc}</param>`);
58
+ }
59
+ lines.push(` /// <param name="options">Request options.</param>`);
60
+ lines.push(` /// <param name="requestOptions">Per-request configuration overrides.</param>`);
61
+ lines.push(` /// <param name="cancellationToken">Cancellation token.</param>`);
62
+ if (responseType) {
63
+ lines.push(` /// <returns>The <see cref="${responseType}"/> result.</returns>`);
64
+ }
65
+
66
+ // Signature
67
+ const sigParams: string[] = [];
68
+ const argNames: string[] = [];
69
+ for (const p of sortPathParamsByTemplateOrder(op)) {
70
+ const name = localName(p.name);
71
+ sigParams.push(`string ${name}`);
72
+ argNames.push(name);
73
+ }
74
+ sigParams.push(`${optionsClass} options`);
75
+ argNames.push('options');
76
+ sigParams.push('RequestOptions? requestOptions = null');
77
+ argNames.push('requestOptions');
78
+ sigParams.push('CancellationToken cancellationToken = default');
79
+ argNames.push('cancellationToken');
80
+
81
+ const returnType = responseType ? `Task<${responseType}>` : 'Task';
82
+ lines.push(` public async ${returnType} ${method}(${sigParams.join(', ')})`);
83
+ lines.push(' {');
84
+
85
+ // Set defaults on options
86
+ for (const [key, value] of Object.entries(wrapper.defaults)) {
87
+ lines.push(` options.${csFieldName(key)} = ${csLiteral(value)};`);
88
+ }
89
+
90
+ // Set inferred fields from client. ClientId is required: fail loudly via RequireClientId()
91
+ // so that callers who forgot to configure it get a clear error instead of a 422 from the API.
92
+ for (const field of wrapper.inferFromClient) {
93
+ if (field === 'client_id') {
94
+ lines.push(` options.${csFieldName(field)} = this.Client.RequireClientId();`);
95
+ } else {
96
+ lines.push(
97
+ ` options.${csFieldName(field)} = this.Client.${clientFieldExpression(field)} ?? string.Empty;`,
98
+ );
99
+ }
100
+ }
101
+
102
+ // Build path
103
+ let pathExpr: string;
104
+ if (op.pathParams.length > 0) {
105
+ let interpolated = op.path;
106
+ for (const p of sortPathParamsByTemplateOrder(op)) {
107
+ interpolated = interpolated.replace(`{${p.name}}`, `{Uri.EscapeDataString(${localName(p.name)})}`);
108
+ }
109
+ pathExpr = `$"${interpolated}"`;
110
+ } else {
111
+ pathExpr = `"${op.path}"`;
112
+ }
113
+
114
+ // Use the Service base-class helper so wrappers read as one-liners.
115
+ const helper = httpMethodHelperName(op.httpMethod);
116
+ if (responseType) {
117
+ lines.push(
118
+ ` return await this.${helper}<${responseType}>(${pathExpr}, options, requestOptions, cancellationToken);`,
119
+ );
120
+ } else if (helper === 'DeleteAsync') {
121
+ lines.push(` await this.${helper}(${pathExpr}, options, requestOptions, cancellationToken);`);
122
+ } else {
123
+ lines.push(` await this.${helper}<object>(${pathExpr}, options, requestOptions, cancellationToken);`);
124
+ }
125
+
126
+ lines.push(' }');
127
+
128
+ lines.push('');
129
+ lines.push(` /// <summary>Compatibility wrapper for <see cref="${method}"/>.</summary>`);
130
+ lines.push(` public Task${responseType ? `<${responseType}>` : ''} ${methodStem}(${sigParams.join(', ')})`);
131
+ lines.push(' {');
132
+ lines.push(` return this.${method}(${argNames.join(', ')});`);
133
+ lines.push(' }');
134
+ }
135
+
136
+ // NOTE: T26 (wrapper DRY) — the AuthenticateWith* wrappers share a small
137
+ // SendAuthenticateAsync helper at runtime to avoid 8x copies of the same
138
+ // MakeAPIRequest call. The helper itself lives in UserManagementService.cs as
139
+ // a hand-maintained method (it can't easily be expressed as a generic because
140
+ // the eight options classes don't share an interface). Keeping each generated
141
+ // wrapper's body short is the practical part of the DRY win.
142
+
143
+ /**
144
+ * Generate wrapper options classes. Called from resources.ts options generation.
145
+ */
146
+ export function generateWrapperOptionsClasses(resolvedOp: ResolvedOperation, ctx: EmitterContext): string[] {
147
+ if (!resolvedOp.wrappers || resolvedOp.wrappers.length === 0) return [];
148
+
149
+ const lines: string[] = [];
150
+
151
+ for (const wrapper of resolvedOp.wrappers) {
152
+ const wrapperParams = resolveWrapperParams(wrapper, ctx);
153
+ const optionsClass = `${csMethodName(wrapper.name)}Options`;
154
+
155
+ lines.push('');
156
+ lines.push(` public class ${optionsClass} : BaseOptions`);
157
+ lines.push(' {');
158
+
159
+ // Exposed params
160
+ for (const { paramName, field, isOptional } of wrapperParams) {
161
+ const csField = csFieldName(paramName);
162
+ const csType = field ? resolveSimpleCsType(field.type, isOptional) : isOptional ? 'string?' : 'string';
163
+ const needsDefault = !isOptional && !csType.endsWith('?') && !(field && isValueTypeRef(field.type));
164
+ const initializer = needsDefault ? ' = default!;' : '';
165
+
166
+ const isRequiredEnum = !isOptional && !!field && isEnumRef(field.type);
167
+ lines.push(...emitXmlDoc(field?.description, ' '));
168
+ lines.push(...emitJsonPropertyAttributes(paramName, { isRequiredEnum }));
169
+ lines.push(` public ${csType} ${csField} { get; set; }${initializer}`);
170
+ lines.push('');
171
+ }
172
+
173
+ // Hidden fields (defaults + inferred)
174
+ for (const key of Object.keys(wrapper.defaults)) {
175
+ const csField = csFieldName(key);
176
+ lines.push(` internal string ${csField} { get; set; } = default!;`);
177
+ lines.push('');
178
+ }
179
+ for (const key of wrapper.inferFromClient) {
180
+ const csField = csFieldName(key);
181
+ // Skip if already added as a default
182
+ if (Object.keys(wrapper.defaults).includes(key)) continue;
183
+ lines.push(` internal string ${csField} { get; set; } = default!;`);
184
+ lines.push('');
185
+ }
186
+
187
+ lines.push(' }');
188
+ }
189
+
190
+ return lines;
191
+ }
192
+
193
+ function resolveSimpleCsType(ref: any, isOptional: boolean): string {
194
+ const base = mapTypeRef(ref);
195
+ if (isOptional && !base.endsWith('?')) return `${base}?`;
196
+ return base;
197
+ }
package/src/go/client.ts CHANGED
@@ -2,8 +2,9 @@ import type { ApiSpec, EmitterContext, GeneratedFile, Service } from '@workos/oa
2
2
  import { toPascalCase, toSnakeCase } from '@workos/oagen';
3
3
  // naming utilities used indirectly via resolveResourceClassName
4
4
  import { resolveResourceClassName } from './resources.js';
5
- import { unexportedName } from './naming.js';
5
+ import { className, unexportedName } from './naming.js';
6
6
  import { getMountTarget } from '../shared/resolved-ops.js';
7
+ import { NON_SPEC_SERVICES } from '../shared/non-spec-services.js';
7
8
 
8
9
  /**
9
10
  * Generate the Go client file with service accessors.
@@ -15,6 +16,16 @@ export function generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFil
15
16
  return [generateWorkOSFile(spec, ctx)];
16
17
  }
17
18
 
19
+ /**
20
+ * Non-spec services marked with `hasClientAccessor: true` (passwordless, vault)
21
+ * are included in the generated Client struct, constructor, and accessor methods
22
+ * — identical to spec-driven services. Their service type (e.g. PasswordlessService)
23
+ * is defined in a hand-written @oagen-ignore-file, but the Client wiring is generated.
24
+ *
25
+ * Other non-spec modules (webhook_verification, actions, etc.) remain fully
26
+ * self-contained in their @oagen-ignore-file files.
27
+ */
28
+
18
29
  /**
19
30
  * Deduplicate services by mount target.
20
31
  */
@@ -72,6 +83,8 @@ function generateWorkOSFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
72
83
  lines.push('\tbaseURL string');
73
84
  lines.push('\thttpClient *http.Client');
74
85
  lines.push('\tmaxRetries int');
86
+ lines.push('\tlogger Logger');
87
+ lines.push('\tappInfo appInfo');
75
88
  lines.push('');
76
89
  // Service fields
77
90
  for (const service of topLevel) {
@@ -80,6 +93,11 @@ function generateWorkOSFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
80
93
  const serviceTypeName = serviceType(resolvedName);
81
94
  lines.push(`\t${fieldNameStr} *${serviceTypeName}`);
82
95
  }
96
+ // Non-spec service fields (hand-written types, generated wiring)
97
+ for (const ns of NON_SPEC_SERVICES.filter((s) => s.hasClientAccessor)) {
98
+ const name = className(toPascalCase(ns.id));
99
+ lines.push(`\t${unexportedName(name)} *${serviceType(name)}`);
100
+ }
83
101
  lines.push('}');
84
102
  lines.push('');
85
103
 
@@ -102,6 +120,11 @@ function generateWorkOSFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
102
120
  const serviceTypeName = serviceType(resolvedName);
103
121
  lines.push(`\tc.${fieldNameStr} = &${serviceTypeName}{client: c}`);
104
122
  }
123
+ // Initialize non-spec services
124
+ for (const ns of NON_SPEC_SERVICES.filter((s) => s.hasClientAccessor)) {
125
+ const name = className(toPascalCase(ns.id));
126
+ lines.push(`\tc.${unexportedName(name)} = &${serviceType(name)}{client: c}`);
127
+ }
105
128
  lines.push('\treturn c');
106
129
  lines.push('}');
107
130
  lines.push('');
@@ -118,7 +141,16 @@ function generateWorkOSFile(spec: ApiSpec, ctx: EmitterContext): GeneratedFile {
118
141
  lines.push('}');
119
142
  lines.push('');
120
143
  }
121
-
144
+ // Non-spec service accessor methods
145
+ for (const ns of NON_SPEC_SERVICES.filter((s) => s.hasClientAccessor)) {
146
+ const name = className(toPascalCase(ns.id));
147
+ const typeName = serviceType(name);
148
+ lines.push(`// ${name} returns the ${name} service.`);
149
+ lines.push(`func (c *Client) ${name}() *${typeName} {`);
150
+ lines.push(`\treturn c.${unexportedName(name)}`);
151
+ lines.push('}');
152
+ lines.push('');
153
+ }
122
154
  return {
123
155
  path: `${ctx.namespace}.go`,
124
156
  content: lines.join('\n'),
@@ -137,5 +169,5 @@ function singularizePascal(name: string): string {
137
169
  }
138
170
 
139
171
  function serviceType(name: string): string {
140
- return `${unexportedName(singularizePascal(name))}Service`;
172
+ return `${className(singularizePascal(name))}Service`;
141
173
  }
package/src/go/enums.ts CHANGED
@@ -29,6 +29,10 @@ export function generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile
29
29
  if (canonicalName) {
30
30
  const aliasType = className(enumDef.name);
31
31
  const canonicalType = className(canonicalName);
32
+ // Skip when different IR names map to the same Go type (e.g. synthetic
33
+ // enums from enrichModelsFromSpec whose underscore names collapse to the
34
+ // same PascalCase as the original enum).
35
+ if (aliasType === canonicalType) continue;
32
36
  lines.push(`// ${aliasType} is an alias for ${canonicalType}.`);
33
37
  lines.push(`type ${aliasType} = ${canonicalType}`);
34
38
  lines.push('');
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[] {
@@ -69,12 +72,17 @@ export const goEmitter: Emitter = {
69
72
  return '// Code generated by oagen. DO NOT EDIT.';
70
73
  },
71
74
 
72
- formatCommand(targetDir: string): FormatCommand | null {
73
- // Pass targetDir as the first path so gofmt formats the entire directory
74
- // (including hand-maintained files), not just the generated file list.
75
+ formatCommand(_targetDir: string): FormatCommand | null {
76
+ // oagen appends all generated file paths (including .json fixtures) to the
77
+ // format command. gofmt errors on non-.go files, so filter them out.
78
+ // Same pattern as the Python emitter's ruff wrapper.
75
79
  return {
76
- cmd: 'gofmt',
77
- args: ['-w', targetDir],
80
+ cmd: 'bash',
81
+ args: [
82
+ '-c',
83
+ 'GO_FILES=$(printf "%s\\n" "$@" | grep "\\.go$"); [ -n "$GO_FILES" ] && echo "$GO_FILES" | xargs gofmt -w',
84
+ '--',
85
+ ],
78
86
  batchSize: 999999,
79
87
  };
80
88
  },
package/src/go/models.ts CHANGED
@@ -59,9 +59,14 @@ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedF
59
59
  if (batchedAliases.has(model.name)) continue;
60
60
 
61
61
  const canonicalStruct = className(canonicalName);
62
+ // Skip when different IR names map to the same Go type (e.g. synthetic
63
+ // models from enrichModelsFromSpec whose underscore names collapse to the
64
+ // same PascalCase as the original model).
65
+ if (structName === canonicalStruct) continue;
66
+
62
67
  const hash = modelHashMap.get(model.name)!;
63
68
  const groupNames = hashGroups.get(hash) ?? [];
64
- const aliases = groupNames.filter((n) => aliasOf.has(n));
69
+ const aliases = groupNames.filter((n) => aliasOf.has(n) && className(n) !== className(aliasOf.get(n)!));
65
70
 
66
71
  if (aliases.length >= 5) {
67
72
  // Batch emit all aliases for this group at once
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
  }