@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,680 @@
1
+ import type {
2
+ ModelNode,
3
+ OpOperationNode,
4
+ OpResponseBodyNode,
5
+ OpResponseHeaderNode,
6
+ OpResponseNode,
7
+ OpRootNode,
8
+ OpRouteNode,
9
+ ParamSource,
10
+ } from '@contractkit/core';
11
+ import { classifyContentType, observableResponses, resolveModifiers } from '@contractkit/core';
12
+ import type { HoistResult } from './hoist.js';
13
+ import { createRenderContext, renderCSharpType, renderFile, type RenderContext } from './codegen-models.js';
14
+ import {
15
+ deriveCSharpFileBase,
16
+ quoteCSharpString,
17
+ safeMemberName,
18
+ toCSharpParameterName,
19
+ toCSharpPropertyName,
20
+ toCSharpTypeName,
21
+ xmlDocLines,
22
+ } from './naming.js';
23
+
24
+ export interface CSharpClientCodegenOptions {
25
+ namespace: string;
26
+ modelsWithInput: ReadonlySet<string>;
27
+ modelIndex?: ReadonlyMap<string, ModelNode>;
28
+ hoisted?: HoistResult;
29
+ includeInternal?: boolean;
30
+ warn?: (message: string) => void;
31
+ }
32
+
33
+ /**
34
+ * The `using` block every generated client file carries. Fixed for the same reason the models
35
+ * block is: everything a client can name is in the base class library or in the SDK's own two
36
+ * namespaces.
37
+ */
38
+ function clientUsings(namespaceName: string): string[] {
39
+ return [
40
+ 'using System;',
41
+ 'using System.Collections.Generic;',
42
+ 'using System.Globalization;',
43
+ 'using System.Net.Http;',
44
+ 'using System.Numerics;',
45
+ 'using System.Text.Json;',
46
+ 'using System.Text.Json.Serialization;',
47
+ 'using System.Threading;',
48
+ 'using System.Threading.Tasks;',
49
+ 'using System.Xml;',
50
+ `using ${namespaceName}.Models;`,
51
+ `using ${namespaceName}.Runtime;`,
52
+ ];
53
+ }
54
+
55
+ /** Whether the root has at least one operation eligible for client emission. */
56
+ export function hasPublicOperations(root: OpRootNode, includeInternal = false): boolean {
57
+ for (const route of root.routes) {
58
+ for (const op of route.operations) {
59
+ if (includeInternal || !resolveModifiers(route, op).includes('internal')) return true;
60
+ }
61
+ }
62
+ return false;
63
+ }
64
+
65
+ export function deriveClientClassName(file: string): string {
66
+ return `${deriveCSharpFileBase(file)}Client`;
67
+ }
68
+
69
+ export function deriveClientPropertyName(file: string): string {
70
+ return deriveCSharpFileBase(file);
71
+ }
72
+
73
+ /**
74
+ * Generate the client class for one operations file: one `Task`-returning method per public
75
+ * operation, plus the request and response shapes those methods name.
76
+ */
77
+ export function generateCSharpClient(root: OpRootNode, opts: CSharpClientCodegenOptions): string {
78
+ const className = deriveClientClassName(root.file);
79
+ const includeInternal = opts.includeInternal ?? false;
80
+ const ctx = createRenderContext(opts);
81
+
82
+ const publicOps: { route: OpRouteNode; op: OpOperationNode }[] = [];
83
+ for (const route of root.routes) {
84
+ for (const op of route.operations) {
85
+ if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
86
+ publicOps.push({ route, op });
87
+ }
88
+ }
89
+
90
+ // Request and response shapes, emitted after the class: a method's signature names them, and C#
91
+ // does not care about declaration order.
92
+ const shapeLines: string[] = [];
93
+ for (const { route, op } of publicOps) {
94
+ const base = methodBase(deriveMethodName(op, route));
95
+ for (const { source, suffix } of [
96
+ { source: op.query, suffix: 'Query' },
97
+ { source: op.headers, suffix: 'Headers' },
98
+ ]) {
99
+ if (source?.kind !== 'params' || source.nodes.length === 0) continue;
100
+ const shapeName = `${base}${suffix}`;
101
+ shapeLines.push('');
102
+ shapeLines.push(
103
+ ...xmlDocLines(`The ${suffix === 'Query' ? 'query parameters' : 'request headers'} declared on ${where(route, op)}.`, ''),
104
+ );
105
+ shapeLines.push(`public sealed record ${shapeName}`);
106
+ shapeLines.push('{');
107
+ source.nodes.forEach((node, index) => {
108
+ if (index > 0) shapeLines.push('');
109
+ const propName = safeMemberName(toCSharpPropertyName(node.name), shapeName);
110
+ let type = renderCSharpType(node.type, ctx, true);
111
+ const optional = Boolean(node.optional) || node.default !== undefined;
112
+ if (optional && !type.endsWith('?')) type += '?';
113
+ shapeLines.push(` [JsonPropertyName(${quoteCSharpString(node.name)})]`);
114
+ if (optional) shapeLines.push(' [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]');
115
+ shapeLines.push(` public ${optional ? '' : 'required '}${type} ${propName} { get; init; }`);
116
+ });
117
+ shapeLines.push('}');
118
+ }
119
+ shapeLines.push(...responseDeclarations(route, op, ctx));
120
+ }
121
+
122
+ const methodLines: string[] = [];
123
+ const seen = new Map<string, string>();
124
+ for (const { route, op } of publicOps) {
125
+ const methodName = deriveMethodName(op, route);
126
+ const clash = seen.get(methodName);
127
+ if (clash) {
128
+ throw new Error(
129
+ `plugin-csharp: ${where(route, op)} and ${clash} both generate the client method '${methodName}' on ${className}. ` +
130
+ `Give one of them a distinct 'sdk:' name.`,
131
+ );
132
+ }
133
+ seen.set(methodName, where(route, op));
134
+ methodLines.push('');
135
+ methodLines.push(...generateMethod(route, op, ctx, methodName));
136
+ }
137
+
138
+ const body: string[] = [];
139
+ body.push('');
140
+ // The basename, not `root.file`: that is an absolute path on whoever ran the build, and
141
+ // embedding it would make the generated source differ between machines.
142
+ body.push(...xmlDocLines(`Operations declared in <c>${root.file.split('/').pop()}</c>.`, ''));
143
+ body.push(`public sealed class ${className}(SdkHttp http)`);
144
+ body.push('{');
145
+ // `methodLines` opens with a blank separator between methods; the first one sits against the
146
+ // class header, so it is dropped rather than left as a gap.
147
+ body.push(...methodLines.slice(1).map(l => (l === '' ? '' : ` ${l}`)));
148
+ body.push('}');
149
+ body.push(...shapeLines);
150
+
151
+ return renderFile(`${opts.namespace}.Clients`, ctx.globalAliases, clientUsings(opts.namespace), body);
152
+ }
153
+
154
+ function where(route: OpRouteNode, op: OpOperationNode): string {
155
+ return `${op.method.toUpperCase()} ${route.path}`;
156
+ }
157
+
158
+ /** The PascalCase stem generated type names hang off: the method name without its `Async` suffix. */
159
+ function methodBase(methodName: string): string {
160
+ return methodName.endsWith('Async') ? methodName.slice(0, -'Async'.length) : methodName;
161
+ }
162
+
163
+ // ─── Response shape ────────────────────────────────────────────────────────
164
+
165
+ /**
166
+ * How a method reports what came back, mirroring the TypeScript, Python and Kotlin SDKs.
167
+ *
168
+ * `simple` is the overwhelmingly common case and returns the body itself. The other two exist
169
+ * because the caller cannot otherwise tell which status, or which mime, it received.
170
+ */
171
+ type ResponseShape =
172
+ | { kind: 'simple'; response?: OpResponseNode }
173
+ | { kind: 'multiMime'; response: OpResponseNode }
174
+ | { kind: 'multiStatus'; responses: OpResponseNode[] };
175
+
176
+ function responseShape(op: OpOperationNode): ResponseShape {
177
+ // `observableResponses` is shared with the router and the other SDKs, so all of them agree on
178
+ // which statuses are values and which are failures.
179
+ const observable = observableResponses(op);
180
+ if (observable.length > 1) return { kind: 'multiStatus', responses: observable };
181
+ const response = observable[0];
182
+ if (response && response.bodies.length > 1) return { kind: 'multiMime', response };
183
+ return { kind: 'simple', response };
184
+ }
185
+
186
+ function observableOf(shape: ResponseShape): OpResponseNode[] {
187
+ if (shape.kind === 'multiStatus') return shape.responses;
188
+ return shape.response ? [shape.response] : [];
189
+ }
190
+
191
+ // ─── Method generation ─────────────────────────────────────────────────────
192
+
193
+ function generateMethod(route: OpRouteNode, op: OpOperationNode, ctx: RenderContext, methodName: string): string[] {
194
+ const base = methodBase(methodName);
195
+ const shape = responseShape(op);
196
+ const returnType = returnTypeFor(shape, base, ctx);
197
+ const observable = observableOf(shape);
198
+ const expectStatuses = observable.filter(r => r.statusCode < 200 || r.statusCode >= 300).map(r => r.statusCode);
199
+
200
+ const params = buildMethodParams(route, op, ctx);
201
+ const signature = [...params.map(p => `${p.type} ${p.name}${p.optional ? ' = null' : ''}`), 'CancellationToken cancellationToken = default'].join(
202
+ ', ',
203
+ );
204
+
205
+ const lines: string[] = [];
206
+ lines.push(...methodDoc(route, op, observable));
207
+ if (resolveModifiers(route, op).includes('deprecated')) lines.push('[Obsolete("Deprecated in the contract")]');
208
+
209
+ lines.push(`public async ${returnType === 'void' ? 'Task' : `Task<${returnType}>`} ${methodName}(${signature})`);
210
+ lines.push('{');
211
+
212
+ const callArgs: string[] = [`HttpMethod.${httpMethodConstant(op.method)}`, buildPathExpression(route.path, route.params)];
213
+ if (op.query) callArgs.push('query: http.Params(query)');
214
+ if (op.headers) callArgs.push('headers: http.Params(customHeaders)');
215
+ const content = bodyArgument(op);
216
+ if (content) callArgs.push(content);
217
+ if (expectStatuses.length > 0) callArgs.push(`expectStatuses: new[] { ${expectStatuses.join(', ')} }`);
218
+ callArgs.push('cancellationToken: cancellationToken');
219
+
220
+ const assignment = returnType === 'void' ? 'await ' : 'var response = await ';
221
+ lines.push(` ${assignment}http.ExecuteAsync(`);
222
+ callArgs.forEach((arg, index) => {
223
+ lines.push(` ${arg}${index === callArgs.length - 1 ? ').ConfigureAwait(false);' : ','}`);
224
+ });
225
+ lines.push(...returnStatements(shape, base, ctx, where(route, op)));
226
+ lines.push('}');
227
+ return lines;
228
+ }
229
+
230
+ /** What a method hands back. Declared before the body so the two cannot drift apart. */
231
+ function returnTypeFor(shape: ResponseShape, base: string, ctx: RenderContext): string {
232
+ if (shape.kind !== 'simple') return `${base}Response`;
233
+ const response = shape.response;
234
+ const body = response?.bodies[0];
235
+ const headers = response?.headers ?? [];
236
+ if (!body) return headers.length > 0 ? `${base}Headers` : 'void';
237
+ const dataType = bodyCSharpType(body, ctx);
238
+ // A declared response header changes the return shape: the body alone cannot carry it.
239
+ return headers.length > 0 ? `${base}Result` : dataType;
240
+ }
241
+
242
+ /** The C# type of one response body. A non-JSON mime ignores the schema, as in every SDK. */
243
+ function bodyCSharpType(body: OpResponseBodyNode, ctx: RenderContext): string {
244
+ switch (classifyContentType(body.contentType)) {
245
+ case 'text':
246
+ return 'string';
247
+ case 'binary':
248
+ return 'byte[]';
249
+ default:
250
+ return renderCSharpType(body.bodyType, ctx, false);
251
+ }
252
+ }
253
+
254
+ /** The expression that reads one body out of the response. */
255
+ function bodyReadExpr(body: OpResponseBodyNode, ctx: RenderContext): string {
256
+ switch (classifyContentType(body.contentType)) {
257
+ case 'text':
258
+ return 'response.Text';
259
+ case 'binary':
260
+ return 'response.Bytes';
261
+ default:
262
+ return `http.ReadJson<${renderCSharpType(body.bodyType, ctx, false)}>(response)`;
263
+ }
264
+ }
265
+
266
+ /** The statements after `ExecuteAsync`, which turn the response into the declared return type. */
267
+ function returnStatements(shape: ResponseShape, base: string, ctx: RenderContext, place: string): string[] {
268
+ if (shape.kind === 'simple') {
269
+ const response = shape.response;
270
+ const body = response?.bodies[0];
271
+ const headers = response?.headers ?? [];
272
+ if (headers.length === 0) return body ? [` return ${bodyReadExpr(body, ctx)};`] : [];
273
+ const lines = readHeaderLines(headers, `${base}Headers`, ctx, place, ' ');
274
+ return body ? [...lines, ` return new ${base}Result(${bodyReadExpr(body, ctx)}, headers);`] : [...lines, ' return headers;'];
275
+ }
276
+
277
+ if (shape.kind === 'multiMime') {
278
+ const headers = shape.response.headers ?? [];
279
+ const lines = headers.length > 0 ? readHeaderLines(headers, `${base}Headers`, ctx, place, ' ') : [];
280
+ lines.push(...mimeSwitch(shape.response, base, undefined, ctx, ' ', headers.length > 0));
281
+ return lines;
282
+ }
283
+
284
+ // The first declared status is the fall-through, so the switch is exhaustive without a branch
285
+ // for a status the service cannot return.
286
+ const [fallback, ...rest] = shape.responses;
287
+ const lines: string[] = [' switch (response.Status)', ' {'];
288
+ for (const response of rest) {
289
+ lines.push(` case ${response.statusCode}:`);
290
+ lines.push(' {');
291
+ lines.push(...statusBranch(response, base, response.statusCode, ctx, place, ' '));
292
+ lines.push(' }');
293
+ lines.push('');
294
+ }
295
+ lines.push(' default:');
296
+ lines.push(' {');
297
+ lines.push(...statusBranch(fallback!, base, fallback!.statusCode, ctx, place, ' '));
298
+ lines.push(' }');
299
+ lines.push(' }');
300
+ return lines;
301
+ }
302
+
303
+ /**
304
+ * One switch branch: read this status's headers, then dispatch over its mimes.
305
+ *
306
+ * Every branch is braced. Two branches each declaring `headers` would otherwise collide, since a
307
+ * declaration in a switch section is scoped to the whole switch block rather than to its own case.
308
+ */
309
+ function statusBranch(response: OpResponseNode, base: string, statusCode: number, ctx: RenderContext, place: string, indent: string): string[] {
310
+ const lines: string[] = [];
311
+ const headers = response.headers ?? [];
312
+ if (headers.length > 0) lines.push(...readHeaderLines(headers, headersRecordName(base, statusCode), ctx, place, indent));
313
+ lines.push(...mimeSwitch(response, base, statusCode, ctx, indent, headers.length > 0));
314
+ return lines;
315
+ }
316
+
317
+ /**
318
+ * Construct the response case, dispatching on the content type when a status declares several
319
+ * mimes. The first declared mime is the fall-through, for the same reason the first status is.
320
+ */
321
+ function mimeSwitch(
322
+ response: OpResponseNode,
323
+ base: string,
324
+ statusCode: number | undefined,
325
+ ctx: RenderContext,
326
+ indent: string,
327
+ hasHeaders: boolean,
328
+ ): string[] {
329
+ const bodies = response.bodies;
330
+ const construct = (body: OpResponseBodyNode | undefined): string => {
331
+ const args: string[] = [];
332
+ if (body) args.push(bodyReadExpr(body, ctx));
333
+ if (hasHeaders) args.push('headers');
334
+ return `new ${base}Response.${leafRecordName(response, body, statusCode)}(${args.join(', ')})`;
335
+ };
336
+
337
+ if (bodies.length <= 1) return [`${indent}return ${construct(bodies[0])};`];
338
+
339
+ const [fallback, ...rest] = bodies;
340
+ const lines: string[] = [`${indent}switch (response.ContentType)`, `${indent}{`];
341
+ for (const body of rest) {
342
+ lines.push(`${indent} case ${quoteCSharpString(body.contentType)}:`);
343
+ lines.push(`${indent} return ${construct(body)};`);
344
+ }
345
+ lines.push(`${indent} default:`);
346
+ lines.push(`${indent} return ${construct(fallback!)};`);
347
+ lines.push(`${indent}}`);
348
+ return lines;
349
+ }
350
+
351
+ /** The `content:` argument for the request body, if the operation declares one. */
352
+ function bodyArgument(op: OpOperationNode): string | undefined {
353
+ // Only the first declared mime is used, matching the Python and Kotlin SDKs: a method has one
354
+ // signature, and the alternatives describe the same payload in a different encoding.
355
+ const body = op.request?.bodies[0];
356
+ if (!body) return undefined;
357
+ const mime = quoteCSharpString(body.contentType);
358
+ switch (classifyContentType(body.contentType)) {
359
+ case 'multipart':
360
+ return 'content: http.MultipartContent(body)';
361
+ case 'urlencoded':
362
+ return 'content: http.FormContent(body)';
363
+ case 'text':
364
+ return `content: http.TextContent(body, ${mime})`;
365
+ case 'binary':
366
+ return `content: http.BinaryContent(body, ${mime})`;
367
+ default:
368
+ return `content: http.JsonContent(body, ${mime})`;
369
+ }
370
+ }
371
+
372
+ // ─── Response declarations ─────────────────────────────────────────────────
373
+
374
+ function headersRecordName(base: string, statusCode?: number): string {
375
+ return statusCode === undefined ? `${base}Headers` : `${base}${statusCode}Headers`;
376
+ }
377
+
378
+ /**
379
+ * The name of one leaf of a method's response union.
380
+ *
381
+ * Leaves are flat rather than nested per status, so a caller switches in one level. A status with
382
+ * several mimes gets one leaf per mime, keeping the mime and the body type it decodes to
383
+ * correlated.
384
+ */
385
+ function leafRecordName(response: OpResponseNode, body: OpResponseBodyNode | undefined, statusCode: number | undefined): string {
386
+ const statusPart = statusCode === undefined ? '' : `Status${statusCode}`;
387
+ if (response.bodies.length <= 1 || !body) return statusPart || 'Body';
388
+ return `${statusPart}${toCSharpTypeName(body.contentType.replace(/[+/.]/g, ' '))}`;
389
+ }
390
+
391
+ /**
392
+ * The `<Method>Headers`, `<Method>Result` and `<Method>Response` declarations a method's return
393
+ * type names. Emitted alongside the client class, since they belong to one method each.
394
+ */
395
+ function responseDeclarations(route: OpRouteNode, op: OpOperationNode, ctx: RenderContext): string[] {
396
+ const shape = responseShape(op);
397
+ const base = methodBase(deriveMethodName(op, route));
398
+ const place = where(route, op);
399
+ const lines: string[] = [];
400
+
401
+ const headerRecord = (headers: OpResponseHeaderNode[], name: string): void => {
402
+ const parameters = headers
403
+ .map(header => {
404
+ const reader = headerReader(header, place);
405
+ const type = header.optional ? `${reader.type}?` : reader.type;
406
+ return `${type} ${safeMemberName(toCSharpPropertyName(header.name), name)}`;
407
+ })
408
+ .join(', ');
409
+ lines.push('');
410
+ lines.push(...xmlDocLines(`Response headers declared on ${place}.`, ''));
411
+ lines.push(`public sealed record ${name}(${parameters});`);
412
+ };
413
+
414
+ if (shape.kind === 'simple') {
415
+ const response = shape.response;
416
+ const headers = response?.headers ?? [];
417
+ if (headers.length === 0) return lines;
418
+ headerRecord(headers, headersRecordName(base));
419
+ const body = response?.bodies[0];
420
+ if (body) {
421
+ lines.push('');
422
+ lines.push(...xmlDocLines(`The body of ${place}, with the response headers the contract declares.`, ''));
423
+ lines.push(`public sealed record ${base}Result(${bodyCSharpType(body, ctx)} Data, ${headersRecordName(base)} Headers);`);
424
+ }
425
+ return lines;
426
+ }
427
+
428
+ const responses = observableOf(shape);
429
+ const withStatus = shape.kind === 'multiStatus';
430
+ for (const response of responses) {
431
+ const headers = response.headers ?? [];
432
+ if (headers.length > 0) headerRecord(headers, headersRecordName(base, withStatus ? response.statusCode : undefined));
433
+ }
434
+
435
+ lines.push('');
436
+ lines.push(
437
+ ...xmlDocLines(
438
+ `What ${place} returned.\n\n` +
439
+ (withStatus
440
+ ? 'The operation declares several statuses the service produces, so the status is part of the value.'
441
+ : 'The status declares several content types, so which one arrived is part of the value.'),
442
+ '',
443
+ ),
444
+ );
445
+ lines.push(`public abstract record ${base}Response`);
446
+ lines.push('{');
447
+ lines.push(` private ${base}Response() { }`);
448
+ for (const response of responses) {
449
+ const statusCode = withStatus ? response.statusCode : undefined;
450
+ const headers = response.headers ?? [];
451
+ const bodies = response.bodies.length > 0 ? response.bodies : [undefined];
452
+ for (const body of bodies) {
453
+ const name = leafRecordName(response, body, statusCode);
454
+ const parameters: string[] = [];
455
+ if (body) parameters.push(`${bodyCSharpType(body, ctx)} Data`);
456
+ if (headers.length > 0) parameters.push(`${headersRecordName(base, statusCode)} Headers`);
457
+ lines.push('');
458
+ lines.push(` public sealed record ${name}(${parameters.join(', ')}) : ${base}Response;`);
459
+ }
460
+ }
461
+ lines.push('}');
462
+ return lines;
463
+ }
464
+
465
+ /**
466
+ * The C# type of a response header, and how to turn the raw string into it.
467
+ *
468
+ * Header values arrive as text, so the declared type is what the caller gets and the conversion
469
+ * happens here. The accepted set mirrors the other SDKs; anything else is rejected at build time
470
+ * rather than silently handed back as a string.
471
+ *
472
+ * @throws {Error} When the header's declared type cannot be read from an HTTP header.
473
+ */
474
+ function headerReader(header: OpResponseHeaderNode, place: string): { type: string; read: (raw: string) => string } {
475
+ const scalar = header.type.kind === 'scalar' ? header.type.name : undefined;
476
+ switch (scalar) {
477
+ case 'string':
478
+ case 'email':
479
+ case 'url':
480
+ case 'interval':
481
+ case 'unknown':
482
+ return { type: 'string', read: raw => raw };
483
+ case 'number':
484
+ return { type: 'double', read: raw => `double.Parse(${raw}, CultureInfo.InvariantCulture)` };
485
+ case 'int':
486
+ return { type: 'long', read: raw => `long.Parse(${raw}, CultureInfo.InvariantCulture)` };
487
+ case 'bigint':
488
+ return { type: 'BigInteger', read: raw => `BigInteger.Parse(${raw}, CultureInfo.InvariantCulture)` };
489
+ case 'boolean':
490
+ return { type: 'bool', read: raw => `${raw} == "true"` };
491
+ case 'uuid':
492
+ return { type: 'Guid', read: raw => `Guid.Parse(${raw})` };
493
+ case 'date':
494
+ return { type: 'DateOnly', read: raw => `DateOnly.Parse(${raw}, CultureInfo.InvariantCulture)` };
495
+ case 'time':
496
+ return { type: 'TimeOnly', read: raw => `TimeOnly.Parse(${raw}, CultureInfo.InvariantCulture)` };
497
+ case 'datetime':
498
+ return { type: 'DateTimeOffset', read: raw => `DateTimeOffset.Parse(${raw}, CultureInfo.InvariantCulture)` };
499
+ case 'duration':
500
+ return { type: 'TimeSpan', read: raw => `XmlConvert.ToTimeSpan(${raw})` };
501
+ default:
502
+ throw new Error(
503
+ `plugin-csharp: response header '${header.name}' on ${place} is declared as ${describeHeaderType(header.type)}, ` +
504
+ `which cannot be read from an HTTP header. Header values arrive as strings — declare it as string, email, url, uuid, ` +
505
+ `date, time, datetime, duration, interval, int, number, boolean or bigint.`,
506
+ );
507
+ }
508
+ }
509
+
510
+ /** A short, contract-facing description of a header type, for the rejection above. */
511
+ function describeHeaderType(type: { kind: string; name?: string }): string {
512
+ if (type.kind === 'scalar') return `the '${type.name}' scalar`;
513
+ if (type.kind === 'ref') return `the contract '${type.name}'`;
514
+ return `${type.kind === 'array' || type.kind === 'inlineObject' ? 'an' : 'a'} ${type.kind}`;
515
+ }
516
+
517
+ /** The lines that build one response-headers value out of the response. */
518
+ function readHeaderLines(headers: OpResponseHeaderNode[], typeName: string, ctx: RenderContext, place: string, indent: string): string[] {
519
+ const args = headers.map(header => {
520
+ const reader = headerReader(header, place);
521
+ const name = quoteCSharpString(header.name);
522
+ // A required header the service omitted is a broken contract, not a null the caller has to
523
+ // handle; an optional one simply stays absent.
524
+ if (!header.optional) return reader.read(`http.RequireHeader(response, ${name})`);
525
+ const local = toCSharpParameterName(header.name);
526
+ return `response.Header(${name}) is { } ${local} ? ${reader.read(local)} : null`;
527
+ });
528
+ const lines: string[] = [`${indent}var headers = new ${typeName}(`];
529
+ args.forEach((arg, index) => lines.push(`${indent} ${arg}${index === args.length - 1 ? ');' : ','}`));
530
+ return lines;
531
+ }
532
+
533
+ function methodDoc(route: OpRouteNode, op: OpOperationNode, observable: OpResponseNode[]): string[] {
534
+ const lines: string[] = [];
535
+ const parts: string[] = [];
536
+ if (op.name) parts.push(op.name);
537
+ const description = op.description ?? route.description;
538
+ if (description) parts.push(description);
539
+ if (parts.length > 0) lines.push(...xmlDocLines(parts.join('\n'), ''));
540
+
541
+ const thrown = op.responses.filter(r => !observable.includes(r)).map(r => r.statusCode);
542
+ if (thrown.length > 0) lines.push(`/// <exception cref="SdkException">On ${thrown.join(', ')}.</exception>`);
543
+ return lines;
544
+ }
545
+
546
+ /** `System.Net.Http.HttpMethod` spells its verbs as `HttpMethod.Get`, `HttpMethod.Delete`, and so on. */
547
+ function httpMethodConstant(method: string): string {
548
+ const lower = method.toLowerCase();
549
+ return lower.charAt(0).toUpperCase() + lower.slice(1);
550
+ }
551
+
552
+ // ─── Path building ─────────────────────────────────────────────────────────
553
+
554
+ /**
555
+ * Placeholder names as the `.ck` grammar allows them: `-` and `.` are legal inside one, so a
556
+ * narrower pattern would leave `{payment-id}` in the path and send the braces to the server.
557
+ */
558
+ const PATH_PLACEHOLDER = /\{([a-zA-Z_$][a-zA-Z0-9_$.-]*)\}/g;
559
+
560
+ /**
561
+ * Render a route path as the `Path(...)` call that builds the URL.
562
+ *
563
+ * Literal segments stay string literals and dynamic ones go through `Segment(...)`, so exactly the
564
+ * values that came from the caller are percent-encoded. `params` says where a value lives: spread
565
+ * across the signature, or behind one `pathParams` argument when the route declares a model.
566
+ */
567
+ export function buildPathExpression(path: string, params?: ParamSource): string {
568
+ const args = path
569
+ .split('/')
570
+ .filter(Boolean)
571
+ .map(raw => {
572
+ PATH_PLACEHOLDER.lastIndex = 0;
573
+ const match = PATH_PLACEHOLDER.exec(raw);
574
+ if (!match || match[0] !== raw) return quoteCSharpString(raw);
575
+ const value = params && params.kind !== 'params' ? `pathParams.${toCSharpPropertyName(match[1]!)}` : toCSharpParameterName(match[1]!);
576
+ return `http.Segment(${value})`;
577
+ });
578
+ return `http.Path(${args.join(', ')})`;
579
+ }
580
+
581
+ // ─── Parameters ────────────────────────────────────────────────────────────
582
+
583
+ interface MethodParam {
584
+ name: string;
585
+ type: string;
586
+ optional: boolean;
587
+ }
588
+
589
+ /**
590
+ * The method signature, in the order a caller reads it: path, body, query, headers — but with every
591
+ * required parameter ahead of every optional one.
592
+ *
593
+ * C# rejects a required parameter after an optional one, which Kotlin allows, so the contract's own
594
+ * order cannot always survive. The relative order within each group is kept, and a trailing
595
+ * `CancellationToken` is appended by the caller.
596
+ */
597
+ function buildMethodParams(route: OpRouteNode, op: OpOperationNode, ctx: RenderContext): MethodParam[] {
598
+ const params: MethodParam[] = [];
599
+
600
+ if (route.params) {
601
+ if (route.params.kind === 'params') {
602
+ for (const node of route.params.nodes) {
603
+ params.push({ name: toCSharpParameterName(node.name), type: renderCSharpType(node.type, ctx, true), optional: false });
604
+ }
605
+ } else {
606
+ // Not `params`, which is a C# keyword: the argument would have to be written `@params`.
607
+ params.push({ name: 'pathParams', type: renderParamSourceType(route.params, ctx, ''), optional: false });
608
+ }
609
+ }
610
+
611
+ const body = op.request?.bodies[0];
612
+ if (body) {
613
+ switch (classifyContentType(body.contentType)) {
614
+ case 'multipart':
615
+ // The caller assembles the parts; the declared contract type describes the fields
616
+ // rather than a value the client can send as one object.
617
+ params.push({ name: 'body', type: 'IEnumerable<SdkPart>', optional: false });
618
+ break;
619
+ case 'binary':
620
+ params.push({ name: 'body', type: 'byte[]', optional: false });
621
+ break;
622
+ case 'text':
623
+ params.push({ name: 'body', type: 'string', optional: false });
624
+ break;
625
+ default:
626
+ params.push({ name: 'body', type: renderCSharpType(body.bodyType, ctx, true), optional: false });
627
+ }
628
+ }
629
+
630
+ const base = methodBase(deriveMethodName(op, route));
631
+ if (op.query) {
632
+ params.push({ name: 'query', type: renderParamSourceType(op.query, ctx, `${base}Query`), optional: allFieldsOptional(op.query) });
633
+ }
634
+ if (op.headers) {
635
+ params.push({
636
+ name: 'customHeaders',
637
+ type: renderParamSourceType(op.headers, ctx, `${base}Headers`),
638
+ optional: allFieldsOptional(op.headers),
639
+ });
640
+ }
641
+
642
+ const widened = params.map(p => (p.optional && !p.type.endsWith('?') ? { ...p, type: `${p.type}?` } : p));
643
+ return [...widened.filter(p => !p.optional), ...widened.filter(p => p.optional)];
644
+ }
645
+
646
+ /** Whether every field of a param source may be omitted, making the whole argument optional. */
647
+ function allFieldsOptional(source: ParamSource): boolean {
648
+ if (source.kind !== 'params') return true;
649
+ return source.nodes.every(node => Boolean(node.optional) || node.default !== undefined);
650
+ }
651
+
652
+ function renderParamSourceType(source: ParamSource, ctx: RenderContext, generatedName: string): string {
653
+ if (source.kind === 'ref') return renderCSharpType({ kind: 'ref', name: source.name }, ctx, true);
654
+ if (source.kind === 'type') return renderCSharpType(source.node, ctx, true);
655
+ // The record emitted for this method, or a plain map when the block declares nothing.
656
+ return source.nodes.length > 0 ? generatedName : 'IReadOnlyDictionary<string, string>';
657
+ }
658
+
659
+ // ─── Method naming ─────────────────────────────────────────────────────────
660
+
661
+ /**
662
+ * The SDK method name, in the same priority order every ContractKit SDK uses: an explicit `sdk:`,
663
+ * then the operation's `name:`, then a name inferred from the verb and path. C# spells it
664
+ * PascalCase with an `Async` suffix, which is what a .NET caller expects of a `Task`-returning
665
+ * method.
666
+ */
667
+ export function deriveMethodName(op: OpOperationNode, route: OpRouteNode): string {
668
+ if (op.sdk) return `${toCSharpTypeName(op.sdk)}Async`;
669
+ if (op.name) return `${toCSharpTypeName(op.name)}Async`;
670
+ return `${inferMethodName(op.method, route.path)}Async`;
671
+ }
672
+
673
+ function inferMethodName(method: string, path: string): string {
674
+ const parts = [toCSharpTypeName(method)];
675
+ for (const segment of path.split('/').filter(Boolean)) {
676
+ if (segment.startsWith('{')) parts.push(`By${toCSharpTypeName(segment.slice(1, -1))}`);
677
+ else parts.push(toCSharpTypeName(segment));
678
+ }
679
+ return parts.join('');
680
+ }