@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.
Files changed (48) hide show
  1. package/.turbo/turbo-build$colon$ci.log +13 -0
  2. package/.turbo/turbo-build.log +12 -0
  3. package/.turbo/turbo-format.log +34 -0
  4. package/.turbo/turbo-test.log +17 -0
  5. package/CHANGELOG.md +1 -0
  6. package/LICENSE +21 -0
  7. package/README.md +173 -0
  8. package/dist/codegen-client.d.ts +35 -0
  9. package/dist/codegen-client.d.ts.map +1 -0
  10. package/dist/codegen-models.d.ts +75 -0
  11. package/dist/codegen-models.d.ts.map +1 -0
  12. package/dist/codegen-sdk.d.ts +13 -0
  13. package/dist/codegen-sdk.d.ts.map +1 -0
  14. package/dist/hoist.d.ts +53 -0
  15. package/dist/hoist.d.ts.map +1 -0
  16. package/dist/index.d.ts +30 -0
  17. package/dist/index.d.ts.map +1 -0
  18. package/dist/index.js +2569 -0
  19. package/dist/index.js.map +1 -0
  20. package/dist/naming.d.ts +89 -0
  21. package/dist/naming.d.ts.map +1 -0
  22. package/dist/runtime-converters.d.ts +15 -0
  23. package/dist/runtime-converters.d.ts.map +1 -0
  24. package/dist/runtime.d.ts +10 -0
  25. package/dist/runtime.d.ts.map +1 -0
  26. package/dist/scaffold.d.ts +26 -0
  27. package/dist/scaffold.d.ts.map +1 -0
  28. package/eslint.config.js +6 -0
  29. package/package.json +48 -0
  30. package/src/codegen-client.ts +680 -0
  31. package/src/codegen-models.ts +909 -0
  32. package/src/codegen-sdk.ts +52 -0
  33. package/src/hoist.ts +402 -0
  34. package/src/index.ts +373 -0
  35. package/src/naming.ts +262 -0
  36. package/src/runtime-converters.ts +147 -0
  37. package/src/runtime.ts +381 -0
  38. package/src/scaffold.ts +41 -0
  39. package/tests/codegen-client.test.ts +275 -0
  40. package/tests/codegen-models.test.ts +410 -0
  41. package/tests/helpers.ts +202 -0
  42. package/tests/hoist.test.ts +92 -0
  43. package/tests/index.test.ts +124 -0
  44. package/tests/naming.test.ts +133 -0
  45. package/tests/runtime.test.ts +104 -0
  46. package/tests/scaffold.test.ts +28 -0
  47. package/tsconfig.json +9 -0
  48. package/vitest.config.ts +14 -0
