@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,410 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import type { ContractRootNode, ScalarTypeNode } from '@contractkit/core';
|
|
3
|
+
import { buildModelIndex } from '@contractkit/core';
|
|
4
|
+
import { generateCSharpModels } from '../src/codegen-models.js';
|
|
5
|
+
import { collectHoistedTypes } from '../src/hoist.js';
|
|
6
|
+
import {
|
|
7
|
+
arrayType,
|
|
8
|
+
contractRoot,
|
|
9
|
+
enumType,
|
|
10
|
+
field,
|
|
11
|
+
inlineObjectType,
|
|
12
|
+
literalType,
|
|
13
|
+
model,
|
|
14
|
+
recordType,
|
|
15
|
+
refType,
|
|
16
|
+
scalarType,
|
|
17
|
+
tupleType,
|
|
18
|
+
unionType,
|
|
19
|
+
} from './helpers.js';
|
|
20
|
+
|
|
21
|
+
/** Render a root the way the plugin does: hoist across the project, then generate. */
|
|
22
|
+
function render(
|
|
23
|
+
root: ContractRootNode,
|
|
24
|
+
opts: { modelsWithInput?: Set<string>; warn?: (m: string) => void; roots?: ContractRootNode[] } = {},
|
|
25
|
+
): string {
|
|
26
|
+
const roots = opts.roots ?? [root];
|
|
27
|
+
const modelIndex = buildModelIndex(roots.flatMap(r => r.models));
|
|
28
|
+
const modelsWithInput = opts.modelsWithInput ?? new Set<string>();
|
|
29
|
+
const hoisted = collectHoistedTypes(roots, { modelIndex, modelsWithInput, warn: message => opts.warn?.(message) });
|
|
30
|
+
return generateCSharpModels(root, { namespace: 'Acme.Sdk', modelsWithInput, modelIndex, hoisted, warn: opts.warn });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function one(name: string, ...fields: Parameters<typeof field>[] extends never ? never : ReturnType<typeof field>[]): ContractRootNode {
|
|
34
|
+
return contractRoot([model(name, fields)]);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('file shape', () => {
|
|
38
|
+
it('opens with the generated header, re-enables nullable, and declares the models namespace', () => {
|
|
39
|
+
const out = render(one('Payment', field('id', scalarType('uuid'))));
|
|
40
|
+
expect(out.startsWith('// <auto-generated/>')).toBe(true);
|
|
41
|
+
// `<auto-generated/>` turns the nullable context off, so it has to be turned back on.
|
|
42
|
+
expect(out).toContain('#nullable enable');
|
|
43
|
+
expect(out).toContain('namespace Acme.Sdk.Models;');
|
|
44
|
+
expect(out).toContain('using System.Text.Json.Serialization;');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('scalar mapping', () => {
|
|
49
|
+
const cases: [ScalarTypeNode['name'], string][] = [
|
|
50
|
+
['string', 'string'],
|
|
51
|
+
['email', 'string'],
|
|
52
|
+
['url', 'string'],
|
|
53
|
+
['interval', 'string'],
|
|
54
|
+
['number', 'double'],
|
|
55
|
+
['int', 'long'],
|
|
56
|
+
['bigint', 'BigInteger'],
|
|
57
|
+
['decimal', 'decimal'],
|
|
58
|
+
['boolean', 'bool'],
|
|
59
|
+
['date', 'DateOnly'],
|
|
60
|
+
['time', 'TimeOnly'],
|
|
61
|
+
['datetime', 'DateTimeOffset'],
|
|
62
|
+
['duration', 'TimeSpan'],
|
|
63
|
+
['uuid', 'Guid'],
|
|
64
|
+
['binary', 'byte[]'],
|
|
65
|
+
['unknown', 'JsonElement'],
|
|
66
|
+
['json', 'JsonElement'],
|
|
67
|
+
['object', 'JsonElement'],
|
|
68
|
+
];
|
|
69
|
+
|
|
70
|
+
it.each(cases)('maps %s to %s', (scalar, expected) => {
|
|
71
|
+
const out = render(one('M', field('f', scalarType(scalar))));
|
|
72
|
+
expect(out).toContain(`public required ${expected} F { get; init; }`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('maps int to long, since the source language means a JS safe integer', () => {
|
|
76
|
+
expect(render(one('M', field('f', scalarType('int'))))).toContain('public required long F { get; init; }');
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('maps a null scalar to a nullable object', () => {
|
|
80
|
+
expect(render(one('M', field('f', scalarType('null'))))).toContain('public required object? F { get; init; }');
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe('composite types', () => {
|
|
85
|
+
it('maps an array to List and a record to Dictionary', () => {
|
|
86
|
+
expect(render(one('M', field('f', arrayType(scalarType('string')))))).toContain('required List<string> F');
|
|
87
|
+
expect(render(one('M', field('f', recordType(scalarType('string'), scalarType('int')))))).toContain('required Dictionary<string, long> F');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('warns about a record key that cannot be a JSON object key', () => {
|
|
91
|
+
const warnings: string[] = [];
|
|
92
|
+
render(one('M', field('f', recordType(scalarType('int'), scalarType('string')))), { warn: m => warnings.push(m) });
|
|
93
|
+
expect(warnings.join('\n')).toMatch(/not representable as a JSON object key/);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('collapses a union of one non-null member to a nullable type rather than a declaration', () => {
|
|
97
|
+
const out = render(one('M', field('f', unionType(scalarType('string'), scalarType('null')))));
|
|
98
|
+
expect(out).toContain('required string? F');
|
|
99
|
+
expect(out).not.toContain('abstract record');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('optional, nullable, default and literal', () => {
|
|
104
|
+
it('makes a plain field required, so a missing property is a read error rather than a silent default', () => {
|
|
105
|
+
expect(render(one('M', field('f', scalarType('string'))))).toContain('public required string F { get; init; }');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('omits an optional field when it is null, and does not mark it required', () => {
|
|
109
|
+
const out = render(one('M', field('f', scalarType('string'), { optional: true })));
|
|
110
|
+
expect(out).toContain('[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]');
|
|
111
|
+
expect(out).toContain('public string? F { get; init; }');
|
|
112
|
+
expect(out).not.toContain('required string? F');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('writes an explicit null for a required nullable field, which the Kotlin SDK cannot express', () => {
|
|
116
|
+
const out = render(one('M', field('f', scalarType('string'), { nullable: true })));
|
|
117
|
+
expect(out).toContain('public required string? F { get; init; }');
|
|
118
|
+
expect(out).not.toContain('[JsonIgnore');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('treats an optional nullable field as optional', () => {
|
|
122
|
+
const out = render(one('M', field('f', scalarType('string'), { optional: true, nullable: true })));
|
|
123
|
+
expect(out).toContain('[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]');
|
|
124
|
+
expect(out).not.toContain('required');
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('initializes a defaulted field instead of requiring it, typed for the scalar', () => {
|
|
128
|
+
expect(render(one('M', field('f', scalarType('int'), { default: 3 })))).toContain('public long F { get; init; } = 3L;');
|
|
129
|
+
expect(render(one('M', field('f', scalarType('number'), { default: 1.5 })))).toContain('public double F { get; init; } = 1.5d;');
|
|
130
|
+
expect(render(one('M', field('f', scalarType('decimal'), { default: 2 })))).toContain('public decimal F { get; init; } = 2m;');
|
|
131
|
+
expect(render(one('M', field('f', scalarType('boolean'), { default: true })))).toContain('public bool F { get; init; } = true;');
|
|
132
|
+
expect(render(one('M', field('f', scalarType('string'), { default: 'hi' })))).toContain('public string F { get; init; } = "hi";');
|
|
133
|
+
expect(render(one('M', field('f', scalarType('bigint'), { default: 7 })))).toContain('= new BigInteger(7);');
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('leaves a field required when its default has no C# literal form', () => {
|
|
137
|
+
const out = render(one('M', field('f', scalarType('uuid'), { default: 'not-a-literal' })));
|
|
138
|
+
expect(out).toContain('public required Guid F { get; init; }');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('defaults a literal field to its one value, so the tag reaches the wire without a call site', () => {
|
|
142
|
+
expect(render(one('M', field('kind', literalType('card'))))).toContain('public string Kind { get; init; } = "card";');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('names an enum member for a default written against a hoisted inline enum', () => {
|
|
146
|
+
const out = render(one('M', field('status', enumType('pending', 'done'), { default: 'done' })));
|
|
147
|
+
expect(out).toContain('public MStatus Status { get; init; } = MStatus.Done;');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('names an enum member for a default written against a named enum contract', () => {
|
|
151
|
+
const root = contractRoot([
|
|
152
|
+
model('Rating', [], { type: enumType('good', 'neutral') }),
|
|
153
|
+
model('M', [field('r', refType('Rating'), { default: 'neutral' })]),
|
|
154
|
+
]);
|
|
155
|
+
expect(render(root)).toContain('public Rating R { get; init; } = Rating.Neutral;');
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe('naming', () => {
|
|
160
|
+
it('PascalCases a property and keeps the declared name on the wire', () => {
|
|
161
|
+
const out = render(one('M', field('x-request-id', scalarType('string'))));
|
|
162
|
+
expect(out).toContain('[JsonPropertyName("x-request-id")]');
|
|
163
|
+
expect(out).toContain('public required string XRequestId { get; init; }');
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('renames a property that would collide with its own record, which C# rejects', () => {
|
|
167
|
+
const out = render(one('Invoice', field('invoice', scalarType('string'))));
|
|
168
|
+
expect(out).toContain('[JsonPropertyName("invoice")]');
|
|
169
|
+
expect(out).toContain('public required string InvoiceValue { get; init; }');
|
|
170
|
+
});
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
describe('wire key casing', () => {
|
|
174
|
+
it('renames keys for format(output=snake) without touching the property name', () => {
|
|
175
|
+
const root = contractRoot([model('M', [field('accessToken', scalarType('string'))], { outputCase: 'snake' })]);
|
|
176
|
+
const out = render(root);
|
|
177
|
+
expect(out).toContain('[JsonPropertyName("access_token")]');
|
|
178
|
+
expect(out).toContain('public required string AccessToken { get; init; }');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('gives a split model one casing per direction', () => {
|
|
182
|
+
const root = contractRoot([
|
|
183
|
+
model('M', [field('accessToken', scalarType('string')), field('id', scalarType('string'), { visibility: 'readonly' })], {
|
|
184
|
+
inputCase: 'pascal',
|
|
185
|
+
outputCase: 'snake',
|
|
186
|
+
}),
|
|
187
|
+
]);
|
|
188
|
+
const out = render(root);
|
|
189
|
+
expect(out).toContain('[JsonPropertyName("access_token")]');
|
|
190
|
+
expect(out).toContain('[JsonPropertyName("AccessToken")]');
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it('warns when one unsplit record is asked for two different key sets', () => {
|
|
194
|
+
const warnings: string[] = [];
|
|
195
|
+
const root = contractRoot([model('M', [field('accessToken', scalarType('string'))], { inputCase: 'pascal', outputCase: 'snake' })]);
|
|
196
|
+
render(root, { warn: m => warnings.push(m) });
|
|
197
|
+
expect(warnings.join('\n')).toMatch(/can only spell one set of keys/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('warns that an anonymous object under a renamed contract keeps its own keys', () => {
|
|
201
|
+
const warnings: string[] = [];
|
|
202
|
+
const root = contractRoot([
|
|
203
|
+
model('M', [field('nested', inlineObjectType([field('innerKey', scalarType('string'))]))], { outputCase: 'snake' }),
|
|
204
|
+
]);
|
|
205
|
+
render(root, { warn: m => warnings.push(m) });
|
|
206
|
+
expect(warnings.join('\n')).toMatch(/will not be snake-cased/);
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('declarations', () => {
|
|
211
|
+
it('emits a sealed record, and a bodiless one for a contract with no fields', () => {
|
|
212
|
+
expect(render(one('M', field('f', scalarType('string'))))).toContain('public sealed record M');
|
|
213
|
+
expect(render(contractRoot([model('Empty', [])]))).toContain('public sealed record Empty;');
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('emits an enum whose members carry their wire spelling', () => {
|
|
217
|
+
const root = contractRoot([model('Status', [], { type: enumType('pending', 'in-progress') })]);
|
|
218
|
+
const out = render(root);
|
|
219
|
+
expect(out).toContain('[JsonConverter(typeof(JsonStringEnumConverter<Status>))]');
|
|
220
|
+
expect(out).toContain('[JsonStringEnumMemberName("in-progress")]');
|
|
221
|
+
expect(out).toContain(' InProgress,');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('emits a non-object alias as a fully qualified global using, which is what an alias target needs', () => {
|
|
225
|
+
const root = contractRoot([model('UserId', [], { type: scalarType('string') })]);
|
|
226
|
+
expect(render(root)).toContain('global using UserId = System.String;');
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('qualifies a generic alias target too', () => {
|
|
230
|
+
const root = contractRoot([model('P', [field('id', scalarType('string'))]), model('Ps', [], { type: arrayType(refType('P')) })]);
|
|
231
|
+
expect(render(root)).toContain('global using Ps = System.Collections.Generic.List<Acme.Sdk.Models.P>;');
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('drops nullability from an alias, which C# cannot express, and says so', () => {
|
|
235
|
+
const warnings: string[] = [];
|
|
236
|
+
const root = contractRoot([model('MaybeName', [], { type: unionType(scalarType('string'), scalarType('null')) })]);
|
|
237
|
+
const out = render(root, { warn: m => warnings.push(m) });
|
|
238
|
+
expect(out).toContain('global using MaybeName = System.String;');
|
|
239
|
+
expect(warnings.join('\n')).toMatch(/cannot express as a using alias/);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it('emits an intersection at model level as a record rather than an opaque JSON value', () => {
|
|
243
|
+
const root = contractRoot([
|
|
244
|
+
model('A', [field('a', scalarType('string'))]),
|
|
245
|
+
model('B', [field('b', scalarType('string'))]),
|
|
246
|
+
model('C', [], { type: { kind: 'intersection', members: [refType('A'), refType('B')] } }),
|
|
247
|
+
]);
|
|
248
|
+
const out = render(root);
|
|
249
|
+
expect(out).toContain('public sealed record C');
|
|
250
|
+
expect(out).toContain('public required string A { get; init; }');
|
|
251
|
+
expect(out).toContain('public required string B { get; init; }');
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
describe('inheritance', () => {
|
|
256
|
+
it('flattens bases into the record, later fields winning', () => {
|
|
257
|
+
const root = contractRoot([
|
|
258
|
+
model('Base', [field('id', scalarType('uuid')), field('note', scalarType('string'))]),
|
|
259
|
+
model('Child', [field('note', scalarType('int'), { override: true })], { bases: ['Base'] }),
|
|
260
|
+
]);
|
|
261
|
+
const out = render(root);
|
|
262
|
+
expect(out).toContain('public sealed record Child');
|
|
263
|
+
expect(out).toContain('public required Guid Id { get; init; }');
|
|
264
|
+
expect(out).toContain('public required long Note { get; init; }');
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('warns rather than throwing when a base is undefined', () => {
|
|
268
|
+
const warnings: string[] = [];
|
|
269
|
+
render(contractRoot([model('Child', [field('x', scalarType('string'))], { bases: ['Missing'] })]), { warn: m => warnings.push(m) });
|
|
270
|
+
expect(warnings.join('\n')).toMatch(/extends 'Missing', which is not defined/);
|
|
271
|
+
});
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
describe('read and Input variants', () => {
|
|
275
|
+
it('splits a model with a readonly field into a read record and an Input twin', () => {
|
|
276
|
+
const root = contractRoot([model('M', [field('id', scalarType('uuid'), { visibility: 'readonly' }), field('name', scalarType('string'))])]);
|
|
277
|
+
const out = render(root);
|
|
278
|
+
expect(out).toContain('public sealed record M');
|
|
279
|
+
expect(out).toContain('public sealed record MInput');
|
|
280
|
+
expect(out.split('public sealed record MInput')[1]).not.toContain('Id');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
it('drops a writeonly field from the read record', () => {
|
|
284
|
+
const root = contractRoot([
|
|
285
|
+
model('M', [field('secret', scalarType('string'), { visibility: 'writeonly' }), field('id', scalarType('uuid'))]),
|
|
286
|
+
]);
|
|
287
|
+
const out = render(root);
|
|
288
|
+
expect(out.split('public sealed record MInput')[0]).not.toContain('Secret');
|
|
289
|
+
expect(out.split('public sealed record MInput')[1]).toContain('Secret');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('aliases an enum Input twin rather than duplicating it, since an enum has no field visibility', () => {
|
|
293
|
+
const root = contractRoot([model('Status', [], { type: enumType('a', 'b') })]);
|
|
294
|
+
expect(render(root, { modelsWithInput: new Set(['Status']) })).toContain('global using StatusInput = Acme.Sdk.Models.Status;');
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
describe('plain unions', () => {
|
|
299
|
+
const root = contractRoot([
|
|
300
|
+
model('P', [field('id', scalarType('string'))]),
|
|
301
|
+
model('M', [field('v', unionType(refType('P'), scalarType('int')))]),
|
|
302
|
+
]);
|
|
303
|
+
|
|
304
|
+
it('emits an abstract record closed by a private constructor', () => {
|
|
305
|
+
const out = render(root);
|
|
306
|
+
expect(out).toContain('public abstract record MV');
|
|
307
|
+
expect(out).toContain(' private MV() { }');
|
|
308
|
+
expect(out).toContain('public sealed record OfP(P Value) : MV;');
|
|
309
|
+
expect(out).toContain('public sealed record OfInt(long Value) : MV;');
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it('tries members in declaration order, matching how the server parses the same union', () => {
|
|
313
|
+
const out = render(root);
|
|
314
|
+
expect(out).toContain('public sealed class MVConverter : JsonConverter<MV>');
|
|
315
|
+
const read = out.slice(out.indexOf('MV Read('), out.indexOf('void Write(', out.indexOf('MV Read(')));
|
|
316
|
+
expect(read.indexOf('OfP')).toBeLessThan(read.indexOf('OfInt'));
|
|
317
|
+
expect(out).toContain('throw new JsonException("No MV member matched the payload.");');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('collapses a union of string literals to an enum instead', () => {
|
|
321
|
+
const literals = contractRoot([model('M', [field('v', unionType(literalType('a'), literalType('b')))])]);
|
|
322
|
+
const out = render(literals);
|
|
323
|
+
expect(out).toContain('public enum MV');
|
|
324
|
+
expect(out).toContain('[JsonStringEnumMemberName("a")]');
|
|
325
|
+
});
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe('discriminated unions', () => {
|
|
329
|
+
const root = contractRoot([
|
|
330
|
+
model('Card', [field('kind', literalType('card')), field('last4', scalarType('string'))]),
|
|
331
|
+
model('Bank', [field('kind', literalType('bank'))]),
|
|
332
|
+
model('M', [field('method', { kind: 'discriminatedUnion', discriminator: 'kind', members: [refType('Card'), refType('Bank')] })]),
|
|
333
|
+
]);
|
|
334
|
+
|
|
335
|
+
it('emits an interface, so one contract can belong to several unions', () => {
|
|
336
|
+
const out = render(root);
|
|
337
|
+
expect(out).toContain('public interface MMethod');
|
|
338
|
+
expect(out).toContain('public sealed record Card : MMethod');
|
|
339
|
+
expect(out).toContain('public sealed record Bank : MMethod');
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
it('keeps the tag as a real defaulted property rather than serializer metadata', () => {
|
|
343
|
+
expect(render(root)).toContain('public string Kind { get; init; } = "card";');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('dispatches on the tag value', () => {
|
|
347
|
+
const out = render(root);
|
|
348
|
+
expect(out).toContain('var tag = element.TryGetProperty("kind", out var tagElement)');
|
|
349
|
+
expect(out).toContain('"card" => element.Deserialize<Card>(options)!,');
|
|
350
|
+
expect(out).toContain('"bank" => element.Deserialize<Bank>(options)!,');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('degrades to a JSON value and warns when the tag is not statically known', () => {
|
|
354
|
+
const warnings: string[] = [];
|
|
355
|
+
const enumTag = contractRoot([
|
|
356
|
+
model('Card', [field('kind', enumType('card'))]),
|
|
357
|
+
model('M', [field('method', { kind: 'discriminatedUnion', discriminator: 'kind', members: [refType('Card')] })]),
|
|
358
|
+
]);
|
|
359
|
+
const out = render(enumTag, { warn: m => warnings.push(m) });
|
|
360
|
+
expect(warnings.join('\n')).toMatch(/is not a literal/);
|
|
361
|
+
expect(out).toContain('required JsonElement Method');
|
|
362
|
+
});
|
|
363
|
+
});
|
|
364
|
+
|
|
365
|
+
describe('tuples', () => {
|
|
366
|
+
it('hoists every arity, including the ones Kotlin maps onto Pair and Triple', () => {
|
|
367
|
+
const out = render(one('M', field('coords', tupleType(scalarType('number'), scalarType('number')))));
|
|
368
|
+
expect(out).toContain('public sealed record MCoords(double Item0, double Item1);');
|
|
369
|
+
expect(out).toContain('public sealed class MCoordsConverter : JsonConverter<MCoords>');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('reads and writes a tuple as a JSON array of the declared length', () => {
|
|
373
|
+
const out = render(one('M', field('coords', tupleType(scalarType('number'), scalarType('string')))));
|
|
374
|
+
expect(out).toContain('array.GetArrayLength() != 2');
|
|
375
|
+
expect(out).toContain('array[0].Deserialize<double>(options)!,');
|
|
376
|
+
expect(out).toContain('array[1].Deserialize<string>(options)!');
|
|
377
|
+
expect(out).toContain('writer.WriteStartArray();');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('travels correctly inside a collection, which a property-level converter could not', () => {
|
|
381
|
+
const out = render(one('M', field('all', arrayType(tupleType(scalarType('int'), scalarType('int'))))));
|
|
382
|
+
expect(out).toContain('required List<MAll> All');
|
|
383
|
+
expect(out).toContain('[JsonConverter(typeof(MAllConverter))]');
|
|
384
|
+
});
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
describe('hoisted shapes', () => {
|
|
388
|
+
it('names an inline object after the model and field that hold it', () => {
|
|
389
|
+
const out = render(one('M', field('nested', inlineObjectType([field('a', scalarType('string'))]))));
|
|
390
|
+
expect(out).toContain('public sealed record MNested');
|
|
391
|
+
expect(out).toContain('required MNested Nested');
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
it('lets a record in one file implement an interface declared in another', () => {
|
|
395
|
+
const a = contractRoot([model('Card', [field('kind', literalType('card'))])], 'a.ck');
|
|
396
|
+
const b = contractRoot([model('M', [field('m', { kind: 'discriminatedUnion', discriminator: 'kind', members: [refType('Card')] })])], 'b.ck');
|
|
397
|
+
const outA = render(a, { roots: [a, b] });
|
|
398
|
+
const outB = render(b, { roots: [a, b] });
|
|
399
|
+
expect(outA).toContain('public sealed record Card : MM');
|
|
400
|
+
expect(outB).toContain('public interface MM');
|
|
401
|
+
});
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
describe('deprecation', () => {
|
|
405
|
+
it('documents a deprecated model rather than marking it obsolete, which generated code would trip over', () => {
|
|
406
|
+
const out = render(contractRoot([model('M', [field('f', scalarType('string'))], { deprecated: true })]));
|
|
407
|
+
expect(out).toContain('/// <remarks>Deprecated in the contract.</remarks>');
|
|
408
|
+
expect(out).not.toContain('[Obsolete');
|
|
409
|
+
});
|
|
410
|
+
});
|
package/tests/helpers.ts
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
// Re-export all helpers from the SDK plugin's test helpers (same AST builder utilities)
|
|
2
|
+
export type {
|
|
3
|
+
ContractRootNode,
|
|
4
|
+
ModelNode,
|
|
5
|
+
FieldNode,
|
|
6
|
+
ContractTypeNode,
|
|
7
|
+
ScalarTypeNode,
|
|
8
|
+
ArrayTypeNode,
|
|
9
|
+
TupleTypeNode,
|
|
10
|
+
RecordTypeNode,
|
|
11
|
+
EnumTypeNode,
|
|
12
|
+
LiteralTypeNode,
|
|
13
|
+
UnionTypeNode,
|
|
14
|
+
ModelRefTypeNode,
|
|
15
|
+
InlineObjectTypeNode,
|
|
16
|
+
LazyTypeNode,
|
|
17
|
+
SourceLocation,
|
|
18
|
+
OpRootNode,
|
|
19
|
+
OpRouteNode,
|
|
20
|
+
OpOperationNode,
|
|
21
|
+
OpParamNode,
|
|
22
|
+
OpRequestNode,
|
|
23
|
+
OpResponseNode,
|
|
24
|
+
HttpMethod,
|
|
25
|
+
ParamSource,
|
|
26
|
+
RouteModifier,
|
|
27
|
+
} from '@contractkit/core';
|
|
28
|
+
|
|
29
|
+
import type {
|
|
30
|
+
ContractRootNode,
|
|
31
|
+
ModelNode,
|
|
32
|
+
FieldNode,
|
|
33
|
+
ContractTypeNode,
|
|
34
|
+
ScalarTypeNode,
|
|
35
|
+
ArrayTypeNode,
|
|
36
|
+
TupleTypeNode,
|
|
37
|
+
RecordTypeNode,
|
|
38
|
+
EnumTypeNode,
|
|
39
|
+
LiteralTypeNode,
|
|
40
|
+
UnionTypeNode,
|
|
41
|
+
ModelRefTypeNode,
|
|
42
|
+
InlineObjectTypeNode,
|
|
43
|
+
LazyTypeNode,
|
|
44
|
+
SourceLocation,
|
|
45
|
+
OpRootNode,
|
|
46
|
+
OpRouteNode,
|
|
47
|
+
OpOperationNode,
|
|
48
|
+
OpParamNode,
|
|
49
|
+
OpRequestNode,
|
|
50
|
+
OpResponseNode,
|
|
51
|
+
HttpMethod,
|
|
52
|
+
ParamSource,
|
|
53
|
+
RouteModifier,
|
|
54
|
+
} from '@contractkit/core';
|
|
55
|
+
|
|
56
|
+
// ─── AST Builder Helpers ────────────────────────────────────────────────────
|
|
57
|
+
|
|
58
|
+
export function loc(line = 1, file = 'test.ck'): SourceLocation {
|
|
59
|
+
return { file, line };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function scalarType(name: ScalarTypeNode['name'], mods?: Partial<ScalarTypeNode>): ScalarTypeNode {
|
|
63
|
+
return { kind: 'scalar', name, ...mods };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function arrayType(item: ContractTypeNode, mods?: { min?: number; max?: number }): ArrayTypeNode {
|
|
67
|
+
return { kind: 'array', item, ...mods };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function tupleType(...items: ContractTypeNode[]): TupleTypeNode {
|
|
71
|
+
return { kind: 'tuple', items };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function recordType(key: ContractTypeNode, value: ContractTypeNode): RecordTypeNode {
|
|
75
|
+
return { kind: 'record', key, value };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function enumType(...values: string[]): EnumTypeNode {
|
|
79
|
+
return { kind: 'enum', values };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function literalType(value: string | number | boolean): LiteralTypeNode {
|
|
83
|
+
return { kind: 'literal', value };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function unionType(...members: ContractTypeNode[]): UnionTypeNode {
|
|
87
|
+
return { kind: 'union', members };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function refType(name: string): ModelRefTypeNode {
|
|
91
|
+
return { kind: 'ref', name };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function inlineObjectType(fields: FieldNode[]): InlineObjectTypeNode {
|
|
95
|
+
return { kind: 'inlineObject', fields };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function lazyType(inner: ContractTypeNode): LazyTypeNode {
|
|
99
|
+
return { kind: 'lazy', inner };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function field(name: string, type: ContractTypeNode, overrides?: Partial<FieldNode>): FieldNode {
|
|
103
|
+
return {
|
|
104
|
+
name,
|
|
105
|
+
optional: false,
|
|
106
|
+
nullable: false,
|
|
107
|
+
visibility: 'normal',
|
|
108
|
+
type,
|
|
109
|
+
loc: loc(),
|
|
110
|
+
...overrides,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function model(name: string, fields: FieldNode[], overrides?: Partial<ModelNode>): ModelNode {
|
|
115
|
+
return {
|
|
116
|
+
kind: 'model',
|
|
117
|
+
name,
|
|
118
|
+
fields,
|
|
119
|
+
loc: loc(),
|
|
120
|
+
...overrides,
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function contractRoot(models: ModelNode[], file = 'test.ck'): ContractRootNode {
|
|
125
|
+
return { kind: 'contractRoot', meta: {}, models, file };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `optional` and `nullable` default to `false` rather than being omitted. Once codegen reads
|
|
130
|
+
* them, an omitted `undefined` is falsy and so silently means "required" — which would make a
|
|
131
|
+
* fixture that meant to say nothing accidentally assert something.
|
|
132
|
+
*/
|
|
133
|
+
export function opParam(name: string, type: ContractTypeNode, overrides?: Partial<OpParamNode>): OpParamNode {
|
|
134
|
+
return { name, type, optional: false, nullable: false, loc: loc(1, 'test.op'), ...overrides };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function paramNodes(nodes: OpParamNode[]): ParamSource {
|
|
138
|
+
return { kind: 'params', nodes };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function paramRef(name: string): ParamSource {
|
|
142
|
+
return { kind: 'ref', name };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function paramType(node: ContractTypeNode): ParamSource {
|
|
146
|
+
return { kind: 'type', node };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export function opRequest(bodyType: string | ContractTypeNode, contentType: string = 'application/json'): OpRequestNode {
|
|
150
|
+
const bt: ContractTypeNode = typeof bodyType === 'string' ? refType(bodyType) : bodyType;
|
|
151
|
+
return { bodies: [{ contentType, bodyType: bt }] };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function opResponse(statusCode: number, bodyType?: string | ContractTypeNode, contentType?: string): OpResponseNode {
|
|
155
|
+
const bt: ContractTypeNode | undefined =
|
|
156
|
+
bodyType === undefined ? undefined : typeof bodyType === 'string' ? parseBodyTypeString(bodyType) : bodyType;
|
|
157
|
+
// A body with no explicit mime defaults to JSON, matching how every plugin used to read
|
|
158
|
+
// the old singular contentType field.
|
|
159
|
+
const bodies = bt === undefined ? [] : [{ contentType: contentType ?? 'application/json', bodyType: bt }];
|
|
160
|
+
return { statusCode, bodies, ...(bt !== undefined ? { hasBlock: true } : {}) };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function parseBodyTypeString(s: string): ContractTypeNode {
|
|
164
|
+
const arrayMatch = s.match(/^array\((.+)\)$/);
|
|
165
|
+
if (arrayMatch?.[1]) return { kind: 'array', item: refType(arrayMatch[1]) };
|
|
166
|
+
return refType(s);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function normalizeParamSource(value: unknown): ParamSource {
|
|
170
|
+
if (!value) return value as ParamSource;
|
|
171
|
+
if (typeof value === 'string') return { kind: 'ref', name: value };
|
|
172
|
+
if (Array.isArray(value)) return { kind: 'params', nodes: value as OpParamNode[] };
|
|
173
|
+
const v = value as ParamSource;
|
|
174
|
+
if (v.kind === 'params' || v.kind === 'ref' || v.kind === 'type') return v;
|
|
175
|
+
return { kind: 'type', node: value as ContractTypeNode };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function opOperation(method: HttpMethod, overrides?: Partial<OpOperationNode> & { query?: unknown; headers?: unknown }): OpOperationNode {
|
|
179
|
+
const normalized = { ...overrides } as Partial<OpOperationNode>;
|
|
180
|
+
if (overrides?.query !== undefined) normalized.query = normalizeParamSource(overrides.query);
|
|
181
|
+
if (overrides?.headers !== undefined) normalized.headers = normalizeParamSource(overrides.headers);
|
|
182
|
+
return {
|
|
183
|
+
method,
|
|
184
|
+
responses: [],
|
|
185
|
+
loc: loc(1, 'test.op'),
|
|
186
|
+
...normalized,
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function opRoute(
|
|
191
|
+
path: string,
|
|
192
|
+
operations: OpOperationNode[],
|
|
193
|
+
params?: ParamSource | OpParamNode[] | string,
|
|
194
|
+
modifiers?: RouteModifier[],
|
|
195
|
+
): OpRouteNode {
|
|
196
|
+
const normalizedParams = params !== undefined ? normalizeParamSource(params) : undefined;
|
|
197
|
+
return { path, params: normalizedParams, operations, modifiers, loc: loc(1, 'test.op') };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function opRoot(routes: OpRouteNode[], file = 'payments.op.ck', meta: Record<string, string> = {}): OpRootNode {
|
|
201
|
+
return { kind: 'opRoot', meta, routes, file };
|
|
202
|
+
}
|