@contractkit/plugin-csharp 0.1.2 → 0.1.4
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 +4 -4
- package/.turbo/turbo-test$colon$ci.log +17 -17
- package/CHANGELOG.md +21 -0
- package/README.md +2 -1
- package/dist/codegen-client.d.ts +1 -1
- package/dist/codegen-client.d.ts.map +1 -1
- package/dist/codegen-models.d.ts.map +1 -1
- package/dist/index.js +67 -33
- package/dist/index.js.map +1 -1
- package/dist/naming.d.ts +12 -0
- package/dist/naming.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/codegen-client.ts +96 -25
- package/src/codegen-models.ts +34 -8
- package/src/naming.ts +23 -0
- package/tests/codegen-client.test.ts +103 -0
- package/tests/codegen-models.test.ts +15 -0
- package/tests/naming.test.ts +26 -0
package/dist/naming.d.ts
CHANGED
|
@@ -36,6 +36,18 @@ export declare function toCSharpPropertyName(name: string): string;
|
|
|
36
36
|
* regularly — a path parameter named `event` or `params` is ordinary in a contract.
|
|
37
37
|
*/
|
|
38
38
|
export declare function toCSharpParameterName(name: string): string;
|
|
39
|
+
/**
|
|
40
|
+
* A C# parameter or local name for each declared name, keyed by that name.
|
|
41
|
+
*
|
|
42
|
+
* Each goes through {@link toCSharpParameterName}, then gains a `_` until it collides with neither
|
|
43
|
+
* one of `taken` nor another name's result, and is keyword-escaped last. The comparison is on the
|
|
44
|
+
* unescaped spelling, since `@class` and `class` are the same identifier to the compiler. Returns
|
|
45
|
+
* the plain conversion in the common case, so existing output stays byte-identical.
|
|
46
|
+
*
|
|
47
|
+
* @param taken Unescaped identifiers the surrounding generated code already binds or reads, which
|
|
48
|
+
* a declared name must not duplicate or shadow.
|
|
49
|
+
*/
|
|
50
|
+
export declare function bindCSharpParameterNames(names: readonly string[], taken: Iterable<string>): Map<string, string>;
|
|
39
51
|
/**
|
|
40
52
|
* Make a property name safe inside `ownerTypeName`.
|
|
41
53
|
*
|
package/dist/naming.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"naming.d.ts","sourceRoot":"","sources":["../src/naming.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,WAAW,CAAC,MAAM,CA8E9C,CAAC;AAQH;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMzD;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQ1D;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,CAGlF;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMrD;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAM3D;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAM5D;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOzD;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,SAAY,GAAG,MAAM,EAAE,CAMnF;AAED,yFAAyF;AACzF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASvD"}
|
|
1
|
+
{"version":3,"file":"naming.d.ts","sourceRoot":"","sources":["../src/naming.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;GAKG;AACH,eAAO,MAAM,eAAe,EAAE,WAAW,CAAC,MAAM,CA8E9C,CAAC;AAQH;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE3D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMzD;AAED;;;;GAIG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQ1D;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,wBAAwB,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,EAAE,KAAK,EAAE,QAAQ,CAAC,MAAM,CAAC,GAAG,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAU/G;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,YAAY,EAAE,MAAM,EAAE,aAAa,EAAE,MAAM,GAAG,MAAM,CAGlF;AAED;;;;GAIG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAMrD;AAED;;;;;GAKG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAM3D;AAED;;;;GAIG;AACH,wBAAgB,sBAAsB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CAM5D;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAOzD;AAED;;;;;;;GAOG;AACH,wBAAgB,WAAW,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,SAAY,GAAG,MAAM,EAAE,CAMnF;AAED,yFAAyF;AACzF,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED;;;GAGG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,CASvD"}
|
package/package.json
CHANGED
package/src/codegen-client.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { classifyContentType, observableResponses, resolveModifiers } from '@con
|
|
|
12
12
|
import type { HoistResult } from './hoist.js';
|
|
13
13
|
import { createRenderContext, renderCSharpType, renderFile, type RenderContext } from './codegen-models.js';
|
|
14
14
|
import {
|
|
15
|
+
bindCSharpParameterNames,
|
|
15
16
|
deriveCSharpFileBase,
|
|
16
17
|
quoteCSharpString,
|
|
17
18
|
safeMemberName,
|
|
@@ -193,11 +194,14 @@ function observableOf(shape: ResponseShape): OpResponseNode[] {
|
|
|
193
194
|
function generateMethod(route: OpRouteNode, op: OpOperationNode, ctx: RenderContext, methodName: string): string[] {
|
|
194
195
|
const base = methodBase(methodName);
|
|
195
196
|
const shape = responseShape(op);
|
|
196
|
-
const returnType = returnTypeFor(shape, base, ctx);
|
|
197
|
+
const returnType = returnTypeFor(shape, op, base, ctx);
|
|
197
198
|
const observable = observableOf(shape);
|
|
198
199
|
const expectStatuses = observable.filter(r => r.statusCode < 200 || r.statusCode >= 300).map(r => r.statusCode);
|
|
199
200
|
|
|
200
|
-
const
|
|
201
|
+
const pathBindings = bindPathParams(route);
|
|
202
|
+
const params = buildMethodParams(route, op, ctx, pathBindings);
|
|
203
|
+
// Everything the body can see by name: a response-header pattern variable must not redeclare one.
|
|
204
|
+
const bound = [...METHOD_LOCALS, ...params.map(p => p.name.replace(/^@/, ''))];
|
|
201
205
|
const signature = [...params.map(p => `${p.type} ${p.name}${p.optional ? ' = null' : ''}`), 'CancellationToken cancellationToken = default'].join(
|
|
202
206
|
', ',
|
|
203
207
|
);
|
|
@@ -209,7 +213,7 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, ctx: RenderCont
|
|
|
209
213
|
lines.push(`public async ${returnType === 'void' ? 'Task' : `Task<${returnType}>`} ${methodName}(${signature})`);
|
|
210
214
|
lines.push('{');
|
|
211
215
|
|
|
212
|
-
const callArgs: string[] = [`HttpMethod.${httpMethodConstant(op.method)}`, buildPathExpression(route.path, route.params)];
|
|
216
|
+
const callArgs: string[] = [`HttpMethod.${httpMethodConstant(op.method)}`, buildPathExpression(route.path, route.params, pathBindings)];
|
|
213
217
|
if (op.query) callArgs.push('query: http.Params(query)');
|
|
214
218
|
if (op.headers) callArgs.push('headers: http.Params(customHeaders)');
|
|
215
219
|
const content = bodyArgument(op);
|
|
@@ -222,18 +226,18 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, ctx: RenderCont
|
|
|
222
226
|
callArgs.forEach((arg, index) => {
|
|
223
227
|
lines.push(` ${arg}${index === callArgs.length - 1 ? ').ConfigureAwait(false);' : ','}`);
|
|
224
228
|
});
|
|
225
|
-
lines.push(...returnStatements(shape, base, ctx, where(route, op)));
|
|
229
|
+
lines.push(...returnStatements(shape, op, base, ctx, where(route, op), bound));
|
|
226
230
|
lines.push('}');
|
|
227
231
|
return lines;
|
|
228
232
|
}
|
|
229
233
|
|
|
230
234
|
/** 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 {
|
|
235
|
+
function returnTypeFor(shape: ResponseShape, op: OpOperationNode, base: string, ctx: RenderContext): string {
|
|
232
236
|
if (shape.kind !== 'simple') return `${base}Response`;
|
|
233
237
|
const response = shape.response;
|
|
234
238
|
const body = response?.bodies[0];
|
|
235
239
|
const headers = response?.headers ?? [];
|
|
236
|
-
if (!body) return headers.length > 0 ?
|
|
240
|
+
if (!body) return headers.length > 0 ? headersRecordName(op, base) : 'void';
|
|
237
241
|
const dataType = bodyCSharpType(body, ctx);
|
|
238
242
|
// A declared response header changes the return shape: the body alone cannot carry it.
|
|
239
243
|
return headers.length > 0 ? `${base}Result` : dataType;
|
|
@@ -264,19 +268,26 @@ function bodyReadExpr(body: OpResponseBodyNode, ctx: RenderContext): string {
|
|
|
264
268
|
}
|
|
265
269
|
|
|
266
270
|
/** The statements after `ExecuteAsync`, which turn the response into the declared return type. */
|
|
267
|
-
function returnStatements(
|
|
271
|
+
function returnStatements(
|
|
272
|
+
shape: ResponseShape,
|
|
273
|
+
op: OpOperationNode,
|
|
274
|
+
base: string,
|
|
275
|
+
ctx: RenderContext,
|
|
276
|
+
place: string,
|
|
277
|
+
bound: readonly string[],
|
|
278
|
+
): string[] {
|
|
268
279
|
if (shape.kind === 'simple') {
|
|
269
280
|
const response = shape.response;
|
|
270
281
|
const body = response?.bodies[0];
|
|
271
282
|
const headers = response?.headers ?? [];
|
|
272
283
|
if (headers.length === 0) return body ? [` return ${bodyReadExpr(body, ctx)};`] : [];
|
|
273
|
-
const lines = readHeaderLines(headers,
|
|
284
|
+
const lines = readHeaderLines(headers, headersRecordName(op, base), ctx, place, ' ', bound);
|
|
274
285
|
return body ? [...lines, ` return new ${base}Result(${bodyReadExpr(body, ctx)}, headers);`] : [...lines, ' return headers;'];
|
|
275
286
|
}
|
|
276
287
|
|
|
277
288
|
if (shape.kind === 'multiMime') {
|
|
278
289
|
const headers = shape.response.headers ?? [];
|
|
279
|
-
const lines = headers.length > 0 ? readHeaderLines(headers,
|
|
290
|
+
const lines = headers.length > 0 ? readHeaderLines(headers, headersRecordName(op, base), ctx, place, ' ', bound) : [];
|
|
280
291
|
lines.push(...mimeSwitch(shape.response, base, undefined, ctx, ' ', headers.length > 0));
|
|
281
292
|
return lines;
|
|
282
293
|
}
|
|
@@ -288,13 +299,13 @@ function returnStatements(shape: ResponseShape, base: string, ctx: RenderContext
|
|
|
288
299
|
for (const response of rest) {
|
|
289
300
|
lines.push(` case ${response.statusCode}:`);
|
|
290
301
|
lines.push(' {');
|
|
291
|
-
lines.push(...statusBranch(response, base, response.statusCode, ctx, place, ' '));
|
|
302
|
+
lines.push(...statusBranch(response, op, base, response.statusCode, ctx, place, ' ', bound));
|
|
292
303
|
lines.push(' }');
|
|
293
304
|
lines.push('');
|
|
294
305
|
}
|
|
295
306
|
lines.push(' default:');
|
|
296
307
|
lines.push(' {');
|
|
297
|
-
lines.push(...statusBranch(fallback!, base, fallback!.statusCode, ctx, place, ' '));
|
|
308
|
+
lines.push(...statusBranch(fallback!, op, base, fallback!.statusCode, ctx, place, ' ', bound));
|
|
298
309
|
lines.push(' }');
|
|
299
310
|
lines.push(' }');
|
|
300
311
|
return lines;
|
|
@@ -306,10 +317,19 @@ function returnStatements(shape: ResponseShape, base: string, ctx: RenderContext
|
|
|
306
317
|
* Every branch is braced. Two branches each declaring `headers` would otherwise collide, since a
|
|
307
318
|
* declaration in a switch section is scoped to the whole switch block rather than to its own case.
|
|
308
319
|
*/
|
|
309
|
-
function statusBranch(
|
|
320
|
+
function statusBranch(
|
|
321
|
+
response: OpResponseNode,
|
|
322
|
+
op: OpOperationNode,
|
|
323
|
+
base: string,
|
|
324
|
+
statusCode: number,
|
|
325
|
+
ctx: RenderContext,
|
|
326
|
+
place: string,
|
|
327
|
+
indent: string,
|
|
328
|
+
bound: readonly string[],
|
|
329
|
+
): string[] {
|
|
310
330
|
const lines: string[] = [];
|
|
311
331
|
const headers = response.headers ?? [];
|
|
312
|
-
if (headers.length > 0) lines.push(...readHeaderLines(headers, headersRecordName(base, statusCode), ctx, place, indent));
|
|
332
|
+
if (headers.length > 0) lines.push(...readHeaderLines(headers, headersRecordName(op, base, statusCode), ctx, place, indent, bound));
|
|
313
333
|
lines.push(...mimeSwitch(response, base, statusCode, ctx, indent, headers.length > 0));
|
|
314
334
|
return lines;
|
|
315
335
|
}
|
|
@@ -371,8 +391,22 @@ function bodyArgument(op: OpOperationNode): string | undefined {
|
|
|
371
391
|
|
|
372
392
|
// ─── Response declarations ─────────────────────────────────────────────────
|
|
373
393
|
|
|
374
|
-
|
|
375
|
-
|
|
394
|
+
/**
|
|
395
|
+
* The name of a response-headers record: `<Method><Status>Headers` when the status is part of the
|
|
396
|
+
* value, otherwise `<Method>Headers`.
|
|
397
|
+
*
|
|
398
|
+
* The request-headers record claims `<Method>Headers` first, since it is the one a caller builds by
|
|
399
|
+
* name, so an operation that declares both gets `<Method>ResponseHeaders` for its response side.
|
|
400
|
+
* Two records of one name in one namespace is CS0101.
|
|
401
|
+
*/
|
|
402
|
+
function headersRecordName(op: OpOperationNode, base: string, statusCode?: number): string {
|
|
403
|
+
if (statusCode !== undefined) return `${base}${statusCode}Headers`;
|
|
404
|
+
return declaresRequestHeadersRecord(op) ? `${base}ResponseHeaders` : `${base}Headers`;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** Whether the operation's `headers:` block gets a generated `<Method>Headers` record. */
|
|
408
|
+
function declaresRequestHeadersRecord(op: OpOperationNode): boolean {
|
|
409
|
+
return op.headers?.kind === 'params' && op.headers.nodes.length > 0;
|
|
376
410
|
}
|
|
377
411
|
|
|
378
412
|
/**
|
|
@@ -415,12 +449,12 @@ function responseDeclarations(route: OpRouteNode, op: OpOperationNode, ctx: Rend
|
|
|
415
449
|
const response = shape.response;
|
|
416
450
|
const headers = response?.headers ?? [];
|
|
417
451
|
if (headers.length === 0) return lines;
|
|
418
|
-
headerRecord(headers, headersRecordName(base));
|
|
452
|
+
headerRecord(headers, headersRecordName(op, base));
|
|
419
453
|
const body = response?.bodies[0];
|
|
420
454
|
if (body) {
|
|
421
455
|
lines.push('');
|
|
422
456
|
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);`);
|
|
457
|
+
lines.push(`public sealed record ${base}Result(${bodyCSharpType(body, ctx)} Data, ${headersRecordName(op, base)} Headers);`);
|
|
424
458
|
}
|
|
425
459
|
return lines;
|
|
426
460
|
}
|
|
@@ -429,7 +463,7 @@ function responseDeclarations(route: OpRouteNode, op: OpOperationNode, ctx: Rend
|
|
|
429
463
|
const withStatus = shape.kind === 'multiStatus';
|
|
430
464
|
for (const response of responses) {
|
|
431
465
|
const headers = response.headers ?? [];
|
|
432
|
-
if (headers.length > 0) headerRecord(headers, headersRecordName(base, withStatus ? response.statusCode : undefined));
|
|
466
|
+
if (headers.length > 0) headerRecord(headers, headersRecordName(op, base, withStatus ? response.statusCode : undefined));
|
|
433
467
|
}
|
|
434
468
|
|
|
435
469
|
lines.push('');
|
|
@@ -453,7 +487,7 @@ function responseDeclarations(route: OpRouteNode, op: OpOperationNode, ctx: Rend
|
|
|
453
487
|
const name = leafRecordName(response, body, statusCode);
|
|
454
488
|
const parameters: string[] = [];
|
|
455
489
|
if (body) parameters.push(`${bodyCSharpType(body, ctx)} Data`);
|
|
456
|
-
if (headers.length > 0) parameters.push(`${headersRecordName(base, statusCode)} Headers`);
|
|
490
|
+
if (headers.length > 0) parameters.push(`${headersRecordName(op, base, statusCode)} Headers`);
|
|
457
491
|
lines.push('');
|
|
458
492
|
lines.push(` public sealed record ${name}(${parameters.join(', ')}) : ${base}Response;`);
|
|
459
493
|
}
|
|
@@ -515,14 +549,27 @@ function describeHeaderType(type: { kind: string; name?: string }): string {
|
|
|
515
549
|
}
|
|
516
550
|
|
|
517
551
|
/** The lines that build one response-headers value out of the response. */
|
|
518
|
-
function readHeaderLines(
|
|
552
|
+
function readHeaderLines(
|
|
553
|
+
headers: OpResponseHeaderNode[],
|
|
554
|
+
typeName: string,
|
|
555
|
+
ctx: RenderContext,
|
|
556
|
+
place: string,
|
|
557
|
+
indent: string,
|
|
558
|
+
bound: readonly string[],
|
|
559
|
+
): string[] {
|
|
560
|
+
// Pattern variables share the statement's scope, and the method's: each needs a name nothing
|
|
561
|
+
// else in either binds.
|
|
562
|
+
const locals = bindCSharpParameterNames(
|
|
563
|
+
headers.filter(h => h.optional).map(h => h.name),
|
|
564
|
+
bound,
|
|
565
|
+
);
|
|
519
566
|
const args = headers.map(header => {
|
|
520
567
|
const reader = headerReader(header, place);
|
|
521
568
|
const name = quoteCSharpString(header.name);
|
|
522
569
|
// A required header the service omitted is a broken contract, not a null the caller has to
|
|
523
570
|
// handle; an optional one simply stays absent.
|
|
524
571
|
if (!header.optional) return reader.read(`http.RequireHeader(response, ${name})`);
|
|
525
|
-
const local =
|
|
572
|
+
const local = locals.get(header.name)!;
|
|
526
573
|
return `response.Header(${name}) is { } ${local} ? ${reader.read(local)} : null`;
|
|
527
574
|
});
|
|
528
575
|
const lines: string[] = [`${indent}var headers = new ${typeName}(`];
|
|
@@ -564,7 +611,7 @@ const PATH_PLACEHOLDER = /\{([a-zA-Z_$][a-zA-Z0-9_$.-]*)\}/g;
|
|
|
564
611
|
* values that came from the caller are percent-encoded. `params` says where a value lives: spread
|
|
565
612
|
* across the signature, or behind one `pathParams` argument when the route declares a model.
|
|
566
613
|
*/
|
|
567
|
-
export function buildPathExpression(path: string, params?: ParamSource): string {
|
|
614
|
+
export function buildPathExpression(path: string, params?: ParamSource, bindings?: ReadonlyMap<string, string>): string {
|
|
568
615
|
const args = path
|
|
569
616
|
.split('/')
|
|
570
617
|
.filter(Boolean)
|
|
@@ -572,7 +619,10 @@ export function buildPathExpression(path: string, params?: ParamSource): string
|
|
|
572
619
|
PATH_PLACEHOLDER.lastIndex = 0;
|
|
573
620
|
const match = PATH_PLACEHOLDER.exec(raw);
|
|
574
621
|
if (!match || match[0] !== raw) return quoteCSharpString(raw);
|
|
575
|
-
const value =
|
|
622
|
+
const value =
|
|
623
|
+
params && params.kind !== 'params'
|
|
624
|
+
? `pathParams.${toCSharpPropertyName(match[1]!)}`
|
|
625
|
+
: (bindings?.get(match[1]!) ?? toCSharpParameterName(match[1]!));
|
|
576
626
|
return `http.Segment(${value})`;
|
|
577
627
|
});
|
|
578
628
|
return `http.Path(${args.join(', ')})`;
|
|
@@ -580,6 +630,27 @@ export function buildPathExpression(path: string, params?: ParamSource): string
|
|
|
580
630
|
|
|
581
631
|
// ─── Parameters ────────────────────────────────────────────────────────────
|
|
582
632
|
|
|
633
|
+
/**
|
|
634
|
+
* Identifiers a generated method binds or reads besides its path parameters: the other arguments
|
|
635
|
+
* {@link buildMethodParams} can declare, the trailing `CancellationToken`, the locals the body
|
|
636
|
+
* declares, and the client's own `http` constructor parameter. A path parameter under one of these
|
|
637
|
+
* names would duplicate an argument (CS0100), clash with a local (CS0136), or hide `http`.
|
|
638
|
+
*/
|
|
639
|
+
const METHOD_LOCALS = ['body', 'query', 'customHeaders', 'pathParams', 'cancellationToken', 'response', 'headers', 'http'] as const;
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* The C# parameter name each inline path parameter is spread into the signature under, keyed by its
|
|
643
|
+
* declared name. Keyword-escaped (`@class`), and suffixed when it lands on one of
|
|
644
|
+
* {@link METHOD_LOCALS} (`body_`). Empty when the route has no inline params.
|
|
645
|
+
*/
|
|
646
|
+
function bindPathParams(route: OpRouteNode): Map<string, string> {
|
|
647
|
+
if (route.params?.kind !== 'params') return new Map();
|
|
648
|
+
return bindCSharpParameterNames(
|
|
649
|
+
route.params.nodes.map(n => n.name),
|
|
650
|
+
METHOD_LOCALS,
|
|
651
|
+
);
|
|
652
|
+
}
|
|
653
|
+
|
|
583
654
|
interface MethodParam {
|
|
584
655
|
name: string;
|
|
585
656
|
type: string;
|
|
@@ -594,13 +665,13 @@ interface MethodParam {
|
|
|
594
665
|
* order cannot always survive. The relative order within each group is kept, and a trailing
|
|
595
666
|
* `CancellationToken` is appended by the caller.
|
|
596
667
|
*/
|
|
597
|
-
function buildMethodParams(route: OpRouteNode, op: OpOperationNode, ctx: RenderContext): MethodParam[] {
|
|
668
|
+
function buildMethodParams(route: OpRouteNode, op: OpOperationNode, ctx: RenderContext, pathBindings: ReadonlyMap<string, string>): MethodParam[] {
|
|
598
669
|
const params: MethodParam[] = [];
|
|
599
670
|
|
|
600
671
|
if (route.params) {
|
|
601
672
|
if (route.params.kind === 'params') {
|
|
602
673
|
for (const node of route.params.nodes) {
|
|
603
|
-
params.push({ name:
|
|
674
|
+
params.push({ name: pathBindings.get(node.name)!, type: renderCSharpType(node.type, ctx, true), optional: false });
|
|
604
675
|
}
|
|
605
676
|
} else {
|
|
606
677
|
// Not `params`, which is a C# keyword: the argument would have to be written `@params`.
|
package/src/codegen-models.ts
CHANGED
|
@@ -276,8 +276,21 @@ function literalCSharpType(value: string | number | boolean, ctx: RenderContext)
|
|
|
276
276
|
* Render a contract default as a C# expression of the field's own type. Returns `undefined` when the
|
|
277
277
|
* value cannot be expressed, so the field is emitted as `required` rather than with an initializer
|
|
278
278
|
* that will not compile.
|
|
279
|
+
*
|
|
280
|
+
* `memberNames` are the property names of the record the initializer sits in. Inside that record a
|
|
281
|
+
* simple name resolves to a member before a type, so `public Rating? Rating { get; init; } =
|
|
282
|
+
* Rating.Neutral;` reads the instance property and fails with CS0236. C#'s rule that lets a member
|
|
283
|
+
* share its type's name applies only when the member's type is exactly that type, and `Rating?` is
|
|
284
|
+
* `Nullable<Rating>`. An enum a member would shadow is written from the global namespace instead.
|
|
279
285
|
*/
|
|
280
|
-
function renderDefault(
|
|
286
|
+
function renderDefault(
|
|
287
|
+
value: string | number | boolean,
|
|
288
|
+
type: ContractTypeNode,
|
|
289
|
+
ctx: RenderContext,
|
|
290
|
+
memberNames: ReadonlySet<string> = new Set(),
|
|
291
|
+
): string | undefined {
|
|
292
|
+
const enumType = (name: string): string => (memberNames.has(name) ? `global::${ctx.namespace}.Models.${name}` : name);
|
|
293
|
+
|
|
281
294
|
const inner = type.kind === 'lazy' ? type.inner : type;
|
|
282
295
|
|
|
283
296
|
if (typeof value === 'boolean') return String(value);
|
|
@@ -303,7 +316,7 @@ function renderDefault(value: string | number | boolean, type: ContractTypeNode,
|
|
|
303
316
|
if (inner.kind === 'enum') {
|
|
304
317
|
const decl = ctx.hoisted?.byNode.get(inner);
|
|
305
318
|
if (!decl || !inner.values.includes(value)) return undefined;
|
|
306
|
-
return `${decl.name}.${enumMemberNames(inner.values).get(value)}`;
|
|
319
|
+
return `${enumType(decl.name)}.${enumMemberNames(inner.values).get(value)}`;
|
|
307
320
|
}
|
|
308
321
|
|
|
309
322
|
// The same default written against a NAMED enum contract — `rating: Rating = "neutral"`, where
|
|
@@ -312,7 +325,7 @@ function renderDefault(value: string | number | boolean, type: ContractTypeNode,
|
|
|
312
325
|
const target = ctx.modelIndex.get(inner.name);
|
|
313
326
|
const targetType = target?.type?.kind === 'lazy' ? target.type.inner : target?.type;
|
|
314
327
|
if (targetType?.kind !== 'enum' || !targetType.values.includes(value)) return undefined;
|
|
315
|
-
return `${inner.name}.${enumMemberNames(targetType.values).get(value)}`;
|
|
328
|
+
return `${enumType(inner.name)}.${enumMemberNames(targetType.values).get(value)}`;
|
|
316
329
|
}
|
|
317
330
|
|
|
318
331
|
if (inner.kind === 'scalar') {
|
|
@@ -619,11 +632,12 @@ function renderRecord(
|
|
|
619
632
|
return lines;
|
|
620
633
|
}
|
|
621
634
|
|
|
635
|
+
const memberNames = new Set(fields.map(field => memberName(field, name)));
|
|
622
636
|
lines.push(`public sealed record ${name}${implementsClause}`);
|
|
623
637
|
lines.push('{');
|
|
624
638
|
fields.forEach((field, index) => {
|
|
625
639
|
if (index > 0) lines.push('');
|
|
626
|
-
lines.push(...renderField(field, ctx, forInput, name, wireCase));
|
|
640
|
+
lines.push(...renderField(field, ctx, forInput, name, memberNames, wireCase));
|
|
627
641
|
});
|
|
628
642
|
lines.push('}');
|
|
629
643
|
return lines;
|
|
@@ -641,19 +655,31 @@ function renderRecord(
|
|
|
641
655
|
* `#nullable enable` and the generated SDK compiles with warnings as errors. `required` is never
|
|
642
656
|
* combined with `[JsonIgnore]`, which System.Text.Json rejects at run time.
|
|
643
657
|
*/
|
|
644
|
-
|
|
645
|
-
|
|
658
|
+
/** The C# property name a field is emitted under inside `ownerTypeName`. */
|
|
659
|
+
function memberName(field: FieldNode, ownerTypeName: string): string {
|
|
660
|
+
return safeMemberName(toCSharpPropertyName(field.name), ownerTypeName);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
function renderField(
|
|
664
|
+
field: FieldNode,
|
|
665
|
+
ctx: RenderContext,
|
|
666
|
+
forInput: boolean,
|
|
667
|
+
ownerTypeName: string,
|
|
668
|
+
memberNames: ReadonlySet<string>,
|
|
669
|
+
wireCase?: WireCase,
|
|
670
|
+
): string[] {
|
|
671
|
+
const propName = memberName(field, ownerTypeName);
|
|
646
672
|
const wireName = applyWireCase(field.name, wireCase);
|
|
647
673
|
|
|
648
674
|
let typeStr = renderCSharpType(field.type, ctx, forInput);
|
|
649
675
|
if ((field.optional || field.nullable) && !typeStr.endsWith('?')) typeStr += '?';
|
|
650
676
|
|
|
651
|
-
let initializer = field.default !== undefined ? renderDefault(field.default, field.type, ctx) : undefined;
|
|
677
|
+
let initializer = field.default !== undefined ? renderDefault(field.default, field.type, ctx, memberNames) : undefined;
|
|
652
678
|
// A `literal()` field carries exactly one value, so it defaults to it rather than being asked
|
|
653
679
|
// for at every call site. The property is ordinary, so the value always reaches the wire.
|
|
654
680
|
if (initializer === undefined && !field.optional && !field.nullable) {
|
|
655
681
|
const inner = field.type.kind === 'lazy' ? field.type.inner : field.type;
|
|
656
|
-
if (inner.kind === 'literal') initializer = renderDefault(inner.value, inner, ctx);
|
|
682
|
+
if (inner.kind === 'literal') initializer = renderDefault(inner.value, inner, ctx, memberNames);
|
|
657
683
|
}
|
|
658
684
|
|
|
659
685
|
const isRequired = !field.optional && initializer === undefined;
|
package/src/naming.ts
CHANGED
|
@@ -141,6 +141,29 @@ export function toCSharpParameterName(name: string): string {
|
|
|
141
141
|
return escapeCSharpIdentifier(result);
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
+
/**
|
|
145
|
+
* A C# parameter or local name for each declared name, keyed by that name.
|
|
146
|
+
*
|
|
147
|
+
* Each goes through {@link toCSharpParameterName}, then gains a `_` until it collides with neither
|
|
148
|
+
* one of `taken` nor another name's result, and is keyword-escaped last. The comparison is on the
|
|
149
|
+
* unescaped spelling, since `@class` and `class` are the same identifier to the compiler. Returns
|
|
150
|
+
* the plain conversion in the common case, so existing output stays byte-identical.
|
|
151
|
+
*
|
|
152
|
+
* @param taken Unescaped identifiers the surrounding generated code already binds or reads, which
|
|
153
|
+
* a declared name must not duplicate or shadow.
|
|
154
|
+
*/
|
|
155
|
+
export function bindCSharpParameterNames(names: readonly string[], taken: Iterable<string>): Map<string, string> {
|
|
156
|
+
const unavailable = new Set<string>(taken);
|
|
157
|
+
const bindings = new Map<string, string>();
|
|
158
|
+
for (const name of names) {
|
|
159
|
+
let local = toCSharpParameterName(name).replace(/^@/, '');
|
|
160
|
+
while (unavailable.has(local)) local += '_';
|
|
161
|
+
unavailable.add(local);
|
|
162
|
+
bindings.set(name, escapeCSharpIdentifier(local));
|
|
163
|
+
}
|
|
164
|
+
return bindings;
|
|
165
|
+
}
|
|
166
|
+
|
|
144
167
|
/**
|
|
145
168
|
* Make a property name safe inside `ownerTypeName`.
|
|
146
169
|
*
|
|
@@ -171,6 +171,37 @@ describe('parameters', () => {
|
|
|
171
171
|
expect(signature).toContain('ListQuery? query = null');
|
|
172
172
|
});
|
|
173
173
|
|
|
174
|
+
it('escapes a path param named after a C# keyword, in the signature and the path alike', () => {
|
|
175
|
+
const root = opRoot([opRoute('/seats/{class}', [opOperation('get', { sdk: 'getSeat' })], [opParam('class', scalarType('string'))])]);
|
|
176
|
+
const out = render(root, { contracts });
|
|
177
|
+
expect(out).toContain('public async Task GetSeatAsync(string @class, CancellationToken cancellationToken = default)');
|
|
178
|
+
expect(out).toContain('http.Path("seats", http.Segment(@class))');
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it('suffixes a path param named like the request body argument, which would otherwise be CS0100', () => {
|
|
182
|
+
const root = opRoot([
|
|
183
|
+
opRoute(
|
|
184
|
+
'/notes/{body}',
|
|
185
|
+
[opOperation('put', { sdk: 'putNote', request: opRequest('Payment') })],
|
|
186
|
+
[opParam('body', scalarType('string'))],
|
|
187
|
+
),
|
|
188
|
+
]);
|
|
189
|
+
const out = render(root, { contracts });
|
|
190
|
+
expect(out).toContain('PutNoteAsync(string body_, Payment body, CancellationToken cancellationToken = default)');
|
|
191
|
+
expect(out).toContain('http.Path("notes", http.Segment(body_))');
|
|
192
|
+
expect(out).toContain('content: http.JsonContent(body, "application/json")');
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it.each(['query', 'customHeaders', 'cancellationToken', 'response', 'headers', 'http', 'pathParams'])(
|
|
196
|
+
'suffixes a path param named `%s`, which the method already binds or reads',
|
|
197
|
+
name => {
|
|
198
|
+
const root = opRoot([opRoute(`/things/{${name}}`, [opOperation('get', { sdk: 'getThing' })], [opParam(name, scalarType('string'))])]);
|
|
199
|
+
const out = render(root, { contracts });
|
|
200
|
+
expect(out).toContain(`GetThingAsync(string ${name}_, CancellationToken cancellationToken = default)`);
|
|
201
|
+
expect(out).toContain(`http.Segment(${name}_)`);
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
|
|
174
205
|
it('names a route-level params model pathParams, since params is a C# keyword', () => {
|
|
175
206
|
const root = opRoot([opRoute('/refunds/{paymentId}', [opOperation('get', { sdk: 'refund' })], paramRef('PaymentRef'))]);
|
|
176
207
|
const out = render(root, { contracts: [contractRoot([model('PaymentRef', [field('paymentId', scalarType('uuid'))])])] });
|
|
@@ -269,7 +300,79 @@ describe('response headers', () => {
|
|
|
269
300
|
expect(withHeaders([{ name: 'h', optional: false, type: scalarType('bigint') }])).toContain('BigInteger.Parse(');
|
|
270
301
|
});
|
|
271
302
|
|
|
303
|
+
it('renames an optional header pattern variable that would redeclare a parameter', () => {
|
|
304
|
+
const root = opRoot([
|
|
305
|
+
opRoute(
|
|
306
|
+
'/payments/{from}',
|
|
307
|
+
[
|
|
308
|
+
opOperation('get', {
|
|
309
|
+
sdk: 'get',
|
|
310
|
+
responses: [{ ...opResponse(200, 'Payment'), headers: [{ name: 'from', optional: true, type: scalarType('string') }] }],
|
|
311
|
+
}),
|
|
312
|
+
],
|
|
313
|
+
[opParam('from', scalarType('string'))],
|
|
314
|
+
),
|
|
315
|
+
]);
|
|
316
|
+
const out = render(root, { contracts });
|
|
317
|
+
// `is { } from` in the method that takes `string from` is CS0136.
|
|
318
|
+
expect(out).toContain('response.Header("from") is { } from_ ? from_ : null');
|
|
319
|
+
});
|
|
320
|
+
|
|
272
321
|
it('rejects a header type that cannot come off the wire as text', () => {
|
|
273
322
|
expect(() => withHeaders([{ name: 'h', optional: false, type: scalarType('json') }])).toThrow(/cannot be read from an HTTP header/);
|
|
274
323
|
});
|
|
275
324
|
});
|
|
325
|
+
|
|
326
|
+
describe('request and response headers on one operation', () => {
|
|
327
|
+
const contracts = [contractRoot([model('Payment', [field('id', scalarType('uuid'))])])];
|
|
328
|
+
const requestHeaders = [opParam('from', scalarType('string'), { optional: true })];
|
|
329
|
+
const responseHeaders = [{ name: 'x-request-id', optional: false, type: scalarType('string') }];
|
|
330
|
+
|
|
331
|
+
it('gives the response side its own record name, so the namespace does not declare GetHeaders twice', () => {
|
|
332
|
+
const root = opRoot([
|
|
333
|
+
opRoute('/payments', [
|
|
334
|
+
opOperation('get', { sdk: 'get', headers: requestHeaders, responses: [{ ...opResponse(200, 'Payment'), headers: responseHeaders }] }),
|
|
335
|
+
]),
|
|
336
|
+
]);
|
|
337
|
+
const out = render(root, { contracts });
|
|
338
|
+
expect(out.match(/public sealed record GetHeaders\b/g)).toHaveLength(1);
|
|
339
|
+
expect(out).toContain('GetHeaders? customHeaders = null');
|
|
340
|
+
expect(out).toContain('public sealed record GetResponseHeaders(string XRequestId);');
|
|
341
|
+
expect(out).toContain('public sealed record GetResult(Payment Data, GetResponseHeaders Headers);');
|
|
342
|
+
expect(out).toContain('var headers = new GetResponseHeaders(');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('returns the renamed record when the response carries headers and no body', () => {
|
|
346
|
+
const root = opRoot([
|
|
347
|
+
opRoute('/payments', [
|
|
348
|
+
opOperation('head', { sdk: 'get', headers: requestHeaders, responses: [{ ...opResponse(204), headers: responseHeaders }] }),
|
|
349
|
+
]),
|
|
350
|
+
]);
|
|
351
|
+
expect(render(root, { contracts })).toContain('public async Task<GetResponseHeaders> GetAsync(');
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it('keeps GetHeaders for the response side when the operation declares no request headers', () => {
|
|
355
|
+
const root = opRoot([
|
|
356
|
+
opRoute('/payments', [opOperation('get', { sdk: 'get', responses: [{ ...opResponse(200, 'Payment'), headers: responseHeaders }] })]),
|
|
357
|
+
]);
|
|
358
|
+
expect(render(root, { contracts })).toContain('public sealed record GetHeaders(string XRequestId);');
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it('leaves per-status header records alone, since they never collide', () => {
|
|
362
|
+
const root = opRoot([
|
|
363
|
+
opRoute('/payments', [
|
|
364
|
+
opOperation('get', {
|
|
365
|
+
sdk: 'get',
|
|
366
|
+
headers: requestHeaders,
|
|
367
|
+
responses: [
|
|
368
|
+
{ ...opResponse(200, 'Payment'), headers: responseHeaders },
|
|
369
|
+
{ ...opResponse(202, 'Payment'), headers: responseHeaders },
|
|
370
|
+
],
|
|
371
|
+
}),
|
|
372
|
+
]),
|
|
373
|
+
]);
|
|
374
|
+
const out = render(root, { contracts });
|
|
375
|
+
expect(out).toContain('public sealed record Get200Headers(string XRequestId);');
|
|
376
|
+
expect(out).toContain('public sealed record Get202Headers(string XRequestId);');
|
|
377
|
+
});
|
|
378
|
+
});
|
|
@@ -154,6 +154,21 @@ describe('optional, nullable, default and literal', () => {
|
|
|
154
154
|
]);
|
|
155
155
|
expect(render(root)).toContain('public Rating R { get; init; } = Rating.Neutral;');
|
|
156
156
|
});
|
|
157
|
+
|
|
158
|
+
it('qualifies the enum when a property of the same record shares its name', () => {
|
|
159
|
+
// `Rating?` is `Nullable<Rating>`, so C#'s same-name rule does not apply and a bare
|
|
160
|
+
// `Rating.Neutral` reads the property: CS0236 at build time.
|
|
161
|
+
const root = contractRoot([
|
|
162
|
+
model('Rating', [], { type: enumType('good', 'neutral') }),
|
|
163
|
+
model('M', [field('rating', refType('Rating'), { optional: true, default: 'neutral' })]),
|
|
164
|
+
]);
|
|
165
|
+
expect(render(root)).toContain('public Rating? Rating { get; init; } = global::Acme.Sdk.Models.Rating.Neutral;');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('qualifies a hoisted enum shadowed by a sibling property', () => {
|
|
169
|
+
const out = render(one('M', field('status', enumType('pending', 'done'), { default: 'done' }), field('mStatus', scalarType('string'))));
|
|
170
|
+
expect(out).toContain('public MStatus Status { get; init; } = global::Acme.Sdk.Models.MStatus.Done;');
|
|
171
|
+
});
|
|
157
172
|
});
|
|
158
173
|
|
|
159
174
|
describe('naming', () => {
|
package/tests/naming.test.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
2
|
import {
|
|
3
3
|
CSHARP_KEYWORDS,
|
|
4
|
+
bindCSharpParameterNames,
|
|
4
5
|
deriveCSharpFileBase,
|
|
5
6
|
escapeCSharpIdentifier,
|
|
6
7
|
escapeXml,
|
|
@@ -59,6 +60,31 @@ describe('toCSharpParameterName', () => {
|
|
|
59
60
|
});
|
|
60
61
|
});
|
|
61
62
|
|
|
63
|
+
describe('bindCSharpParameterNames', () => {
|
|
64
|
+
it('converts and escapes like toCSharpParameterName when nothing collides', () => {
|
|
65
|
+
expect(bindCSharpParameterNames(['invoice-id', 'class'], [])).toEqual(
|
|
66
|
+
new Map([
|
|
67
|
+
['invoice-id', 'invoiceId'],
|
|
68
|
+
['class', '@class'],
|
|
69
|
+
]),
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('suffixes a name that lands on one already taken', () => {
|
|
74
|
+
expect(bindCSharpParameterNames(['body'], ['body']).get('body')).toBe('body_');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('compares the unescaped spelling, since @class and class are one identifier', () => {
|
|
78
|
+
expect(bindCSharpParameterNames(['class'], ['class']).get('class')).toBe('class_');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('keeps two names distinct when a suffix would collapse them onto one', () => {
|
|
82
|
+
const bindings = bindCSharpParameterNames(['body', 'body_'], ['body']);
|
|
83
|
+
expect(bindings.get('body')).toBe('body_');
|
|
84
|
+
expect(bindings.get('body_')).toBe('body__');
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
62
88
|
describe('safeMemberName', () => {
|
|
63
89
|
it('renames a member that matches its enclosing type, which C# rejects', () => {
|
|
64
90
|
expect(safeMemberName('Invoice', 'Invoice')).toBe('InvoiceValue');
|