@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.
@@ -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
- /** Generate `Runtime/Converters.cs` for `namespaceName`. Content depends on nothing but the namespace. */
14
- export declare function generateConvertersCs(namespaceName: string): string;
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,0GAA0G;AAC1G,wBAAgB,oBAAoB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAoIlE"}
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"}
@@ -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,CAmX/D"}
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"}
@@ -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
- * There is deliberately no dependency list to go with it: the generated SDK uses only
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
@@ -1 +1 @@
1
- {"version":3,"file":"scaffold.d.ts","sourceRoot":"","sources":["../src/scaffold.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH;;;;;;GAMG;AACH,eAAO,MAAM,iBAAiB;;CAEpB,CAAC;AAEX;;;;;;GAMG;AACH,wBAAgB,cAAc,CAAC,aAAa,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAc7E"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-csharp",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "ContractKit built-in plugin: C#/.NET SDK client generation (System.Text.Json + HttpClient, no NuGet dependencies)",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -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[] = [`HttpMethod.${httpMethodConstant(op.method)}`, buildPathExpression(route.path, route.params, pathBindings)];
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 { type: 'DateOnly', read: raw => `DateOnly.Parse(${raw}, CultureInfo.InvariantCulture)` };
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
- /** `System.Net.Http.HttpMethod` spells its verbs as `HttpMethod.Get`, `HttpMethod.Delete`, and so on. */
594
- function httpMethodConstant(method: string): string {
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
- return lower.charAt(0).toUpperCase() + lower.slice(1);
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 ─────────────────────────────────────────────────────────
@@ -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 { generateCsproj } from './scaffold.js';
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 = '1';
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({ relativePath: `${sdkName}.csproj`, content: generateCsproj(namespaceName, sdkName), ifAbsent: true });
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
- /** Generate `Runtime/Converters.cs` for `namespaceName`. Content depends on nothing but the namespace. */
15
- export function generateConvertersCs(namespaceName: string): string {
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
- var raw = Encoding.UTF8.GetString(reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan);
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
  }