@@ -0,0 +1,275 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { buildModelIndex } from '@contractkit/core';
3
+ import type { ContractRootNode, OpRootNode } from '@contractkit/core';
4
+ import { buildPathExpression, deriveClientClassName, deriveMethodName, generateCSharpClient, hasPublicOperations } from '../src/codegen-client.js';
5
+ import { collectHoistedTypes } from '../src/hoist.js';
6
+ import {
7
+ contractRoot,
8
+ field,
9
+ model,
10
+ opOperation,
11
+ opParam,
12
+ opRequest,
13
+ opResponse,
14
+ opRoot,
15
+ opRoute,
16
+ paramRef,
17
+ refType,
18
+ scalarType,
19
+ } from './helpers.js';
20
+
21
+ function render(root: OpRootNode, opts: { contracts?: ContractRootNode[]; modelsWithInput?: Set<string>; includeInternal?: boolean } = {}): string {
22
+ const contracts = opts.contracts ?? [];
23
+ const modelIndex = buildModelIndex(contracts.flatMap(r => r.models));
24
+ const modelsWithInput = opts.modelsWithInput ?? new Set<string>();
25
+ const hoisted = collectHoistedTypes(contracts, { modelIndex, modelsWithInput });
26
+ return generateCSharpClient(root, { namespace: 'Acme.Sdk', modelsWithInput, modelIndex, hoisted, includeInternal: opts.includeInternal });
27
+ }
28
+
29
+ describe('naming', () => {
30
+ it('names the client after its file', () => {
31
+ expect(deriveClientClassName('contracts/billing.op.ck')).toBe('BillingClient');
32
+ });
33
+
34
+ it('resolves the method name by sdk, then name, then verb and path', () => {
35
+ const route = opRoute('/payments/{paymentId}', []);
36
+ expect(deriveMethodName(opOperation('get', { sdk: 'fetchOne' }), route)).toBe('FetchOneAsync');
37
+ expect(deriveMethodName(opOperation('get', { name: 'Create an Offer' }), route)).toBe('CreateAnOfferAsync');
38
+ expect(deriveMethodName(opOperation('get'), route)).toBe('GetPaymentsByPaymentIdAsync');
39
+ });
40
+
41
+ it('rejects two operations that would generate the same method', () => {
42
+ const root = opRoot([opRoute('/a', [opOperation('get', { sdk: 'thing' })]), opRoute('/b', [opOperation('post', { sdk: 'thing' })])]);
43
+ expect(() => render(root)).toThrow(/both generate the client method 'ThingAsync'/);
44
+ });
45
+ });
46
+
47
+ describe('path building', () => {
48
+ it('keeps literal segments literal and escapes caller values', () => {
49
+ expect(buildPathExpression('/payments/{paymentId}/receipt')).toBe('http.Path("payments", http.Segment(paymentId), "receipt")');
50
+ });
51
+
52
+ it('camelCases a hyphenated placeholder into a valid identifier', () => {
53
+ expect(buildPathExpression('/invoices/{invoice-id}')).toBe('http.Path("invoices", http.Segment(invoiceId))');
54
+ });
55
+
56
+ it('reads a placeholder off the route model when the route declares one', () => {
57
+ expect(buildPathExpression('/refunds/{paymentId}', paramRef('PaymentRef'))).toBe('http.Path("refunds", http.Segment(pathParams.PaymentId))');
58
+ });
59
+ });
60
+
61
+ describe('class and method shape', () => {
62
+ const contracts = [contractRoot([model('Payment', [field('id', scalarType('uuid'))])])];
63
+
64
+ it('takes the shared SdkHttp through a primary constructor', () => {
65
+ const root = opRoot([opRoute('/payments', [opOperation('get', { sdk: 'list', responses: [opResponse(200, 'Payment')] })])], 'billing.ck');
66
+ const out = render(root, { contracts });
67
+ expect(out).toContain('public sealed class BillingClient(SdkHttp http)');
68
+ expect(out).toContain('namespace Acme.Sdk.Clients;');
69
+ expect(out).toContain('using Acme.Sdk.Models;');
70
+ });
71
+
72
+ it('returns the decoded body and always takes a trailing cancellation token', () => {
73
+ const root = opRoot([opRoute('/payments', [opOperation('get', { sdk: 'list', responses: [opResponse(200, 'array(Payment)')] })])]);
74
+ const out = render(root, { contracts });
75
+ expect(out).toContain('public async Task<List<Payment>> ListAsync(CancellationToken cancellationToken = default)');
76
+ expect(out).toContain('return http.ReadJson<List<Payment>>(response);');
77
+ expect(out).toContain('.ConfigureAwait(false);');
78
+ });
79
+
80
+ it('returns a plain Task and reads no body when the operation declares none', () => {
81
+ const root = opRoot([opRoute('/payments/{id}', [opOperation('delete', { sdk: 'remove' })], [opParam('id', scalarType('uuid'))])]);
82
+ const out = render(root, { contracts });
83
+ expect(out).toContain('public async Task RemoveAsync(Guid id, CancellationToken cancellationToken = default)');
84
+ expect(out).toContain('await http.ExecuteAsync(');
85
+ expect(out).not.toContain('var response = await');
86
+ });
87
+
88
+ it('sends the Input variant of a body model', () => {
89
+ const root = opRoot([opRoute('/payments', [opOperation('post', { sdk: 'create', request: opRequest('Payment') })])]);
90
+ const out = render(root, { contracts, modelsWithInput: new Set(['Payment']) });
91
+ expect(out).toContain('PaymentInput body');
92
+ expect(out).toContain('content: http.JsonContent(body, "application/json")');
93
+ });
94
+
95
+ it('picks a content factory per body mime', () => {
96
+ const kinds: [string, string, string][] = [
97
+ ['multipart/form-data', 'IEnumerable<SdkPart> body', 'content: http.MultipartContent(body)'],
98
+ ['application/x-www-form-urlencoded', 'Payment body', 'content: http.FormContent(body)'],
99
+ ['text/plain', 'string body', 'content: http.TextContent(body, "text/plain")'],
100
+ ['application/octet-stream', 'byte[] body', 'content: http.BinaryContent(body, "application/octet-stream")'],
101
+ ];
102
+ for (const [mime, param, call] of kinds) {
103
+ const root = opRoot([opRoute('/x', [opOperation('post', { sdk: 'send', request: opRequest('Payment', mime) })])]);
104
+ const out = render(root, { contracts });
105
+ expect(out).toContain(param);
106
+ expect(out).toContain(call);
107
+ }
108
+ });
109
+
110
+ it('marks a deprecated operation obsolete', () => {
111
+ const root = opRoot([opRoute('/x', [opOperation('get', { sdk: 'old' })], undefined, ['deprecated'])]);
112
+ expect(render(root, { contracts })).toContain('[Obsolete("Deprecated in the contract")]');
113
+ });
114
+
115
+ it('skips internal operations unless asked for them', () => {
116
+ const root = opRoot([
117
+ opRoute('/pub', [opOperation('get', { sdk: 'pub' })]),
118
+ opRoute('/priv', [opOperation('get', { sdk: 'priv' })], undefined, ['internal']),
119
+ ]);
120
+ expect(render(root, { contracts })).not.toContain('PrivAsync');
121
+ expect(render(root, { contracts, includeInternal: true })).toContain('PrivAsync');
122
+ expect(hasPublicOperations(opRoot([opRoute('/p', [opOperation('get')], undefined, ['internal'])]))).toBe(false);
123
+ });
124
+ });
125
+
126
+ describe('parameters', () => {
127
+ const contracts = [contractRoot([model('Payment', [field('id', scalarType('uuid'))])])];
128
+
129
+ it('emits a query record whose keys are the declared names', () => {
130
+ const root = opRoot([
131
+ opRoute('/payments', [
132
+ opOperation('get', {
133
+ sdk: 'list',
134
+ query: [opParam('cursor', scalarType('string')), opParam('limit', scalarType('int'), { optional: true })],
135
+ }),
136
+ ]),
137
+ ]);
138
+ const out = render(root, { contracts });
139
+ expect(out).toContain('public sealed record ListQuery');
140
+ expect(out).toContain('[JsonPropertyName("cursor")]');
141
+ expect(out).toContain('public required string Cursor { get; init; }');
142
+ expect(out).toContain('public long? Limit { get; init; }');
143
+ expect(out).toContain('query: http.Params(query)');
144
+ });
145
+
146
+ it('makes the whole argument optional when every field may be omitted', () => {
147
+ const root = opRoot([
148
+ opRoute('/payments', [opOperation('get', { sdk: 'list', query: [opParam('limit', scalarType('int'), { optional: true })] })]),
149
+ ]);
150
+ expect(render(root, { contracts })).toContain('ListQuery? query = null');
151
+ });
152
+
153
+ it('falls back to a plain map when a block declares nothing', () => {
154
+ const root = opRoot([opRoute('/payments', [opOperation('get', { sdk: 'list', query: [] })])]);
155
+ expect(render(root, { contracts })).toContain('IReadOnlyDictionary<string, string>? query = null');
156
+ });
157
+
158
+ it('puts every required parameter ahead of every optional one, which C# requires', () => {
159
+ const root = opRoot([
160
+ opRoute('/payments', [
161
+ opOperation('get', {
162
+ sdk: 'list',
163
+ query: [opParam('limit', scalarType('int'), { optional: true })],
164
+ headers: [opParam('x-tenant', scalarType('string'))],
165
+ }),
166
+ ]),
167
+ ]);
168
+ const out = render(root, { contracts });
169
+ const signature = out.slice(out.indexOf('public async Task ListAsync('), out.indexOf('\n', out.indexOf('public async Task ListAsync(')));
170
+ expect(signature.indexOf('customHeaders')).toBeLessThan(signature.indexOf('query'));
171
+ expect(signature).toContain('ListQuery? query = null');
172
+ });
173
+
174
+ it('names a route-level params model pathParams, since params is a C# keyword', () => {
175
+ const root = opRoot([opRoute('/refunds/{paymentId}', [opOperation('get', { sdk: 'refund' })], paramRef('PaymentRef'))]);
176
+ const out = render(root, { contracts: [contractRoot([model('PaymentRef', [field('paymentId', scalarType('uuid'))])])] });
177
+ expect(out).toContain('PaymentRef pathParams');
178
+ expect(out).not.toContain('@params');
179
+ });
180
+ });
181
+
182
+ describe('responses', () => {
183
+ const contracts = [contractRoot([model('Payment', [field('id', scalarType('uuid'))])])];
184
+
185
+ it('passes a declared error status as expected rather than letting it throw', () => {
186
+ const root = opRoot([
187
+ opRoute('/payments', [opOperation('get', { sdk: 'list', responses: [opResponse(200, 'Payment'), opResponse(404, 'Payment')] })]),
188
+ ]);
189
+ expect(render(root, { contracts })).toContain('expectStatuses: new[] { 404 }');
190
+ });
191
+
192
+ it('documents the statuses that do throw', () => {
193
+ const root = opRoot([
194
+ opRoute('/payments', [opOperation('get', { sdk: 'list', responses: [opResponse(200, 'Payment'), { statusCode: 500, bodies: [] }] })]),
195
+ ]);
196
+ expect(render(root, { contracts })).toContain('/// <exception cref="SdkException">On 500.</exception>');
197
+ });
198
+
199
+ it('switches on the status when the operation declares several', () => {
200
+ const root = opRoot([
201
+ opRoute('/payments', [
202
+ opOperation('get', { sdk: 'get', responses: [opResponse(200, 'Payment'), { statusCode: 304, bodies: [], hasBlock: true }] }),
203
+ ]),
204
+ ]);
205
+ const out = render(root, { contracts });
206
+ expect(out).toContain('switch (response.Status)');
207
+ expect(out).toContain('public abstract record GetResponse');
208
+ expect(out).toContain(' private GetResponse() { }');
209
+ expect(out).toContain('public sealed record Status200(Payment Data) : GetResponse;');
210
+ expect(out).toContain('public sealed record Status304() : GetResponse;');
211
+ expect(out).toContain('case 304:');
212
+ });
213
+
214
+ it('switches on the content type when one status declares several mimes', () => {
215
+ const root = opRoot([
216
+ opRoute('/payments', [
217
+ opOperation('get', {
218
+ sdk: 'get',
219
+ responses: [
220
+ {
221
+ statusCode: 200,
222
+ hasBlock: true,
223
+ bodies: [
224
+ { contentType: 'application/json', bodyType: refType('Payment') },
225
+ { contentType: 'text/plain', bodyType: refType('Payment') },
226
+ ],
227
+ },
228
+ ],
229
+ }),
230
+ ]),
231
+ ]);
232
+ const out = render(root, { contracts });
233
+ expect(out).toContain('switch (response.ContentType)');
234
+ expect(out).toContain('case "text/plain":');
235
+ expect(out).toContain('public sealed record TextPlain(string Data) : GetResponse;');
236
+ expect(out).toContain('public sealed record ApplicationJson(Payment Data) : GetResponse;');
237
+ });
238
+ });
239
+
240
+ describe('response headers', () => {
241
+ const contracts = [contractRoot([model('Payment', [field('id', scalarType('uuid'))])])];
242
+
243
+ function withHeaders(headers: { name: string; optional: boolean; type: ReturnType<typeof scalarType> }[]): string {
244
+ const root = opRoot([opRoute('/payments', [opOperation('get', { sdk: 'get', responses: [{ ...opResponse(200, 'Payment'), headers }] })])]);
245
+ return render(root, { contracts });
246
+ }
247
+
248
+ it('requires a declared header and parses it to its type', () => {
249
+ const out = withHeaders([
250
+ { name: 'x-request-id', optional: false, type: scalarType('string') },
251
+ { name: 'x-count', optional: false, type: scalarType('int') },
252
+ ]);
253
+ expect(out).toContain('http.RequireHeader(response, "x-request-id")');
254
+ expect(out).toContain('long.Parse(http.RequireHeader(response, "x-count"), CultureInfo.InvariantCulture)');
255
+ expect(out).toContain('public sealed record GetHeaders(string XRequestId, long XCount);');
256
+ expect(out).toContain('public sealed record GetResult(Payment Data, GetHeaders Headers);');
257
+ });
258
+
259
+ it('leaves an optional header absent rather than failing', () => {
260
+ const out = withHeaders([{ name: 'x-cache-hit', optional: true, type: scalarType('boolean') }]);
261
+ expect(out).toContain('response.Header("x-cache-hit") is { } xCacheHit ? xCacheHit == "true" : null');
262
+ expect(out).toContain('public sealed record GetHeaders(bool? XCacheHit);');
263
+ });
264
+
265
+ it('parses each header scalar the way the other SDKs do', () => {
266
+ expect(withHeaders([{ name: 'h', optional: false, type: scalarType('uuid') }])).toContain('Guid.Parse(');
267
+ expect(withHeaders([{ name: 'h', optional: false, type: scalarType('datetime') }])).toContain('DateTimeOffset.Parse(');
268
+ expect(withHeaders([{ name: 'h', optional: false, type: scalarType('duration') }])).toContain('XmlConvert.ToTimeSpan(');
269
+ expect(withHeaders([{ name: 'h', optional: false, type: scalarType('bigint') }])).toContain('BigInteger.Parse(');
270
+ });
271
+
272
+ it('rejects a header type that cannot come off the wire as text', () => {
273
+ expect(() => withHeaders([{ name: 'h', optional: false, type: scalarType('json') }])).toThrow(/cannot be read from an HTTP header/);
274
+ });
275
+ });