@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.
- package/.github/workflows/ci.yml +1 -1
- package/.github/workflows/lint.yml +1 -1
- package/.github/workflows/release-please.yml +2 -2
- package/.github/workflows/release.yml +1 -1
- package/.husky/pre-push +11 -0
- package/.node-version +1 -1
- package/.release-please-manifest.json +1 -1
- package/CHANGELOG.md +15 -0
- package/README.md +35 -224
- package/dist/index.d.mts +12 -1
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +2 -12737
- package/dist/plugin-BSop9f9z.mjs +21471 -0
- package/dist/plugin-BSop9f9z.mjs.map +1 -0
- package/dist/plugin.d.mts +7 -0
- package/dist/plugin.d.mts.map +1 -0
- package/dist/plugin.mjs +2 -0
- package/docs/sdk-architecture/dotnet.md +336 -0
- package/oagen.config.ts +5 -343
- package/package.json +10 -34
- package/smoke/sdk-dotnet.ts +45 -12
- package/src/dotnet/client.ts +89 -0
- package/src/dotnet/enums.ts +323 -0
- package/src/dotnet/fixtures.ts +236 -0
- package/src/dotnet/index.ts +248 -0
- package/src/dotnet/manifest.ts +36 -0
- package/src/dotnet/models.ts +320 -0
- package/src/dotnet/naming.ts +368 -0
- package/src/dotnet/resources.ts +943 -0
- package/src/dotnet/tests.ts +713 -0
- package/src/dotnet/type-map.ts +228 -0
- package/src/dotnet/wrappers.ts +197 -0
- package/src/go/client.ts +35 -3
- package/src/go/enums.ts +4 -0
- package/src/go/index.ts +15 -7
- package/src/go/models.ts +6 -1
- package/src/go/naming.ts +5 -17
- package/src/go/resources.ts +534 -73
- package/src/go/tests.ts +39 -3
- package/src/go/type-map.ts +8 -3
- package/src/go/wrappers.ts +79 -21
- package/src/index.ts +15 -0
- package/src/kotlin/client.ts +58 -0
- package/src/kotlin/enums.ts +189 -0
- package/src/kotlin/index.ts +92 -0
- package/src/kotlin/manifest.ts +55 -0
- package/src/kotlin/models.ts +486 -0
- package/src/kotlin/naming.ts +229 -0
- package/src/kotlin/overrides.ts +25 -0
- package/src/kotlin/resources.ts +998 -0
- package/src/kotlin/tests.ts +1133 -0
- package/src/kotlin/type-map.ts +123 -0
- package/src/kotlin/wrappers.ts +168 -0
- package/src/node/client.ts +84 -7
- package/src/node/field-plan.ts +12 -14
- package/src/node/fixtures.ts +39 -3
- package/src/node/index.ts +1 -0
- package/src/node/models.ts +281 -37
- package/src/node/resources.ts +319 -95
- package/src/node/tests.ts +108 -29
- package/src/node/type-map.ts +1 -31
- package/src/node/utils.ts +96 -6
- package/src/node/wrappers.ts +31 -1
- package/src/php/client.ts +11 -3
- package/src/php/models.ts +0 -33
- package/src/php/naming.ts +2 -21
- package/src/php/resources.ts +275 -19
- package/src/php/tests.ts +118 -18
- package/src/php/type-map.ts +16 -2
- package/src/php/wrappers.ts +7 -2
- package/src/plugin.ts +50 -0
- package/src/python/client.ts +50 -32
- package/src/python/enums.ts +35 -10
- package/src/python/index.ts +35 -27
- package/src/python/models.ts +139 -2
- package/src/python/naming.ts +2 -22
- package/src/python/resources.ts +234 -17
- package/src/python/tests.ts +260 -16
- package/src/python/type-map.ts +16 -2
- package/src/ruby/client.ts +238 -0
- package/src/ruby/enums.ts +149 -0
- package/src/ruby/index.ts +93 -0
- package/src/ruby/manifest.ts +35 -0
- package/src/ruby/models.ts +360 -0
- package/src/ruby/naming.ts +187 -0
- package/src/ruby/rbi.ts +313 -0
- package/src/ruby/resources.ts +799 -0
- package/src/ruby/tests.ts +459 -0
- package/src/ruby/type-map.ts +97 -0
- package/src/ruby/wrappers.ts +161 -0
- package/src/shared/model-utils.ts +357 -16
- package/src/shared/naming-utils.ts +83 -0
- package/src/shared/non-spec-services.ts +13 -0
- package/src/shared/resolved-ops.ts +75 -1
- package/src/shared/wrapper-utils.ts +12 -1
- package/test/dotnet/client.test.ts +121 -0
- package/test/dotnet/enums.test.ts +193 -0
- package/test/dotnet/errors.test.ts +9 -0
- package/test/dotnet/manifest.test.ts +82 -0
- package/test/dotnet/models.test.ts +258 -0
- package/test/dotnet/resources.test.ts +387 -0
- package/test/dotnet/tests.test.ts +202 -0
- package/test/entrypoint.test.ts +89 -0
- package/test/go/client.test.ts +6 -6
- package/test/go/resources.test.ts +156 -7
- package/test/kotlin/models.test.ts +135 -0
- package/test/kotlin/resources.test.ts +210 -0
- package/test/kotlin/tests.test.ts +176 -0
- package/test/node/client.test.ts +74 -0
- package/test/node/models.test.ts +134 -1
- package/test/node/resources.test.ts +343 -34
- package/test/node/utils.test.ts +140 -0
- package/test/php/client.test.ts +2 -1
- package/test/php/models.test.ts +5 -4
- package/test/php/resources.test.ts +103 -0
- package/test/php/tests.test.ts +67 -0
- package/test/plugin.test.ts +50 -0
- package/test/python/client.test.ts +56 -0
- package/test/python/models.test.ts +99 -0
- package/test/python/resources.test.ts +294 -0
- package/test/python/tests.test.ts +91 -0
- package/test/ruby/client.test.ts +81 -0
- package/test/ruby/resources.test.ts +386 -0
- package/test/shared/resolved-ops.test.ts +122 -0
- package/tsdown.config.ts +1 -1
- package/dist/index.mjs.map +0 -1
- package/scripts/generate-php.js +0 -13
- 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
|
+
}
|