@contractkit/plugin-csharp 0.0.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/.turbo/turbo-build$colon$ci.log +13 -0
- package/.turbo/turbo-build.log +12 -0
- package/.turbo/turbo-format.log +34 -0
- package/.turbo/turbo-test.log +17 -0
- package/CHANGELOG.md +1 -0
- package/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/codegen-client.d.ts +35 -0
- package/dist/codegen-client.d.ts.map +1 -0
- package/dist/codegen-models.d.ts +75 -0
- package/dist/codegen-models.d.ts.map +1 -0
- package/dist/codegen-sdk.d.ts +13 -0
- package/dist/codegen-sdk.d.ts.map +1 -0
- package/dist/hoist.d.ts +53 -0
- package/dist/hoist.d.ts.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2569 -0
- package/dist/index.js.map +1 -0
- package/dist/naming.d.ts +89 -0
- package/dist/naming.d.ts.map +1 -0
- package/dist/runtime-converters.d.ts +15 -0
- package/dist/runtime-converters.d.ts.map +1 -0
- package/dist/runtime.d.ts +10 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/scaffold.d.ts +26 -0
- package/dist/scaffold.d.ts.map +1 -0
- package/eslint.config.js +6 -0
- package/package.json +48 -0
- package/src/codegen-client.ts +680 -0
- package/src/codegen-models.ts +909 -0
- package/src/codegen-sdk.ts +52 -0
- package/src/hoist.ts +402 -0
- package/src/index.ts +373 -0
- package/src/naming.ts +262 -0
- package/src/runtime-converters.ts +147 -0
- package/src/runtime.ts +381 -0
- package/src/scaffold.ts +41 -0
- package/tests/codegen-client.test.ts +275 -0
- package/tests/codegen-models.test.ts +410 -0
- package/tests/helpers.ts +202 -0
- package/tests/hoist.test.ts +92 -0
- package/tests/index.test.ts +124 -0
- package/tests/naming.test.ts +133 -0
- package/tests/runtime.test.ts +104 -0
- package/tests/scaffold.test.ts +28 -0
- package/tsconfig.json +9 -0
- package/vitest.config.ts +14 -0
|
@@ -0,0 +1,909 @@
|
|
|
1
|
+
import type { ContractRootNode, ContractTypeNode, FieldNode, ModelNode, ScalarTypeNode } from '@contractkit/core';
|
|
2
|
+
import { buildModelIndex, computeModelsWithInput, resolveEffectiveFields, topoSortModels } from '@contractkit/core';
|
|
3
|
+
import type { HoistedDecl, HoistResult } from './hoist.js';
|
|
4
|
+
import { quoteCSharpString, safeMemberName, toCSharpEnumMemberName, toCSharpPropertyName, xmlDocLines } from './naming.js';
|
|
5
|
+
|
|
6
|
+
// ─── Public entry point ────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
export interface CSharpModelCodegenOptions {
|
|
9
|
+
/** Root namespace the SDK is generated into. Models land in `<namespace>.Models`. */
|
|
10
|
+
namespace: string;
|
|
11
|
+
/** Model names that have a distinct `Input` variant, including ones declared in other files. */
|
|
12
|
+
modelsWithInput?: ReadonlySet<string>;
|
|
13
|
+
/**
|
|
14
|
+
* Every model in the project, for flattening bases and intersections. Defaults to an index of
|
|
15
|
+
* this root's own models, which is enough for a single-file project and for unit tests.
|
|
16
|
+
*/
|
|
17
|
+
modelIndex?: ReadonlyMap<string, ModelNode>;
|
|
18
|
+
/** Names assigned to anonymous types by {@link collectHoistedTypes}, across the whole project. */
|
|
19
|
+
hoisted?: HoistResult;
|
|
20
|
+
warn?: (message: string) => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The `using` block every generated models file carries.
|
|
25
|
+
*
|
|
26
|
+
* There is no import tracker, unlike the Kotlin plugin: every type the models can name is in the
|
|
27
|
+
* base class library, so the set is fixed. An unused `using` is not a compiler warning, and pinning
|
|
28
|
+
* the block keeps the output stable and free of the ordering churn a tracker would produce.
|
|
29
|
+
*/
|
|
30
|
+
const MODEL_USINGS = [
|
|
31
|
+
'using System;',
|
|
32
|
+
'using System.Collections.Generic;',
|
|
33
|
+
'using System.Numerics;',
|
|
34
|
+
'using System.Text.Json;',
|
|
35
|
+
'using System.Text.Json.Serialization;',
|
|
36
|
+
] as const;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Generate the C# models file for one contract root: a `sealed record` per model, plus `<Name>Input`
|
|
40
|
+
* variants, enums, aliases, and the records, interfaces and converters standing in for the unions
|
|
41
|
+
* and anonymous shapes this file owns.
|
|
42
|
+
*
|
|
43
|
+
* Every model in the project shares the single `<namespace>.Models` namespace, so a reference to a
|
|
44
|
+
* model declared in another `.ck` file needs no import and resolves by name alone. That is also what
|
|
45
|
+
* lets an interface declared in one file be implemented by a record generated in another.
|
|
46
|
+
*/
|
|
47
|
+
export function generateCSharpModels(root: ContractRootNode, opts: CSharpModelCodegenOptions): string {
|
|
48
|
+
const modelsWithInput = resolveModelsWithInput(root.models, opts.modelsWithInput);
|
|
49
|
+
const modelIndex = opts.modelIndex ?? buildModelIndex(root.models);
|
|
50
|
+
|
|
51
|
+
const ctx: RenderContext = {
|
|
52
|
+
namespace: opts.namespace,
|
|
53
|
+
modelsWithInput,
|
|
54
|
+
modelIndex,
|
|
55
|
+
hoisted: opts.hoisted,
|
|
56
|
+
globalAliases: [],
|
|
57
|
+
warn: opts.warn,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const bodies: string[] = [];
|
|
61
|
+
const append = (lines: string[]): void => {
|
|
62
|
+
// A model whose type is a union emits nothing here — the hoisting pass owns the declaration
|
|
63
|
+
// named after it — so the blank separator has to be conditional or it leaves a gap behind.
|
|
64
|
+
if (lines.length === 0) return;
|
|
65
|
+
bodies.push('', ...lines);
|
|
66
|
+
};
|
|
67
|
+
for (const model of topoSortModels(root.models)) append(generateModel(model, ctx));
|
|
68
|
+
for (const decl of opts.hoisted?.byFile.get(root.file) ?? []) append(generateHoisted(decl, ctx));
|
|
69
|
+
|
|
70
|
+
return renderFile(`${opts.namespace}.Models`, ctx.globalAliases, [...MODEL_USINGS], bodies);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The complete set of model names that need a distinct `Input` variant: the ones passed in, plus
|
|
75
|
+
* the transitive closure over `models`.
|
|
76
|
+
*
|
|
77
|
+
* The hoisting pass and the renderer both have to agree on this — a hoisted shape whose Input twin
|
|
78
|
+
* one of them thinks is unnecessary would leave the other referring to a type nobody emitted.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveModelsWithInput(models: readonly ModelNode[], external: ReadonlySet<string> = new Set()): Set<string> {
|
|
81
|
+
const seed = new Set(external);
|
|
82
|
+
return new Set([...seed, ...computeModelsWithInput([...models], seed)]);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ─── Render context ────────────────────────────────────────────────────────
|
|
86
|
+
|
|
87
|
+
interface RenderContext {
|
|
88
|
+
namespace: string;
|
|
89
|
+
modelsWithInput: ReadonlySet<string>;
|
|
90
|
+
modelIndex: ReadonlyMap<string, ModelNode>;
|
|
91
|
+
hoisted?: HoistResult;
|
|
92
|
+
/** `global using` alias lines this file has to emit above its own `using` block. */
|
|
93
|
+
globalAliases: string[];
|
|
94
|
+
/** When set, type names render fully qualified, as a `global using` alias target must be. */
|
|
95
|
+
qualify?: boolean;
|
|
96
|
+
warn?: (message: string) => void;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Build a rendering context for a file outside the models namespace, such as a client. */
|
|
100
|
+
export function createRenderContext(opts: CSharpModelCodegenOptions & { modelsWithInput: ReadonlySet<string> }): RenderContext {
|
|
101
|
+
return {
|
|
102
|
+
namespace: opts.namespace,
|
|
103
|
+
modelsWithInput: opts.modelsWithInput,
|
|
104
|
+
modelIndex: opts.modelIndex ?? new Map(),
|
|
105
|
+
hoisted: opts.hoisted,
|
|
106
|
+
globalAliases: [],
|
|
107
|
+
warn: opts.warn,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Assemble a generated C# file: header, nullable context, global aliases, usings, namespace, bodies.
|
|
113
|
+
*
|
|
114
|
+
* `// <auto-generated/>` turns the nullable context off, so `#nullable enable` follows it
|
|
115
|
+
* explicitly. A `global using` alias has to precede every ordinary `using` in its file, which is
|
|
116
|
+
* why the aliases are collected during rendering and emitted here rather than inline.
|
|
117
|
+
*/
|
|
118
|
+
export function renderFile(namespaceName: string, globalAliases: readonly string[], usings: readonly string[], bodies: string[]): string {
|
|
119
|
+
const lines: string[] = ['// <auto-generated/>', '// Generated by @contractkit/plugin-csharp. Do not edit manually.', '#nullable enable', ''];
|
|
120
|
+
if (globalAliases.length > 0) {
|
|
121
|
+
lines.push(...[...globalAliases].sort());
|
|
122
|
+
lines.push('');
|
|
123
|
+
}
|
|
124
|
+
lines.push(...usings);
|
|
125
|
+
lines.push('');
|
|
126
|
+
lines.push(`namespace ${namespaceName};`);
|
|
127
|
+
lines.push(...bodies);
|
|
128
|
+
lines.push('');
|
|
129
|
+
return lines.join('\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ─── Type rendering ────────────────────────────────────────────────────────
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Render a ContractKit type as its C# type expression. Never returns a nullable type unless the type
|
|
136
|
+
* itself is one — the caller appends `?` from the field's own `optional`/`nullable` flags.
|
|
137
|
+
*
|
|
138
|
+
* @param forInput - When true, a reference to a model or hoisted shape with an Input variant renders
|
|
139
|
+
* as `<Name>Input`.
|
|
140
|
+
* @throws {Error} Via the scalar renderer, if a scalar has no C# mapping.
|
|
141
|
+
*/
|
|
142
|
+
export function renderCSharpType(type: ContractTypeNode, ctx: RenderContext, forInput = false): string {
|
|
143
|
+
const decl = ctx.hoisted?.byNode.get(type);
|
|
144
|
+
if (decl) return hoistedTypeName(decl, ctx, forInput);
|
|
145
|
+
|
|
146
|
+
switch (type.kind) {
|
|
147
|
+
case 'scalar':
|
|
148
|
+
return renderScalar(type.name, ctx);
|
|
149
|
+
case 'literal':
|
|
150
|
+
return literalCSharpType(type.value, ctx);
|
|
151
|
+
case 'array':
|
|
152
|
+
return `${qualify('List', 'System.Collections.Generic.List', ctx)}<${renderCSharpType(type.item, ctx, forInput)}>`;
|
|
153
|
+
case 'record': {
|
|
154
|
+
const key = renderCSharpType(type.key, ctx, forInput);
|
|
155
|
+
const value = renderCSharpType(type.value, ctx, forInput);
|
|
156
|
+
const stringType = qualify('string', 'System.String', ctx);
|
|
157
|
+
if (key !== stringType) {
|
|
158
|
+
ctx.warn?.(
|
|
159
|
+
`A record key of type '${key}' is not representable as a JSON object key; emitting Dictionary<string, ${value}>. ` +
|
|
160
|
+
`Parse the key yourself, or declare the key as a string.`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
return `${qualify('Dictionary', 'System.Collections.Generic.Dictionary', ctx)}<${stringType}, ${value}>`;
|
|
164
|
+
}
|
|
165
|
+
case 'tuple':
|
|
166
|
+
// Unreachable in the real pipeline: the hoisting pass gives every tuple a record of its
|
|
167
|
+
// own, so the `byNode` lookup above has already returned.
|
|
168
|
+
return jsonElement(ctx);
|
|
169
|
+
case 'ref': {
|
|
170
|
+
const name = forInput && ctx.modelsWithInput.has(type.name) ? `${type.name}Input` : type.name;
|
|
171
|
+
return ctx.qualify ? `${ctx.namespace}.Models.${name}` : name;
|
|
172
|
+
}
|
|
173
|
+
case 'lazy':
|
|
174
|
+
return renderCSharpType(type.inner, ctx, forInput);
|
|
175
|
+
case 'union': {
|
|
176
|
+
// A union with at most one non-null member never gets a declaration: it is either C#'s
|
|
177
|
+
// own nullable type or nothing at all.
|
|
178
|
+
const nonNull = type.members.filter(m => !isNullScalar(m));
|
|
179
|
+
const nullable = nonNull.length !== type.members.length;
|
|
180
|
+
if (nonNull.length === 0) return `${qualify('object', 'System.Object', ctx)}?`;
|
|
181
|
+
if (nonNull.length === 1) {
|
|
182
|
+
const inner = renderCSharpType(nonNull[0]!, ctx, forInput);
|
|
183
|
+
return nullable && !inner.endsWith('?') ? `${inner}?` : inner;
|
|
184
|
+
}
|
|
185
|
+
return jsonElement(ctx);
|
|
186
|
+
}
|
|
187
|
+
case 'enum':
|
|
188
|
+
case 'inlineObject':
|
|
189
|
+
case 'intersection':
|
|
190
|
+
case 'discriminatedUnion':
|
|
191
|
+
// Reached only when the shape could not be given a name — a discriminated union whose
|
|
192
|
+
// tag is not statically known, or a caller that skipped the hoisting pass.
|
|
193
|
+
return jsonElement(ctx);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function hoistedTypeName(decl: HoistedDecl, ctx: RenderContext, forInput: boolean): string {
|
|
198
|
+
const bare = forInput && decl.needsInput ? `${decl.name}Input` : decl.name;
|
|
199
|
+
const name = ctx.qualify ? `${ctx.namespace}.Models.${bare}` : bare;
|
|
200
|
+
return decl.nullable ? `${name}?` : name;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Pick the short or the fully-qualified spelling, depending on where the type is being written. */
|
|
204
|
+
function qualify(short: string, full: string, ctx: RenderContext): string {
|
|
205
|
+
return ctx.qualify ? full : short;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function jsonElement(ctx: RenderContext): string {
|
|
209
|
+
return qualify('JsonElement', 'System.Text.Json.JsonElement', ctx);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function isNullScalar(type: ContractTypeNode): boolean {
|
|
213
|
+
return type.kind === 'scalar' && type.name === 'null';
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Map a ContractKit scalar to its C# type.
|
|
218
|
+
*
|
|
219
|
+
* @throws {Error} When a scalar has no mapping, so a scalar added to core fails the build here
|
|
220
|
+
* rather than emitting C# that does not compile.
|
|
221
|
+
*/
|
|
222
|
+
export function renderScalar(name: ScalarTypeNode['name'], ctx: RenderContext): string {
|
|
223
|
+
switch (name) {
|
|
224
|
+
case 'string':
|
|
225
|
+
case 'email':
|
|
226
|
+
case 'url':
|
|
227
|
+
case 'interval':
|
|
228
|
+
return qualify('string', 'System.String', ctx);
|
|
229
|
+
case 'number':
|
|
230
|
+
return qualify('double', 'System.Double', ctx);
|
|
231
|
+
// `int` is a JS safe integer in the source language, which overflows C#'s 32-bit int.
|
|
232
|
+
case 'int':
|
|
233
|
+
return qualify('long', 'System.Int64', ctx);
|
|
234
|
+
case 'bigint':
|
|
235
|
+
return qualify('BigInteger', 'System.Numerics.BigInteger', ctx);
|
|
236
|
+
// Carried as a quoted string by DecimalStringConverter, never as a JSON number.
|
|
237
|
+
case 'decimal':
|
|
238
|
+
return qualify('decimal', 'System.Decimal', ctx);
|
|
239
|
+
case 'boolean':
|
|
240
|
+
return qualify('bool', 'System.Boolean', ctx);
|
|
241
|
+
case 'date':
|
|
242
|
+
return qualify('DateOnly', 'System.DateOnly', ctx);
|
|
243
|
+
case 'time':
|
|
244
|
+
return qualify('TimeOnly', 'System.TimeOnly', ctx);
|
|
245
|
+
case 'datetime':
|
|
246
|
+
return qualify('DateTimeOffset', 'System.DateTimeOffset', ctx);
|
|
247
|
+
// Carried as ISO 8601 by IsoTimeSpanConverter, not the BCL's own `d.hh:mm:ss`.
|
|
248
|
+
case 'duration':
|
|
249
|
+
return qualify('TimeSpan', 'System.TimeSpan', ctx);
|
|
250
|
+
case 'uuid':
|
|
251
|
+
return qualify('Guid', 'System.Guid', ctx);
|
|
252
|
+
case 'binary':
|
|
253
|
+
return qualify('byte[]', 'System.Byte[]', ctx);
|
|
254
|
+
case 'null':
|
|
255
|
+
return `${qualify('object', 'System.Object', ctx)}?`;
|
|
256
|
+
case 'unknown':
|
|
257
|
+
case 'json':
|
|
258
|
+
case 'object':
|
|
259
|
+
return jsonElement(ctx);
|
|
260
|
+
default: {
|
|
261
|
+
const _exhaustive: never = name;
|
|
262
|
+
throw new Error(`plugin-csharp: unmapped scalar '${String(_exhaustive)}' — add a case`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function literalCSharpType(value: string | number | boolean, ctx: RenderContext): string {
|
|
268
|
+
if (typeof value === 'string') return qualify('string', 'System.String', ctx);
|
|
269
|
+
if (typeof value === 'boolean') return qualify('bool', 'System.Boolean', ctx);
|
|
270
|
+
return Number.isInteger(value) ? qualify('long', 'System.Int64', ctx) : qualify('double', 'System.Double', ctx);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// ─── Default values ────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Render a contract default as a C# expression of the field's own type. Returns `undefined` when the
|
|
277
|
+
* value cannot be expressed, so the field is emitted as `required` rather than with an initializer
|
|
278
|
+
* that will not compile.
|
|
279
|
+
*/
|
|
280
|
+
function renderDefault(value: string | number | boolean, type: ContractTypeNode, ctx: RenderContext): string | undefined {
|
|
281
|
+
const inner = type.kind === 'lazy' ? type.inner : type;
|
|
282
|
+
|
|
283
|
+
if (typeof value === 'boolean') return String(value);
|
|
284
|
+
|
|
285
|
+
if (typeof value === 'number') {
|
|
286
|
+
if (inner.kind === 'scalar') {
|
|
287
|
+
switch (inner.name) {
|
|
288
|
+
case 'int':
|
|
289
|
+
return `${value}L`;
|
|
290
|
+
case 'number':
|
|
291
|
+
return `${value}d`;
|
|
292
|
+
case 'decimal':
|
|
293
|
+
return `${value}m`;
|
|
294
|
+
case 'bigint':
|
|
295
|
+
return Number.isSafeInteger(value) ? `new BigInteger(${value})` : `BigInteger.Parse("${value}")`;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return Number.isInteger(value) ? `${value}L` : `${value}d`;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// A string default against an enum names one of its members. When the enum was hoisted into a
|
|
302
|
+
// real C# enum, that is expressible; a bare inline enum has no type to qualify.
|
|
303
|
+
if (inner.kind === 'enum') {
|
|
304
|
+
const decl = ctx.hoisted?.byNode.get(inner);
|
|
305
|
+
if (!decl || !inner.values.includes(value)) return undefined;
|
|
306
|
+
return `${decl.name}.${enumMemberNames(inner.values).get(value)}`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// The same default written against a NAMED enum contract — `rating: Rating = "neutral"`, where
|
|
310
|
+
// `contract Rating: enum(...)` — arrives here as a ref rather than as the enum node.
|
|
311
|
+
if (inner.kind === 'ref') {
|
|
312
|
+
const target = ctx.modelIndex.get(inner.name);
|
|
313
|
+
const targetType = target?.type?.kind === 'lazy' ? target.type.inner : target?.type;
|
|
314
|
+
if (targetType?.kind !== 'enum' || !targetType.values.includes(value)) return undefined;
|
|
315
|
+
return `${inner.name}.${enumMemberNames(targetType.values).get(value)}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (inner.kind === 'scalar') {
|
|
319
|
+
switch (inner.name) {
|
|
320
|
+
case 'decimal':
|
|
321
|
+
return /^-?\d+(\.\d+)?$/.test(value) ? `${value}m` : undefined;
|
|
322
|
+
case 'bigint':
|
|
323
|
+
return /^-?\d+$/.test(value) ? `BigInteger.Parse(${quoteCSharpString(value)})` : undefined;
|
|
324
|
+
case 'string':
|
|
325
|
+
case 'email':
|
|
326
|
+
case 'url':
|
|
327
|
+
case 'interval':
|
|
328
|
+
return quoteCSharpString(value);
|
|
329
|
+
default:
|
|
330
|
+
// date/uuid/datetime and friends have no literal syntax; leave the field required.
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
return quoteCSharpString(value);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// ─── Wire key casing ───────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
/** The key casing a contract's `format(input=)` / `format(output=)` names. */
|
|
340
|
+
type WireCase = NonNullable<ModelNode['outputCase']>;
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* A field name as it travels, which is not always the name the contract declares it under.
|
|
344
|
+
*
|
|
345
|
+
* The two transforms are spelled exactly as `plugin-typescript` spells them, deliberately: the
|
|
346
|
+
* server parses and emits through that plugin's schemas, so a C# client that disagreed with it about
|
|
347
|
+
* where an underscore goes would be wrong in a way no test in either package could see.
|
|
348
|
+
*/
|
|
349
|
+
function applyWireCase(name: string, wireCase: WireCase | undefined): string {
|
|
350
|
+
if (!wireCase || wireCase === 'camel') return name;
|
|
351
|
+
if (wireCase === 'snake') return name.replace(/[A-Z]/g, c => `_${c.toLowerCase()}`);
|
|
352
|
+
return name.charAt(0).toUpperCase() + name.slice(1);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/** A case that actually renames something. `camel` is the identity and is treated as absent. */
|
|
356
|
+
function renamingCase(wireCase: WireCase | undefined): WireCase | undefined {
|
|
357
|
+
return wireCase && wireCase !== 'camel' ? wireCase : undefined;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/**
|
|
361
|
+
* Which casing one generated record's keys travel in.
|
|
362
|
+
*
|
|
363
|
+
* A response is decoded through `format(output=)` and a request is encoded through `format(input=)`,
|
|
364
|
+
* so a model split into a read record and an `Input` twin takes one each. A model that is NOT split
|
|
365
|
+
* is one record used in both directions, and a `[JsonPropertyName]` cannot spell two different key
|
|
366
|
+
* sets — so a contract asking for two is reported rather than silently resolved in whichever
|
|
367
|
+
* direction happens to be rendered.
|
|
368
|
+
*/
|
|
369
|
+
function wireCaseFor(model: ModelNode, forInput: boolean, split: boolean, ctx: RenderContext): WireCase | undefined {
|
|
370
|
+
const input = renamingCase(model.inputCase);
|
|
371
|
+
const output = renamingCase(model.outputCase);
|
|
372
|
+
if (split) return forInput ? input : output;
|
|
373
|
+
if (input && output && input !== output) {
|
|
374
|
+
ctx.warn?.(
|
|
375
|
+
`Contract '${model.name}' sets format(input=${input}) and format(output=${output}), but nothing about it splits into an Input variant, ` +
|
|
376
|
+
`so one C# record carries both directions and can only spell one set of keys. The generated keys follow the output casing; ` +
|
|
377
|
+
`a request built from this record will send the wrong ones.`,
|
|
378
|
+
);
|
|
379
|
+
return output;
|
|
380
|
+
}
|
|
381
|
+
return output ?? input;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Whether a type puts an anonymous object under a renamed model.
|
|
386
|
+
*
|
|
387
|
+
* Such an object is hoisted into a record of its own, which is rendered without the owning model's
|
|
388
|
+
* casing — the hoisting pass records no owner to take it from. That is a real gap rather than a
|
|
389
|
+
* decision, so it is reported at the one place the owner is still known.
|
|
390
|
+
*/
|
|
391
|
+
function containsInlineObject(type: ContractTypeNode | undefined): boolean {
|
|
392
|
+
if (!type) return false;
|
|
393
|
+
switch (type.kind) {
|
|
394
|
+
case 'inlineObject':
|
|
395
|
+
return true;
|
|
396
|
+
case 'lazy':
|
|
397
|
+
return containsInlineObject(type.inner);
|
|
398
|
+
case 'array':
|
|
399
|
+
return containsInlineObject(type.item);
|
|
400
|
+
case 'record':
|
|
401
|
+
return containsInlineObject(type.value);
|
|
402
|
+
case 'tuple':
|
|
403
|
+
return type.items.some(containsInlineObject);
|
|
404
|
+
case 'union':
|
|
405
|
+
case 'discriminatedUnion':
|
|
406
|
+
case 'intersection':
|
|
407
|
+
return (type.members ?? []).some(containsInlineObject);
|
|
408
|
+
default:
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Report the gap above, once per model rather than once per field. */
|
|
414
|
+
function warnUncasedNesting(model: ModelNode, fields: readonly FieldNode[], wireCase: WireCase | undefined, ctx: RenderContext): void {
|
|
415
|
+
if (!wireCase) return;
|
|
416
|
+
if (!fields.some(f => containsInlineObject(f.type)) && !containsInlineObject(model.type)) return;
|
|
417
|
+
ctx.warn?.(
|
|
418
|
+
`Contract '${model.name}' is declared format(${model.outputCase ? 'output' : 'input'}=${wireCase}) and holds an anonymous object. ` +
|
|
419
|
+
`The record hoisted out of that object keeps its declared key names, so its keys will not be ${wireCase}-cased. ` +
|
|
420
|
+
`Name the shape as its own contract to fix it.`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
// ─── Model generation ──────────────────────────────────────────────────────
|
|
425
|
+
|
|
426
|
+
function generateModel(model: ModelNode, ctx: RenderContext): string[] {
|
|
427
|
+
if (model.type) return generateAliasModel(model, ctx);
|
|
428
|
+
|
|
429
|
+
const effective = effectiveFieldsFor(model, ctx);
|
|
430
|
+
const needsSplit = ctx.modelsWithInput.has(model.name) || effective.some(f => f.visibility !== 'normal');
|
|
431
|
+
|
|
432
|
+
if (!needsSplit) return generateRecordForModel(model.name, effective, ctx, false, model, false);
|
|
433
|
+
|
|
434
|
+
const readFields = effective.filter(f => f.visibility !== 'writeonly');
|
|
435
|
+
const inputFields = effective.filter(f => f.visibility !== 'readonly');
|
|
436
|
+
return [
|
|
437
|
+
...generateRecordForModel(model.name, readFields, ctx, false, model, true),
|
|
438
|
+
'',
|
|
439
|
+
...generateRecordForModel(`${model.name}Input`, inputFields, ctx, true, model, true),
|
|
440
|
+
];
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* Bases are flattened rather than expressed as C# inheritance. A record can inherit, but a base's
|
|
445
|
+
* `required` properties would then be re-declared by the override rule the contract language
|
|
446
|
+
* applies, and a sealed leaf is what the serializer wants. `resolveEffectiveFields` applies the same
|
|
447
|
+
* later-wins override rule the inheritance validator enforces.
|
|
448
|
+
*/
|
|
449
|
+
function effectiveFieldsFor(model: ModelNode, ctx: RenderContext): FieldNode[] {
|
|
450
|
+
if (!model.bases || model.bases.length === 0) return model.fields;
|
|
451
|
+
const { fields, unresolved } = resolveEffectiveFields(model.name, ctx.modelIndex);
|
|
452
|
+
for (const name of unresolved) {
|
|
453
|
+
ctx.warn?.(`Contract '${model.name}' extends '${name}', which is not defined; its fields are missing from the generated record.`);
|
|
454
|
+
}
|
|
455
|
+
return fields;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function generateAliasModel(model: ModelNode, ctx: RenderContext): string[] {
|
|
459
|
+
const type = model.type!;
|
|
460
|
+
const inner = type.kind === 'lazy' ? type.inner : type;
|
|
461
|
+
|
|
462
|
+
// A union alias is emitted by the hoisting pass, which owns the declaration named after it.
|
|
463
|
+
if (ctx.hoisted?.byNode.has(inner)) return [];
|
|
464
|
+
|
|
465
|
+
if (inner.kind === 'enum') return generateEnum(model.name, inner.values, ctx, model.description, model.deprecated);
|
|
466
|
+
|
|
467
|
+
// An intersection or inline object at model level names a real shape, so it becomes a record
|
|
468
|
+
// rather than an alias to an opaque JSON object.
|
|
469
|
+
if (inner.kind === 'intersection' || inner.kind === 'inlineObject') {
|
|
470
|
+
const { fields, unresolved } = resolveEffectiveFields(inner, ctx.modelIndex);
|
|
471
|
+
for (const name of unresolved) {
|
|
472
|
+
ctx.warn?.(`Contract '${model.name}' references '${name}', which is not defined; its fields are missing from the generated record.`);
|
|
473
|
+
}
|
|
474
|
+
const needsSplit = ctx.modelsWithInput.has(model.name) || fields.some(f => f.visibility !== 'normal');
|
|
475
|
+
if (!needsSplit) return generateRecordForModel(model.name, fields, ctx, false, model, false);
|
|
476
|
+
return [
|
|
477
|
+
...generateRecordForModel(
|
|
478
|
+
model.name,
|
|
479
|
+
fields.filter(f => f.visibility !== 'writeonly'),
|
|
480
|
+
ctx,
|
|
481
|
+
false,
|
|
482
|
+
model,
|
|
483
|
+
true,
|
|
484
|
+
),
|
|
485
|
+
'',
|
|
486
|
+
...generateRecordForModel(
|
|
487
|
+
`${model.name}Input`,
|
|
488
|
+
fields.filter(f => f.visibility !== 'readonly'),
|
|
489
|
+
ctx,
|
|
490
|
+
true,
|
|
491
|
+
model,
|
|
492
|
+
true,
|
|
493
|
+
),
|
|
494
|
+
];
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// Everything else is a name for an existing type, which C# spells as a using alias. The target
|
|
498
|
+
// has to be fully qualified: a global alias is resolved without the file's own using block.
|
|
499
|
+
addAlias(model.name, type, ctx, false);
|
|
500
|
+
if (ctx.modelsWithInput.has(model.name)) addAlias(`${model.name}Input`, type, ctx, true);
|
|
501
|
+
return [];
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Record one `global using X = Y;`. A nullable reference type is illegal as an alias target, so the
|
|
506
|
+
* `?` is dropped and the loss reported rather than emitting a file that does not compile.
|
|
507
|
+
*/
|
|
508
|
+
function addAlias(name: string, type: ContractTypeNode, ctx: RenderContext, forInput: boolean): void {
|
|
509
|
+
const target = renderCSharpType(type, { ...ctx, qualify: true }, forInput);
|
|
510
|
+
let aliased = target;
|
|
511
|
+
if (aliased.endsWith('?') && !isNullableValueType(type, ctx)) {
|
|
512
|
+
aliased = aliased.slice(0, -1);
|
|
513
|
+
ctx.warn?.(
|
|
514
|
+
`Contract '${name}' aliases a nullable type, which C# cannot express as a using alias; ` +
|
|
515
|
+
`'${name}' is generated as '${aliased}'. Declare the nullability at each use site instead.`,
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
ctx.globalAliases.push(`global using ${name} = ${aliased};`);
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/** Whether `type` renders as a nullable *value* type, which is a legal alias target. */
|
|
522
|
+
function isNullableValueType(type: ContractTypeNode, ctx: RenderContext): boolean {
|
|
523
|
+
const inner = type.kind === 'lazy' ? type.inner : type;
|
|
524
|
+
if (inner.kind !== 'union') return false;
|
|
525
|
+
const nonNull = inner.members.filter(m => !isNullScalar(m));
|
|
526
|
+
if (nonNull.length !== 1) return false;
|
|
527
|
+
return VALUE_TYPES.has(renderCSharpType(nonNull[0]!, { ...ctx, qualify: false }, false));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
/** The C# spellings that are value types, so `T?` is `Nullable<T>` rather than a nullable reference. */
|
|
531
|
+
const VALUE_TYPES: ReadonlySet<string> = new Set([
|
|
532
|
+
'bool',
|
|
533
|
+
'byte',
|
|
534
|
+
'decimal',
|
|
535
|
+
'double',
|
|
536
|
+
'long',
|
|
537
|
+
'BigInteger',
|
|
538
|
+
'DateOnly',
|
|
539
|
+
'TimeOnly',
|
|
540
|
+
'DateTimeOffset',
|
|
541
|
+
'TimeSpan',
|
|
542
|
+
'Guid',
|
|
543
|
+
'JsonElement',
|
|
544
|
+
]);
|
|
545
|
+
|
|
546
|
+
function enumMemberNames(values: string[]): Map<string, string> {
|
|
547
|
+
const out = new Map<string, string>();
|
|
548
|
+
const used = new Set<string>();
|
|
549
|
+
for (const value of values) out.set(value, uniqueName(toCSharpEnumMemberName(value), used));
|
|
550
|
+
return out;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/**
|
|
554
|
+
* A C# enum whose members carry their wire spelling.
|
|
555
|
+
*
|
|
556
|
+
* `JsonStringEnumConverter<T>` plus `[JsonStringEnumMemberName]` is what makes the wire value travel
|
|
557
|
+
* without a converter of the generator's own. Both are framework features, so nothing reflective is
|
|
558
|
+
* generated for an enum.
|
|
559
|
+
*/
|
|
560
|
+
function generateEnum(name: string, values: string[], ctx: RenderContext, description?: string, deprecated?: boolean): string[] {
|
|
561
|
+
const entries = enumMemberNames(values);
|
|
562
|
+
const lines: string[] = [];
|
|
563
|
+
lines.push(...docLines(description, deprecated, ''));
|
|
564
|
+
lines.push(`[JsonConverter(typeof(JsonStringEnumConverter<${name}>))]`);
|
|
565
|
+
lines.push(`public enum ${name}`);
|
|
566
|
+
lines.push('{');
|
|
567
|
+
values.forEach((value, index) => {
|
|
568
|
+
if (index > 0) lines.push('');
|
|
569
|
+
lines.push(` [JsonStringEnumMemberName(${quoteCSharpString(value)})]`);
|
|
570
|
+
lines.push(` ${entries.get(value)},`);
|
|
571
|
+
});
|
|
572
|
+
lines.push('}');
|
|
573
|
+
// An enum has no fields, so no visibility can differ between reading and writing it.
|
|
574
|
+
if (ctx.modelsWithInput.has(name)) ctx.globalAliases.push(`global using ${name}Input = ${ctx.namespace}.Models.${name};`);
|
|
575
|
+
return lines;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/** The union interfaces a generated record has to declare it implements. */
|
|
579
|
+
function supertypesFor(readName: string, ctx: RenderContext, forInput: boolean): string[] {
|
|
580
|
+
const unions = ctx.hoisted?.memberships.get(readName) ?? [];
|
|
581
|
+
return unions.map(union => {
|
|
582
|
+
const decl = ctx.hoisted?.byName.get(union);
|
|
583
|
+
return forInput && decl?.needsInput ? `${union}Input` : union;
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
function generateRecordForModel(
|
|
588
|
+
name: string,
|
|
589
|
+
fields: FieldNode[],
|
|
590
|
+
ctx: RenderContext,
|
|
591
|
+
forInput: boolean,
|
|
592
|
+
model: ModelNode,
|
|
593
|
+
split: boolean,
|
|
594
|
+
): string[] {
|
|
595
|
+
const readName = forInput && name.endsWith('Input') ? name.slice(0, -'Input'.length) : name;
|
|
596
|
+
const wireCase = wireCaseFor(model, forInput, split, ctx);
|
|
597
|
+
// Once per model rather than once per generated record, so a split model does not say it twice.
|
|
598
|
+
if (!forInput) warnUncasedNesting(model, fields, wireCase, ctx);
|
|
599
|
+
return renderRecord(name, fields, ctx, forInput, supertypesFor(readName, ctx, forInput), model.description, model.deprecated, wireCase);
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function renderRecord(
|
|
603
|
+
name: string,
|
|
604
|
+
fields: FieldNode[],
|
|
605
|
+
ctx: RenderContext,
|
|
606
|
+
forInput: boolean,
|
|
607
|
+
supertypes: string[],
|
|
608
|
+
description?: string,
|
|
609
|
+
deprecated?: boolean,
|
|
610
|
+
wireCase?: WireCase,
|
|
611
|
+
): string[] {
|
|
612
|
+
const lines: string[] = [];
|
|
613
|
+
lines.push(...docLines(description, deprecated, ''));
|
|
614
|
+
const implementsClause = supertypes.length > 0 ? ` : ${supertypes.join(', ')}` : '';
|
|
615
|
+
|
|
616
|
+
// A contract with no visible fields still has to produce a serializable type.
|
|
617
|
+
if (fields.length === 0) {
|
|
618
|
+
lines.push(`public sealed record ${name}${implementsClause};`);
|
|
619
|
+
return lines;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
lines.push(`public sealed record ${name}${implementsClause}`);
|
|
623
|
+
lines.push('{');
|
|
624
|
+
fields.forEach((field, index) => {
|
|
625
|
+
if (index > 0) lines.push('');
|
|
626
|
+
lines.push(...renderField(field, ctx, forInput, name, wireCase));
|
|
627
|
+
});
|
|
628
|
+
lines.push('}');
|
|
629
|
+
return lines;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
/**
|
|
633
|
+
* One property.
|
|
634
|
+
*
|
|
635
|
+
* The `optional` and `nullable` flags are kept apart, which the Kotlin plugin cannot do: its
|
|
636
|
+
* `explicitNulls = false` is one global switch, so a required-nullable null is dropped from the
|
|
637
|
+
* payload along with the absent optionals. Here each property says for itself whether a null is
|
|
638
|
+
* written, so `x: T | null` sends `null` and `x?: T` sends nothing.
|
|
639
|
+
*
|
|
640
|
+
* Every property is `required` or carries an initializer, so the record is fully assigned under
|
|
641
|
+
* `#nullable enable` and the generated SDK compiles with warnings as errors. `required` is never
|
|
642
|
+
* combined with `[JsonIgnore]`, which System.Text.Json rejects at run time.
|
|
643
|
+
*/
|
|
644
|
+
function renderField(field: FieldNode, ctx: RenderContext, forInput: boolean, ownerTypeName: string, wireCase?: WireCase): string[] {
|
|
645
|
+
const propName = safeMemberName(toCSharpPropertyName(field.name), ownerTypeName);
|
|
646
|
+
const wireName = applyWireCase(field.name, wireCase);
|
|
647
|
+
|
|
648
|
+
let typeStr = renderCSharpType(field.type, ctx, forInput);
|
|
649
|
+
if ((field.optional || field.nullable) && !typeStr.endsWith('?')) typeStr += '?';
|
|
650
|
+
|
|
651
|
+
let initializer = field.default !== undefined ? renderDefault(field.default, field.type, ctx) : undefined;
|
|
652
|
+
// A `literal()` field carries exactly one value, so it defaults to it rather than being asked
|
|
653
|
+
// for at every call site. The property is ordinary, so the value always reaches the wire.
|
|
654
|
+
if (initializer === undefined && !field.optional && !field.nullable) {
|
|
655
|
+
const inner = field.type.kind === 'lazy' ? field.type.inner : field.type;
|
|
656
|
+
if (inner.kind === 'literal') initializer = renderDefault(inner.value, inner, ctx);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
const isRequired = !field.optional && initializer === undefined;
|
|
660
|
+
|
|
661
|
+
const lines: string[] = [];
|
|
662
|
+
lines.push(...docLines(field.description, field.deprecated, ' '));
|
|
663
|
+
lines.push(` [JsonPropertyName(${quoteCSharpString(wireName)})]`);
|
|
664
|
+
if (field.optional) lines.push(' [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]');
|
|
665
|
+
const suffix = initializer !== undefined ? ` = ${initializer};` : '';
|
|
666
|
+
lines.push(` public ${isRequired ? 'required ' : ''}${typeStr} ${propName} { get; init; }${suffix}`);
|
|
667
|
+
return lines;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// ─── Hoisted declarations ──────────────────────────────────────────────────
|
|
671
|
+
|
|
672
|
+
/** Emit the declaration standing in for one anonymous type, plus its Input twin when it needs one. */
|
|
673
|
+
function generateHoisted(decl: HoistedDecl, ctx: RenderContext): string[] {
|
|
674
|
+
const read = generateHoistedVariant(decl, ctx, false);
|
|
675
|
+
if (!decl.needsInput) return read;
|
|
676
|
+
return [...read, '', ...generateHoistedVariant(decl, ctx, true)];
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function generateHoistedVariant(decl: HoistedDecl, ctx: RenderContext, forInput: boolean): string[] {
|
|
680
|
+
const name = forInput ? `${decl.name}Input` : decl.name;
|
|
681
|
+
switch (decl.kind) {
|
|
682
|
+
case 'enum':
|
|
683
|
+
return generateEnum(name, decl.values ?? [], ctx, decl.description);
|
|
684
|
+
case 'record':
|
|
685
|
+
return renderRecord(
|
|
686
|
+
name,
|
|
687
|
+
(decl.fields ?? []).filter(f => (forInput ? f.visibility !== 'readonly' : f.visibility !== 'writeonly')),
|
|
688
|
+
ctx,
|
|
689
|
+
forInput,
|
|
690
|
+
supertypesFor(decl.name, ctx, forInput),
|
|
691
|
+
decl.description,
|
|
692
|
+
);
|
|
693
|
+
case 'tuple':
|
|
694
|
+
return generateTupleRecord(decl, name, ctx, forInput);
|
|
695
|
+
case 'plainUnion':
|
|
696
|
+
return generatePlainUnion(decl, name, ctx, forInput);
|
|
697
|
+
case 'discriminatedUnion':
|
|
698
|
+
return generateDiscriminatedUnion(decl, name, ctx, forInput);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/** `element.Deserialize<T>(options)!`, the read expression a generated converter uses per member. */
|
|
703
|
+
function deserializeExpr(type: ContractTypeNode, ctx: RenderContext, forInput: boolean): string {
|
|
704
|
+
return `element.Deserialize<${renderCSharpType(type, ctx, forInput)}>(options)!`;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* A contract tuple. It travels as a JSON array, which no BCL type does: `ValueTuple` serializes as
|
|
709
|
+
* an object, and a property-level `[JsonConverter]` cannot reach a tuple nested inside a `List<>`.
|
|
710
|
+
* A record with a type-level converter travels correctly wherever the type appears.
|
|
711
|
+
*/
|
|
712
|
+
function generateTupleRecord(decl: HoistedDecl, name: string, ctx: RenderContext, forInput: boolean): string[] {
|
|
713
|
+
const items = decl.items ?? [];
|
|
714
|
+
const converterName = `${name}Converter`;
|
|
715
|
+
const parameters = items.map((item, index) => `${renderCSharpType(item, ctx, forInput)} Item${index}`).join(', ');
|
|
716
|
+
|
|
717
|
+
const lines: string[] = [];
|
|
718
|
+
lines.push(...docLines(decl.description, undefined, ''));
|
|
719
|
+
lines.push(`[JsonConverter(typeof(${converterName}))]`);
|
|
720
|
+
lines.push(`public sealed record ${name}(${parameters});`);
|
|
721
|
+
lines.push('');
|
|
722
|
+
lines.push(`/// <summary>Reads and writes <see cref="${name}"/> as a JSON array.</summary>`);
|
|
723
|
+
lines.push(`public sealed class ${converterName} : JsonConverter<${name}>`);
|
|
724
|
+
lines.push('{');
|
|
725
|
+
lines.push(` public override ${name} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)`);
|
|
726
|
+
lines.push(' {');
|
|
727
|
+
lines.push(' using var document = JsonDocument.ParseValue(ref reader);');
|
|
728
|
+
lines.push(' var array = document.RootElement;');
|
|
729
|
+
lines.push(` if (array.ValueKind != JsonValueKind.Array || array.GetArrayLength() != ${items.length})`);
|
|
730
|
+
lines.push(' {');
|
|
731
|
+
lines.push(` throw new JsonException("Expected a JSON array of ${items.length} elements for ${name}.");`);
|
|
732
|
+
lines.push(' }');
|
|
733
|
+
lines.push('');
|
|
734
|
+
lines.push(` return new ${name}(`);
|
|
735
|
+
items.forEach((item, index) => {
|
|
736
|
+
const expr = `array[${index}].Deserialize<${renderCSharpType(item, ctx, forInput)}>(options)!`;
|
|
737
|
+
lines.push(` ${expr}${index === items.length - 1 ? '' : ','}`);
|
|
738
|
+
});
|
|
739
|
+
lines.push(' );');
|
|
740
|
+
lines.push(' }');
|
|
741
|
+
lines.push('');
|
|
742
|
+
lines.push(` public override void Write(Utf8JsonWriter writer, ${name} value, JsonSerializerOptions options)`);
|
|
743
|
+
lines.push(' {');
|
|
744
|
+
lines.push(' writer.WriteStartArray();');
|
|
745
|
+
items.forEach((_, index) => lines.push(` JsonSerializer.Serialize(writer, value.Item${index}, options);`));
|
|
746
|
+
lines.push(' writer.WriteEndArray();');
|
|
747
|
+
lines.push(' }');
|
|
748
|
+
lines.push('}');
|
|
749
|
+
return lines;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* A plain `union(A | B)` becomes an abstract record with one nested member record per member, so
|
|
754
|
+
* callers get a closed set to switch over instead of an untyped JSON value. The private constructor
|
|
755
|
+
* is what closes it: only the nested records can derive from it.
|
|
756
|
+
*
|
|
757
|
+
* Decoding tries each member in declaration order and takes the first that parses, which is exactly
|
|
758
|
+
* what Zod's `z.union` does on the server. Anything else would let the client and the service
|
|
759
|
+
* disagree about a payload both of them accept.
|
|
760
|
+
*/
|
|
761
|
+
function generatePlainUnion(decl: HoistedDecl, name: string, ctx: RenderContext, forInput: boolean): string[] {
|
|
762
|
+
const converterName = `${name}Converter`;
|
|
763
|
+
const members = decl.members ?? [];
|
|
764
|
+
|
|
765
|
+
const lines: string[] = [];
|
|
766
|
+
lines.push(...docLines(decl.description, undefined, ''));
|
|
767
|
+
lines.push(`[JsonConverter(typeof(${converterName}))]`);
|
|
768
|
+
lines.push(`public abstract record ${name}`);
|
|
769
|
+
lines.push('{');
|
|
770
|
+
lines.push(` private ${name}() { }`);
|
|
771
|
+
for (const member of members) {
|
|
772
|
+
lines.push('');
|
|
773
|
+
lines.push(` public sealed record ${member.wrapperName}(${renderCSharpType(member.type, ctx, forInput)} Value) : ${name};`);
|
|
774
|
+
}
|
|
775
|
+
lines.push('}');
|
|
776
|
+
lines.push('');
|
|
777
|
+
lines.push(`/// <summary>Reads <see cref="${name}"/> by trying each member in declaration order.</summary>`);
|
|
778
|
+
lines.push(`public sealed class ${converterName} : JsonConverter<${name}>`);
|
|
779
|
+
lines.push('{');
|
|
780
|
+
lines.push(` public override ${name} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)`);
|
|
781
|
+
lines.push(' {');
|
|
782
|
+
lines.push(' using var document = JsonDocument.ParseValue(ref reader);');
|
|
783
|
+
lines.push(' var element = document.RootElement;');
|
|
784
|
+
lines.push('');
|
|
785
|
+
for (const member of members) {
|
|
786
|
+
lines.push(' try');
|
|
787
|
+
lines.push(' {');
|
|
788
|
+
lines.push(` return new ${name}.${member.wrapperName}(${deserializeExpr(member.type, ctx, forInput)});`);
|
|
789
|
+
lines.push(' }');
|
|
790
|
+
lines.push(' catch (JsonException)');
|
|
791
|
+
lines.push(' {');
|
|
792
|
+
lines.push(' // Not this member; fall through to the next.');
|
|
793
|
+
lines.push(' }');
|
|
794
|
+
lines.push('');
|
|
795
|
+
}
|
|
796
|
+
lines.push(` throw new JsonException("No ${name} member matched the payload.");`);
|
|
797
|
+
lines.push(' }');
|
|
798
|
+
lines.push('');
|
|
799
|
+
lines.push(` public override void Write(Utf8JsonWriter writer, ${name} value, JsonSerializerOptions options)`);
|
|
800
|
+
lines.push(' {');
|
|
801
|
+
lines.push(' switch (value)');
|
|
802
|
+
lines.push(' {');
|
|
803
|
+
for (const member of members) {
|
|
804
|
+
lines.push(` case ${name}.${member.wrapperName} member:`);
|
|
805
|
+
lines.push(' JsonSerializer.Serialize(writer, member.Value, options);');
|
|
806
|
+
lines.push(' break;');
|
|
807
|
+
}
|
|
808
|
+
lines.push(' default:');
|
|
809
|
+
lines.push(` throw new JsonException($"Unknown ${name} member {value.GetType().Name}.");`);
|
|
810
|
+
lines.push(' }');
|
|
811
|
+
lines.push(' }');
|
|
812
|
+
lines.push('}');
|
|
813
|
+
return lines;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
/**
|
|
817
|
+
* A `discriminated(by=tag, A | B)` becomes an interface its member records implement, with a
|
|
818
|
+
* converter that dispatches on the tag value.
|
|
819
|
+
*
|
|
820
|
+
* An interface rather than an abstract base record: a record has single inheritance, and the
|
|
821
|
+
* hoisting pass allows one contract to belong to several unions. It is also why the tag stays a real
|
|
822
|
+
* property on each member rather than becoming `[JsonPolymorphic]` metadata, which System.Text.Json
|
|
823
|
+
* refuses to pair with a property of the same name.
|
|
824
|
+
*/
|
|
825
|
+
function generateDiscriminatedUnion(decl: HoistedDecl, name: string, ctx: RenderContext, forInput: boolean): string[] {
|
|
826
|
+
const converterName = `${name}Converter`;
|
|
827
|
+
const members = (decl.members ?? []).map(member => ({ ...member, recordName: memberRecordName(member.typeName, ctx, forInput) }));
|
|
828
|
+
const discriminator = decl.discriminator ?? '';
|
|
829
|
+
|
|
830
|
+
const lines: string[] = [];
|
|
831
|
+
lines.push(...docLines(decl.description, undefined, ''));
|
|
832
|
+
lines.push(`[JsonConverter(typeof(${converterName}))]`);
|
|
833
|
+
lines.push(`public interface ${name}`);
|
|
834
|
+
lines.push('{');
|
|
835
|
+
lines.push('}');
|
|
836
|
+
lines.push('');
|
|
837
|
+
lines.push(`/// <summary>Reads <see cref="${name}"/> by dispatching on its '${discriminator}' tag.</summary>`);
|
|
838
|
+
lines.push(`public sealed class ${converterName} : JsonConverter<${name}>`);
|
|
839
|
+
lines.push('{');
|
|
840
|
+
lines.push(` public override ${name} Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)`);
|
|
841
|
+
lines.push(' {');
|
|
842
|
+
lines.push(' using var document = JsonDocument.ParseValue(ref reader);');
|
|
843
|
+
lines.push(' var element = document.RootElement;');
|
|
844
|
+
lines.push(
|
|
845
|
+
` var tag = element.TryGetProperty(${quoteCSharpString(discriminator)}, out var tagElement) && tagElement.ValueKind == JsonValueKind.String`,
|
|
846
|
+
);
|
|
847
|
+
lines.push(' ? tagElement.GetString()');
|
|
848
|
+
lines.push(' : null;');
|
|
849
|
+
lines.push('');
|
|
850
|
+
lines.push(' return tag switch');
|
|
851
|
+
lines.push(' {');
|
|
852
|
+
for (const member of members) {
|
|
853
|
+
lines.push(` ${quoteCSharpString(member.tag ?? '')} => element.Deserialize<${member.recordName}>(options)!,`);
|
|
854
|
+
}
|
|
855
|
+
lines.push(` _ => throw new JsonException($"Unknown ${name} ${discriminator}: {tag}"),`);
|
|
856
|
+
lines.push(' };');
|
|
857
|
+
lines.push(' }');
|
|
858
|
+
lines.push('');
|
|
859
|
+
lines.push(` public override void Write(Utf8JsonWriter writer, ${name} value, JsonSerializerOptions options)`);
|
|
860
|
+
lines.push(' {');
|
|
861
|
+
lines.push(' switch (value)');
|
|
862
|
+
lines.push(' {');
|
|
863
|
+
for (const member of members) {
|
|
864
|
+
lines.push(` case ${member.recordName} member:`);
|
|
865
|
+
lines.push(' JsonSerializer.Serialize(writer, member, options);');
|
|
866
|
+
lines.push(' break;');
|
|
867
|
+
}
|
|
868
|
+
lines.push(' default:');
|
|
869
|
+
lines.push(` throw new JsonException($"Unknown ${name} member {value.GetType().Name}.");`);
|
|
870
|
+
lines.push(' }');
|
|
871
|
+
lines.push(' }');
|
|
872
|
+
lines.push('}');
|
|
873
|
+
return lines;
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
/** The concrete record name of a union member, in the read or input variant. */
|
|
877
|
+
function memberRecordName(typeName: string, ctx: RenderContext, forInput: boolean): string {
|
|
878
|
+
if (!forInput) return typeName;
|
|
879
|
+
const decl = ctx.hoisted?.byName.get(typeName);
|
|
880
|
+
if (decl) return decl.needsInput ? `${typeName}Input` : typeName;
|
|
881
|
+
return ctx.modelsWithInput.has(typeName) ? `${typeName}Input` : typeName;
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
// ─── Shared helpers ────────────────────────────────────────────────────────
|
|
885
|
+
|
|
886
|
+
/**
|
|
887
|
+
* XML doc for a declaration. Deprecation is a `<remarks>` line rather than `[Obsolete]`: an obsolete
|
|
888
|
+
* model would raise CS0618 in every generated converter and client that names it, which the
|
|
889
|
+
* compile check treats as an error. Operations, which nothing generated calls, do get `[Obsolete]`.
|
|
890
|
+
*/
|
|
891
|
+
function docLines(description: string | undefined, deprecated: boolean | undefined, indent: string): string[] {
|
|
892
|
+
const lines: string[] = [];
|
|
893
|
+
if (description) lines.push(...xmlDocLines(description, indent));
|
|
894
|
+
if (deprecated) lines.push(...xmlDocLines('Deprecated in the contract.', indent, 'remarks'));
|
|
895
|
+
return lines;
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
function uniqueName(name: string, used: Set<string>): string {
|
|
899
|
+
if (!used.has(name)) {
|
|
900
|
+
used.add(name);
|
|
901
|
+
return name;
|
|
902
|
+
}
|
|
903
|
+
let n = 2;
|
|
904
|
+
while (used.has(`${name}${n}`)) n++;
|
|
905
|
+
used.add(`${name}${n}`);
|
|
906
|
+
return `${name}${n}`;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
export type { RenderContext };
|