@contractkit/plugin-csharp 0.1.6 → 0.2.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 +5 -5
- package/.turbo/turbo-test$colon$ci.log +16 -15
- package/CHANGELOG.md +21 -0
- package/README.md +86 -13
- package/dist/codegen-client.d.ts +3 -0
- package/dist/codegen-client.d.ts.map +1 -1
- package/dist/codegen-models.d.ts +15 -0
- package/dist/codegen-models.d.ts.map +1 -1
- package/dist/index.d.ts +24 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +566 -19
- package/dist/index.js.map +1 -1
- package/dist/runtime-converters.d.ts +8 -2
- package/dist/runtime-converters.d.ts.map +1 -1
- package/dist/runtime-polyfills.d.ts +20 -0
- package/dist/runtime-polyfills.d.ts.map +1 -0
- package/dist/runtime.d.ts.map +1 -1
- package/dist/scaffold.d.ts +18 -4
- package/dist/scaffold.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/codegen-client.ts +19 -9
- package/src/codegen-models.ts +53 -2
- package/src/index.ts +74 -5
- package/src/runtime-converters.ts +135 -4
- package/src/runtime-polyfills.ts +352 -0
- package/src/runtime.ts +24 -0
- package/src/scaffold.ts +45 -6
- package/tests/codegen-client.test.ts +38 -4
- package/tests/codegen-models.test.ts +40 -3
- package/tests/index.test.ts +43 -0
- package/tests/runtime.test.ts +83 -1
- package/tests/scaffold.test.ts +22 -0
|
@@ -10,6 +10,12 @@
|
|
|
10
10
|
* own, so it serializes correctly under any options. These three are options-level, which is why
|
|
11
11
|
* anything serializing a generated model by hand has to pass `SdkJson.Options`.
|
|
12
12
|
*/
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
import type { CSharpDateTypes } from './codegen-models.js';
|
|
14
|
+
/**
|
|
15
|
+
* Generate `Runtime/Converters.cs`.
|
|
16
|
+
*
|
|
17
|
+
* Which date converter is registered follows `dateTypes`, since that decides the CLR type a `date`
|
|
18
|
+
* arrives as and serialization dispatches on nothing else.
|
|
19
|
+
*/
|
|
20
|
+
export declare function generateConvertersCs(namespaceName: string, dateTypes?: CSharpDateTypes): string;
|
|
15
21
|
//# sourceMappingURL=runtime-converters.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime-converters.d.ts","sourceRoot":"","sources":["../src/runtime-converters.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,
|
|
1
|
+
{"version":3,"file":"runtime-converters.d.ts","sourceRoot":"","sources":["../src/runtime-converters.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,qBAAqB,CAAC;AAmF3D;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,aAAa,EAAE,MAAM,EAAE,SAAS,GAAE,eAA4B,GAAG,MAAM,CA+K3G"}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `Runtime/Polyfills.cs` file: what .NET Standard 2.0 does not carry.
|
|
3
|
+
*
|
|
4
|
+
* Emitted only when the SDK targets netstandard2.0, and compiled only on that leg of a multi-target
|
|
5
|
+
* build, so a project on net10.0 alone never sees any of it. Two groups, for two different reasons:
|
|
6
|
+
*
|
|
7
|
+
* - The attributes the C# compiler looks for before it will accept an `init` accessor, a `required`
|
|
8
|
+
* member, or the records built on them. The compiler asks for these by full name and does not care
|
|
9
|
+
* which assembly declares them, so `internal` copies are enough and cannot collide with a
|
|
10
|
+
* consumer's own.
|
|
11
|
+
* - `DateOnly` and `TimeOnly`, which arrived in .NET 6. These are declared in the SDK's own runtime
|
|
12
|
+
* namespace rather than in `System`, because a consumer is free to reference a package that
|
|
13
|
+
* backfills `System.DateOnly` for their own code and two declarations of one full name are
|
|
14
|
+
* ambiguous wherever they meet. Generated files import the runtime namespace, so `DateOnly`
|
|
15
|
+
* resolves to the polyfill here on netstandard2.0 and to the framework's type on net10.0 without a
|
|
16
|
+
* line of conditional code in a model.
|
|
17
|
+
*/
|
|
18
|
+
/** Generate `Runtime/Polyfills.cs` for `namespaceName`. Content depends on nothing but the namespace. */
|
|
19
|
+
export declare function generatePolyfillsCs(namespaceName: string): string;
|
|
20
|
+
//# sourceMappingURL=runtime-polyfills.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"runtime-polyfills.d.ts","sourceRoot":"","sources":["../src/runtime-polyfills.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAEH,yGAAyG;AACzG,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CA4UjE"}
|
package/dist/runtime.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,0GAA0G;AAC1G,wBAAgB,iBAAiB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,
|
|
1
|
+
{"version":3,"file":"runtime.d.ts","sourceRoot":"","sources":["../src/runtime.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,0GAA0G;AAC1G,wBAAgB,iBAAiB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CA2Y/D"}
|
package/dist/scaffold.d.ts
CHANGED
|
@@ -3,24 +3,38 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Emitted with `ifAbsent`, so it is created once and then belongs to the user: a project will add a
|
|
5
5
|
* package id, a version, an analyzer set and a signing key of its own, and regenerating over that
|
|
6
|
-
* would throw the work away. Generated C# sources are rewritten every run; this is not.
|
|
6
|
+
* would throw the work away. Generated C# sources are rewritten every run; this is not. A project
|
|
7
|
+
* created before a scaffold change therefore keeps its own file — the README carries the snippet to
|
|
8
|
+
* paste when the change is one the project wants.
|
|
7
9
|
*/
|
|
10
|
+
/** The frameworks a generated SDK can be built for. */
|
|
11
|
+
export type CSharpTargetFramework = 'netstandard2.0' | 'net10.0';
|
|
8
12
|
/**
|
|
9
13
|
* What the scaffold pins. One object so a bump is one edit.
|
|
10
14
|
*
|
|
11
|
-
*
|
|
15
|
+
* A single-framework SDK on `net10.0` has no dependency list to go with it: it uses only
|
|
12
16
|
* `System.Text.Json` and `HttpClient` from the shared framework, so `dotnet build` restores with no
|
|
13
|
-
* NuGet feed reachable at all.
|
|
17
|
+
* NuGet feed reachable at all. `netstandard2.0` is the exception — `System.Text.Json` is a package
|
|
18
|
+
* there, and one that brings `System.Memory` and `System.Threading.Tasks.Extensions` with it.
|
|
14
19
|
*/
|
|
15
20
|
export declare const SCAFFOLD_VERSIONS: {
|
|
16
21
|
readonly targetFramework: "net10.0";
|
|
22
|
+
readonly systemTextJson: "10.0.12";
|
|
23
|
+
/** The oldest language version the generated sources compile under: records, `required`, primary constructors. */
|
|
24
|
+
readonly netstandardLangVersion: "12.0";
|
|
17
25
|
};
|
|
26
|
+
/** The framework the scaffold targets when the config names none. */
|
|
27
|
+
export declare const DEFAULT_TARGET_FRAMEWORKS: readonly CSharpTargetFramework[];
|
|
18
28
|
/**
|
|
19
29
|
* Generate `<SdkName>.csproj`.
|
|
20
30
|
*
|
|
21
31
|
* `ImplicitUsings` is off because generated files carry an explicit `using` block of their own, and
|
|
22
32
|
* leaving it on would make the output depend on the SDK's implicit set rather than on what the
|
|
23
33
|
* generator wrote.
|
|
34
|
+
*
|
|
35
|
+
* Only a build that includes `netstandard2.0` pins `LangVersion` or references a package. On its own,
|
|
36
|
+
* `net10.0` defaults to the newest language version the SDK knows, and pinning one here would hold a
|
|
37
|
+
* project back rather than help it.
|
|
24
38
|
*/
|
|
25
|
-
export declare function generateCsproj(namespaceName: string, sdkName: string): string;
|
|
39
|
+
export declare function generateCsproj(namespaceName: string, sdkName: string, targetFrameworks?: readonly CSharpTargetFramework[]): string;
|
|
26
40
|
//# sourceMappingURL=scaffold.d.ts.map
|
package/dist/scaffold.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scaffold.d.ts","sourceRoot":"","sources":["../src/scaffold.ts"],"names":[],"mappings":"AAAA
|
|
1
|
+
{"version":3,"file":"scaffold.d.ts","sourceRoot":"","sources":["../src/scaffold.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,uDAAuD;AACvD,MAAM,MAAM,qBAAqB,GAAG,gBAAgB,GAAG,SAAS,CAAC;AAEjE;;;;;;;GAOG;AACH,eAAO,MAAM,iBAAiB;;;IAG1B,kHAAkH;;CAE5G,CAAC;AAEX,qEAAqE;AACrE,eAAO,MAAM,yBAAyB,EAAE,SAAS,qBAAqB,EAAwC,CAAC;AAE/G;;;;;;;;;;GAUG;AACH,wBAAgB,cAAc,CAC1B,aAAa,EAAE,MAAM,EACrB,OAAO,EAAE,MAAM,EACf,gBAAgB,GAAE,SAAS,qBAAqB,EAA8B,GAC/E,MAAM,CAiCR"}
|
package/package.json
CHANGED
package/src/codegen-client.ts
CHANGED
|
@@ -10,7 +10,7 @@ import type {
|
|
|
10
10
|
} from '@contractkit/core';
|
|
11
11
|
import { classifyContentType, observableResponses, resolveModifiers } from '@contractkit/core';
|
|
12
12
|
import type { HoistResult } from './hoist.js';
|
|
13
|
-
import { createRenderContext, renderCSharpType, renderFile, type RenderContext } from './codegen-models.js';
|
|
13
|
+
import { createRenderContext, renderCSharpType, renderFile, type CSharpDateTypes, type RenderContext } from './codegen-models.js';
|
|
14
14
|
import {
|
|
15
15
|
bindCSharpParameterNames,
|
|
16
16
|
deriveCSharpFileBase,
|
|
@@ -24,6 +24,8 @@ import {
|
|
|
24
24
|
|
|
25
25
|
export interface CSharpClientCodegenOptions {
|
|
26
26
|
namespace: string;
|
|
27
|
+
/** Which C# type a `date` maps to (default: `dateonly`). Applies to response headers too. */
|
|
28
|
+
dateTypes?: CSharpDateTypes;
|
|
27
29
|
modelsWithInput: ReadonlySet<string>;
|
|
28
30
|
modelIndex?: ReadonlyMap<string, ModelNode>;
|
|
29
31
|
hoisted?: HoistResult;
|
|
@@ -213,7 +215,7 @@ function generateMethod(route: OpRouteNode, op: OpOperationNode, ctx: RenderCont
|
|
|
213
215
|
lines.push(`public async ${returnType === 'void' ? 'Task' : `Task<${returnType}>`} ${methodName}(${signature})`);
|
|
214
216
|
lines.push('{');
|
|
215
217
|
|
|
216
|
-
const callArgs: string[] = [
|
|
218
|
+
const callArgs: string[] = [httpMethodExpression(op.method), buildPathExpression(route.path, route.params, pathBindings)];
|
|
217
219
|
if (op.query) callArgs.push('query: http.Params(query)');
|
|
218
220
|
if (op.headers) callArgs.push('headers: http.Params(customHeaders)');
|
|
219
221
|
const content = bodyArgument(op);
|
|
@@ -435,7 +437,7 @@ function responseDeclarations(route: OpRouteNode, op: OpOperationNode, ctx: Rend
|
|
|
435
437
|
const headerRecord = (headers: OpResponseHeaderNode[], name: string): void => {
|
|
436
438
|
const parameters = headers
|
|
437
439
|
.map(header => {
|
|
438
|
-
const reader = headerReader(header, place);
|
|
440
|
+
const reader = headerReader(header, place, ctx.dateTypes);
|
|
439
441
|
const type = header.optional ? `${reader.type}?` : reader.type;
|
|
440
442
|
return `${type} ${safeMemberName(toCSharpPropertyName(header.name), name)}`;
|
|
441
443
|
})
|
|
@@ -505,7 +507,7 @@ function responseDeclarations(route: OpRouteNode, op: OpOperationNode, ctx: Rend
|
|
|
505
507
|
*
|
|
506
508
|
* @throws {Error} When the header's declared type cannot be read from an HTTP header.
|
|
507
509
|
*/
|
|
508
|
-
function headerReader(header: OpResponseHeaderNode, place: string): { type: string; read: (raw: string) => string } {
|
|
510
|
+
function headerReader(header: OpResponseHeaderNode, place: string, dateTypes: CSharpDateTypes): { type: string; read: (raw: string) => string } {
|
|
509
511
|
const scalar = header.type.kind === 'scalar' ? header.type.name : undefined;
|
|
510
512
|
switch (scalar) {
|
|
511
513
|
case 'string':
|
|
@@ -525,7 +527,9 @@ function headerReader(header: OpResponseHeaderNode, place: string): { type: stri
|
|
|
525
527
|
case 'uuid':
|
|
526
528
|
return { type: 'Guid', read: raw => `Guid.Parse(${raw})` };
|
|
527
529
|
case 'date':
|
|
528
|
-
return
|
|
530
|
+
return dateTypes === 'datetime'
|
|
531
|
+
? { type: 'DateTime', read: raw => `DateTime.Parse(${raw}, CultureInfo.InvariantCulture)` }
|
|
532
|
+
: { type: 'DateOnly', read: raw => `DateOnly.Parse(${raw}, CultureInfo.InvariantCulture)` };
|
|
529
533
|
case 'time':
|
|
530
534
|
return { type: 'TimeOnly', read: raw => `TimeOnly.Parse(${raw}, CultureInfo.InvariantCulture)` };
|
|
531
535
|
case 'datetime':
|
|
@@ -564,7 +568,7 @@ function readHeaderLines(
|
|
|
564
568
|
bound,
|
|
565
569
|
);
|
|
566
570
|
const args = headers.map(header => {
|
|
567
|
-
const reader = headerReader(header, place);
|
|
571
|
+
const reader = headerReader(header, place, ctx.dateTypes);
|
|
568
572
|
const name = quoteCSharpString(header.name);
|
|
569
573
|
// A required header the service omitted is a broken contract, not a null the caller has to
|
|
570
574
|
// handle; an optional one simply stays absent.
|
|
@@ -590,10 +594,16 @@ function methodDoc(route: OpRouteNode, op: OpOperationNode, observable: OpRespon
|
|
|
590
594
|
return lines;
|
|
591
595
|
}
|
|
592
596
|
|
|
593
|
-
/**
|
|
594
|
-
|
|
597
|
+
/**
|
|
598
|
+
* How a verb is spelled at the call site.
|
|
599
|
+
*
|
|
600
|
+
* `System.Net.Http.HttpMethod` carries a static for every verb the grammar allows except PATCH,
|
|
601
|
+
* which netstandard2.0 does not have, so PATCH goes through the runtime's own `SdkHttp.Patch`.
|
|
602
|
+
*/
|
|
603
|
+
function httpMethodExpression(method: string): string {
|
|
595
604
|
const lower = method.toLowerCase();
|
|
596
|
-
|
|
605
|
+
if (lower === 'patch') return 'SdkHttp.Patch';
|
|
606
|
+
return `HttpMethod.${lower.charAt(0).toUpperCase()}${lower.slice(1)}`;
|
|
597
607
|
}
|
|
598
608
|
|
|
599
609
|
// ─── Path building ─────────────────────────────────────────────────────────
|
package/src/codegen-models.ts
CHANGED
|
@@ -5,9 +5,24 @@ import { quoteCSharpString, safeMemberName, toCSharpEnumMemberName, toCSharpProp
|
|
|
5
5
|
|
|
6
6
|
// ─── Public entry point ────────────────────────────────────────────────────
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Which C# type a contract's `date` maps to.
|
|
10
|
+
*
|
|
11
|
+
* `dateonly` is `DateOnly`, the type the framework added for exactly this. `datetime` is `DateTime`
|
|
12
|
+
* at midnight with an unspecified kind, for a UI stack whose date controls bind to that and nothing
|
|
13
|
+
* else — XAML's `DatePicker` among them. The choice applies to every framework the SDK is built for,
|
|
14
|
+
* so the public surface never differs between them.
|
|
15
|
+
*
|
|
16
|
+
* `time` is `TimeOnly` either way: `duration` already maps to `TimeSpan`, and serialization dispatches
|
|
17
|
+
* on the CLR type, so a `time` carried as a `TimeSpan` would go out as `PT9H30M`.
|
|
18
|
+
*/
|
|
19
|
+
export type CSharpDateTypes = 'dateonly' | 'datetime';
|
|
20
|
+
|
|
8
21
|
export interface CSharpModelCodegenOptions {
|
|
9
22
|
/** Root namespace the SDK is generated into. Models land in `<namespace>.Models`. */
|
|
10
23
|
namespace: string;
|
|
24
|
+
/** Which C# type a `date` maps to (default: `dateonly`). */
|
|
25
|
+
dateTypes?: CSharpDateTypes;
|
|
11
26
|
/** Model names that have a distinct `Input` variant, including ones declared in other files. */
|
|
12
27
|
modelsWithInput?: ReadonlySet<string>;
|
|
13
28
|
/**
|
|
@@ -26,6 +41,11 @@ export interface CSharpModelCodegenOptions {
|
|
|
26
41
|
* There is no import tracker, unlike the Kotlin plugin: every type the models can name is in the
|
|
27
42
|
* base class library, so the set is fixed. An unused `using` is not a compiler warning, and pinning
|
|
28
43
|
* the block keeps the output stable and free of the ordering churn a tracker would produce.
|
|
44
|
+
*
|
|
45
|
+
* The SDK's own runtime namespace is added to this list per file, because it needs the namespace
|
|
46
|
+
* name. It is where `DateOnly` and `TimeOnly` come from on a framework too old to have them: the
|
|
47
|
+
* polyfills are declared there and nowhere else, so the same short spelling resolves to the
|
|
48
|
+
* framework's type wherever the framework has one, with no conditional code in a model.
|
|
29
49
|
*/
|
|
30
50
|
const MODEL_USINGS = [
|
|
31
51
|
'using System;',
|
|
@@ -50,6 +70,7 @@ export function generateCSharpModels(root: ContractRootNode, opts: CSharpModelCo
|
|
|
50
70
|
|
|
51
71
|
const ctx: RenderContext = {
|
|
52
72
|
namespace: opts.namespace,
|
|
73
|
+
dateTypes: opts.dateTypes ?? 'dateonly',
|
|
53
74
|
modelsWithInput,
|
|
54
75
|
modelIndex,
|
|
55
76
|
hoisted: opts.hoisted,
|
|
@@ -67,7 +88,7 @@ export function generateCSharpModels(root: ContractRootNode, opts: CSharpModelCo
|
|
|
67
88
|
for (const model of topoSortModels(root.models)) append(generateModel(model, ctx));
|
|
68
89
|
for (const decl of opts.hoisted?.byFile.get(root.file) ?? []) append(generateHoisted(decl, ctx));
|
|
69
90
|
|
|
70
|
-
return renderFile(`${opts.namespace}.Models`, ctx.globalAliases, [...MODEL_USINGS], bodies);
|
|
91
|
+
return renderFile(`${opts.namespace}.Models`, ctx.globalAliases, [...MODEL_USINGS, `using ${opts.namespace}.Runtime;`], bodies);
|
|
71
92
|
}
|
|
72
93
|
|
|
73
94
|
/**
|
|
@@ -86,6 +107,7 @@ export function resolveModelsWithInput(models: readonly ModelNode[], external: R
|
|
|
86
107
|
|
|
87
108
|
interface RenderContext {
|
|
88
109
|
namespace: string;
|
|
110
|
+
dateTypes: CSharpDateTypes;
|
|
89
111
|
modelsWithInput: ReadonlySet<string>;
|
|
90
112
|
modelIndex: ReadonlyMap<string, ModelNode>;
|
|
91
113
|
hoisted?: HoistResult;
|
|
@@ -100,6 +122,7 @@ interface RenderContext {
|
|
|
100
122
|
export function createRenderContext(opts: CSharpModelCodegenOptions & { modelsWithInput: ReadonlySet<string> }): RenderContext {
|
|
101
123
|
return {
|
|
102
124
|
namespace: opts.namespace,
|
|
125
|
+
dateTypes: opts.dateTypes ?? 'dateonly',
|
|
103
126
|
modelsWithInput: opts.modelsWithInput,
|
|
104
127
|
modelIndex: opts.modelIndex ?? new Map(),
|
|
105
128
|
hoisted: opts.hoisted,
|
|
@@ -238,8 +261,9 @@ export function renderScalar(name: ScalarTypeNode['name'], ctx: RenderContext):
|
|
|
238
261
|
return qualify('decimal', 'System.Decimal', ctx);
|
|
239
262
|
case 'boolean':
|
|
240
263
|
return qualify('bool', 'System.Boolean', ctx);
|
|
264
|
+
// Both carried as the wire form by a converter: `yyyy-MM-dd` and `HH:mm:ss`.
|
|
241
265
|
case 'date':
|
|
242
|
-
return qualify('DateOnly', 'System.DateOnly', ctx);
|
|
266
|
+
return ctx.dateTypes === 'datetime' ? qualify('DateTime', 'System.DateTime', ctx) : qualify('DateOnly', 'System.DateOnly', ctx);
|
|
243
267
|
case 'time':
|
|
244
268
|
return qualify('TimeOnly', 'System.TimeOnly', ctx);
|
|
245
269
|
case 'datetime':
|
|
@@ -541,9 +565,35 @@ function addAlias(name: string, type: ContractTypeNode, ctx: RenderContext, forI
|
|
|
541
565
|
`'${name}' is generated as '${aliased}'. Declare the nullability at each use site instead.`,
|
|
542
566
|
);
|
|
543
567
|
}
|
|
568
|
+
const polyfilled = polyfillAliasTarget(aliased, ctx);
|
|
569
|
+
if (polyfilled) {
|
|
570
|
+
// A using alias has to name its target in full, which is the one place the short spelling
|
|
571
|
+
// cannot do the work: `System.DateOnly` does not exist on netstandard2.0, so the alias is
|
|
572
|
+
// written per framework rather than resolved by the file's imports.
|
|
573
|
+
ctx.globalAliases.push(`#if NETSTANDARD2_0\nglobal using ${name} = ${polyfilled};\n#else\nglobal using ${name} = ${aliased};\n#endif`);
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
544
577
|
ctx.globalAliases.push(`global using ${name} = ${aliased};`);
|
|
545
578
|
}
|
|
546
579
|
|
|
580
|
+
/** The framework types the SDK carries a polyfill for, by their fully-qualified spelling. */
|
|
581
|
+
const POLYFILLED_TYPES: readonly string[] = ['System.DateOnly', 'System.TimeOnly'];
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The netstandard2.0 spelling of an alias target, or undefined when it names no polyfilled type.
|
|
585
|
+
*
|
|
586
|
+
* Substring replacement rather than a lookup, because the target may be a container: `array(date)`
|
|
587
|
+
* aliases to `System.Collections.Generic.List<System.DateOnly>`.
|
|
588
|
+
*/
|
|
589
|
+
function polyfillAliasTarget(target: string, ctx: RenderContext): string | undefined {
|
|
590
|
+
let out = target;
|
|
591
|
+
for (const full of POLYFILLED_TYPES) {
|
|
592
|
+
out = out.split(full).join(`${ctx.namespace}.Runtime.${full.slice('System.'.length)}`);
|
|
593
|
+
}
|
|
594
|
+
return out === target ? undefined : out;
|
|
595
|
+
}
|
|
596
|
+
|
|
547
597
|
/** Whether `type` renders as a nullable *value* type, which is a legal alias target. */
|
|
548
598
|
function isNullableValueType(type: ContractTypeNode, ctx: RenderContext): boolean {
|
|
549
599
|
const inner = type.kind === 'lazy' ? type.inner : type;
|
|
@@ -563,6 +613,7 @@ const VALUE_TYPES: ReadonlySet<string> = new Set([
|
|
|
563
613
|
'BigInteger',
|
|
564
614
|
'DateOnly',
|
|
565
615
|
'TimeOnly',
|
|
616
|
+
'DateTime',
|
|
566
617
|
'DateTimeOffset',
|
|
567
618
|
'TimeSpan',
|
|
568
619
|
'Guid',
|
package/src/index.ts
CHANGED
|
@@ -22,13 +22,14 @@ import {
|
|
|
22
22
|
runIncrementalCodegen,
|
|
23
23
|
serializeIncrementalManifest,
|
|
24
24
|
} from '@contractkit/core';
|
|
25
|
-
import { generateCSharpModels, resolveModelsWithInput } from './codegen-models.js';
|
|
25
|
+
import { generateCSharpModels, resolveModelsWithInput, type CSharpDateTypes } from './codegen-models.js';
|
|
26
26
|
import { deriveClientClassName, deriveClientPropertyName, generateCSharpClient, hasPublicOperations } from './codegen-client.js';
|
|
27
27
|
import { generateSdkCs, type SdkAggregatorClient } from './codegen-sdk.js';
|
|
28
28
|
import { collectHoistedTypes } from './hoist.js';
|
|
29
29
|
import { generateRuntimeCs } from './runtime.js';
|
|
30
30
|
import { generateConvertersCs } from './runtime-converters.js';
|
|
31
|
-
import {
|
|
31
|
+
import { generatePolyfillsCs } from './runtime-polyfills.js';
|
|
32
|
+
import { DEFAULT_TARGET_FRAMEWORKS, generateCsproj, type CSharpTargetFramework } from './scaffold.js';
|
|
32
33
|
import { CSHARP_KEYWORDS, deriveCSharpFileBase } from './naming.js';
|
|
33
34
|
|
|
34
35
|
export interface CSharpSdkPluginConfig {
|
|
@@ -45,18 +46,41 @@ export interface CSharpSdkPluginConfig {
|
|
|
45
46
|
includeInternal?: boolean;
|
|
46
47
|
/** Emit `<SdkName>.csproj` once, as a user-owned file. Never overwritten. */
|
|
47
48
|
scaffold?: boolean;
|
|
49
|
+
/**
|
|
50
|
+
* The frameworks the SDK is built for (default: `["net10.0"]`).
|
|
51
|
+
*
|
|
52
|
+
* Naming `netstandard2.0` is what makes the output usable from a UWP or .NET Framework project:
|
|
53
|
+
* `Runtime/Polyfills.cs` is emitted alongside the rest, and a scaffolded `.csproj` multi-targets
|
|
54
|
+
* and references `System.Text.Json` on that leg. Convention puts the oldest framework first.
|
|
55
|
+
*/
|
|
56
|
+
targetFrameworks?: CSharpTargetFramework[];
|
|
57
|
+
/**
|
|
58
|
+
* Which C# type a contract's `date` maps to (default: `"dateonly"`).
|
|
59
|
+
*
|
|
60
|
+
* `"datetime"` maps it to `DateTime` at midnight with an unspecified kind, for a UI stack whose
|
|
61
|
+
* date controls bind to that and nothing else — XAML's `DatePicker` among them. It applies to
|
|
62
|
+
* every framework the SDK is built for, so the public surface never differs between them.
|
|
63
|
+
*
|
|
64
|
+
* `time` is `TimeOnly` either way, because `duration` already maps to `TimeSpan` and a `time`
|
|
65
|
+
* carried as one would go out as `PT9H30M`.
|
|
66
|
+
*/
|
|
67
|
+
dateTypes?: CSharpDateTypes;
|
|
48
68
|
}
|
|
49
69
|
|
|
50
70
|
/**
|
|
51
71
|
* Bumped when the C# codegen output shape changes in a way that should invalidate every per-file
|
|
52
72
|
* fingerprint, so a plugin upgrade forces full regeneration even when no `.ck` file has changed.
|
|
53
73
|
*/
|
|
54
|
-
export const CSHARP_CODEGEN_VERSION = '
|
|
74
|
+
export const CSHARP_CODEGEN_VERSION = '2';
|
|
75
|
+
|
|
76
|
+
export type { CSharpTargetFramework } from './scaffold.js';
|
|
77
|
+
export type { CSharpDateTypes } from './codegen-models.js';
|
|
55
78
|
|
|
56
79
|
const CACHE_MANIFEST_FILENAME = 'csharp-manifest.json';
|
|
57
80
|
const DEFAULT_BASE_DIR = 'csharp-sdk';
|
|
58
81
|
const DEFAULT_NAMESPACE = 'ContractKit.Sdk';
|
|
59
82
|
const DEFAULT_SDK_NAME = 'Sdk';
|
|
83
|
+
const DEFAULT_DATE_TYPES: CSharpDateTypes = 'dateonly';
|
|
60
84
|
|
|
61
85
|
const plugin: ContractKitPlugin = {
|
|
62
86
|
name: 'csharp-sdk',
|
|
@@ -111,6 +135,35 @@ export function assertValidConfig(config: CSharpSdkPluginConfig): void {
|
|
|
111
135
|
throw new Error(`plugin-csharp: ${key} must be a boolean — got ${JSON.stringify(value)}.`);
|
|
112
136
|
}
|
|
113
137
|
}
|
|
138
|
+
assertValidTargetFrameworks(config.targetFrameworks);
|
|
139
|
+
if (config.dateTypes !== undefined && !DATE_TYPES.includes(config.dateTypes)) {
|
|
140
|
+
throw new Error(`plugin-csharp: dateTypes ${JSON.stringify(config.dateTypes)} is not supported — expected one of ${DATE_TYPES.join(', ')}.`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The C# types a contract's `date` can map to. */
|
|
145
|
+
const DATE_TYPES: readonly CSharpDateTypes[] = ['dateonly', 'datetime'];
|
|
146
|
+
|
|
147
|
+
/** Every framework the scaffold knows how to write a project file for. */
|
|
148
|
+
const TARGET_FRAMEWORKS: readonly CSharpTargetFramework[] = ['netstandard2.0', 'net10.0'];
|
|
149
|
+
|
|
150
|
+
function assertValidTargetFrameworks(frameworks: CSharpSdkPluginConfig['targetFrameworks']): void {
|
|
151
|
+
if (frameworks === undefined) return;
|
|
152
|
+
if (!Array.isArray(frameworks) || frameworks.length === 0) {
|
|
153
|
+
throw new Error(`plugin-csharp: targetFrameworks must be a non-empty array — got ${JSON.stringify(frameworks)}.`);
|
|
154
|
+
}
|
|
155
|
+
const seen = new Set<string>();
|
|
156
|
+
for (const framework of frameworks) {
|
|
157
|
+
if (typeof framework !== 'string' || !TARGET_FRAMEWORKS.includes(framework)) {
|
|
158
|
+
throw new Error(
|
|
159
|
+
`plugin-csharp: targetFrameworks entry ${JSON.stringify(framework)} is not supported — expected one of ${TARGET_FRAMEWORKS.join(', ')}.`,
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
if (seen.has(framework)) {
|
|
163
|
+
throw new Error(`plugin-csharp: targetFrameworks lists '${framework}' twice.`);
|
|
164
|
+
}
|
|
165
|
+
seen.add(framework);
|
|
166
|
+
}
|
|
114
167
|
}
|
|
115
168
|
|
|
116
169
|
/**
|
|
@@ -131,6 +184,8 @@ async function runCSharpCodegen(
|
|
|
131
184
|
const { contractRoots } = inputs;
|
|
132
185
|
const namespaceName = config.namespace ?? DEFAULT_NAMESPACE;
|
|
133
186
|
const sdkName = config.sdkName ?? DEFAULT_SDK_NAME;
|
|
187
|
+
const targetFrameworks = config.targetFrameworks ?? DEFAULT_TARGET_FRAMEWORKS;
|
|
188
|
+
const dateTypes = config.dateTypes ?? DEFAULT_DATE_TYPES;
|
|
134
189
|
const outDir = resolve(rootDir, config.baseDir ?? DEFAULT_BASE_DIR);
|
|
135
190
|
const manifestPath = resolve(ctx.cacheDir, CACHE_MANIFEST_FILENAME);
|
|
136
191
|
|
|
@@ -182,6 +237,7 @@ async function runCSharpCodegen(
|
|
|
182
237
|
v: CSHARP_CODEGEN_VERSION,
|
|
183
238
|
relPath,
|
|
184
239
|
namespace: namespaceName,
|
|
240
|
+
dateTypes,
|
|
185
241
|
root,
|
|
186
242
|
externalBases,
|
|
187
243
|
modelsWithInput: relevantInputModels,
|
|
@@ -197,6 +253,7 @@ async function runCSharpCodegen(
|
|
|
197
253
|
relativePath: relPath,
|
|
198
254
|
content: generateCSharpModels(root, {
|
|
199
255
|
namespace: namespaceName,
|
|
256
|
+
dateTypes,
|
|
200
257
|
modelsWithInput,
|
|
201
258
|
modelIndex,
|
|
202
259
|
hoisted,
|
|
@@ -227,6 +284,7 @@ async function runCSharpCodegen(
|
|
|
227
284
|
v: CSHARP_CODEGEN_VERSION,
|
|
228
285
|
relPath,
|
|
229
286
|
namespace: namespaceName,
|
|
287
|
+
dateTypes,
|
|
230
288
|
root,
|
|
231
289
|
referencedModels,
|
|
232
290
|
modelsWithInput: relevantInputModels,
|
|
@@ -241,6 +299,7 @@ async function runCSharpCodegen(
|
|
|
241
299
|
relativePath: relPath,
|
|
242
300
|
content: generateCSharpClient(root, {
|
|
243
301
|
namespace: namespaceName,
|
|
302
|
+
dateTypes,
|
|
244
303
|
modelsWithInput,
|
|
245
304
|
modelIndex,
|
|
246
305
|
hoisted,
|
|
@@ -255,15 +314,25 @@ async function runCSharpCodegen(
|
|
|
255
314
|
// The runtime is a constant, and the aggregator depends only on the list of public clients.
|
|
256
315
|
// Both are small enough that rewriting them every run beats a cache entry.
|
|
257
316
|
const globalFiles: IncrementalOutputFile[] = [
|
|
258
|
-
{ relativePath: 'Runtime/Converters.cs', content: generateConvertersCs(namespaceName) },
|
|
317
|
+
{ relativePath: 'Runtime/Converters.cs', content: generateConvertersCs(namespaceName, dateTypes) },
|
|
259
318
|
{ relativePath: 'Runtime/SdkRuntime.cs', content: generateRuntimeCs(namespaceName) },
|
|
260
319
|
{ relativePath: `${sdkName}.cs`, content: generateSdkCs(namespaceName, sdkName, clients) },
|
|
261
320
|
];
|
|
262
321
|
|
|
322
|
+
// Only a build that includes netstandard2.0 has anything to fill in. Dropping the framework from
|
|
323
|
+
// the config drops the file, which the incremental pass then deletes as an orphan.
|
|
324
|
+
if (targetFrameworks.includes('netstandard2.0')) {
|
|
325
|
+
globalFiles.push({ relativePath: 'Runtime/Polyfills.cs', content: generatePolyfillsCs(namespaceName) });
|
|
326
|
+
}
|
|
327
|
+
|
|
263
328
|
// `ifAbsent` marks this user-owned: written once, never overwritten, and never removed as an
|
|
264
329
|
// orphan when the generated tree changes around it.
|
|
265
330
|
if (config.scaffold) {
|
|
266
|
-
globalFiles.push({
|
|
331
|
+
globalFiles.push({
|
|
332
|
+
relativePath: `${sdkName}.csproj`,
|
|
333
|
+
content: generateCsproj(namespaceName, sdkName, targetFrameworks),
|
|
334
|
+
ifAbsent: true,
|
|
335
|
+
});
|
|
267
336
|
}
|
|
268
337
|
|
|
269
338
|
const result = runIncrementalCodegen({
|
|
@@ -11,8 +11,98 @@
|
|
|
11
11
|
* anything serializing a generated model by hand has to pass `SdkJson.Options`.
|
|
12
12
|
*/
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
import type { CSharpDateTypes } from './codegen-models.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A `date` carried as a `DateTime`, under `dateTypes: "datetime"`.
|
|
18
|
+
*
|
|
19
|
+
* Registered on every framework, not only the old one: the framework's own `DateTime` handling writes
|
|
20
|
+
* a full round-trip timestamp, which is not what the contract says a `date` looks like. Safe to make
|
|
21
|
+
* options-level because `datetime` maps to `DateTimeOffset`, so nothing else in a generated model is
|
|
22
|
+
* a `DateTime`.
|
|
23
|
+
*/
|
|
24
|
+
const ISO_DATE_CONVERTER = `/// <summary>
|
|
25
|
+
/// A calendar date, as <c>yyyy-MM-dd</c>, carried in the date part of a <c>DateTime</c>.
|
|
26
|
+
/// </summary>
|
|
27
|
+
/// <remarks>
|
|
28
|
+
/// The time is midnight and the kind is unspecified: a contract's <c>date</c> names neither, and
|
|
29
|
+
/// pretending to either would put a zone offset on the wire.
|
|
30
|
+
/// </remarks>
|
|
31
|
+
public sealed class IsoDateConverter : JsonConverter<DateTime>
|
|
32
|
+
{
|
|
33
|
+
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
34
|
+
{
|
|
35
|
+
if (reader.TokenType != JsonTokenType.String)
|
|
36
|
+
{
|
|
37
|
+
throw new JsonException($"Expected a date string, got {reader.TokenType}.");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
var text = reader.GetString() ?? throw new JsonException("Expected a date string.");
|
|
41
|
+
if (DateTime.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var exact))
|
|
42
|
+
{
|
|
43
|
+
return exact;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// A service sending more than the contract promised is read for the part it promised.
|
|
47
|
+
if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
|
|
48
|
+
{
|
|
49
|
+
return parsed.Date;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
throw new JsonException($"'{text}' is not a date.");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
|
|
56
|
+
{
|
|
57
|
+
writer.WriteStringValue(value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
`;
|
|
62
|
+
|
|
63
|
+
/** A `date` carried as the polyfilled `DateOnly`, under the default `dateTypes: "dateonly"`. */
|
|
64
|
+
const DATE_ONLY_CONVERTER = `
|
|
65
|
+
/// <summary>
|
|
66
|
+
/// A calendar date, as <c>yyyy-MM-dd</c>.
|
|
67
|
+
/// </summary>
|
|
68
|
+
/// <remarks>
|
|
69
|
+
/// Compiled only where <c>DateOnly</c> is the SDK's own polyfill. The wire form is the one the
|
|
70
|
+
/// framework's converter writes on net10.0, so a service reads a body from either leg of the build.
|
|
71
|
+
/// </remarks>
|
|
72
|
+
public sealed class DateOnlyConverter : JsonConverter<DateOnly>
|
|
73
|
+
{
|
|
74
|
+
public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
75
|
+
{
|
|
76
|
+
if (reader.TokenType != JsonTokenType.String)
|
|
77
|
+
{
|
|
78
|
+
throw new JsonException($"Expected a date string, got {reader.TokenType}.");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
var text = reader.GetString() ?? throw new JsonException("Expected a date string.");
|
|
82
|
+
if (!DateOnly.TryParse(text, CultureInfo.InvariantCulture, out var value))
|
|
83
|
+
{
|
|
84
|
+
throw new JsonException($"'{text}' is not a date.");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
return value;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
|
|
91
|
+
{
|
|
92
|
+
writer.WriteStringValue(value.ToString());
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
`;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Generate `Runtime/Converters.cs`.
|
|
99
|
+
*
|
|
100
|
+
* Which date converter is registered follows `dateTypes`, since that decides the CLR type a `date`
|
|
101
|
+
* arrives as and serialization dispatches on nothing else.
|
|
102
|
+
*/
|
|
103
|
+
export function generateConvertersCs(namespaceName: string, dateTypes: CSharpDateTypes = 'dateonly'): string {
|
|
104
|
+
const asDateTime = dateTypes === 'datetime';
|
|
105
|
+
|
|
16
106
|
return `// <auto-generated/>
|
|
17
107
|
// Generated by @contractkit/plugin-csharp. Do not edit manually.
|
|
18
108
|
#nullable enable
|
|
@@ -46,6 +136,11 @@ public static class SdkJson
|
|
|
46
136
|
options.Converters.Add(new BigIntegerConverter());
|
|
47
137
|
options.Converters.Add(new DecimalStringConverter());
|
|
48
138
|
options.Converters.Add(new IsoTimeSpanConverter());
|
|
139
|
+
${asDateTime ? ' options.Converters.Add(new IsoDateConverter());\n' : ''}#if NETSTANDARD2_0
|
|
140
|
+
// These are the SDK's own types on this framework, so System.Text.Json has no built-in
|
|
141
|
+
// converter for them. On net10.0 the framework handles them and these are not compiled.
|
|
142
|
+
${asDateTime ? '' : ' options.Converters.Add(new DateOnlyConverter());\n'} options.Converters.Add(new TimeOnlyConverter());
|
|
143
|
+
#endif
|
|
49
144
|
return options;
|
|
50
145
|
}
|
|
51
146
|
}
|
|
@@ -70,8 +165,9 @@ public sealed class BigIntegerConverter : JsonConverter<BigInteger>
|
|
|
70
165
|
if (reader.TokenType == JsonTokenType.Number)
|
|
71
166
|
{
|
|
72
167
|
// Read the raw token rather than a long: the value may be wider than any BCL integer,
|
|
73
|
-
// which is the whole reason the contract called it a bigint.
|
|
74
|
-
|
|
168
|
+
// which is the whole reason the contract called it a bigint. Copied to an array rather
|
|
169
|
+
// than handed to the span overload of GetString, which netstandard2.0 does not have.
|
|
170
|
+
var raw = Encoding.UTF8.GetString(reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray());
|
|
75
171
|
return BigInteger.Parse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture);
|
|
76
172
|
}
|
|
77
173
|
|
|
@@ -143,5 +239,40 @@ public sealed class IsoTimeSpanConverter : JsonConverter<TimeSpan>
|
|
|
143
239
|
writer.WriteStringValue(XmlConvert.ToString(value));
|
|
144
240
|
}
|
|
145
241
|
}
|
|
242
|
+
|
|
243
|
+
${asDateTime ? ISO_DATE_CONVERTER : ''}#if NETSTANDARD2_0
|
|
244
|
+
${asDateTime ? '' : DATE_ONLY_CONVERTER}
|
|
245
|
+
/// <summary>
|
|
246
|
+
/// A time of day, as <c>HH:mm:ss</c>, with a seven-digit fraction when there is one.
|
|
247
|
+
/// </summary>
|
|
248
|
+
/// <remarks>
|
|
249
|
+
/// Compiled only where <c>TimeOnly</c> is the SDK's own polyfill, and writing what the framework's
|
|
250
|
+
/// own converter writes on net10.0, so a service reads a body from either leg of the build.
|
|
251
|
+
/// </remarks>
|
|
252
|
+
public sealed class TimeOnlyConverter : JsonConverter<TimeOnly>
|
|
253
|
+
{
|
|
254
|
+
public override TimeOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
255
|
+
{
|
|
256
|
+
if (reader.TokenType != JsonTokenType.String)
|
|
257
|
+
{
|
|
258
|
+
throw new JsonException($"Expected a time-of-day string, got {reader.TokenType}.");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
var text = reader.GetString() ?? throw new JsonException("Expected a time-of-day string.");
|
|
262
|
+
if (!TimeOnly.TryParse(text, CultureInfo.InvariantCulture, out var value))
|
|
263
|
+
{
|
|
264
|
+
throw new JsonException($"'{text}' is not a time of day.");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return value;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
public override void Write(Utf8JsonWriter writer, TimeOnly value, JsonSerializerOptions options)
|
|
271
|
+
{
|
|
272
|
+
writer.WriteStringValue(value.ToString());
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
#endif
|
|
146
277
|
`;
|
|
147
278
|
}
|