@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,248 @@
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 * as fs from 'node:fs';
12
+ import * as path from 'node:path';
13
+
14
+ import { generateModels, primeModelAliases } from './models.js';
15
+ import { enrichModelsFromSpec, getSyntheticEnums } from '../shared/model-utils.js';
16
+ import { generateEnums, primeEnumAliases } from './enums.js';
17
+ import { generateResources } from './resources.js';
18
+ import { generateClient } from './client.js';
19
+ import { generateTests } from './tests.js';
20
+ import { generateManifest } from './manifest.js';
21
+ import { generateWrapperOptionsClasses } from './wrappers.js';
22
+ import { groupByMount } from '../shared/resolved-ops.js';
23
+ import { discriminatedUnions } from './type-map.js';
24
+
25
+ /**
26
+ * Fix the namespace for C#. The CLI passes `--namespace workos` which gives
27
+ * namespacePascal = "Workos", but C# needs "WorkOS" (preserving the brand casing).
28
+ */
29
+ function fixNamespace(ctx: EmitterContext): EmitterContext {
30
+ if (ctx.namespace === 'workos' || ctx.namespacePascal === 'Workos') {
31
+ return { ...ctx, namespacePascal: 'WorkOS' };
32
+ }
33
+ return ctx;
34
+ }
35
+
36
+ /** Ensure every generated file's content ends with a trailing newline. */
37
+ function ensureTrailingNewlines(files: GeneratedFile[]): GeneratedFile[] {
38
+ for (const f of files) {
39
+ if (f.content && !f.content.endsWith('\n')) {
40
+ f.content += '\n';
41
+ }
42
+ }
43
+ return files;
44
+ }
45
+
46
+ /** Prefix for source files so they land under the .csproj directory. */
47
+ const SRC_PREFIX = 'src/WorkOS.net/';
48
+ /** Prefix for test files so they land under the test project directory. */
49
+ const TEST_PREFIX = 'test/WorkOSTests/';
50
+
51
+ /** Prefix generated source file paths to match the .NET project layout. */
52
+ function prefixSourcePaths(files: GeneratedFile[]): GeneratedFile[] {
53
+ for (const f of files) {
54
+ f.path = `${SRC_PREFIX}${f.path}`;
55
+ }
56
+ return files;
57
+ }
58
+
59
+ /** Prefix generated test/fixture paths to match the .NET test project layout. */
60
+ function prefixTestPaths(files: GeneratedFile[]): GeneratedFile[] {
61
+ for (const f of files) {
62
+ f.path = `${TEST_PREFIX}${f.path}`;
63
+ }
64
+ return files;
65
+ }
66
+
67
+ export const dotnetEmitter: Emitter = {
68
+ language: 'dotnet',
69
+
70
+ generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
71
+ const c = fixNamespace(ctx);
72
+ primeEnumAliases(c.spec.enums);
73
+ const enriched = enrichModelsFromSpec(models);
74
+ // Re-prime after enrichment so synthetic enums from oneOf branches are
75
+ // included in the alias map used by mapTypeRef during model emission.
76
+ const synEnumsForModels = getSyntheticEnums();
77
+ if (synEnumsForModels.length > 0) {
78
+ primeEnumAliases([...c.spec.enums, ...synEnumsForModels]);
79
+ }
80
+ const files = generateModels(enriched, c);
81
+
82
+ // Generate discriminator converters for oneOf unions with discriminator
83
+ if (discriminatedUnions.size > 0) {
84
+ for (const [baseName, disc] of discriminatedUnions) {
85
+ const converterName = `${baseName}DiscriminatorConverter`;
86
+ const lines: string[] = [];
87
+ lines.push(`namespace ${c.namespacePascal}`);
88
+ lines.push('{');
89
+ lines.push(' using System;');
90
+ lines.push(' using System.Text.Json;');
91
+ lines.push(' using System.Text.Json.Serialization;');
92
+ lines.push(' using Newtonsoft.Json;');
93
+ lines.push(' using Newtonsoft.Json.Linq;');
94
+ lines.push('');
95
+ lines.push(` /// <summary>`);
96
+ lines.push(` /// JSON converter that deserializes discriminated union variants`);
97
+ lines.push(` /// based on the "${disc.property}" property.`);
98
+ lines.push(` /// </summary>`);
99
+ lines.push(` public class ${converterName} : Newtonsoft.Json.JsonConverter`);
100
+ lines.push(' {');
101
+ lines.push(' public override bool CanConvert(Type objectType) => objectType == typeof(object);');
102
+ lines.push('');
103
+ lines.push(
104
+ ' public override object ReadJson(Newtonsoft.Json.JsonReader reader, Type objectType, object existingValue, Newtonsoft.Json.JsonSerializer serializer)',
105
+ );
106
+ lines.push(' {');
107
+ lines.push(' var jObject = JObject.Load(reader);');
108
+ lines.push(` var discriminatorValue = jObject["${disc.property}"]?.ToString();`);
109
+ lines.push(' switch (discriminatorValue)');
110
+ lines.push(' {');
111
+ for (const [value, modelName] of Object.entries(disc.mapping)) {
112
+ const csName = modelName.replace(/([a-z])([A-Z])/g, '$1$2');
113
+ lines.push(` case "${value}": return jObject.ToObject<${csName}>(serializer);`);
114
+ }
115
+ lines.push(' default: return jObject.ToObject<object>(serializer);');
116
+ lines.push(' }');
117
+ lines.push(' }');
118
+ lines.push('');
119
+ lines.push(
120
+ ' public override void WriteJson(Newtonsoft.Json.JsonWriter writer, object value, Newtonsoft.Json.JsonSerializer serializer)',
121
+ );
122
+ lines.push(' {');
123
+ lines.push(' serializer.Serialize(writer, value);');
124
+ lines.push(' }');
125
+ lines.push(' }');
126
+ lines.push('}');
127
+
128
+ files.push({
129
+ path: `Client/Utilities/${converterName}.cs`,
130
+ content: lines.join('\n'),
131
+ overwriteExisting: true,
132
+ });
133
+ }
134
+ }
135
+
136
+ return prefixSourcePaths(ensureTrailingNewlines(files));
137
+ },
138
+
139
+ generateEnums(enums: Enum[], ctx: EmitterContext): GeneratedFile[] {
140
+ const c = fixNamespace(ctx);
141
+ // Ensure synthetic enums are populated regardless of method execution order.
142
+ // enrichModelsFromSpec is idempotent (cached raw spec) and populates the
143
+ // module-level synthetic-enum store consumed by getSyntheticEnums().
144
+ enrichModelsFromSpec(c.spec.models);
145
+ const syntheticEnums = getSyntheticEnums();
146
+ const allEnums = syntheticEnums.length > 0 ? [...enums, ...syntheticEnums] : enums;
147
+ return prefixSourcePaths(ensureTrailingNewlines(generateEnums(allEnums, c)));
148
+ },
149
+
150
+ generateResources(services: Service[], ctx: EmitterContext): GeneratedFile[] {
151
+ const c = fixNamespace(ctx);
152
+ const synEnums = getSyntheticEnums();
153
+ primeEnumAliases(synEnums.length > 0 ? [...c.spec.enums, ...synEnums] : c.spec.enums);
154
+ primeModelAliases(enrichModelsFromSpec(c.spec.models));
155
+ const files = generateResources(services, c);
156
+
157
+ // Also generate wrapper options classes
158
+ const mountGroups = groupByMount(c);
159
+ for (const [, group] of mountGroups) {
160
+ for (const resolvedOp of group.resolvedOps) {
161
+ if (resolvedOp.wrappers && resolvedOp.wrappers.length > 0) {
162
+ const wrapperOptionsLines = generateWrapperOptionsClasses(resolvedOp, c);
163
+ if (wrapperOptionsLines.length > 0) {
164
+ const mountName = resolvedOp.mountOn;
165
+ const optionsPath = `Services/${mountName}/_interfaces/${mountName}WrapperOptions.cs`;
166
+ const content = [
167
+ `namespace ${c.namespacePascal}`,
168
+ '{',
169
+ ' using System.Collections.Generic;',
170
+ ' using Newtonsoft.Json;',
171
+ ' using STJS = System.Text.Json.Serialization;',
172
+ ...wrapperOptionsLines,
173
+ '}',
174
+ ].join('\n');
175
+ files.push({
176
+ path: optionsPath,
177
+ content,
178
+ overwriteExisting: true,
179
+ });
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ return prefixSourcePaths(ensureTrailingNewlines(files));
186
+ },
187
+
188
+ generateClient(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
189
+ const c = fixNamespace(ctx);
190
+ return prefixSourcePaths(ensureTrailingNewlines(generateClient(spec, c)));
191
+ },
192
+
193
+ generateErrors(): GeneratedFile[] {
194
+ return [];
195
+ },
196
+
197
+ generateTypeSignatures(): GeneratedFile[] {
198
+ return [];
199
+ },
200
+
201
+ generateTests(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
202
+ const c = fixNamespace(ctx);
203
+ const synEnumsForTests = getSyntheticEnums();
204
+ primeEnumAliases(synEnumsForTests.length > 0 ? [...spec.enums, ...synEnumsForTests] : spec.enums);
205
+ primeModelAliases(enrichModelsFromSpec(c.spec.models));
206
+ return prefixTestPaths(ensureTrailingNewlines(generateTests(spec, c)));
207
+ },
208
+
209
+ generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
210
+ return ensureTrailingNewlines(generateManifest(spec, fixNamespace(ctx)));
211
+ },
212
+
213
+ fileHeader(): string {
214
+ return '// This file is auto-generated by oagen. Do not edit.';
215
+ },
216
+
217
+ formatCommand(targetDir: string): FormatCommand | null {
218
+ // `dotnet format` applies both whitespace rules and analyzer code fixes
219
+ // (StyleCop, etc.) to the generated files, matching the target project's
220
+ // conventions. We prefer a .sln/.slnx/.csproj workspace so MSBuild loads
221
+ // the analyzer ruleset correctly.
222
+ const workspace = findDotnetWorkspace(targetDir);
223
+ if (!workspace) return null;
224
+
225
+ // `dotnet format` expects `--include` paths relative to the workspace
226
+ // (or absolute). Our harness appends absolute paths, which is fine.
227
+ // Run `--no-restore` so formatting doesn't trigger a package restore on
228
+ // every codegen run.
229
+ return {
230
+ cmd: 'dotnet',
231
+ args: ['format', workspace, '--no-restore', '--include'],
232
+ // Keep batches small enough to stay under argv length limits while
233
+ // still amortizing MSBuild startup across many files.
234
+ batchSize: 500,
235
+ };
236
+ },
237
+ };
238
+
239
+ /** Locate a .sln/.slnx/.csproj file in the target directory for `dotnet format`. */
240
+ function findDotnetWorkspace(targetDir: string): string | null {
241
+ if (!fs.existsSync(targetDir)) return null;
242
+ const entries = fs.readdirSync(targetDir);
243
+ const sln = entries.find((e) => e.endsWith('.sln') || e.endsWith('.slnx'));
244
+ if (sln) return path.resolve(targetDir, sln);
245
+ const csproj = entries.find((e) => e.endsWith('.csproj'));
246
+ if (csproj) return path.resolve(targetDir, csproj);
247
+ return null;
248
+ }
@@ -0,0 +1,36 @@
1
+ import type { ApiSpec, EmitterContext, GeneratedFile } from '@workos/oagen';
2
+ import { resolveMethodName } from './naming.js';
3
+ import { buildServiceAccessPaths } from './client.js';
4
+ import { getMountTarget } from '../shared/resolved-ops.js';
5
+
6
+ /**
7
+ * Generate smoke test manifest mapping HTTP operations to SDK methods.
8
+ */
9
+ export function generateManifest(spec: ApiSpec, ctx: EmitterContext): GeneratedFile[] {
10
+ const manifest: Record<string, { sdkMethod: string; service: string }> = {};
11
+ const accessPaths = buildServiceAccessPaths(spec.services, ctx);
12
+
13
+ for (const service of spec.services) {
14
+ let propName = accessPaths.get(service.name);
15
+ if (!propName) {
16
+ const mountTarget = getMountTarget(service, ctx);
17
+ propName = accessPaths.get(mountTarget);
18
+ }
19
+ if (!propName) {
20
+ throw new Error(`Missing public client access path for service ${service.name}`);
21
+ }
22
+ for (const op of service.operations) {
23
+ const httpKey = `${op.httpMethod.toUpperCase()} ${op.path}`;
24
+ const method = resolveMethodName(op, service, ctx);
25
+ manifest[httpKey] = { sdkMethod: method, service: propName };
26
+ }
27
+ }
28
+
29
+ return [
30
+ {
31
+ path: 'smoke-manifest.json',
32
+ content: JSON.stringify(manifest, null, 2),
33
+ integrateTarget: false,
34
+ },
35
+ ];
36
+ }
@@ -0,0 +1,320 @@
1
+ import type { Model, EmitterContext, GeneratedFile, TypeRef } from '@workos/oagen';
2
+ import {
3
+ mapTypeRef,
4
+ isValueTypeRef,
5
+ isEnumRef,
6
+ emitJsonPropertyAttributes,
7
+ setModelAliases,
8
+ isModelAlias,
9
+ } from './type-map.js';
10
+ import {
11
+ articleFor,
12
+ fieldName,
13
+ humanize,
14
+ emitXmlDoc,
15
+ deprecationMessage,
16
+ escapeCsAttributeString,
17
+ modelClassName,
18
+ } from './naming.js';
19
+
20
+ // Import and re-export shared model detection utilities
21
+ import { isListWrapperModel, isListMetadataModel } from '../shared/model-utils.js';
22
+ export { isListWrapperModel, isListMetadataModel };
23
+
24
+ /**
25
+ * Generate C# model classes from IR Models.
26
+ * Each model becomes a separate .cs file under Services/{mount}/Entities/.
27
+ * For initial generation, all models go into a flat Entities/ directory.
28
+ */
29
+ export function generateModels(models: Model[], ctx: EmitterContext): GeneratedFile[] {
30
+ if (models.length === 0) return [];
31
+
32
+ // Build a lookup from enum name → single wire value for 1-value enums so
33
+ // we can emit a const initializer on the owning property without needing
34
+ // the full EnumRef.values payload (which the IR sometimes omits on refs).
35
+ const enumConstByName = new Map<string, string>();
36
+ for (const e of ctx.spec.enums) {
37
+ if (e.values.length === 1) {
38
+ enumConstByName.set(e.name, String(e.values[0].value));
39
+ }
40
+ }
41
+
42
+ const files: GeneratedFile[] = [];
43
+
44
+ // Compute and publish model aliases so mapTypeRef rewrites references.
45
+ primeModelAliases(models);
46
+
47
+ for (const model of models) {
48
+ if (isListWrapperModel(model) || isListMetadataModel(model)) continue;
49
+
50
+ const csClassName = modelClassName(model.name);
51
+
52
+ // Skip alias models — all references are already rewritten to the
53
+ // canonical type by mapTypeRef, so the alias class would be dead code.
54
+ if (isModelAlias(model.name)) continue;
55
+
56
+ const lines: string[] = [];
57
+ const fieldTypes = model.fields.map((f) => mapTypeRef(f.type));
58
+ const needsCollections = fieldTypes.some((t) => t.startsWith('List<') || t.startsWith('Dictionary<'));
59
+ const needsSystem = fieldTypes.some((t) => t.includes('DateTimeOffset'));
60
+ const needsJsonAttrs = model.fields.some((f) => f.required && isEnumRef(f.type));
61
+
62
+ lines.push(`namespace ${ctx.namespacePascal}`);
63
+ lines.push('{');
64
+ if (needsSystem) {
65
+ lines.push(' using System;');
66
+ }
67
+ if (needsCollections) {
68
+ lines.push(' using System.Collections.Generic;');
69
+ }
70
+ if (needsJsonAttrs) {
71
+ lines.push(' using Newtonsoft.Json;');
72
+ lines.push(' using STJS = System.Text.Json.Serialization;');
73
+ }
74
+ lines.push('');
75
+
76
+ // XML doc comment
77
+ if (model.description) {
78
+ lines.push(...emitXmlDoc(model.description, ' '));
79
+ } else {
80
+ const human = humanize(model.name);
81
+ lines.push(` /// <summary>Represents ${articleFor(human)} ${human}.</summary>`);
82
+ }
83
+
84
+ lines.push(` public class ${csClassName}`);
85
+ lines.push(' {');
86
+
87
+ // Track Dictionary<string, object> fields so we can emit a typed
88
+ // accessor helper per field at the end of the class body.
89
+ const dictObjectFields: Array<{ csName: string; typeText: string }> = [];
90
+
91
+ // Deduplicate fields by C# property name
92
+ const seenFieldNames = new Set<string>();
93
+ for (const field of model.fields) {
94
+ const csFieldName = fieldName(field.name);
95
+ if (seenFieldNames.has(csFieldName)) continue;
96
+ seenFieldNames.add(csFieldName);
97
+
98
+ const isOptional = !field.required;
99
+ const baseType = mapTypeRef(field.type);
100
+ const isAlreadyNullable = baseType.endsWith('?');
101
+ const constInit = singleValueConstInitializer(field.type, enumConstByName);
102
+ let csType: string;
103
+ let initializer = '';
104
+ let setterModifier = '';
105
+
106
+ if (constInit !== null) {
107
+ // Discriminator-style single-value enum/literal: emit with a const
108
+ // initializer and a non-public setter so callers can't drift the
109
+ // wire value. The converter still reads whatever the server sends.
110
+ csType = baseType;
111
+ initializer = ` = ${constInit};`;
112
+ setterModifier = 'internal ';
113
+ } else if (isOptional) {
114
+ if (isAlreadyNullable) {
115
+ csType = baseType;
116
+ } else if (isValueTypeRef(field.type)) {
117
+ csType = `${baseType}?`;
118
+ } else {
119
+ // With nullable enabled, optional reference types need `?`
120
+ csType = `${baseType}?`;
121
+ }
122
+ } else {
123
+ csType = baseType;
124
+ // Required non-nullable reference types need = default! to suppress CS8618
125
+ if (!isAlreadyNullable && !isValueTypeRef(field.type)) {
126
+ initializer = ' = default!;';
127
+ }
128
+ }
129
+
130
+ // Field description (full multi-line, with continuations as <remarks>)
131
+ const fieldDocs = emitXmlDoc(field.description, ' ');
132
+ if (fieldDocs.length > 0) {
133
+ lines.push('');
134
+ lines.push(...fieldDocs);
135
+ }
136
+
137
+ if (field.deprecated) {
138
+ const msg = escapeCsAttributeString(deprecationMessage(field.description, 'field'));
139
+ lines.push(` [System.Obsolete("${msg}")]`);
140
+ }
141
+
142
+ const isRequiredEnum = field.required && isEnumRef(field.type) && constInit === null;
143
+ lines.push(...emitJsonPropertyAttributes(field.name, { isRequiredEnum }));
144
+ lines.push(` public ${csType} ${csFieldName} { get; ${setterModifier}set; }${initializer}`);
145
+
146
+ // Track additional-properties / metadata dictionaries for typed accessors.
147
+ // Skip deprecated fields so the generated accessor doesn't reference
148
+ // a field marked `[System.Obsolete]` (which would fail the build).
149
+ if (isDictionaryOfObject(csType) && !field.deprecated) {
150
+ dictObjectFields.push({ csName: csFieldName, typeText: csType });
151
+ }
152
+ }
153
+
154
+ for (const dict of dictObjectFields) {
155
+ lines.push('');
156
+ lines.push(` /// <summary>`);
157
+ lines.push(` /// Typed accessor for <see cref="${dict.csName}"/>. Returns the value stored under`);
158
+ lines.push(` /// <paramref name="key"/> coerced to <typeparamref name="T"/>, or the default`);
159
+ lines.push(` /// value when the key is missing or the value is not convertible.`);
160
+ lines.push(` /// </summary>`);
161
+ lines.push(` /// <typeparam name="T">Expected value type.</typeparam>`);
162
+ lines.push(` /// <param name="key">The key to look up.</param>`);
163
+ lines.push(` public T? Get${dict.csName}Attribute<T>(string key)`);
164
+ lines.push(' {');
165
+ lines.push(` if (this.${dict.csName} == null)`);
166
+ lines.push(' {');
167
+ lines.push(' return default;');
168
+ lines.push(' }');
169
+ lines.push('');
170
+ lines.push(` if (!this.${dict.csName}.TryGetValue(key, out var value))`);
171
+ lines.push(' {');
172
+ lines.push(' return default;');
173
+ lines.push(' }');
174
+ lines.push('');
175
+ lines.push(' if (value is T typed)');
176
+ lines.push(' {');
177
+ lines.push(' return typed;');
178
+ lines.push(' }');
179
+ lines.push('');
180
+ lines.push(' if (value is Newtonsoft.Json.Linq.JToken token)');
181
+ lines.push(' {');
182
+ lines.push(' return token.ToObject<T>();');
183
+ lines.push(' }');
184
+ lines.push('');
185
+ lines.push(' if (value is System.Text.Json.JsonElement element)');
186
+ lines.push(' {');
187
+ lines.push(' return System.Text.Json.JsonSerializer.Deserialize<T>(element.GetRawText());');
188
+ lines.push(' }');
189
+ lines.push('');
190
+ lines.push(' return default;');
191
+ lines.push(' }');
192
+ }
193
+
194
+ lines.push(' }');
195
+ lines.push('}');
196
+
197
+ files.push({
198
+ path: `Entities/${csClassName}.cs`,
199
+ content: lines.join('\n'),
200
+ overwriteExisting: true,
201
+ });
202
+ }
203
+
204
+ return files;
205
+ }
206
+
207
+ /**
208
+ * Whether the emitted C# type is `Dictionary<string, object>` or its
209
+ * nullable variant — the usual shape of metadata / additional-properties
210
+ * fields that get typed accessors.
211
+ */
212
+ function isDictionaryOfObject(csType: string): boolean {
213
+ const bare = csType.endsWith('?') ? csType.slice(0, -1) : csType;
214
+ return bare === 'Dictionary<string, object>';
215
+ }
216
+
217
+ /**
218
+ * If the given TypeRef is a single-value enum / literal (a discriminator
219
+ * const masquerading as an enum), return the C# literal expression (already
220
+ * quoted for strings, bare for bool/number) so the emitter can lock the
221
+ * field down with a const initializer and non-public setter. Returns null
222
+ * for any other type.
223
+ */
224
+ function singleValueConstInitializer(ref: TypeRef, enumConstByName: Map<string, string>): string | null {
225
+ // OpenAPI `enum: [value]` (single-value) is normalized by the IR to a
226
+ // LiteralType on the field, not an EnumRef. Emit per-type: booleans and
227
+ // numbers are bare literals; strings get JSON-quoted.
228
+ if (ref.kind === 'literal') {
229
+ if (ref.value === null) return null;
230
+ if (typeof ref.value === 'boolean') return ref.value ? 'true' : 'false';
231
+ if (typeof ref.value === 'number') return String(ref.value);
232
+ if (typeof ref.value === 'string') return JSON.stringify(ref.value);
233
+ return null;
234
+ }
235
+ if (ref.kind !== 'enum') return null;
236
+ let wire: string | null = null;
237
+ if (ref.values && ref.values.length === 1) {
238
+ const v = ref.values[0] as string | number | { value: string | number };
239
+ wire = typeof v === 'string' || typeof v === 'number' ? String(v) : String(v.value);
240
+ } else {
241
+ wire = enumConstByName.get(ref.name) ?? null;
242
+ }
243
+ if (wire === null) return null;
244
+ // Enum wire values serialize as strings in JSON, and mapTypeRef returns
245
+ // `string` for single-value enums — so always quote.
246
+ return JSON.stringify(wire);
247
+ }
248
+
249
+ /**
250
+ * Compute and publish the model alias map. Safe to call multiple times
251
+ * (idempotent for a given set of models). Must be invoked before any emitter
252
+ * phase that calls `mapTypeRef` with model references.
253
+ */
254
+ export function primeModelAliases(models: Model[]): void {
255
+ const eligibleModels = models.filter((m) => !isListWrapperModel(m) && !isListMetadataModel(m));
256
+ const aliasOf = new Map<string, string>();
257
+ while (true) {
258
+ const hashGroups = new Map<string, string[]>();
259
+ for (const model of eligibleModels) {
260
+ const hash = structuralHash(model, aliasOf);
261
+ if (!hashGroups.has(hash)) hashGroups.set(hash, []);
262
+ hashGroups.get(hash)!.push(model.name);
263
+ }
264
+
265
+ let added = false;
266
+ for (const [hash, names] of hashGroups) {
267
+ if (names.length <= 1) continue;
268
+ if (hash === '') continue;
269
+ const sorted = [...names].sort();
270
+ const canonical = sorted[0];
271
+ for (let i = 1; i < sorted.length; i++) {
272
+ const name = sorted[i];
273
+ if (aliasOf.get(name) !== canonical) {
274
+ aliasOf.set(name, canonical);
275
+ added = true;
276
+ }
277
+ }
278
+ }
279
+ if (!added) break;
280
+ }
281
+ setModelAliases(aliasOf);
282
+ }
283
+
284
+ /**
285
+ * Normalize a TypeRef for structural comparison.
286
+ * Enum references are normalized to their values (not names) so that
287
+ * structurally identical enums with different names still match.
288
+ * Model references are rewritten to their canonical alias (if any) so that
289
+ * parents whose only difference is an already-aliased child collapse too.
290
+ */
291
+ function normalizeTypeForHash(ref: TypeRef, aliasOf: Map<string, string>): any {
292
+ if (ref.kind === 'enum') {
293
+ // Normalize enum refs by their sorted values, not their name
294
+ const vals = ref.values ? [...ref.values].sort() : [];
295
+ return { kind: 'enum', values: vals };
296
+ }
297
+ if (ref.kind === 'model') {
298
+ return { kind: 'model', name: aliasOf.get(ref.name) ?? ref.name };
299
+ }
300
+ if (ref.kind === 'nullable') {
301
+ return { kind: 'nullable', inner: normalizeTypeForHash(ref.inner, aliasOf) };
302
+ }
303
+ if (ref.kind === 'array') {
304
+ return { kind: 'array', items: normalizeTypeForHash(ref.items, aliasOf) };
305
+ }
306
+ if (ref.kind === 'union') {
307
+ return { kind: 'union', variants: ref.variants.map((v) => normalizeTypeForHash(v, aliasOf)) };
308
+ }
309
+ if (ref.kind === 'map') {
310
+ return { kind: 'map', valueType: normalizeTypeForHash(ref.valueType, aliasOf) };
311
+ }
312
+ return ref;
313
+ }
314
+
315
+ function structuralHash(model: Model, aliasOf: Map<string, string> = new Map()): string {
316
+ return model.fields
317
+ .map((f) => `${f.name}:${JSON.stringify(normalizeTypeForHash(f.type, aliasOf))}:${f.required}`)
318
+ .sort()
319
+ .join('|');
320
+ }