@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/dist/index.js CHANGED
@@ -189,6 +189,7 @@ function generateCSharpModels(root, opts) {
189
189
  const modelIndex = opts.modelIndex ?? buildModelIndex(root.models);
190
190
  const ctx = {
191
191
  namespace: opts.namespace,
192
+ dateTypes: opts.dateTypes ?? "dateonly",
192
193
  modelsWithInput,
193
194
  modelIndex,
194
195
  hoisted: opts.hoisted,
@@ -202,7 +203,7 @@ function generateCSharpModels(root, opts) {
202
203
  };
203
204
  for (const model of topoSortModels(root.models)) append(generateModel(model, ctx));
204
205
  for (const decl of opts.hoisted?.byFile.get(root.file) ?? []) append(generateHoisted(decl, ctx));
205
- return renderFile(`${opts.namespace}.Models`, ctx.globalAliases, [...MODEL_USINGS], bodies);
206
+ return renderFile(`${opts.namespace}.Models`, ctx.globalAliases, [...MODEL_USINGS, `using ${opts.namespace}.Runtime;`], bodies);
206
207
  }
207
208
  function resolveModelsWithInput(models, external = /* @__PURE__ */ new Set()) {
208
209
  const seed = new Set(external);
@@ -211,6 +212,7 @@ function resolveModelsWithInput(models, external = /* @__PURE__ */ new Set()) {
211
212
  function createRenderContext(opts) {
212
213
  return {
213
214
  namespace: opts.namespace,
215
+ dateTypes: opts.dateTypes ?? "dateonly",
214
216
  modelsWithInput: opts.modelsWithInput,
215
217
  modelIndex: opts.modelIndex ?? /* @__PURE__ */ new Map(),
216
218
  hoisted: opts.hoisted,
@@ -310,8 +312,9 @@ function renderScalar(name, ctx) {
310
312
  return qualify("decimal", "System.Decimal", ctx);
311
313
  case "boolean":
312
314
  return qualify("bool", "System.Boolean", ctx);
315
+ // Both carried as the wire form by a converter: `yyyy-MM-dd` and `HH:mm:ss`.
313
316
  case "date":
314
- return qualify("DateOnly", "System.DateOnly", ctx);
317
+ return ctx.dateTypes === "datetime" ? qualify("DateTime", "System.DateTime", ctx) : qualify("DateOnly", "System.DateOnly", ctx);
315
318
  case "time":
316
319
  return qualify("TimeOnly", "System.TimeOnly", ctx);
317
320
  case "datetime":
@@ -506,8 +509,25 @@ function addAlias(name, type, ctx, forInput) {
506
509
  `Contract '${name}' aliases a nullable type, which C# cannot express as a using alias; '${name}' is generated as '${aliased}'. Declare the nullability at each use site instead.`
507
510
  );
508
511
  }
512
+ const polyfilled = polyfillAliasTarget(aliased, ctx);
513
+ if (polyfilled) {
514
+ ctx.globalAliases.push(`#if NETSTANDARD2_0
515
+ global using ${name} = ${polyfilled};
516
+ #else
517
+ global using ${name} = ${aliased};
518
+ #endif`);
519
+ return;
520
+ }
509
521
  ctx.globalAliases.push(`global using ${name} = ${aliased};`);
510
522
  }
523
+ var POLYFILLED_TYPES = ["System.DateOnly", "System.TimeOnly"];
524
+ function polyfillAliasTarget(target, ctx) {
525
+ let out = target;
526
+ for (const full of POLYFILLED_TYPES) {
527
+ out = out.split(full).join(`${ctx.namespace}.Runtime.${full.slice("System.".length)}`);
528
+ }
529
+ return out === target ? void 0 : out;
530
+ }
511
531
  function isNullableValueType(type, ctx) {
512
532
  const inner = type.kind === "lazy" ? type.inner : type;
513
533
  if (inner.kind !== "union") return false;
@@ -524,6 +544,7 @@ var VALUE_TYPES = /* @__PURE__ */ new Set([
524
544
  "BigInteger",
525
545
  "DateOnly",
526
546
  "TimeOnly",
547
+ "DateTime",
527
548
  "DateTimeOffset",
528
549
  "TimeSpan",
529
550
  "Guid",
@@ -927,7 +948,7 @@ function generateMethod(route, op, ctx, methodName) {
927
948
  if (resolveModifiers(route, op).includes("deprecated")) lines.push('[Obsolete("Deprecated in the contract")]');
928
949
  lines.push(`public async ${returnType === "void" ? "Task" : `Task<${returnType}>`} ${methodName}(${signature})`);
929
950
  lines.push("{");
930
- const callArgs = [`HttpMethod.${httpMethodConstant(op.method)}`, buildPathExpression(route.path, route.params, pathBindings)];
951
+ const callArgs = [httpMethodExpression(op.method), buildPathExpression(route.path, route.params, pathBindings)];
931
952
  if (op.query) callArgs.push("query: http.Params(query)");
932
953
  if (op.headers) callArgs.push("headers: http.Params(customHeaders)");
933
954
  const content = bodyArgument(op);
@@ -1066,7 +1087,7 @@ function responseDeclarations(route, op, ctx) {
1066
1087
  const lines = [];
1067
1088
  const headerRecord = (headers, name) => {
1068
1089
  const parameters = headers.map((header) => {
1069
- const reader = headerReader(header, place);
1090
+ const reader = headerReader(header, place, ctx.dateTypes);
1070
1091
  const type = header.optional ? `${reader.type}?` : reader.type;
1071
1092
  return `${type} ${safeMemberName(toCSharpPropertyName(header.name), name)}`;
1072
1093
  }).join(", ");
@@ -1121,7 +1142,7 @@ function responseDeclarations(route, op, ctx) {
1121
1142
  lines.push("}");
1122
1143
  return lines;
1123
1144
  }
1124
- function headerReader(header, place) {
1145
+ function headerReader(header, place, dateTypes) {
1125
1146
  const scalar = header.type.kind === "scalar" ? header.type.name : void 0;
1126
1147
  switch (scalar) {
1127
1148
  case "string":
@@ -1141,7 +1162,7 @@ function headerReader(header, place) {
1141
1162
  case "uuid":
1142
1163
  return { type: "Guid", read: (raw) => `Guid.Parse(${raw})` };
1143
1164
  case "date":
1144
- return { type: "DateOnly", read: (raw) => `DateOnly.Parse(${raw}, CultureInfo.InvariantCulture)` };
1165
+ return dateTypes === "datetime" ? { type: "DateTime", read: (raw) => `DateTime.Parse(${raw}, CultureInfo.InvariantCulture)` } : { type: "DateOnly", read: (raw) => `DateOnly.Parse(${raw}, CultureInfo.InvariantCulture)` };
1145
1166
  case "time":
1146
1167
  return { type: "TimeOnly", read: (raw) => `TimeOnly.Parse(${raw}, CultureInfo.InvariantCulture)` };
1147
1168
  case "datetime":
@@ -1165,7 +1186,7 @@ function readHeaderLines(headers, typeName, ctx, place, indent, bound) {
1165
1186
  bound
1166
1187
  );
1167
1188
  const args = headers.map((header) => {
1168
- const reader = headerReader(header, place);
1189
+ const reader = headerReader(header, place, ctx.dateTypes);
1169
1190
  const name = quoteCSharpString(header.name);
1170
1191
  if (!header.optional) return reader.read(`http.RequireHeader(response, ${name})`);
1171
1192
  const local = locals.get(header.name);
@@ -1186,9 +1207,10 @@ function methodDoc(route, op, observable) {
1186
1207
  if (thrown.length > 0) lines.push(`/// <exception cref="SdkException">On ${thrown.join(", ")}.</exception>`);
1187
1208
  return lines;
1188
1209
  }
1189
- function httpMethodConstant(method) {
1210
+ function httpMethodExpression(method) {
1190
1211
  const lower = method.toLowerCase();
1191
- return lower.charAt(0).toUpperCase() + lower.slice(1);
1212
+ if (lower === "patch") return "SdkHttp.Patch";
1213
+ return `HttpMethod.${lower.charAt(0).toUpperCase()}${lower.slice(1)}`;
1192
1214
  }
1193
1215
  var PATH_PLACEHOLDER = /\{([a-zA-Z_$][a-zA-Z0-9_$.-]*)\}/g;
1194
1216
  function buildPathExpression(path, params, bindings) {
@@ -1624,7 +1646,13 @@ public sealed class SdkOptions
1624
1646
  public class SdkException : HttpRequestException
1625
1647
  {
1626
1648
  public SdkException(int status, string body, HttpResponseHeaders? responseHeaders = null, string? message = null)
1649
+ #if NETSTANDARD2_0
1650
+ // .NET Standard 2.0's HttpRequestException carries no status of its own. Status below does,
1651
+ // so nothing is lost but the base type's own StatusCode property.
1652
+ : base(message ?? $"Request failed with status {status}")
1653
+ #else
1627
1654
  : base(message ?? $"Request failed with status {status}", null, ToStatusCode(status))
1655
+ #endif
1628
1656
  {
1629
1657
  Status = status;
1630
1658
  Body = body;
@@ -1683,8 +1711,10 @@ public class SdkException : HttpRequestException
1683
1711
  }
1684
1712
  }
1685
1713
 
1714
+ #if !NETSTANDARD2_0
1686
1715
  private static HttpStatusCode? ToStatusCode(int status) =>
1687
1716
  status is >= 100 and <= 599 ? (HttpStatusCode)status : null;
1717
+ #endif
1688
1718
  }
1689
1719
 
1690
1720
  /// <summary>
@@ -1772,6 +1802,16 @@ public sealed class SdkHttp : IDisposable
1772
1802
 
1773
1803
  public JsonSerializerOptions Json { get; }
1774
1804
 
1805
+ /// <summary>
1806
+ /// The PATCH verb.
1807
+ /// </summary>
1808
+ /// <remarks>
1809
+ /// <c>HttpMethod</c> carries a static for every other verb a contract can declare, but not for
1810
+ /// this one on every framework the SDK builds against, so it is spelled once here rather than
1811
+ /// allocated per call.
1812
+ /// </remarks>
1813
+ public static readonly HttpMethod Patch = new HttpMethod("PATCH");
1814
+
1775
1815
  /// <summary>
1776
1816
  /// Send one request and read its body.
1777
1817
  /// </summary>
@@ -1810,7 +1850,13 @@ public sealed class SdkHttp : IDisposable
1810
1850
  }
1811
1851
 
1812
1852
  var message = await Client.SendAsync(request, HttpCompletionOption.ResponseContentRead, cancellationToken).ConfigureAwait(false);
1853
+ #if NETSTANDARD2_0
1854
+ // The cancellable overload arrived in .NET 5. ResponseContentRead above has already buffered
1855
+ // the body, so this read is a copy rather than a wait on the network.
1856
+ var bytes = await message.Content.ReadAsByteArrayAsync().ConfigureAwait(false);
1857
+ #else
1813
1858
  var bytes = await message.Content.ReadAsByteArrayAsync(cancellationToken).ConfigureAwait(false);
1859
+ #endif
1814
1860
  var response = new SdkResponse(message, bytes);
1815
1861
 
1816
1862
  var status = response.Status;
@@ -1943,7 +1989,78 @@ public sealed class SdkHttp : IDisposable
1943
1989
  }
1944
1990
 
1945
1991
  // src/runtime-converters.ts
1946
- function generateConvertersCs(namespaceName) {
1992
+ var ISO_DATE_CONVERTER = `/// <summary>
1993
+ /// A calendar date, as <c>yyyy-MM-dd</c>, carried in the date part of a <c>DateTime</c>.
1994
+ /// </summary>
1995
+ /// <remarks>
1996
+ /// The time is midnight and the kind is unspecified: a contract's <c>date</c> names neither, and
1997
+ /// pretending to either would put a zone offset on the wire.
1998
+ /// </remarks>
1999
+ public sealed class IsoDateConverter : JsonConverter<DateTime>
2000
+ {
2001
+ public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
2002
+ {
2003
+ if (reader.TokenType != JsonTokenType.String)
2004
+ {
2005
+ throw new JsonException($"Expected a date string, got {reader.TokenType}.");
2006
+ }
2007
+
2008
+ var text = reader.GetString() ?? throw new JsonException("Expected a date string.");
2009
+ if (DateTime.TryParseExact(text, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out var exact))
2010
+ {
2011
+ return exact;
2012
+ }
2013
+
2014
+ // A service sending more than the contract promised is read for the part it promised.
2015
+ if (DateTime.TryParse(text, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed))
2016
+ {
2017
+ return parsed.Date;
2018
+ }
2019
+
2020
+ throw new JsonException($"'{text}' is not a date.");
2021
+ }
2022
+
2023
+ public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
2024
+ {
2025
+ writer.WriteStringValue(value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture));
2026
+ }
2027
+ }
2028
+
2029
+ `;
2030
+ var DATE_ONLY_CONVERTER = `
2031
+ /// <summary>
2032
+ /// A calendar date, as <c>yyyy-MM-dd</c>.
2033
+ /// </summary>
2034
+ /// <remarks>
2035
+ /// Compiled only where <c>DateOnly</c> is the SDK's own polyfill. The wire form is the one the
2036
+ /// framework's converter writes on net10.0, so a service reads a body from either leg of the build.
2037
+ /// </remarks>
2038
+ public sealed class DateOnlyConverter : JsonConverter<DateOnly>
2039
+ {
2040
+ public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
2041
+ {
2042
+ if (reader.TokenType != JsonTokenType.String)
2043
+ {
2044
+ throw new JsonException($"Expected a date string, got {reader.TokenType}.");
2045
+ }
2046
+
2047
+ var text = reader.GetString() ?? throw new JsonException("Expected a date string.");
2048
+ if (!DateOnly.TryParse(text, CultureInfo.InvariantCulture, out var value))
2049
+ {
2050
+ throw new JsonException($"'{text}' is not a date.");
2051
+ }
2052
+
2053
+ return value;
2054
+ }
2055
+
2056
+ public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options)
2057
+ {
2058
+ writer.WriteStringValue(value.ToString());
2059
+ }
2060
+ }
2061
+ `;
2062
+ function generateConvertersCs(namespaceName, dateTypes = "dateonly") {
2063
+ const asDateTime = dateTypes === "datetime";
1947
2064
  return `// <auto-generated/>
1948
2065
  // Generated by @contractkit/plugin-csharp. Do not edit manually.
1949
2066
  #nullable enable
@@ -1977,6 +2094,11 @@ public static class SdkJson
1977
2094
  options.Converters.Add(new BigIntegerConverter());
1978
2095
  options.Converters.Add(new DecimalStringConverter());
1979
2096
  options.Converters.Add(new IsoTimeSpanConverter());
2097
+ ${asDateTime ? " options.Converters.Add(new IsoDateConverter());\n" : ""}#if NETSTANDARD2_0
2098
+ // These are the SDK's own types on this framework, so System.Text.Json has no built-in
2099
+ // converter for them. On net10.0 the framework handles them and these are not compiled.
2100
+ ${asDateTime ? "" : " options.Converters.Add(new DateOnlyConverter());\n"} options.Converters.Add(new TimeOnlyConverter());
2101
+ #endif
1980
2102
  return options;
1981
2103
  }
1982
2104
  }
@@ -2001,8 +2123,9 @@ public sealed class BigIntegerConverter : JsonConverter<BigInteger>
2001
2123
  if (reader.TokenType == JsonTokenType.Number)
2002
2124
  {
2003
2125
  // Read the raw token rather than a long: the value may be wider than any BCL integer,
2004
- // which is the whole reason the contract called it a bigint.
2005
- var raw = Encoding.UTF8.GetString(reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan);
2126
+ // which is the whole reason the contract called it a bigint. Copied to an array rather
2127
+ // than handed to the span overload of GetString, which netstandard2.0 does not have.
2128
+ var raw = Encoding.UTF8.GetString(reader.HasValueSequence ? reader.ValueSequence.ToArray() : reader.ValueSpan.ToArray());
2006
2129
  return BigInteger.Parse(raw, NumberStyles.Integer, CultureInfo.InvariantCulture);
2007
2130
  }
2008
2131
 
@@ -2074,35 +2197,422 @@ public sealed class IsoTimeSpanConverter : JsonConverter<TimeSpan>
2074
2197
  writer.WriteStringValue(XmlConvert.ToString(value));
2075
2198
  }
2076
2199
  }
2200
+
2201
+ ${asDateTime ? ISO_DATE_CONVERTER : ""}#if NETSTANDARD2_0
2202
+ ${asDateTime ? "" : DATE_ONLY_CONVERTER}
2203
+ /// <summary>
2204
+ /// A time of day, as <c>HH:mm:ss</c>, with a seven-digit fraction when there is one.
2205
+ /// </summary>
2206
+ /// <remarks>
2207
+ /// Compiled only where <c>TimeOnly</c> is the SDK's own polyfill, and writing what the framework's
2208
+ /// own converter writes on net10.0, so a service reads a body from either leg of the build.
2209
+ /// </remarks>
2210
+ public sealed class TimeOnlyConverter : JsonConverter<TimeOnly>
2211
+ {
2212
+ public override TimeOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
2213
+ {
2214
+ if (reader.TokenType != JsonTokenType.String)
2215
+ {
2216
+ throw new JsonException($"Expected a time-of-day string, got {reader.TokenType}.");
2217
+ }
2218
+
2219
+ var text = reader.GetString() ?? throw new JsonException("Expected a time-of-day string.");
2220
+ if (!TimeOnly.TryParse(text, CultureInfo.InvariantCulture, out var value))
2221
+ {
2222
+ throw new JsonException($"'{text}' is not a time of day.");
2223
+ }
2224
+
2225
+ return value;
2226
+ }
2227
+
2228
+ public override void Write(Utf8JsonWriter writer, TimeOnly value, JsonSerializerOptions options)
2229
+ {
2230
+ writer.WriteStringValue(value.ToString());
2231
+ }
2232
+ }
2233
+
2234
+ #endif
2235
+ `;
2236
+ }
2237
+
2238
+ // src/runtime-polyfills.ts
2239
+ function generatePolyfillsCs(namespaceName) {
2240
+ return `// <auto-generated/>
2241
+ // Generated by @contractkit/plugin-csharp. Do not edit manually.
2242
+ #nullable enable
2243
+
2244
+ #if NETSTANDARD2_0
2245
+
2246
+ using System;
2247
+ using System.Globalization;
2248
+
2249
+ namespace System.Runtime.CompilerServices
2250
+ {
2251
+ /// <summary>
2252
+ /// The marker the compiler requires before it will emit an <c>init</c> accessor.
2253
+ /// </summary>
2254
+ internal static class IsExternalInit
2255
+ {
2256
+ }
2257
+
2258
+ /// <summary>
2259
+ /// Marks a member whose initialization the compiler requires at every construction site.
2260
+ /// </summary>
2261
+ [AttributeUsage(AttributeTargets.Field | AttributeTargets.Property, AllowMultiple = false, Inherited = false)]
2262
+ internal sealed class RequiredMemberAttribute : Attribute
2263
+ {
2264
+ }
2265
+
2266
+ /// <summary>
2267
+ /// Names a compiler feature a member depends on, so a compiler that does not have it refuses the
2268
+ /// member rather than misreading it.
2269
+ /// </summary>
2270
+ [AttributeUsage(AttributeTargets.All, AllowMultiple = true, Inherited = false)]
2271
+ internal sealed class CompilerFeatureRequiredAttribute : Attribute
2272
+ {
2273
+ public CompilerFeatureRequiredAttribute(string featureName)
2274
+ {
2275
+ FeatureName = featureName;
2276
+ }
2277
+
2278
+ public string FeatureName { get; }
2279
+
2280
+ public bool IsOptional { get; init; }
2281
+
2282
+ public const string RefStructs = nameof(RefStructs);
2283
+
2284
+ public const string RequiredMembers = nameof(RequiredMembers);
2285
+ }
2286
+ }
2287
+
2288
+ namespace System.Diagnostics.CodeAnalysis
2289
+ {
2290
+ /// <summary>
2291
+ /// Marks a constructor that sets every required member itself.
2292
+ /// </summary>
2293
+ [AttributeUsage(AttributeTargets.Constructor, AllowMultiple = false, Inherited = false)]
2294
+ internal sealed class SetsRequiredMembersAttribute : Attribute
2295
+ {
2296
+ }
2297
+ }
2298
+
2299
+ namespace ${namespaceName}.Runtime
2300
+ {
2301
+ /// <summary>
2302
+ /// A calendar date with no time and no offset. Stands in for <c>System.DateOnly</c>.
2303
+ /// </summary>
2304
+ /// <remarks>
2305
+ /// Carries what a generated SDK and the code around it need: construction, ordering, conversion
2306
+ /// to and from <see cref="DateTime"/>, and the <c>yyyy-MM-dd</c> form a contract's <c>date</c>
2307
+ /// travels as. Not a complete reimplementation of the framework type.
2308
+ /// </remarks>
2309
+ public readonly struct DateOnly : IEquatable<DateOnly>, IComparable<DateOnly>, IComparable
2310
+ {
2311
+ private readonly DateTime _value;
2312
+
2313
+ public DateOnly(int year, int month, int day)
2314
+ {
2315
+ _value = new DateTime(year, month, day);
2316
+ }
2317
+
2318
+ private DateOnly(DateTime value)
2319
+ {
2320
+ _value = value.Date;
2321
+ }
2322
+
2323
+ public static DateOnly MinValue => new DateOnly(DateTime.MinValue);
2324
+
2325
+ public static DateOnly MaxValue => new DateOnly(DateTime.MaxValue);
2326
+
2327
+ public int Year => _value.Year;
2328
+
2329
+ public int Month => _value.Month;
2330
+
2331
+ public int Day => _value.Day;
2332
+
2333
+ public int DayOfYear => _value.DayOfYear;
2334
+
2335
+ public DayOfWeek DayOfWeek => _value.DayOfWeek;
2336
+
2337
+ /// <summary>The date part of <paramref name="value"/>, dropping its time and kind.</summary>
2338
+ public static DateOnly FromDateTime(DateTime value) => new DateOnly(value);
2339
+
2340
+ /// <summary>This date at midnight, with an unspecified kind. What a XAML date picker binds to.</summary>
2341
+ public DateTime ToDateTime() => _value;
2342
+
2343
+ /// <summary>This date at <paramref name="time"/>, with an unspecified kind.</summary>
2344
+ public DateTime ToDateTime(TimeOnly time) => _value.Add(time.ToTimeSpan());
2345
+
2346
+ public DateOnly AddDays(int value) => new DateOnly(_value.AddDays(value));
2347
+
2348
+ public DateOnly AddMonths(int value) => new DateOnly(_value.AddMonths(value));
2349
+
2350
+ public DateOnly AddYears(int value) => new DateOnly(_value.AddYears(value));
2351
+
2352
+ public static DateOnly Parse(string s) => Parse(s, CultureInfo.InvariantCulture);
2353
+
2354
+ /// <exception cref="FormatException">When <paramref name="s"/> is not a date.</exception>
2355
+ public static DateOnly Parse(string s, IFormatProvider? provider)
2356
+ {
2357
+ if (!TryParse(s, provider, out var result))
2358
+ {
2359
+ throw new FormatException("'" + s + "' is not a date.");
2360
+ }
2361
+
2362
+ return result;
2363
+ }
2364
+
2365
+ public static bool TryParse(string? s, out DateOnly result) => TryParse(s, CultureInfo.InvariantCulture, out result);
2366
+
2367
+ /// <remarks>
2368
+ /// The wire form is tried first and exactly; anything else the culture reads as a date is
2369
+ /// then taken by its date part, which is what the framework type does too.
2370
+ /// </remarks>
2371
+ public static bool TryParse(string? s, IFormatProvider? provider, out DateOnly result)
2372
+ {
2373
+ var culture = provider ?? CultureInfo.InvariantCulture;
2374
+ if (DateTime.TryParseExact(s, "yyyy-MM-dd", culture, DateTimeStyles.None, out var exact))
2375
+ {
2376
+ result = new DateOnly(exact);
2377
+ return true;
2378
+ }
2379
+
2380
+ if (DateTime.TryParse(s, culture, DateTimeStyles.None, out var parsed))
2381
+ {
2382
+ result = new DateOnly(parsed);
2383
+ return true;
2384
+ }
2385
+
2386
+ result = default;
2387
+ return false;
2388
+ }
2389
+
2390
+ /// <summary>The ISO 8601 form, <c>yyyy-MM-dd</c>. Also how the date travels on the wire.</summary>
2391
+ public override string ToString() => _value.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
2392
+
2393
+ public string ToString(string? format) => _value.ToString(format, CultureInfo.InvariantCulture);
2394
+
2395
+ public string ToString(string? format, IFormatProvider? provider) => _value.ToString(format, provider);
2396
+
2397
+ public bool Equals(DateOnly other) => _value == other._value;
2398
+
2399
+ public override bool Equals(object? obj) => obj is DateOnly other && Equals(other);
2400
+
2401
+ public override int GetHashCode() => _value.GetHashCode();
2402
+
2403
+ public int CompareTo(DateOnly other) => _value.CompareTo(other._value);
2404
+
2405
+ public int CompareTo(object? obj)
2406
+ {
2407
+ if (obj is null) return 1;
2408
+ if (obj is DateOnly other) return CompareTo(other);
2409
+ throw new ArgumentException("Object must be of type DateOnly.", nameof(obj));
2410
+ }
2411
+
2412
+ public static bool operator ==(DateOnly left, DateOnly right) => left.Equals(right);
2413
+
2414
+ public static bool operator !=(DateOnly left, DateOnly right) => !left.Equals(right);
2415
+
2416
+ public static bool operator <(DateOnly left, DateOnly right) => left.CompareTo(right) < 0;
2417
+
2418
+ public static bool operator <=(DateOnly left, DateOnly right) => left.CompareTo(right) <= 0;
2419
+
2420
+ public static bool operator >(DateOnly left, DateOnly right) => left.CompareTo(right) > 0;
2421
+
2422
+ public static bool operator >=(DateOnly left, DateOnly right) => left.CompareTo(right) >= 0;
2423
+ }
2424
+
2425
+ /// <summary>
2426
+ /// A time of day with no date and no offset. Stands in for <c>System.TimeOnly</c>.
2427
+ /// </summary>
2428
+ /// <remarks>
2429
+ /// At least zero and less than 24 hours, which is what separates it from the
2430
+ /// <see cref="TimeSpan"/> a contract's <c>duration</c> maps to.
2431
+ /// </remarks>
2432
+ public readonly struct TimeOnly : IEquatable<TimeOnly>, IComparable<TimeOnly>, IComparable
2433
+ {
2434
+ private static readonly TimeSpan OneDay = TimeSpan.FromDays(1);
2435
+
2436
+ /// <summary>Accepted on the way in: the wire form, with or without a fraction, and <c>HH:mm</c>.</summary>
2437
+ private static readonly string[] Formats = { "HH:mm:ss.FFFFFFF", "HH:mm" };
2438
+
2439
+ private readonly TimeSpan _value;
2440
+
2441
+ public TimeOnly(int hour, int minute)
2442
+ : this(new TimeSpan(hour, minute, 0))
2443
+ {
2444
+ }
2445
+
2446
+ public TimeOnly(int hour, int minute, int second)
2447
+ : this(new TimeSpan(hour, minute, second))
2448
+ {
2449
+ }
2450
+
2451
+ public TimeOnly(int hour, int minute, int second, int millisecond)
2452
+ : this(new TimeSpan(0, hour, minute, second, millisecond))
2453
+ {
2454
+ }
2455
+
2456
+ private TimeOnly(TimeSpan value)
2457
+ {
2458
+ if (value < TimeSpan.Zero || value >= OneDay)
2459
+ {
2460
+ throw new ArgumentOutOfRangeException(nameof(value), "A time of day is at least zero and less than 24 hours.");
2461
+ }
2462
+
2463
+ _value = value;
2464
+ }
2465
+
2466
+ public static TimeOnly MinValue => new TimeOnly(TimeSpan.Zero);
2467
+
2468
+ public static TimeOnly MaxValue => new TimeOnly(OneDay - TimeSpan.FromTicks(1));
2469
+
2470
+ public int Hour => _value.Hours;
2471
+
2472
+ public int Minute => _value.Minutes;
2473
+
2474
+ public int Second => _value.Seconds;
2475
+
2476
+ public int Millisecond => _value.Milliseconds;
2477
+
2478
+ public long Ticks => _value.Ticks;
2479
+
2480
+ /// <summary>The time part of <paramref name="value"/>.</summary>
2481
+ public static TimeOnly FromDateTime(DateTime value) => new TimeOnly(value.TimeOfDay);
2482
+
2483
+ /// <exception cref="ArgumentOutOfRangeException">When <paramref name="value"/> is negative or a day or more.</exception>
2484
+ public static TimeOnly FromTimeSpan(TimeSpan value) => new TimeOnly(value);
2485
+
2486
+ /// <summary>This time as a span since midnight. What a XAML time picker binds to.</summary>
2487
+ public TimeSpan ToTimeSpan() => _value;
2488
+
2489
+ public static TimeOnly Parse(string s) => Parse(s, CultureInfo.InvariantCulture);
2490
+
2491
+ /// <exception cref="FormatException">When <paramref name="s"/> is not a time of day.</exception>
2492
+ public static TimeOnly Parse(string s, IFormatProvider? provider)
2493
+ {
2494
+ if (!TryParse(s, provider, out var result))
2495
+ {
2496
+ throw new FormatException("'" + s + "' is not a time of day.");
2497
+ }
2498
+
2499
+ return result;
2500
+ }
2501
+
2502
+ public static bool TryParse(string? s, out TimeOnly result) => TryParse(s, CultureInfo.InvariantCulture, out result);
2503
+
2504
+ public static bool TryParse(string? s, IFormatProvider? provider, out TimeOnly result)
2505
+ {
2506
+ var culture = provider ?? CultureInfo.InvariantCulture;
2507
+ if (DateTime.TryParseExact(s, Formats, culture, DateTimeStyles.None, out var exact))
2508
+ {
2509
+ result = new TimeOnly(exact.TimeOfDay);
2510
+ return true;
2511
+ }
2512
+
2513
+ if (DateTime.TryParse(s, culture, DateTimeStyles.None, out var parsed))
2514
+ {
2515
+ result = new TimeOnly(parsed.TimeOfDay);
2516
+ return true;
2517
+ }
2518
+
2519
+ result = default;
2520
+ return false;
2521
+ }
2522
+
2523
+ /// <summary>
2524
+ /// The ISO 8601 form: <c>HH:mm:ss</c>, and a fraction of seven digits when there is one.
2525
+ /// </summary>
2526
+ /// <remarks>
2527
+ /// Byte for byte what System.Text.Json's own converter writes for a <c>TimeOnly</c> on
2528
+ /// net10.0 \u2014 including the seven digits, which it does not trim \u2014 so a service cannot tell
2529
+ /// which leg of the build sent a body.
2530
+ /// </remarks>
2531
+ public override string ToString() =>
2532
+ _value.Ticks % TimeSpan.TicksPerSecond == 0
2533
+ ? _value.ToString("hh':'mm':'ss", CultureInfo.InvariantCulture)
2534
+ : _value.ToString("hh':'mm':'ss'.'fffffff", CultureInfo.InvariantCulture);
2535
+
2536
+ public string ToString(string? format) => _value.ToString(format, CultureInfo.InvariantCulture);
2537
+
2538
+ public string ToString(string? format, IFormatProvider? provider) => _value.ToString(format, provider);
2539
+
2540
+ public bool Equals(TimeOnly other) => _value == other._value;
2541
+
2542
+ public override bool Equals(object? obj) => obj is TimeOnly other && Equals(other);
2543
+
2544
+ public override int GetHashCode() => _value.GetHashCode();
2545
+
2546
+ public int CompareTo(TimeOnly other) => _value.CompareTo(other._value);
2547
+
2548
+ public int CompareTo(object? obj)
2549
+ {
2550
+ if (obj is null) return 1;
2551
+ if (obj is TimeOnly other) return CompareTo(other);
2552
+ throw new ArgumentException("Object must be of type TimeOnly.", nameof(obj));
2553
+ }
2554
+
2555
+ public static bool operator ==(TimeOnly left, TimeOnly right) => left.Equals(right);
2556
+
2557
+ public static bool operator !=(TimeOnly left, TimeOnly right) => !left.Equals(right);
2558
+
2559
+ public static bool operator <(TimeOnly left, TimeOnly right) => left.CompareTo(right) < 0;
2560
+
2561
+ public static bool operator <=(TimeOnly left, TimeOnly right) => left.CompareTo(right) <= 0;
2562
+
2563
+ public static bool operator >(TimeOnly left, TimeOnly right) => left.CompareTo(right) > 0;
2564
+
2565
+ public static bool operator >=(TimeOnly left, TimeOnly right) => left.CompareTo(right) >= 0;
2566
+ }
2567
+ }
2568
+
2569
+ #endif
2077
2570
  `;
2078
2571
  }
2079
2572
 
2080
2573
  // src/scaffold.ts
2081
2574
  var SCAFFOLD_VERSIONS = {
2082
- targetFramework: "net10.0"
2575
+ targetFramework: "net10.0",
2576
+ systemTextJson: "10.0.12",
2577
+ /** The oldest language version the generated sources compile under: records, `required`, primary constructors. */
2578
+ netstandardLangVersion: "12.0"
2083
2579
  };
2084
- function generateCsproj(namespaceName, sdkName) {
2580
+ var DEFAULT_TARGET_FRAMEWORKS = [SCAFFOLD_VERSIONS.targetFramework];
2581
+ function generateCsproj(namespaceName, sdkName, targetFrameworks = DEFAULT_TARGET_FRAMEWORKS) {
2582
+ const frameworks = targetFrameworks.length === 1 ? ` <TargetFramework>${targetFrameworks[0]}</TargetFramework>` : ` <TargetFrameworks>${targetFrameworks.join(";")}</TargetFrameworks>`;
2583
+ const netstandard = targetFrameworks.includes("netstandard2.0") ? `
2584
+ <!-- .NET Standard 2.0 predates the language features the generated sources use. -->
2585
+ <PropertyGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
2586
+ <LangVersion>${SCAFFOLD_VERSIONS.netstandardLangVersion}</LangVersion>
2587
+ </PropertyGroup>
2588
+
2589
+ <!-- The one framework where System.Text.Json is a package rather than part of the platform. -->
2590
+ <ItemGroup Condition="'$(TargetFramework)' == 'netstandard2.0'">
2591
+ <PackageReference Include="System.Text.Json" Version="${SCAFFOLD_VERSIONS.systemTextJson}" />
2592
+ </ItemGroup>
2593
+ ` : "";
2085
2594
  return `<!-- Created once by @contractkit/plugin-csharp. Yours to edit: it is never regenerated. -->
2086
2595
  <Project Sdk="Microsoft.NET.Sdk">
2087
2596
 
2088
2597
  <PropertyGroup>
2089
- <TargetFramework>${SCAFFOLD_VERSIONS.targetFramework}</TargetFramework>
2598
+ ${frameworks}
2090
2599
  <Nullable>enable</Nullable>
2091
2600
  <ImplicitUsings>disable</ImplicitUsings>
2092
2601
  <RootNamespace>${namespaceName}</RootNamespace>
2093
2602
  <AssemblyName>${sdkName}</AssemblyName>
2094
2603
  </PropertyGroup>
2095
-
2604
+ ${netstandard}
2096
2605
  </Project>
2097
2606
  `;
2098
2607
  }
2099
2608
 
2100
2609
  // src/index.ts
2101
- var CSHARP_CODEGEN_VERSION = "1";
2610
+ var CSHARP_CODEGEN_VERSION = "2";
2102
2611
  var CACHE_MANIFEST_FILENAME = "csharp-manifest.json";
2103
2612
  var DEFAULT_BASE_DIR = "csharp-sdk";
2104
2613
  var DEFAULT_NAMESPACE = "ContractKit.Sdk";
2105
2614
  var DEFAULT_SDK_NAME = "Sdk";
2615
+ var DEFAULT_DATE_TYPES = "dateonly";
2106
2616
  var plugin = {
2107
2617
  name: "csharp-sdk",
2108
2618
  async generateTargets(inputs, ctx) {
@@ -2148,12 +2658,38 @@ function assertValidConfig(config) {
2148
2658
  throw new Error(`plugin-csharp: ${key} must be a boolean \u2014 got ${JSON.stringify(value)}.`);
2149
2659
  }
2150
2660
  }
2661
+ assertValidTargetFrameworks(config.targetFrameworks);
2662
+ if (config.dateTypes !== void 0 && !DATE_TYPES.includes(config.dateTypes)) {
2663
+ throw new Error(`plugin-csharp: dateTypes ${JSON.stringify(config.dateTypes)} is not supported \u2014 expected one of ${DATE_TYPES.join(", ")}.`);
2664
+ }
2665
+ }
2666
+ var DATE_TYPES = ["dateonly", "datetime"];
2667
+ var TARGET_FRAMEWORKS = ["netstandard2.0", "net10.0"];
2668
+ function assertValidTargetFrameworks(frameworks) {
2669
+ if (frameworks === void 0) return;
2670
+ if (!Array.isArray(frameworks) || frameworks.length === 0) {
2671
+ throw new Error(`plugin-csharp: targetFrameworks must be a non-empty array \u2014 got ${JSON.stringify(frameworks)}.`);
2672
+ }
2673
+ const seen = /* @__PURE__ */ new Set();
2674
+ for (const framework of frameworks) {
2675
+ if (typeof framework !== "string" || !TARGET_FRAMEWORKS.includes(framework)) {
2676
+ throw new Error(
2677
+ `plugin-csharp: targetFrameworks entry ${JSON.stringify(framework)} is not supported \u2014 expected one of ${TARGET_FRAMEWORKS.join(", ")}.`
2678
+ );
2679
+ }
2680
+ if (seen.has(framework)) {
2681
+ throw new Error(`plugin-csharp: targetFrameworks lists '${framework}' twice.`);
2682
+ }
2683
+ seen.add(framework);
2684
+ }
2151
2685
  }
2152
2686
  async function runCSharpCodegen(inputs, ctx, config, rootDir) {
2153
2687
  assertValidConfig(config);
2154
2688
  const { contractRoots } = inputs;
2155
2689
  const namespaceName = config.namespace ?? DEFAULT_NAMESPACE;
2156
2690
  const sdkName = config.sdkName ?? DEFAULT_SDK_NAME;
2691
+ const targetFrameworks = config.targetFrameworks ?? DEFAULT_TARGET_FRAMEWORKS;
2692
+ const dateTypes = config.dateTypes ?? DEFAULT_DATE_TYPES;
2157
2693
  const outDir = resolve(rootDir, config.baseDir ?? DEFAULT_BASE_DIR);
2158
2694
  const manifestPath = resolve(ctx.cacheDir, CACHE_MANIFEST_FILENAME);
2159
2695
  const allModels = contractRoots.flatMap((root) => root.models);
@@ -2181,6 +2717,7 @@ async function runCSharpCodegen(inputs, ctx, config, rootDir) {
2181
2717
  v: CSHARP_CODEGEN_VERSION,
2182
2718
  relPath,
2183
2719
  namespace: namespaceName,
2720
+ dateTypes,
2184
2721
  root,
2185
2722
  externalBases,
2186
2723
  modelsWithInput: relevantInputModels,
@@ -2195,6 +2732,7 @@ async function runCSharpCodegen(inputs, ctx, config, rootDir) {
2195
2732
  relativePath: relPath,
2196
2733
  content: generateCSharpModels(root, {
2197
2734
  namespace: namespaceName,
2735
+ dateTypes,
2198
2736
  modelsWithInput,
2199
2737
  modelIndex,
2200
2738
  hoisted,
@@ -2216,6 +2754,7 @@ async function runCSharpCodegen(inputs, ctx, config, rootDir) {
2216
2754
  v: CSHARP_CODEGEN_VERSION,
2217
2755
  relPath,
2218
2756
  namespace: namespaceName,
2757
+ dateTypes,
2219
2758
  root,
2220
2759
  referencedModels,
2221
2760
  modelsWithInput: relevantInputModels,
@@ -2229,6 +2768,7 @@ async function runCSharpCodegen(inputs, ctx, config, rootDir) {
2229
2768
  relativePath: relPath,
2230
2769
  content: generateCSharpClient(root, {
2231
2770
  namespace: namespaceName,
2771
+ dateTypes,
2232
2772
  modelsWithInput,
2233
2773
  modelIndex,
2234
2774
  hoisted,
@@ -2240,12 +2780,19 @@ async function runCSharpCodegen(inputs, ctx, config, rootDir) {
2240
2780
  });
2241
2781
  }
2242
2782
  const globalFiles = [
2243
- { relativePath: "Runtime/Converters.cs", content: generateConvertersCs(namespaceName) },
2783
+ { relativePath: "Runtime/Converters.cs", content: generateConvertersCs(namespaceName, dateTypes) },
2244
2784
  { relativePath: "Runtime/SdkRuntime.cs", content: generateRuntimeCs(namespaceName) },
2245
2785
  { relativePath: `${sdkName}.cs`, content: generateSdkCs(namespaceName, sdkName, clients) }
2246
2786
  ];
2787
+ if (targetFrameworks.includes("netstandard2.0")) {
2788
+ globalFiles.push({ relativePath: "Runtime/Polyfills.cs", content: generatePolyfillsCs(namespaceName) });
2789
+ }
2247
2790
  if (config.scaffold) {
2248
- globalFiles.push({ relativePath: `${sdkName}.csproj`, content: generateCsproj(namespaceName, sdkName), ifAbsent: true });
2791
+ globalFiles.push({
2792
+ relativePath: `${sdkName}.csproj`,
2793
+ content: generateCsproj(namespaceName, sdkName, targetFrameworks),
2794
+ ifAbsent: true
2795
+ });
2249
2796
  }
2250
2797
  const result = runIncrementalCodegen({
2251
2798
  codegenVersion: CSHARP_CODEGEN_VERSION,